ADR-011's single-user authz (ID-token subject must equal AllowedSubject, else 403) is replaced by ADR-012's model: Dex authentication is the only gate — 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 (next commit). Removals (noted): oidc.Config.AllowedSubject + its required-field check + the callback 403 branch; config.Config.AllowedSubject + TAPIR_ALLOWED_SUBJECT env wiring; the AllowedSubject arg in cmdServe. ui-spec.md updated to reflect the supersession. Sessions, cookie signing, login/callback/logout unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
310 lines
8.9 KiB
Go
310 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) 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 server-side
|
|
// (in-memory, fine for the single Stage-1 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; 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 = 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,
|
|
} {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
|
|
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/")
|
|
}
|