feat(auth): implement OIDC token exchange to get real username from Authelia

This commit is contained in:
Claus Lohmar 2026-06-14 15:00:04 +00:00
parent 1780cea340
commit 9922349351

View file

@ -1,23 +1,22 @@
package auth package auth
import ( import (
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"strings"
) )
// OIDCConfig holds the configuration for the Authelia OIDC client. // OIDCConfig holds the configuration for the Authelia OIDC client.
type OIDCConfig struct { type OIDCConfig struct {
// Authelia's OIDC issuer URL (e.g., http://127.0.0.1:9091) IssuerURL string
IssuerURL string ClientID string
// Client ID registered in Authelia
ClientID string
// Client secret (if required)
ClientSecret string ClientSecret string
// Redirect URL after OIDC login (e.g., https://sechpoint.app/auth/callback) RedirectURL string
RedirectURL string Domain string
// The public-facing domain for cookie domain
Domain string
} }
// OIDCHandler handles OIDC authentication flows with Authelia. // OIDCHandler handles OIDC authentication flows with Authelia.
@ -39,12 +38,11 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
state := generateToken(16) state := generateToken(16)
nonce := generateToken(16) nonce := generateToken(16)
// Store state in a short-lived cookie for CSRF protection
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "oidc_state", Name: "oidc_state",
Value: state, Value: state,
Path: "/", Path: "/",
MaxAge: 300, // 5 minutes MaxAge: 300,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
@ -62,18 +60,14 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
} }
// Callback handles the OIDC authorization code callback from Authelia. // Callback handles the OIDC authorization code callback from Authelia.
// For now, this validates state and creates a session.
// Full token exchange requires an HTTP client to Authelia's token endpoint.
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
// Get state from cookie for CSRF check
stateCookie, err := r.Cookie("oidc_state") stateCookie, err := r.Cookie("oidc_state")
if err != nil { if err != nil {
http.Error(w, "missing state cookie", http.StatusBadRequest) http.Error(w, "missing state cookie", http.StatusBadRequest)
return return
} }
// Verify state parameter matches // Get state from URL query (GET) or form body (POST)
// Get state from URL query (GET) or form body (POST form_post mode)
stateParam := r.URL.Query().Get("state") stateParam := r.URL.Query().Get("state")
if stateParam == "" { if stateParam == "" {
r.ParseForm() r.ParseForm()
@ -93,7 +87,7 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
HttpOnly: true, HttpOnly: true,
}) })
// Get code from URL query (GET) or form body (POST form_post mode) // Get code from URL query (GET) or form body (POST)
code := r.URL.Query().Get("code") code := r.URL.Query().Get("code")
if code == "" { if code == "" {
code = r.Form.Get("code") code = r.Form.Get("code")
@ -103,17 +97,11 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
return return
} }
// TODO: Exchange code for tokens using Authelia's token endpoint. // Exchange code for tokens
// For now, we create a session with the authorization code as a placeholder. username, err := h.exchangeCode(code)
// In production, you would: if err != nil {
// 1. POST to /api/oidc/token with the code http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
// 2. Validate the ID token return
// 3. Extract the user's subject (sub) claim
// 4. Create a session with that subject
username := r.URL.Query().Get("sub")
if username == "" {
username = "authenticated-user" // placeholder until token exchange
} }
token, err := h.store.CreateSession(username, 60) token, err := h.store.CreateSession(username, 60)
@ -127,22 +115,89 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
Name: "nextwks_session", Name: "nextwks_session",
Value: token, Value: token,
Path: "/", Path: "/",
MaxAge: 3600, // 1 hour MaxAge: 3600,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
// Redirect to the workspace
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }
// exchangeCode exchanges an OIDC authorization code for an ID token
// and extracts the username (sub claim) from it.
func (h *OIDCHandler) exchangeCode(code string) (string, error) {
tokenURL := h.config.IssuerURL + "/api/oidc/token"
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {h.config.RedirectURL},
"client_id": {h.config.ClientID},
"client_secret": {h.config.ClientSecret},
}
resp, err := http.PostForm(tokenURL, data)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", fmt.Errorf("parse token response: %w", err)
}
if tokenResp.IDToken == "" {
return "", fmt.Errorf("no id_token in response")
}
// Decode JWT payload (without signature verification for now)
username, err := decodeJWTSub(tokenResp.IDToken)
if err != nil {
return "", fmt.Errorf("decode id_token: %w", err)
}
return username, nil
}
// decodeJWTSub extracts the "sub" (subject/username) from a JWT without verifying the signature.
func decodeJWTSub(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid JWT format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("decode JWT payload: %w", err)
}
var claims struct {
Sub string `json:"sub"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", fmt.Errorf("parse JWT claims: %w", err)
}
if claims.Sub == "" {
return "", fmt.Errorf("missing sub claim in id_token")
}
return claims.Sub, nil
}
// AuthGateMiddleware protects routes behind OIDC authentication. // AuthGateMiddleware protects routes behind OIDC authentication.
// If the user has no valid session, redirect to Authelia login.
func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler { func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := GetUserID(r) _, ok := GetUserID(r)
if !ok { if !ok {
// Not authenticated — redirect to login
h.LoginRedirect(w, r) h.LoginRedirect(w, r)
return return
} }