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>
This commit is contained in:
2026-06-02 23:46:20 +02:00
co-authored by Claude Opus 4.8
parent e38fa792ee
commit 57c06e5a12
5 changed files with 806 additions and 3 deletions
+143
View File
@@ -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
}