diff --git a/src/core/auth/oidc.go b/src/core/auth/oidc.go index a58d369..681ded5 100644 --- a/src/core/auth/oidc.go +++ b/src/core/auth/oidc.go @@ -1,23 +1,22 @@ package auth import ( + "encoding/base64" + "encoding/json" "fmt" + "io" "net/http" "net/url" + "strings" ) // OIDCConfig holds the configuration for the Authelia OIDC client. type OIDCConfig struct { - // Authelia's OIDC issuer URL (e.g., http://127.0.0.1:9091) - IssuerURL string - // Client ID registered in Authelia - ClientID string - // Client secret (if required) + IssuerURL string + ClientID string ClientSecret string - // Redirect URL after OIDC login (e.g., https://sechpoint.app/auth/callback) - RedirectURL string - // The public-facing domain for cookie domain - Domain string + RedirectURL string + Domain string } // OIDCHandler handles OIDC authentication flows with Authelia. @@ -39,12 +38,11 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) { state := generateToken(16) nonce := generateToken(16) - // Store state in a short-lived cookie for CSRF protection http.SetCookie(w, &http.Cookie{ Name: "oidc_state", Value: state, Path: "/", - MaxAge: 300, // 5 minutes + MaxAge: 300, HttpOnly: true, 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. -// 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) { - // Get state from cookie for CSRF check stateCookie, err := r.Cookie("oidc_state") if err != nil { http.Error(w, "missing state cookie", http.StatusBadRequest) return } - // Verify state parameter matches - // Get state from URL query (GET) or form body (POST form_post mode) + // Get state from URL query (GET) or form body (POST) stateParam := r.URL.Query().Get("state") if stateParam == "" { r.ParseForm() @@ -93,7 +87,7 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { 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") if code == "" { code = r.Form.Get("code") @@ -103,17 +97,11 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { return } - // TODO: Exchange code for tokens using Authelia's token endpoint. - // For now, we create a session with the authorization code as a placeholder. - // In production, you would: - // 1. POST to /api/oidc/token with the code - // 2. Validate the ID token - // 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 + // Exchange code for tokens + username, err := h.exchangeCode(code) + if err != nil { + http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError) + return } token, err := h.store.CreateSession(username, 60) @@ -127,22 +115,89 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { Name: "nextwks_session", Value: token, Path: "/", - MaxAge: 3600, // 1 hour + MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) - // Redirect to the workspace 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. -// If the user has no valid session, redirect to Authelia login. func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, ok := GetUserID(r) if !ok { - // Not authenticated — redirect to login h.LoginRedirect(w, r) return }