Changeset 0.24.1 (#157)
This commit is contained in:
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user