Files
tapir/internal/web/oidc/session.go
T
mathiasandClaude Sonnet 4.6 38f222c931
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
chore: rename Go module path gitea.d-ma.be → git.d-ma.be
Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and
all .go import paths. Build and tests pass unchanged.

Closes #20

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
2026-07-02 14:37:33 +02:00

133 lines
3.9 KiB
Go

package oidc
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"git.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
}