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
133 lines
3.9 KiB
Go
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
|
|
}
|