package oidc import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "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 } // sessionClaims is the self-contained session payload carried INSIDE the signed // cookie — there is no server-side session table. This is deliberate (ADR-029): // an in-memory store was wiped on every pod restart, logging every user out on // each deploy, and a stateless cookie also survives browser-close and works // across replicas. It holds only the identity (subject + email, not secret) and // an absolute expiry; the HMAC tag (sign/unsign) makes it tamper-proof. type sessionClaims struct { Sub string `json:"s"` Email string `json:"e"` Exp int64 `json:"x"` // unix seconds; absolute expiry } // encodeSession produces the signed cookie value for a user with the given expiry. func (d *DexAuth) encodeSession(u web.User, exp time.Time) string { b, _ := json.Marshal(sessionClaims{Sub: u.Subject, Email: u.Email, Exp: exp.Unix()}) return d.sign(base64.RawURLEncoding.EncodeToString(b)) } // decodeSession verifies the cookie's HMAC, parses the claims, and checks expiry. // It returns the user and the absolute expiry on success. func (d *DexAuth) decodeSession(cookieValue string, now time.Time) (web.User, time.Time, bool) { payload, ok := d.unsign(cookieValue) if !ok { return web.User{}, time.Time{}, false } raw, err := base64.RawURLEncoding.DecodeString(payload) if err != nil { return web.User{}, time.Time{}, false } var c sessionClaims if err := json.Unmarshal(raw, &c); err != nil { return web.User{}, time.Time{}, false } exp := time.Unix(c.Exp, 0) if !now.Before(exp) { return web.User{}, time.Time{}, false // expired } return web.User{Subject: c.Sub, Email: c.Email}, exp, true } // 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 }