fix(auth): stateless session cookie — stop logging users out on deploy (ADR-029)
Pilot feedback: lots of re-logging-in on iPhone. Three causes: sessions lived in an in-memory map (wiped on every pod restart/deploy), a 1h TTL (idle >1h forced re-login on a check-back-tomorrow reader), and a session cookie with no Max-Age (dropped on Safari close). Each re-login is the full IdP redirect dance. Make sessions stateless: identity + absolute expiry live inside the existing HMAC-signed cookie (no server table), TTL 1h → 30 days sliding, cookie now persistent (Max-Age). Survives restarts (test: a cookie from one instance is accepted by a fresh instance with the same secret), browser-close, and idle. Trade: no server-side revocation — logout clears the cookie client-side; rotating tapir-session-secret is the global logout lever. Accepted for the Stage-0 reader. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -53,56 +54,44 @@ func (d *DexAuth) unsign(signed string) (string, bool) {
|
||||
return value, true
|
||||
}
|
||||
|
||||
// sessionData is the server-side session record.
|
||||
type sessionData struct {
|
||||
user web.User
|
||||
expiry time.Time
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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))
|
||||
}
|
||||
|
||||
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]
|
||||
// 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 sessionData{}, false
|
||||
return web.User{}, time.Time{}, false
|
||||
}
|
||||
if !now.Before(d.expiry) {
|
||||
delete(s.m, id)
|
||||
return sessionData{}, false
|
||||
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return web.User{}, time.Time{}, 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
|
||||
var c sessionClaims
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return web.User{}, time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStore) delete(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.m, id)
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user