// Package oidc implements web.Auth against a Dex OIDC provider using the // standard Authorization Code flow (docs/ui-spec.md §6, ADR-011). It is the // production counterpart to web.StubAuth: handlers depend only on the web.Auth // interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not // a code change (ADR-003). // // Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated // subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist). // Authorization/registration is layered on top in internal/web (an authenticated // subject with no tapir user is routed to registration). Sessions are STATELESS // (ADR-029): the identity + expiry live inside an HMAC-signed (HS256) HttpOnly // Secure SameSite=Lax persistent cookie with a long sliding TTL — no server-side // table, so a deploy/restart never logs anyone out and the cookie also survives // browser-close. Tokens are never logged; logout clears the cookie client-side. // // This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates // inbound Bearer JWTs for MCP APIs; this is a browser session login. package oidc import ( "context" "fmt" "net/http" "strings" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "gitea.d-ma.be/mathias/tapir/internal/web" ) // Config is the OIDC + session configuration. cmd/tapir maps these from // TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET; this package takes the resolved // struct. type Config struct { // Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery // (.well-known/openid-configuration) runs against it in New. Issuer string // ClientID / ClientSecret are the registered Dex static client credentials. ClientID string ClientSecret string // RedirectURL is the absolute callback URL, e.g. // https://tapir.d-ma.be/auth/callback. Must match the Dex client config. RedirectURL string // SessionSecret keys the HS256 session-cookie signature. Never logged. SessionSecret string } const ( // defaultSessionTTL is generous and sliding: Tapir is a "check back tomorrow" // reader, so a short TTL meant a re-login (full IdP redirect dance) on almost // every visit. 30 days, slid forward on each request, keeps a regular user // logged in indefinitely while an abandoned session still lapses. defaultSessionTTL = 30 * 24 * time.Hour pendingTTL = 10 * time.Minute sessionCookie = "tapir_session" loginPath = "/auth/login" ) // DexAuth implements web.Auth against Dex. type DexAuth struct { cfg Config oauth *oauth2.Config verifier *oidc.IDTokenVerifier pending *pendingStore secret []byte sessionTTL time.Duration now func() time.Time insecure bool // tests only: drop the Secure attribute so cookies flow over http } var _ web.Auth = (*DexAuth)(nil) // Option tunes a DexAuth. Production wiring passes none; tests inject a clock, // a shorter TTL, or insecure cookies. type Option func(*DexAuth) // WithClock overrides the time source (sliding-refresh and expiry tests). func WithClock(now func() time.Time) Option { return func(d *DexAuth) { if now != nil { d.now = now } } } // WithSessionTTL overrides the session lifetime. func WithSessionTTL(ttl time.Duration) Option { return func(d *DexAuth) { if ttl > 0 { d.sessionTTL = ttl } } } // WithInsecureCookies drops the Secure cookie attribute. TEST ONLY — it lets // the session cookie travel over plain http; never use it in production. func WithInsecureCookies() Option { return func(d *DexAuth) { d.insecure = true } } // New discovers the issuer and returns a configured DexAuth. ctx bounds the // discovery request only. func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) { for name, val := range map[string]string{ "issuer": cfg.Issuer, "client id": cfg.ClientID, "client secret": cfg.ClientSecret, "redirect url": cfg.RedirectURL, "session secret": cfg.SessionSecret, } { if strings.TrimSpace(val) == "" { return nil, fmt.Errorf("oidc: missing %s", name) } } provider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { return nil, fmt.Errorf("oidc: discover issuer: %w", err) } d := &DexAuth{ cfg: cfg, verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), oauth: &oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: provider.Endpoint(), RedirectURL: cfg.RedirectURL, Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, }, pending: newPendingStore(), secret: []byte(cfg.SessionSecret), sessionTTL: defaultSessionTTL, now: time.Now, } for _, o := range opts { o(d) } return d, nil } // Routes mounts the auth endpoints. Patterns are absolute so the handler works // whether the caller mounts it at the root or under the /auth/ subtree. func (d *DexAuth) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/auth/login", d.handleLogin) mux.HandleFunc("/auth/callback", d.handleCallback) mux.HandleFunc("/auth/logout", d.handleLogout) return mux } // Middleware redirects unauthenticated requests to the login endpoint and lets // authenticated ones through, sliding the session expiry forward. /healthz and // /auth/* are always public (avoids a redirect loop when the whole mux is // wrapped). func (d *DexAuth) Middleware(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isPublicPath(r.URL.Path) { h.ServeHTTP(w, r) return } c, err := r.Cookie(sessionCookie) if err != nil { d.redirectUnauthenticated(w, r) return } user, _, ok := d.decodeSession(c.Value, d.now()) if !ok { d.redirectUnauthenticated(w, r) return } // Sliding refresh: re-issue the cookie with a fresh expiry so an active // user never lapses (the expiry lives in the cookie, so sliding = re-sign). d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL))) h.ServeHTTP(w, r) }) } // CurrentUser resolves the authenticated principal from the stateless cookie. func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) { c, err := r.Cookie(sessionCookie) if err != nil { return web.User{}, false } user, _, ok := d.decodeSession(c.Value, d.now()) return user, ok } func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) { state, err := randToken() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } nonce, err := randToken() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } d.pending.put(state, nonce, d.now().Add(pendingTTL)) http.Redirect(w, r, d.oauth.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) } func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if q.Get("error") != "" { http.Error(w, "authentication failed", http.StatusBadRequest) return } state := q.Get("state") nonce, ok := d.pending.take(state, d.now()) if state == "" || !ok { http.Error(w, "invalid or expired state", http.StatusBadRequest) return } code := q.Get("code") if code == "" { http.Error(w, "missing authorization code", http.StatusBadRequest) return } tok, err := d.oauth.Exchange(r.Context(), code) if err != nil { http.Error(w, "token exchange failed", http.StatusBadGateway) return } rawID, ok := tok.Extra("id_token").(string) if !ok { http.Error(w, "no id token in response", http.StatusBadGateway) return } idToken, err := d.verifier.Verify(r.Context(), rawID) if err != nil { http.Error(w, "invalid id token", http.StatusUnauthorized) return } if idToken.Nonce != nonce { http.Error(w, "nonce mismatch", http.StatusBadRequest) return } // Authentication is the only gate (ADR-012): any Dex-authenticated subject may // establish a session. Whether that subject has a tapir user — and routing to // registration if not — is decided downstream in internal/web, not here. var claims struct { Email string `json:"email"` } _ = idToken.Claims(&claims) // email is best-effort; subject is the identity user := web.User{Subject: idToken.Subject, Email: claims.Email} d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL))) http.Redirect(w, r, "/", http.StatusFound) } func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) { // Stateless sessions: clearing the cookie logs the browser out. There is no // server-side record to delete (ADR-029); a copy of the cookie stays valid // until its expiry — an accepted trade for the Stage-0 reader app. d.clearSessionCookie(w) // Land on the public landing page, not the login endpoint: a just-logged-out // visitor should see /welcome, not be bounced straight back into a Dex login. http.Redirect(w, r, "/welcome", http.StatusFound) } // redirectUnauthenticated sends an unauthenticated visitor somewhere useful: the // bare root goes to the public landing page (/welcome), any deeper guarded path // goes to login so the post-login round-trip can return them to it. isPublicPath // has already let /welcome and /auth/* through, so this never loops. func (d *DexAuth) redirectUnauthenticated(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.Redirect(w, r, "/welcome", http.StatusFound) return } d.redirectToLogin(w, r) } func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, loginPath, http.StatusFound) } // setSessionCookie writes the signed session value as a PERSISTENT cookie // (Max-Age set), so it survives the browser/app being closed — a session cookie // (no Max-Age) was dropped on iPhone Safari close, forcing re-login. value is the // already-signed payload from encodeSession. func (d *DexAuth) setSessionCookie(w http.ResponseWriter, value string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: !d.insecure, SameSite: http.SameSiteLaxMode, MaxAge: int(d.sessionTTL.Seconds()), }) } func (d *DexAuth) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: !d.insecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } func isPublicPath(p string) bool { return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/") }