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 }