package api import ( "context" "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "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/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 ) // 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. func (o *oidcClient) ensure(ctx context.Context) error { o.mu.Lock() defer o.mu.Unlock() if o.provider != nil { return nil } dctx, cancel := context.WithTimeout(ctx, oidcDiscoveryTimeout) defer cancel() provider, err := oidc.NewProvider(dctx, o.issuer) if err != nil { return err } 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"` } // 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) c.Redirect(http.StatusFound, "/login?error=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) c.Redirect(http.StatusFound, "/login?error=oidc") return } verifier := oauth2.GenerateVerifier() h.setOIDCTxCookie(c, oidcTx{State: state, Verifier: verifier, Nonce: nonce}) 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) { ctx := c.Request.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) c.Redirect(http.StatusFound, "/login?error=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") c.Redirect(http.StatusFound, "/login?error=state") return } if err := h.oidc.ensure(ctx); err != nil { slog.Error("api: oidc discovery failed on callback", "error", err) c.Redirect(http.StatusFound, "/login?error=oidc_unavailable") return } code := c.Query("code") if code == "" { c.Redirect(http.StatusFound, "/login?error=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) c.Redirect(http.StatusFound, "/login?error=oidc") return } rawIDToken, ok := token.Extra("id_token").(string) if !ok || rawIDToken == "" { slog.Error("api: oidc token response missing id_token") c.Redirect(http.StatusFound, "/login?error=oidc") return } idToken, err := h.oidc.verifier.Verify(ctx, rawIDToken) if err != nil { slog.Error("api: oidc id_token verification failed", "error", err) c.Redirect(http.StatusFound, "/login?error=oidc") return } if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(tx.Nonce)) != 1 { slog.Warn("api: oidc nonce mismatch") c.Redirect(http.StatusFound, "/login?error=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) c.Redirect(http.StatusFound, "/login?error=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) c.Redirect(http.StatusFound, "/login?error=oidc") return } if err := h.startSession(c, user.ID); err != nil { slog.Error("api: oidc session start failed", "user_id", user.ID, "error", err) c.Redirect(http.StatusFound, "/login?error=oidc") return } c.Redirect(http.StatusFound, "/gardens") } // setOIDCTxCookie writes the login transaction as a compact base64 JSON cookie. func (h *handlers) setOIDCTxCookie(c *gin.Context, tx oidcTx) { b, err := json.Marshal(tx) if err != nil { // tx holds only our own generated strings, so this cannot fail in practice. slog.Error("api: marshal oidc tx", "error", err) return } 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) } 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 if err := json.Unmarshal(raw, &tx); err != nil || tx.State == "" || tx.Verifier == "" { 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 }