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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user