Files
tapir/internal/web/oidc/oidc.go
T
mathiasandClaude Opus 4.8 57c06e5a12 feat(web): implement Dex OIDC auth (web.Auth) with single-user allowlist
Adds internal/web/oidc.DexAuth, the production web.Auth impl behind the seam
(ADR-011, docs/ui-spec.md §6). Standard Authorization Code flow against Dex:

- Routes() mounts /auth/login (state+nonce, redirect to authorize),
  /auth/callback (code exchange, ID-token verify, nonce check, allowlist:
  sub must equal Config.AllowedSubject else 403, set session, redirect /),
  /auth/logout (clear session).
- Middleware redirects unauthenticated requests to /auth/login, slides the
  session expiry on each authenticated request; /healthz and /auth/* bypass.
- CurrentUser resolves the principal from the session cookie.
- Sessions: server-side in-memory store (single Stage-0 replica) keyed by an
  HMAC-SHA256 (HS256) signed, HttpOnly, Secure, SameSite=Lax cookie with a
  short TTL + sliding refresh. State->nonce pending map is one-time + expiring
  (replay/CSRF defense). Tokens are never logged.

Constructor New(ctx, Config, ...Option); the six-field Config (Issuer,
ClientID, ClientSecret, RedirectURL, SessionSecret, AllowedSubject) is what
cmd/tapir wires from TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET/
TAPIR_ALLOWED_SUBJECT. Options (clock, TTL, insecure cookies) are test-only.

Tests use a fake OIDC issuer via httptest (discovery + JWKS + token endpoint
signing an RS256 ID token) — no live Dex: login 302s to authorize; callback
for the allowlisted sub sets a session and 302s to /; non-allowlisted sub 403;
middleware redirects unauthenticated and passes authenticated; logout clears;
plus expiry, tampered-cookie, and unknown-state cases.

Deps (per ADR-006 / ui-spec §6): adds github.com/coreos/go-oidc/v3 — the
homelab-standard OIDC lib, small, handles discovery + JWKS + ID-token
verification; pairs with the already-present golang.org/x/oauth2. go-jose/v4
(transitive via go-oidc) is used directly only in tests to sign the fake
issuer's tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:46:20 +02:00

315 lines
8.9 KiB
Go

// 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); authorization is single-user — the ID
// token's subject must equal Config.AllowedSubject or the request is refused
// with 403. Sessions are server-side (in-memory, fine for the single Stage-0
// replica) addressed by an HMAC-signed (HS256) HttpOnly Secure SameSite=Lax
// cookie with a short TTL and sliding refresh. Tokens are never logged.
//
// 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/TAPIR_ALLOWED_SUBJECT; 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
// AllowedSubject is the single Dex subject permitted to sign in. Everyone
// else is refused 403 (single-user authz, ADR-011).
AllowedSubject string
}
const (
defaultSessionTTL = 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
sessions *sessionStore
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,
"allowed subject": cfg.AllowedSubject,
} {
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"},
},
sessions: newSessionStore(),
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
}
sid, ok := d.sessionID(r)
if !ok {
d.redirectToLogin(w, r)
return
}
if _, ok := d.sessions.get(sid, d.now()); !ok {
d.redirectToLogin(w, r)
return
}
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
h.ServeHTTP(w, r)
})
}
// CurrentUser resolves the authenticated principal from the session cookie.
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
sid, ok := d.sessionID(r)
if !ok {
return web.User{}, false
}
data, ok := d.sessions.get(sid, d.now())
if !ok {
return web.User{}, false
}
return data.user, true
}
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
}
// Single-user authz: only the allowlisted subject may sign in.
if idToken.Subject != d.cfg.AllowedSubject {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var claims struct {
Email string `json:"email"`
}
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
sid, err := randToken()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
d.sessions.put(sid, sessionData{
user: web.User{Subject: idToken.Subject, Email: claims.Email},
expiry: d.now().Add(d.sessionTTL),
})
d.setSessionCookie(w, sid)
http.Redirect(w, r, "/", http.StatusFound)
}
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
if sid, ok := d.sessionID(r); ok {
d.sessions.delete(sid)
}
d.clearSessionCookie(w)
http.Redirect(w, r, loginPath, http.StatusFound)
}
func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, loginPath, http.StatusFound)
}
func (d *DexAuth) sessionID(r *http.Request) (string, bool) {
c, err := r.Cookie(sessionCookie)
if err != nil {
return "", false
}
return d.unsign(c.Value)
}
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, sid string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: d.sign(sid),
Path: "/",
HttpOnly: true,
Secure: !d.insecure,
SameSite: http.SameSiteLaxMode,
})
}
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" || strings.HasPrefix(p, "/auth/")
}