From 57c06e5a126fb7d4837a48be4111bf04f7477cef Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 23:46:20 +0200 Subject: [PATCH] feat(web): implement Dex OIDC auth (web.Auth) with single-user allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- go.mod | 4 +- go.sum | 8 +- internal/web/oidc/oidc.go | 314 ++++++++++++++++++++++++++++++ internal/web/oidc/oidc_test.go | 340 +++++++++++++++++++++++++++++++++ internal/web/oidc/session.go | 143 ++++++++++++++ 5 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 internal/web/oidc/oidc.go create mode 100644 internal/web/oidc/oidc_test.go create mode 100644 internal/web/oidc/session.go diff --git a/go.mod b/go.mod index f798053..f7addac 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module gitea.d-ma.be/mathias/tapir go 1.25.0 require ( + github.com/coreos/go-oidc/v3 v3.18.0 github.com/fergusstrange/embedded-postgres v1.34.0 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.9.2 github.com/stretchr/testify v1.11.1 - golang.org/x/oauth2 v0.30.0 + golang.org/x/oauth2 v0.36.0 ) require ( diff --git a/go.sum b/go.sum index c950b79..82448a2 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,6 +25,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s= github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -83,8 +87,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= diff --git a/internal/web/oidc/oidc.go b/internal/web/oidc/oidc.go new file mode 100644 index 0000000..591b980 --- /dev/null +++ b/internal/web/oidc/oidc.go @@ -0,0 +1,314 @@ +// 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/") +} diff --git a/internal/web/oidc/oidc_test.go b/internal/web/oidc/oidc_test.go new file mode 100644 index 0000000..b8d4783 --- /dev/null +++ b/internal/web/oidc/oidc_test.go @@ -0,0 +1,340 @@ +package oidc_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + josev4 "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/web" + "gitea.d-ma.be/mathias/tapir/internal/web/oidc" +) + +const ( + testClientID = "tapir-web" + allowedSub = "allowed-subject-123" +) + +// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery +// document, a JWKS, and a token endpoint that mints an RS256-signed ID token +// from its mutable sub/email/nonce fields. No live Dex. +type fakeIssuer struct { + server *httptest.Server + key *rsa.PrivateKey + clientID string + + sub string + email string + nonce string +} + +const testKID = "test-key" + +func newFakeIssuer(t *testing.T) *fakeIssuer { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + f := &fakeIssuer{key: key, clientID: testClientID} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, map[string]any{ + "issuer": f.server.URL, + "authorization_endpoint": f.server.URL + "/authorize", + "token_endpoint": f.server.URL + "/token", + "jwks_uri": f.server.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, josev4.JSONWebKeySet{Keys: []josev4.JSONWebKey{{ + Key: &f.key.PublicKey, + KeyID: testKID, + Algorithm: "RS256", + Use: "sig", + }}}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, map[string]any{ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": f.signIDToken(t), + }) + }) + + f.server = httptest.NewServer(mux) + t.Cleanup(f.server.Close) + return f +} + +func (f *fakeIssuer) signIDToken(t *testing.T) string { + t.Helper() + signer, err := josev4.NewSigner( + josev4.SigningKey{Algorithm: josev4.RS256, Key: f.key}, + (&josev4.SignerOptions{}).WithType("JWT").WithHeader("kid", testKID), + ) + require.NoError(t, err) + + now := time.Now() + payload, err := json.Marshal(map[string]any{ + "iss": f.server.URL, + "sub": f.sub, + "aud": f.clientID, + "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), + "nonce": f.nonce, + "email": f.email, + }) + require.NoError(t, err) + + obj, err := signer.Sign(payload) + require.NoError(t, err) + s, err := obj.CompactSerialize() + require.NoError(t, err) + return s +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(v)) +} + +func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth { + t.Helper() + auth, err := oidc.New(context.Background(), oidc.Config{ + Issuer: f.server.URL, + ClientID: testClientID, + ClientSecret: "test-client-secret", + RedirectURL: "http://tapir.test/auth/callback", + SessionSecret: "test-session-secret-please-change", + AllowedSubject: allowedSub, + }, oidc.WithInsecureCookies()) + require.NoError(t, err) + return auth +} + +// login drives /auth/login and returns the state and nonce from the authorize +// redirect, as a real browser hop would surface them. +func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) { + t.Helper() + rec := httptest.NewRecorder() + auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + require.Equal(t, http.StatusFound, rec.Code) + + loc, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + q := loc.Query() + return q.Get("state"), q.Get("nonce") +} + +// authenticate completes a full login+callback for the allowlisted subject and +// returns the resulting session cookie. +func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie { + t.Helper() + state, nonce := login(t, auth) + f.sub, f.email, f.nonce = allowedSub, "maintainer@d-ma.be", nonce + + rec := httptest.NewRecorder() + auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, + "/auth/callback?code=valid-code&state="+state, nil)) + require.Equal(t, http.StatusFound, rec.Code) + require.Equal(t, "/", rec.Header().Get("Location")) + + c := sessionCookie(t, rec.Result()) + require.NotEmpty(t, c.Value) + return c +} + +func sessionCookie(t *testing.T, resp *http.Response) *http.Cookie { + t.Helper() + for _, c := range resp.Cookies() { + if c.Name == "tapir_session" { + return c + } + } + t.Fatal("no tapir_session cookie set") + return nil +} + +func TestLoginRedirectsToAuthorize(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + + rec := httptest.NewRecorder() + auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + + require.Equal(t, http.StatusFound, rec.Code) + loc, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + require.Equal(t, f.server.URL+"/authorize", loc.Scheme+"://"+loc.Host+loc.Path) + + q := loc.Query() + require.Equal(t, "code", q.Get("response_type")) + require.Equal(t, testClientID, q.Get("client_id")) + require.NotEmpty(t, q.Get("state")) + require.NotEmpty(t, q.Get("nonce")) + require.Contains(t, q.Get("scope"), "openid") +} + +func TestCallbackAllowedSubjectSetsSession(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + + cookie := authenticate(t, auth, f) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + user, ok := auth.CurrentUser(req) + require.True(t, ok) + require.Equal(t, allowedSub, user.Subject) + require.Equal(t, "maintainer@d-ma.be", user.Email) + + require.True(t, cookie.HttpOnly) + require.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestCallbackNonAllowedSubjectForbidden(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + + state, nonce := login(t, auth) + f.sub, f.email, f.nonce = "intruder-999", "intruder@elsewhere.test", nonce + + rec := httptest.NewRecorder() + auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, + "/auth/callback?code=valid-code&state="+state, nil)) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Empty(t, rec.Result().Cookies(), "no session for a rejected subject") +} + +func TestCallbackUnknownStateRejected(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + + rec := httptest.NewRecorder() + auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, + "/auth/callback?code=valid-code&state=forged-state", nil)) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestMiddlewareRedirectsUnauthenticated(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + + guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + require.Equal(t, http.StatusFound, rec.Code) + require.Equal(t, "/auth/login", rec.Header().Get("Location")) +} + +func TestMiddlewarePassesAuthenticated(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + cookie := authenticate(t, auth, f) + + guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) // a marker the handler actually ran + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + guarded.ServeHTTP(rec, req) + + require.Equal(t, http.StatusTeapot, rec.Code) +} + +func TestMiddlewarePublicPathsBypassAuth(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + + guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + for _, path := range []string{"/healthz", "/auth/login"} { + rec := httptest.NewRecorder() + guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path) + } +} + +func TestLogoutClearsSession(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + cookie := authenticate(t, auth, f) + + req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + auth.Routes().ServeHTTP(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + cleared := sessionCookie(t, rec.Result()) + require.Less(t, cleared.MaxAge, 0, "logout expires the cookie") + + // The server-side session is gone: the original cookie no longer resolves. + check := httptest.NewRequest(http.MethodGet, "/", nil) + check.AddCookie(cookie) + _, ok := auth.CurrentUser(check) + require.False(t, ok) +} + +func TestExpiredSessionRejected(t *testing.T) { + f := newFakeIssuer(t) + clock := time.Now() + auth, err := oidc.New(context.Background(), oidc.Config{ + Issuer: f.server.URL, + ClientID: testClientID, + ClientSecret: "test-client-secret", + RedirectURL: "http://tapir.test/auth/callback", + SessionSecret: "test-session-secret-please-change", + AllowedSubject: allowedSub, + }, oidc.WithInsecureCookies(), + oidc.WithSessionTTL(time.Minute), + oidc.WithClock(func() time.Time { return clock })) + require.NoError(t, err) + + cookie := authenticate(t, auth, f) + + clock = clock.Add(2 * time.Minute) // push past the TTL + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + _, ok := auth.CurrentUser(req) + require.False(t, ok) +} + +func TestTamperedCookieRejected(t *testing.T) { + f := newFakeIssuer(t) + auth := newAuth(t, f) + cookie := authenticate(t, auth, f) + + tampered := &http.Cookie{Name: cookie.Name, Value: cookie.Value + "x"} + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(tampered) + _, ok := auth.CurrentUser(req) + require.False(t, ok) +} + +var _ web.Auth = (*oidc.DexAuth)(nil) diff --git a/internal/web/oidc/session.go b/internal/web/oidc/session.go new file mode 100644 index 0000000..45f1100 --- /dev/null +++ b/internal/web/oidc/session.go @@ -0,0 +1,143 @@ +package oidc + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" + + "gitea.d-ma.be/mathias/tapir/internal/web" +) + +// randToken returns a URL-safe 256-bit random string for session IDs, OIDC +// state, and nonces. +func randToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("oidc: read random: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// sign appends an HMAC-SHA256 (HS256) tag so a tampered cookie value is +// rejected. The opaque session ID — not any token — is what travels in the +// cookie. +func (d *DexAuth) sign(value string) string { + mac := hmac.New(sha256.New, d.secret) + mac.Write([]byte(value)) + return value + "." + hex.EncodeToString(mac.Sum(nil)) +} + +// unsign verifies the HMAC tag and returns the value. It uses a constant-time +// comparison so a bad tag cannot be probed byte by byte. +func (d *DexAuth) unsign(signed string) (string, bool) { + i := strings.LastIndex(signed, ".") + if i < 0 { + return "", false + } + value, tag := signed[:i], signed[i+1:] + want, err := hex.DecodeString(tag) + if err != nil { + return "", false + } + mac := hmac.New(sha256.New, d.secret) + mac.Write([]byte(value)) + if !hmac.Equal(want, mac.Sum(nil)) { + return "", false + } + return value, true +} + +// sessionData is the server-side session record. +type sessionData struct { + user web.User + expiry time.Time +} + +// sessionStore is an in-memory session table. Single replica at Stage 0, so an +// in-process map is sufficient; it is safe for concurrent use. +type sessionStore struct { + mu sync.Mutex + m map[string]sessionData +} + +func newSessionStore() *sessionStore { return &sessionStore{m: make(map[string]sessionData)} } + +func (s *sessionStore) put(id string, d sessionData) { + s.mu.Lock() + defer s.mu.Unlock() + s.m[id] = d +} + +// get returns the session if present and unexpired; expired entries are evicted. +func (s *sessionStore) get(id string, now time.Time) (sessionData, bool) { + s.mu.Lock() + defer s.mu.Unlock() + d, ok := s.m[id] + if !ok { + return sessionData{}, false + } + if !now.Before(d.expiry) { + delete(s.m, id) + return sessionData{}, false + } + return d, true +} + +// refresh slides an existing session's expiry forward; a no-op for unknown ids. +func (s *sessionStore) refresh(id string, expiry time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + if d, ok := s.m[id]; ok { + d.expiry = expiry + s.m[id] = d + } +} + +func (s *sessionStore) delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, id) +} + +// pendingData holds the nonce bound to an in-flight authorization request. +type pendingData struct { + nonce string + expiry time.Time +} + +// pendingStore maps OIDC state to its nonce between /auth/login and the +// callback. Entries are one-time (take deletes) and short-lived, defeating +// replay and CSRF on the callback. +type pendingStore struct { + mu sync.Mutex + m map[string]pendingData +} + +func newPendingStore() *pendingStore { return &pendingStore{m: make(map[string]pendingData)} } + +func (p *pendingStore) put(state, nonce string, expiry time.Time) { + p.mu.Lock() + defer p.mu.Unlock() + p.m[state] = pendingData{nonce: nonce, expiry: expiry} +} + +// take consumes the nonce for state, returning false if absent or expired. +func (p *pendingStore) take(state string, now time.Time) (string, bool) { + p.mu.Lock() + defer p.mu.Unlock() + d, ok := p.m[state] + if !ok { + return "", false + } + delete(p.m, state) + if !now.Before(d.expiry) { + return "", false + } + return d.nonce, true +}