Changeset 0.24.1 (#157)

This commit is contained in:
2026-03-07 17:11:32 +00:00
parent a63728a481
commit b6cc4df6e7
27 changed files with 2094 additions and 453 deletions

View File

@@ -3,7 +3,10 @@ package handlers
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
@@ -155,6 +158,153 @@ func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
// ── OIDC Flow ──────────────────────────────
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
// GET /api/v1/auth/oidc/login
func (h *AuthHandler) OIDCLogin(c *gin.Context) {
oidcProv, ok := h.provider.(*auth.OIDCProvider)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
return
}
state := uuid.New().String()
nonce := uuid.New().String()
// Determine redirect URI
redirectURI := h.cfg.OIDCRedirectURL
if redirectURI == "" {
// Auto-derive from request
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
}
// Store state for callback verification
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3)
`), state, nonce, c.Query("redirect"))
if err != nil {
log.Printf("[auth/oidc] warn: could not store state: %v", err)
}
authURL := oidcProv.AuthorizationURL(state, nonce, redirectURI)
c.Redirect(http.StatusFound, authURL)
}
// OIDCCallback handles the IdP redirect after successful authentication.
// GET /api/v1/auth/oidc/callback?code=...&state=...
func (h *AuthHandler) OIDCCallback(c *gin.Context) {
oidcProv, ok := h.provider.(*auth.OIDCProvider)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
return
}
code := c.Query("code")
state := c.Query("state")
// Check for IdP error response (Keycloak returns ?error=...&error_description=...)
if errCode := c.Query("error"); errCode != "" {
errDesc := c.Query("error_description")
log.Printf("[auth/oidc] IdP returned error: %s — %s", errCode, errDesc)
c.JSON(http.StatusUnauthorized, gin.H{"error": "IdP error: " + errCode, "description": errDesc})
return
}
if code == "" || state == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"})
return
}
// Verify state
var nonce, redirectTo string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1
`), state).Scan(&nonce, &redirectTo)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
return
}
// Clean up state (one-time use)
database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM oidc_auth_state WHERE state = $1
`), state)
// Also clean up stale states (> 10 minutes old)
if database.IsSQLite() {
database.DB.ExecContext(c.Request.Context(),
`DELETE FROM oidc_auth_state WHERE created_at < datetime('now', '-10 minutes')`)
} else {
database.DB.ExecContext(c.Request.Context(),
`DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`)
}
// Determine redirect URI (must match what was sent in the login request)
redirectURI := h.cfg.OIDCRedirectURL
if redirectURI == "" {
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
}
// Exchange code for tokens
tokenResp, err := oidcProv.ExchangeCode(c.Request.Context(), code, redirectURI)
if err != nil {
log.Printf("[auth/oidc] code exchange failed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
// Use the ID token (or access token) to authenticate via the provider
// Temporarily set the Authorization header so Authenticate() can read it
tokenToValidate := tokenResp.IDToken
if tokenToValidate == "" {
tokenToValidate = tokenResp.AccessToken
}
c.Request.Header.Set("Authorization", "Bearer "+tokenToValidate)
result, err := oidcProv.Authenticate(c, h.stores)
if err != nil {
log.Printf("[auth/oidc] authenticate after code exchange failed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
// Issue internal JWT
tokens, err := h.generateTokens(result.User)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
// For browser flow: encode tokens into a fragment that the login page JS
// can pick up, store in localStorage, and redirect to the app.
// This avoids httpOnly cookie issues — JS needs the token in localStorage.
accessToken, _ := tokens["access_token"].(string)
refreshToken, _ := tokens["refresh_token"].(string)
userJSON, _ := json.Marshal(tokens["user"])
// Set page-auth cookie too (for SSR middleware)
c.SetCookie("sb_token", accessToken, 900, "/", "", false, false)
// Base64-encode the token payload for the fragment
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
accessToken, refreshToken, string(userJSON))
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
dest := h.cfg.BasePath + "/login#oidc_result=" + encoded
c.Redirect(http.StatusFound, dest)
}
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
// Access token (15 min)
accessClaims := Claims{