// Package oidc implements the runtime-configurable OpenID Connect login // flow for Dialectic. Mirrors Fabric.Backend.Center's pattern: // // 1. Browser hits GET /api/auth/oidc/start // 2. We 302 to the IdP authorize endpoint (PKCE + state) // 3. IdP redirects back to /api/auth/oidc/callback?code=...&state=... // 4. We exchange the code for tokens, verify the ID token, mint a // one-time "ticket" (short random string), and 302 to the SPA at // the post-login redirect with the ticket in the URL fragment. // 5. SPA POSTs the ticket to /api/auth/oidc/exchange and gets a // session JWT set as an HTTP-only cookie. // // Why a ticket vs cookie at callback: the callback URL gets logged by // every nginx / cloudflare in the path. A short-lived ticket that we // then trade for a real cookie is much safer than putting the session // JWT in the callback URL. // // Storage: // - config persisted to oidc_config (single-row table; mutated by // `dialectic-cli config oidc ...`). // - state + PKCE verifier kept in-memory (sync.Map keyed by state; // entries expire after 10 min). Single-instance backend is OK with // in-memory; multi-instance would need a shared store (DB or redis). // - tickets also in-memory, 60s ttl. // // Session JWT: // - HS256, signed with cfg.SystemAPIKey OR a dedicated SessionSigningKey // env (latter is cleaner — see config.go). // - 24h ttl, cookie HttpOnly + Secure + SameSite=Lax + path=/. // - Claims: sub (user_id), email, name, exp. package oidc import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "errors" "fmt" "net/url" "strings" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v5" "github.com/jmoiron/sqlx" "golang.org/x/oauth2" ) type Config struct { Issuer string `db:"issuer"` ClientID string `db:"client_id"` ClientSecret string `db:"client_secret"` RedirectURI string `db:"redirect_uri"` PostLoginRedirect string `db:"post_login_redirect"` Scopes string `db:"scopes"` // space-separated Enabled bool `db:"enabled"` } type Service struct { db *sqlx.DB sessionSecret []byte sessionTTL time.Duration state sync.Map // state -> *stateEntry tickets sync.Map // ticket -> *ticketEntry cachedProvider *oidc.Provider cachedProviderAt time.Time cachedIssuer string mu sync.Mutex } type stateEntry struct { verifier string expiresAt time.Time postRedir string // the eventual SPA URL to bounce to } type ticketEntry struct { userID string email string name string expiresAt time.Time } // NewService wires the OIDC service. sessionSecret is the HS256 signing // key for session JWTs — must be a stable per-deployment secret (>= 32 // bytes random). sessionTTL is how long the cookie stays valid (default // 24h is fine for a dashboard). func NewService(db *sqlx.DB, sessionSecret []byte, sessionTTL time.Duration) *Service { if sessionTTL <= 0 { sessionTTL = 24 * time.Hour } return &Service{ db: db, sessionSecret: sessionSecret, sessionTTL: sessionTTL, } } // IsEnabled returns true iff a fully-configured + flag-on OIDC config // is in the DB. Safe to call frequently — direct DB hit, no cache (the // SPA polls this on every load). func (s *Service) IsEnabled(ctx context.Context) (bool, error) { c, err := s.GetConfig(ctx) if err != nil { return false, err } if c == nil { return false, nil } return c.Enabled && c.Issuer != "" && c.ClientID != "" && c.RedirectURI != "", nil } func (s *Service) GetConfig(ctx context.Context) (*Config, error) { var c Config err := s.db.GetContext(ctx, &c, `SELECT issuer, client_id, client_secret, redirect_uri, post_login_redirect, scopes, enabled FROM oidc_config WHERE id='singleton'`) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &c, nil } // SetConfig mutates only the fields present in patch (non-nil pointer). // Pass &"" to clear a string; pass nil to leave unchanged. Enabled is // the only field where false has a meaning distinct from "unchanged" // so use a pointer. type ConfigPatch struct { Issuer *string ClientID *string ClientSecret *string RedirectURI *string PostLoginRedirect *string Scopes *string Enabled *bool } func (s *Service) SetConfig(ctx context.Context, p ConfigPatch) (*Config, error) { // Read-modify-write under a tx so two concurrent CLI invocations // don't lose updates. tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() var c Config if err := tx.GetContext(ctx, &c, `SELECT issuer, client_id, client_secret, redirect_uri, post_login_redirect, scopes, enabled FROM oidc_config WHERE id='singleton' FOR UPDATE`); err != nil { return nil, err } if p.Issuer != nil { c.Issuer = *p.Issuer } if p.ClientID != nil { c.ClientID = *p.ClientID } if p.ClientSecret != nil { c.ClientSecret = *p.ClientSecret } if p.RedirectURI != nil { c.RedirectURI = *p.RedirectURI } if p.PostLoginRedirect != nil { c.PostLoginRedirect = *p.PostLoginRedirect } if p.Scopes != nil { c.Scopes = *p.Scopes } if p.Enabled != nil { c.Enabled = *p.Enabled } if _, err := tx.ExecContext(ctx, `UPDATE oidc_config SET issuer=?, client_id=?, client_secret=?, redirect_uri=?, post_login_redirect=?, scopes=?, enabled=? WHERE id='singleton'`, c.Issuer, c.ClientID, c.ClientSecret, c.RedirectURI, c.PostLoginRedirect, c.Scopes, c.Enabled, ); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } // Bust provider cache — the issuer might have changed. s.mu.Lock() s.cachedProvider = nil s.cachedIssuer = "" s.mu.Unlock() return &c, nil } // provider returns a cached *oidc.Provider; cache invalidates when the // issuer changes (SetConfig clears it) or after 1 hour (defensive // against stale JWKS cache). func (s *Service) provider(ctx context.Context, issuer string) (*oidc.Provider, error) { s.mu.Lock() defer s.mu.Unlock() if s.cachedProvider != nil && s.cachedIssuer == issuer && time.Since(s.cachedProviderAt) < time.Hour { return s.cachedProvider, nil } p, err := oidc.NewProvider(ctx, issuer) if err != nil { return nil, fmt.Errorf("oidc discovery for %s: %w", issuer, err) } s.cachedProvider = p s.cachedIssuer = issuer s.cachedProviderAt = time.Now() return p, nil } // BuildAuthorizeURL generates the IdP authorize URL + remembers the // PKCE verifier + state. Caller redirects the browser to the URL. func (s *Service) BuildAuthorizeURL(ctx context.Context) (string, error) { c, err := s.GetConfig(ctx) if err != nil { return "", err } if c == nil || !c.Enabled { return "", errors.New("oidc not enabled") } if c.Issuer == "" || c.ClientID == "" || c.RedirectURI == "" { return "", errors.New("oidc config incomplete (need issuer + client_id + redirect_uri)") } p, err := s.provider(ctx, c.Issuer) if err != nil { return "", err } verifier := randomBase64URL(48) challenge := pkceS256(verifier) state := randomBase64URL(24) s.state.Store(state, &stateEntry{ verifier: verifier, expiresAt: time.Now().Add(10 * time.Minute), postRedir: c.PostLoginRedirect, }) cfg := oauth2.Config{ ClientID: c.ClientID, ClientSecret: c.ClientSecret, Endpoint: p.Endpoint(), RedirectURL: c.RedirectURI, Scopes: strings.Fields(c.Scopes), } return cfg.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), ), nil } // HandleCallback exchanges the code for tokens, verifies the ID token, // mints a one-time ticket, and returns the ticket + post-login redirect. // Caller (HTTP handler) 302's the browser there with the ticket in the // URL fragment. func (s *Service) HandleCallback(ctx context.Context, code, state string) (ticket, redirect string, err error) { if code == "" || state == "" { return "", "", errors.New("missing code or state") } v, ok := s.state.LoadAndDelete(state) if !ok { return "", "", errors.New("unknown state (expired or replay)") } entry := v.(*stateEntry) if time.Now().After(entry.expiresAt) { return "", "", errors.New("state expired") } c, err := s.GetConfig(ctx) if err != nil || c == nil || !c.Enabled { return "", "", errors.New("oidc not enabled") } p, err := s.provider(ctx, c.Issuer) if err != nil { return "", "", err } cfg := oauth2.Config{ ClientID: c.ClientID, ClientSecret: c.ClientSecret, Endpoint: p.Endpoint(), RedirectURL: c.RedirectURI, Scopes: strings.Fields(c.Scopes), } tok, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", entry.verifier), ) if err != nil { return "", "", fmt.Errorf("token exchange: %w", err) } rawID, ok := tok.Extra("id_token").(string) if !ok || rawID == "" { return "", "", errors.New("no id_token in response") } verifier := p.Verifier(&oidc.Config{ClientID: c.ClientID}) idTok, err := verifier.Verify(ctx, rawID) if err != nil { return "", "", fmt.Errorf("id_token verify: %w", err) } var claims struct { Sub string `json:"sub"` Email string `json:"email"` PreferredUsername string `json:"preferred_username"` Name string `json:"name"` } if err := idTok.Claims(&claims); err != nil { return "", "", fmt.Errorf("claims decode: %w", err) } // Pick the most useful display name. displayName := claims.Name if displayName == "" { displayName = claims.PreferredUsername } if displayName == "" { displayName = claims.Email } if claims.Sub == "" { return "", "", errors.New("id_token missing sub claim") } // Mint one-time ticket (60s, single-use). t := randomBase64URL(32) s.tickets.Store(t, &ticketEntry{ userID: claims.Sub, email: claims.Email, name: displayName, expiresAt: time.Now().Add(60 * time.Second), }) redirect = entry.postRedir if redirect == "" { redirect = "/" } return t, redirect, nil } // ExchangeTicket validates the ticket and returns a signed session JWT // + its expiry. Single-use; deleted on first read regardless of result. func (s *Service) ExchangeTicket(ticket string) (jwt string, expiresAt time.Time, user UserClaims, err error) { if ticket == "" { err = errors.New("missing ticket") return } v, ok := s.tickets.LoadAndDelete(ticket) if !ok { err = errors.New("unknown ticket (expired, used, or invalid)") return } entry := v.(*ticketEntry) if time.Now().After(entry.expiresAt) { err = errors.New("ticket expired") return } user = UserClaims{ Sub: entry.userID, Email: entry.email, Name: entry.name, } expiresAt = time.Now().Add(s.sessionTTL) jwt, err = s.signSession(user, expiresAt) return } // UserClaims is what a session cookie carries. Kept tiny: sub + email // + name. Anything heavier (roles, group memberships) should hit // /api/auth/me which can re-derive on demand. type UserClaims struct { Sub string `json:"sub"` Email string `json:"email"` Name string `json:"name"` } func (s *Service) signSession(u UserClaims, expiresAt time.Time) (string, error) { tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": u.Sub, "email": u.Email, "name": u.Name, "exp": expiresAt.Unix(), }) return tok.SignedString(s.sessionSecret) } // VerifySession parses a session JWT and returns the claims if valid. // Used by auth middleware. func (s *Service) VerifySession(raw string) (*UserClaims, error) { tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) { if t.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return s.sessionSecret, nil }) if err != nil { return nil, err } claims, ok := tok.Claims.(jwt.MapClaims) if !ok || !tok.Valid { return nil, errors.New("invalid token") } sub, _ := claims["sub"].(string) email, _ := claims["email"].(string) name, _ := claims["name"].(string) if sub == "" { return nil, errors.New("token missing sub") } return &UserClaims{Sub: sub, Email: email, Name: name}, nil } // SweepExpired removes expired state + ticket entries. Call from a // background goroutine every ~1min so the maps don't grow unbounded // on a forever-running process. func (s *Service) SweepExpired() { now := time.Now() s.state.Range(func(k, v any) bool { if e, ok := v.(*stateEntry); ok && now.After(e.expiresAt) { s.state.Delete(k) } return true }) s.tickets.Range(func(k, v any) bool { if e, ok := v.(*ticketEntry); ok && now.After(e.expiresAt) { s.tickets.Delete(k) } return true }) } // randomBase64URL returns a URL-safe random string of the given byte length // (so the resulting string is ~1.3x longer after base64). Panics on // crypto/rand failure (the process is doomed anyway). func randomBase64URL(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic("rand: " + err.Error()) } return base64.RawURLEncoding.EncodeToString(b) } // pkceS256 returns the BASE64URL(SHA256(verifier)) PKCE code challenge. func pkceS256(verifier string) string { h := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(h[:]) } // hashCallbackHint is a tiny utility for logging callback errors // without dumping the raw URL (which may contain tokens). Not used in // hot path; here so callers can build observability later. func hashCallbackHint(u *url.URL) string { if u == nil { return "" } h := sha256.Sum256([]byte(u.Path + "?" + u.RawQuery)) return hex.EncodeToString(h[:6]) } // silence unused — keep available for future logging. var _ = hashCallbackHint