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>
144 lines
3.5 KiB
Go
144 lines
3.5 KiB
Go
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
|
|
}
|