package api import ( "context" "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-gonic/gin" "golang.org/x/oauth2" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) const ( // oidcCallbackPath is appended to PANSY_BASE_URL to form the redirect URI // registered with the IdP. oidcCallbackPath = "/api/v1/auth/oidc/callback" // oidcTxCookie holds the short-lived per-login transaction state (CSRF state, // PKCE verifier, nonce) between /oidc/login and /oidc/callback. oidcTxCookie = "pansy_oidc_tx" oidcTxMaxAge = 10 * time.Minute // oidcDiscoveryTimeout bounds a single lazy discovery attempt so a hung IdP // can't wedge a request goroutine. oidcDiscoveryTimeout = 10 * time.Second // oidcExchangeTimeout bounds the callback's token exchange + ID-token // verification (which also fetches JWKS) so a slow IdP can't outlast the // server's write timeout and leave the user on a blank page. oidcExchangeTimeout = 15 * time.Second ) // oidcClient lazily performs OIDC discovery and holds the derived verifier and // oauth2 config. Discovery is deferred to the first login/callback (and retried // on the next request if it fails) so an IdP that's momentarily unreachable at // boot doesn't stop the server — or local auth — from starting. type oidcClient struct { issuer string clientID string clientSecret string redirectURL string mu sync.Mutex provider *oidc.Provider verifier *oidc.IDTokenVerifier oauth *oauth2.Config } func newOIDCClient(cfg *config.Config) *oidcClient { return &oidcClient{ issuer: cfg.OIDC.Issuer, clientID: cfg.OIDC.ClientID, clientSecret: cfg.OIDC.ClientSecret, redirectURL: cfg.BaseURL + oidcCallbackPath, } } // ensure performs discovery once (idempotent) and builds the verifier + oauth2 // config. Safe for concurrent callers; a failure leaves the client uninitialized // so the next request retries. The network discovery runs OUTSIDE the mutex, so // concurrent cold-start (or IdP-outage) requests don't serialize behind one // another's full timeout; the first to finish installs the result. func (o *oidcClient) ensure(ctx context.Context) error { o.mu.Lock() ready := o.provider != nil o.mu.Unlock() if ready { return nil } dctx, cancel := context.WithTimeout(ctx, oidcDiscoveryTimeout) defer cancel() provider, err := oidc.NewProvider(dctx, o.issuer) if err != nil { return err } o.mu.Lock() defer o.mu.Unlock() if o.provider != nil { return nil // another goroutine won the race; keep its result. } o.provider = provider o.verifier = provider.Verifier(&oidc.Config{ClientID: o.clientID}) o.oauth = &oauth2.Config{ ClientID: o.clientID, ClientSecret: o.clientSecret, Endpoint: provider.Endpoint(), RedirectURL: o.redirectURL, Scopes: []string{oidc.ScopeOpenID, "email", "profile"}, } return nil } // oidcTx is the per-login transaction stashed in the tx cookie. It never leaves // the browser as anything an attacker can forge (HttpOnly, our domain), and the // callback checks the returned state against it (CSRF), uses the PKCE verifier // for the code exchange, and checks the ID-token nonce. type oidcTx struct { State string `json:"s"` Verifier string `json:"v"` Nonce string `json:"n"` } // redirectAuthError sends the browser back to the login page with an error code // the UI (#6) can render. Codes: oidc_unavailable, state, no_email, // email_unverified, oidc_conflict, oidc (generic). func redirectAuthError(c *gin.Context, code string) { c.Redirect(http.StatusFound, "/login?error="+code) } // oidcLogin starts the authorization-code + PKCE flow: it stashes fresh state, // PKCE verifier, and nonce in a short-lived cookie, then redirects to the IdP. func (h *handlers) oidcLogin(c *gin.Context) { if err := h.oidc.ensure(c.Request.Context()); err != nil { slog.Error("api: oidc discovery failed", "error", err) redirectAuthError(c, "oidc_unavailable") return } state, err1 := randToken() nonce, err2 := randToken() if err1 != nil || err2 != nil { slog.Error("api: oidc token generation failed", "state_err", err1, "nonce_err", err2) redirectAuthError(c, "oidc") return } verifier := oauth2.GenerateVerifier() if err := h.setOIDCTxCookie(c, oidcTx{State: state, Verifier: verifier, Nonce: nonce}); err != nil { slog.Error("api: set oidc tx cookie", "error", err) redirectAuthError(c, "oidc") return } authURL := h.oidc.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier), oidc.Nonce(nonce), ) c.Redirect(http.StatusFound, authURL) } // oidcCallback completes the flow: verify state, exchange the code (with the PKCE // verifier), verify the ID token and nonce, provision/link the user, and start a // pansy session. Every failure clears the tx cookie and redirects to the login // page with an error code rather than leaking details to the browser. func (h *handlers) oidcCallback(c *gin.Context) { tx, haveTx := h.readOIDCTxCookie(c) h.clearOIDCTxCookie(c) // A provider-side error (e.g. user denied consent) comes back as ?error=. if e := c.Query("error"); e != "" { slog.Warn("api: oidc provider returned error", "error", e) redirectAuthError(c, "oidc") return } // State must be present and match the cookie (CSRF protection). Constant-time // compare avoids leaking via timing. state := c.Query("state") if !haveTx || state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(tx.State)) != 1 { slog.Warn("api: oidc state mismatch or missing transaction") redirectAuthError(c, "state") return } // Bound the network work (token exchange + JWKS fetch during verify) so a slow // IdP can't outlast the server's write timeout. ctx, cancel := context.WithTimeout(c.Request.Context(), oidcExchangeTimeout) defer cancel() if err := h.oidc.ensure(ctx); err != nil { slog.Error("api: oidc discovery failed on callback", "error", err) redirectAuthError(c, "oidc_unavailable") return } code := c.Query("code") if code == "" { slog.Warn("api: oidc callback missing code") redirectAuthError(c, "oidc") return } token, err := h.oidc.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier)) if err != nil { slog.Error("api: oidc code exchange failed", "error", err) redirectAuthError(c, "oidc") return } rawIDToken, ok := token.Extra("id_token").(string) if !ok || rawIDToken == "" { slog.Error("api: oidc token response missing id_token") redirectAuthError(c, "oidc") return } idToken, err := h.oidc.verifier.Verify(ctx, rawIDToken) if err != nil { slog.Error("api: oidc id_token verification failed", "error", err) redirectAuthError(c, "oidc") return } if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(tx.Nonce)) != 1 { slog.Warn("api: oidc nonce mismatch") redirectAuthError(c, "oidc") return } var claims struct { Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` } if err := idToken.Claims(&claims); err != nil { slog.Error("api: oidc claims decode failed", "error", err) redirectAuthError(c, "oidc") return } name := claims.Name if name == "" { name = claims.PreferredUsername } // Issuer/Subject come from the *verified* token, not raw claims. user, err := h.svc.LoginOIDC(ctx, service.OIDCIdentity{ Issuer: idToken.Issuer, Subject: idToken.Subject, Email: claims.Email, EmailVerified: claims.EmailVerified, Name: name, }) if err != nil { slog.Warn("api: oidc provisioning failed", "error", err) redirectAuthError(c, oidcProvisionErrorCode(err)) return } if err := h.startSession(c, user.ID); err != nil { slog.Error("api: oidc session start failed", "user_id", user.ID, "error", err) redirectAuthError(c, "oidc") return } c.Redirect(http.StatusFound, "/gardens") } // oidcProvisionErrorCode maps a LoginOIDC failure to a login-page error code so // the UI can explain what went wrong instead of showing a generic message. func oidcProvisionErrorCode(err error) string { switch { case errors.Is(err, domain.ErrOIDCNoEmail): return "no_email" case errors.Is(err, domain.ErrOIDCEmailUnverified): return "email_unverified" case errors.Is(err, domain.ErrOIDCIdentityConflict): return "oidc_conflict" default: return "oidc" } } // setOIDCTxCookie writes the login transaction as a compact base64 JSON cookie. // It returns an error rather than swallowing a marshal failure, so oidcLogin // never redirects to the IdP with no way to complete the callback. func (h *handlers) setOIDCTxCookie(c *gin.Context, tx oidcTx) error { b, err := json.Marshal(tx) if err != nil { return err // tx holds only our own strings, so this can't happen in practice. } value := base64.RawURLEncoding.EncodeToString(b) // SameSite=Lax so the cookie survives the IdP's top-level redirect back to the // callback (Strict would drop it on that cross-site navigation). c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(oidcTxCookie, value, int(oidcTxMaxAge.Seconds()), "/", "", h.cookieSecure(), true) return nil } func (h *handlers) readOIDCTxCookie(c *gin.Context) (oidcTx, bool) { value, err := c.Cookie(oidcTxCookie) if err != nil || value == "" { return oidcTx{}, false } raw, err := base64.RawURLEncoding.DecodeString(value) if err != nil { return oidcTx{}, false } var tx oidcTx // All three fields must be present: an empty nonce would make the callback's // nonce check pass vacuously against an ID token that omitted the claim. if err := json.Unmarshal(raw, &tx); err != nil || tx.State == "" || tx.Verifier == "" || tx.Nonce == "" { return oidcTx{}, false } return tx, true } func (h *handlers) clearOIDCTxCookie(c *gin.Context) { c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(oidcTxCookie, "", -1, "/", "", h.cookieSecure(), true) } // randToken returns 32 bytes of URL-safe randomness for state/nonce values. func randToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil }