First-login chicken-egg: TAPIR_ALLOWED_SUBJECT can't be known until the user logs in once, but the allowlist gates login. Echo the (non-secret, opaque) subject in the forbidden response so the maintainer can read it in the browser, set the 1P item, and lock the allowlist.
317 lines
9.1 KiB
Go
317 lines
9.1 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. On mismatch
|
|
// we echo the caller's own subject (an opaque id, not a secret) so the
|
|
// maintainer can bootstrap TAPIR_ALLOWED_SUBJECT on first login.
|
|
if idToken.Subject != d.cfg.AllowedSubject {
|
|
http.Error(w, "forbidden — not the allowlisted subject. your subject is: "+idToken.Subject, 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/")
|
|
}
|