feat(backend): add OAuth2/JWT authentication support

- Add OAuth2 client for Authentik integration
- Implement JWT token generation and validation
- Add refresh token support with database storage
- Update database schema with oauth_subject, oauth_provider, and refresh_tokens table
- Create auth package with config, jwt, oauth, and token management
- Add OAuth endpoints: /auth/login, /auth/callback, /auth/refresh, /auth/logout
- Update AuthMiddleware to support both JWT and API key authentication
- Add user helper functions for OAuth user creation and retrieval
- Add .env.example with OAuth configuration template

API keys still work for CLI compatibility while JWT tokens support web/mobile clients.
This commit is contained in:
2026-01-06 15:42:03 +01:00
parent e506d76e6a
commit 4eb18388db
27 changed files with 965 additions and 6 deletions
+167
View File
@@ -0,0 +1,167 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"git.jnss.me/joakim/opal/internal/auth"
"git.jnss.me/joakim/opal/internal/engine"
)
var authConfig = auth.LoadConfig()
var oauthClient *auth.OAuthClient
func init() {
if authConfig.OAuthEnabled {
oauthClient = auth.NewOAuthClient(authConfig)
}
}
// GetLoginURL returns the OAuth authorization URL
func GetLoginURL(w http.ResponseWriter, r *http.Request) {
if !authConfig.OAuthEnabled {
errorResponse(w, http.StatusNotImplemented, "OAuth not enabled")
return
}
state := generateState()
url := oauthClient.GetAuthURL(state)
jsonResponse(w, http.StatusOK, map[string]string{
"url": url,
"state": state,
})
}
// OAuthCallback handles the OAuth callback
func OAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
errorResponse(w, http.StatusBadRequest, "missing code parameter")
return
}
// Exchange code for token
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
oauthToken, err := oauthClient.ExchangeCode(ctx, code)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to exchange code: "+err.Error())
return
}
// Get user info
userInfo, err := oauthClient.GetUserInfo(ctx, oauthToken.AccessToken)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to get user info: "+err.Error())
return
}
// Find or create user
user, err := engine.FindOrCreateOAuthUser(userInfo.Sub, userInfo.Username, userInfo.Email)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
// Generate JWT
accessToken, expiresAt, err := auth.GenerateJWT(user.ID, user.Username, user.Email, authConfig)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to generate token: "+err.Error())
return
}
// Generate refresh token
refreshToken, err := auth.GenerateRefreshToken()
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to generate refresh token: "+err.Error())
return
}
// Store refresh token
if err := auth.StoreRefreshToken(user.ID, refreshToken, authConfig.RefreshTokenExpiry); err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to store refresh token: "+err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshToken,
"expires_at": expiresAt,
"token_type": "Bearer",
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"email": user.Email,
},
})
}
// RefreshToken handles token refresh
func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request")
return
}
// Validate refresh token
userID, err := auth.ValidateRefreshToken(req.RefreshToken)
if err != nil {
errorResponse(w, http.StatusUnauthorized, "invalid refresh token: "+err.Error())
return
}
// Get user
user, err := engine.GetUser(userID)
if err != nil {
errorResponse(w, http.StatusNotFound, "user not found")
return
}
// Generate new access token
accessToken, expiresAt, err := auth.GenerateJWT(user.ID, user.Username, user.Email, authConfig)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to generate token")
return
}
jsonResponse(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"expires_at": expiresAt,
"token_type": "Bearer",
})
}
// Logout revokes refresh token
func Logout(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request")
return
}
if err := auth.RevokeRefreshToken(req.RefreshToken); err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to revoke token")
return
}
jsonResponse(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func generateState() string {
b := make([]byte, 16)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
+19 -6
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"git.jnss.me/joakim/opal/internal/auth"
"git.jnss.me/joakim/opal/internal/engine"
)
@@ -12,8 +13,10 @@ type contextKey string
const userIDKey contextKey = "userID"
// AuthMiddleware validates API keys and adds userID to context
// AuthMiddleware validates JWT tokens or API keys and adds userID to context
func AuthMiddleware() func(http.Handler) http.Handler {
authCfg := auth.LoadConfig()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get Authorization header
@@ -30,17 +33,27 @@ func AuthMiddleware() func(http.Handler) http.Handler {
return
}
apiKey := parts[1]
token := parts[1]
// Validate API key
valid, userID, err := engine.ValidateAPIKey(apiKey)
// Try JWT first if OAuth is enabled
if authCfg.OAuthEnabled {
if claims, err := auth.ValidateJWT(token, authCfg); err == nil {
// Valid JWT - add userID to context
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
// Fall back to API key validation (for CLI compatibility)
valid, userID, err := engine.ValidateAPIKey(token)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to validate API key")
Error(w, http.StatusInternalServerError, "failed to validate credentials")
return
}
if !valid {
Error(w, http.StatusUnauthorized, "invalid API key")
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
+6
View File
@@ -39,6 +39,12 @@ func (s *Server) setupRoutes() {
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// OAuth endpoints (no auth required)
r.Get("/auth/login", handlers.GetLoginURL)
r.Post("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/refresh", handlers.RefreshToken)
r.Post("/auth/logout", handlers.Logout)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware())