diff --git a/src/core/auth/oidc.go b/src/core/auth/oidc.go index 681ded5..c9c5875 100644 --- a/src/core/auth/oidc.go +++ b/src/core/auth/oidc.go @@ -1,6 +1,7 @@ package auth import ( + "crypto/sha256" "encoding/base64" "encoding/json" "fmt" @@ -38,6 +39,11 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) { state := generateToken(16) nonce := generateToken(16) + // PKCE: generate code verifier and challenge + verifier := generateToken(32) + challenge := pkceChallenge(verifier) + + // Store state + verifier in cookies http.SetCookie(w, &http.Cookie{ Name: "oidc_state", Value: state, @@ -46,14 +52,23 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) { HttpOnly: true, SameSite: http.SameSiteLaxMode, }) + http.SetCookie(w, &http.Cookie{ + Name: "oidc_verifier", + Value: verifier, + Path: "/", + MaxAge: 300, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) authURL := fmt.Sprintf( - "%s/api/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s", + "%s/api/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=S256", h.config.IssuerURL, url.QueryEscape(h.config.ClientID), url.QueryEscape(h.config.RedirectURL), state, nonce, + challenge, ) http.Redirect(w, r, authURL, http.StatusFound) @@ -78,14 +93,16 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { return } - // Clear the state cookie - http.SetCookie(w, &http.Cookie{ - Name: "oidc_state", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - }) + // Get PKCE verifier from cookie + verifierCookie, _ := r.Cookie("oidc_verifier") + verifier := "" + if verifierCookie != nil { + verifier = verifierCookie.Value + } + + // Clear state cookies + http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) + http.SetCookie(w, &http.Cookie{Name: "oidc_verifier", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) // Get code from URL query (GET) or form body (POST) code := r.URL.Query().Get("code") @@ -97,8 +114,8 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { return } - // Exchange code for tokens - username, err := h.exchangeCode(code) + // Exchange code for tokens (with PKCE verifier) + username, err := h.exchangeCode(code, verifier) if err != nil { http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError) return @@ -123,9 +140,8 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { 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) { +// exchangeCode exchanges an OIDC authorization code for an ID token. +func (h *OIDCHandler) exchangeCode(code, verifier string) (string, error) { tokenURL := h.config.IssuerURL + "/api/oidc/token" data := url.Values{ @@ -133,7 +149,7 @@ func (h *OIDCHandler) exchangeCode(code string) (string, error) { "code": {code}, "redirect_uri": {h.config.RedirectURL}, "client_id": {h.config.ClientID}, - "client_secret": {h.config.ClientSecret}, + "code_verifier": {verifier}, } resp, err := http.PostForm(tokenURL, data) @@ -158,7 +174,6 @@ func (h *OIDCHandler) exchangeCode(code string) (string, error) { 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) @@ -193,6 +208,12 @@ func decodeJWTSub(token string) (string, error) { return claims.Sub, nil } +// pkceChallenge creates a PKCE S256 challenge from a verifier. +func pkceChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + // AuthGateMiddleware protects routes behind OIDC authentication. func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {