Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7314895ec4 |
@@ -1128,6 +1128,40 @@ Spec: `docs/specs/onboarding-wow-burst.md`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-029 — Stateless session cookie (survives restarts, browser-close, idle)
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-11). Triggered by pilot feedback: "lots of clicking to log in again
|
||||||
|
on iPhone." Supersedes the in-memory session store in the ADR-011 login.
|
||||||
|
|
||||||
|
**Context.** Three compounding causes made users re-login constantly:
|
||||||
|
1. **In-memory session store** (`sessionStore` map) — wiped on every pod restart, so each deploy
|
||||||
|
logged everyone out. During the active build period that was ~15 logouts.
|
||||||
|
2. **1-hour session TTL** — for a "check back tomorrow" reader, idle > 1h forced a re-login on
|
||||||
|
nearly every visit.
|
||||||
|
3. **No cookie Max-Age** — a session cookie (deleted on browser/app close); iPhone Safari closing
|
||||||
|
the tab dropped it.
|
||||||
|
Each re-login is the full Dex/Authentik redirect dance — many taps on mobile.
|
||||||
|
|
||||||
|
**Decision.** Make the session **stateless**: the identity (subject + email) and an absolute
|
||||||
|
expiry live INSIDE the existing HMAC-signed (HS256) cookie — no server-side table. Plus:
|
||||||
|
- **30-day sliding TTL** (was 1h), re-signed on each request so an active user never lapses.
|
||||||
|
- **Persistent cookie** (`Max-Age` set) so it survives browser/app close.
|
||||||
|
The cookie is HttpOnly + Secure + SameSite=Lax; the HMAC (keyed by the stable ESO
|
||||||
|
`tapir-session-secret`, which does NOT rotate per deploy) makes it tamper-proof. The payload is
|
||||||
|
identity, not secrets — the OIDC access/ID tokens are still discarded after callback.
|
||||||
|
|
||||||
|
**Consequences.** A deploy/restart no longer logs anyone out (proven by a test: a cookie issued by
|
||||||
|
one instance is accepted by a fresh instance with the same secret); works across replicas for
|
||||||
|
free. **Trade:** no server-side revocation — `logout` clears the cookie client-side, but a copied
|
||||||
|
cookie stays valid until expiry. Accepted for the Stage-0 reader pilot; revisit (server-side
|
||||||
|
revocation list, or shorter TTL + refresh) if it ever holds sensitive actions. Rotating
|
||||||
|
`tapir-session-secret` invalidates all sessions — the global logout lever.
|
||||||
|
|
||||||
|
**Not addressed here:** the tap-count of the IdP login page itself is Authentik's UX; with
|
||||||
|
re-login now rare (30-day idle or explicit logout), it matters far less.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rejected alternatives
|
## Rejected alternatives
|
||||||
|
|
||||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||||
|
|||||||
+36
-44
@@ -7,10 +7,11 @@
|
|||||||
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
|
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
|
||||||
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
|
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
|
||||||
// Authorization/registration is layered on top in internal/web (an authenticated
|
// Authorization/registration is layered on top in internal/web (an authenticated
|
||||||
// subject with no tapir user is routed to registration). Sessions are server-side
|
// subject with no tapir user is routed to registration). Sessions are STATELESS
|
||||||
// (in-memory, fine for the single Stage-1 replica) addressed by an HMAC-signed
|
// (ADR-029): the identity + expiry live inside an HMAC-signed (HS256) HttpOnly
|
||||||
// (HS256) HttpOnly Secure SameSite=Lax cookie with a short TTL and sliding
|
// Secure SameSite=Lax persistent cookie with a long sliding TTL — no server-side
|
||||||
// refresh. Tokens are never logged.
|
// table, so a deploy/restart never logs anyone out and the cookie also survives
|
||||||
|
// browser-close. Tokens are never logged; logout clears the cookie client-side.
|
||||||
//
|
//
|
||||||
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
|
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
|
||||||
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
||||||
@@ -47,7 +48,11 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultSessionTTL = time.Hour
|
// defaultSessionTTL is generous and sliding: Tapir is a "check back tomorrow"
|
||||||
|
// reader, so a short TTL meant a re-login (full IdP redirect dance) on almost
|
||||||
|
// every visit. 30 days, slid forward on each request, keeps a regular user
|
||||||
|
// logged in indefinitely while an abandoned session still lapses.
|
||||||
|
defaultSessionTTL = 30 * 24 * time.Hour
|
||||||
pendingTTL = 10 * time.Minute
|
pendingTTL = 10 * time.Minute
|
||||||
sessionCookie = "tapir_session"
|
sessionCookie = "tapir_session"
|
||||||
loginPath = "/auth/login"
|
loginPath = "/auth/login"
|
||||||
@@ -59,7 +64,6 @@ type DexAuth struct {
|
|||||||
oauth *oauth2.Config
|
oauth *oauth2.Config
|
||||||
verifier *oidc.IDTokenVerifier
|
verifier *oidc.IDTokenVerifier
|
||||||
|
|
||||||
sessions *sessionStore
|
|
||||||
pending *pendingStore
|
pending *pendingStore
|
||||||
secret []byte
|
secret []byte
|
||||||
sessionTTL time.Duration
|
sessionTTL time.Duration
|
||||||
@@ -127,7 +131,6 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
|
|||||||
RedirectURL: cfg.RedirectURL,
|
RedirectURL: cfg.RedirectURL,
|
||||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
},
|
},
|
||||||
sessions: newSessionStore(),
|
|
||||||
pending: newPendingStore(),
|
pending: newPendingStore(),
|
||||||
secret: []byte(cfg.SessionSecret),
|
secret: []byte(cfg.SessionSecret),
|
||||||
sessionTTL: defaultSessionTTL,
|
sessionTTL: defaultSessionTTL,
|
||||||
@@ -159,31 +162,31 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
|
|||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sid, ok := d.sessionID(r)
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
d.redirectUnauthenticated(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _, ok := d.decodeSession(c.Value, d.now())
|
||||||
if !ok {
|
if !ok {
|
||||||
d.redirectUnauthenticated(w, r)
|
d.redirectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
// Sliding refresh: re-issue the cookie with a fresh expiry so an active
|
||||||
d.redirectUnauthenticated(w, r)
|
// user never lapses (the expiry lives in the cookie, so sliding = re-sign).
|
||||||
return
|
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||||
}
|
|
||||||
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentUser resolves the authenticated principal from the session cookie.
|
// CurrentUser resolves the authenticated principal from the stateless cookie.
|
||||||
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
|
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
|
||||||
sid, ok := d.sessionID(r)
|
c, err := r.Cookie(sessionCookie)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return web.User{}, false
|
return web.User{}, false
|
||||||
}
|
}
|
||||||
data, ok := d.sessions.get(sid, d.now())
|
user, _, ok := d.decodeSession(c.Value, d.now())
|
||||||
if !ok {
|
return user, ok
|
||||||
return web.User{}, false
|
|
||||||
}
|
|
||||||
return data.user, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -248,23 +251,15 @@ func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
|
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
|
||||||
|
|
||||||
sid, err := randToken()
|
user := web.User{Subject: idToken.Subject, Email: claims.Email}
|
||||||
if err != nil {
|
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.sessions.put(sid, sessionData{
|
|
||||||
user: web.User{Subject: idToken.Subject, Email: claims.Email},
|
|
||||||
expiry: d.now().Add(d.sessionTTL),
|
|
||||||
})
|
|
||||||
d.setSessionCookie(w, sid)
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
if sid, ok := d.sessionID(r); ok {
|
// Stateless sessions: clearing the cookie logs the browser out. There is no
|
||||||
d.sessions.delete(sid)
|
// server-side record to delete (ADR-029); a copy of the cookie stays valid
|
||||||
}
|
// until its expiry — an accepted trade for the Stage-0 reader app.
|
||||||
d.clearSessionCookie(w)
|
d.clearSessionCookie(w)
|
||||||
// Land on the public landing page, not the login endpoint: a just-logged-out
|
// Land on the public landing page, not the login endpoint: a just-logged-out
|
||||||
// visitor should see /welcome, not be bounced straight back into a Dex login.
|
// visitor should see /welcome, not be bounced straight back into a Dex login.
|
||||||
@@ -287,22 +282,19 @@ func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) sessionID(r *http.Request) (string, bool) {
|
// setSessionCookie writes the signed session value as a PERSISTENT cookie
|
||||||
c, err := r.Cookie(sessionCookie)
|
// (Max-Age set), so it survives the browser/app being closed — a session cookie
|
||||||
if err != nil {
|
// (no Max-Age) was dropped on iPhone Safari close, forcing re-login. value is the
|
||||||
return "", false
|
// already-signed payload from encodeSession.
|
||||||
}
|
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, value string) {
|
||||||
return d.unsign(c.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, sid string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
Name: sessionCookie,
|
||||||
Value: d.sign(sid),
|
Value: value,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: !d.insecure,
|
Secure: !d.insecure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(d.sessionTTL.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,13 +306,42 @@ func TestLogoutClearsSession(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusFound, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
||||||
cleared := sessionCookie(t, rec.Result())
|
cleared := sessionCookie(t, rec.Result())
|
||||||
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
|
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie so the browser drops it")
|
||||||
|
require.Empty(t, cleared.Value, "logout blanks the cookie value")
|
||||||
|
|
||||||
// The server-side session is gone: the original cookie no longer resolves.
|
// Sessions are stateless (ADR-029): logout clears the cookie client-side, so a
|
||||||
|
// request carrying the cleared (empty) cookie is unauthenticated. The original
|
||||||
|
// signed cookie remains technically valid until its expiry — the accepted
|
||||||
|
// trade for no server-side store; the browser no longer holds it.
|
||||||
check := httptest.NewRequest(http.MethodGet, "/", nil)
|
check := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
check.AddCookie(cookie)
|
check.AddCookie(cleared)
|
||||||
_, ok := auth.CurrentUser(check)
|
_, ok := auth.CurrentUser(check)
|
||||||
require.False(t, ok)
|
require.False(t, ok, "the cleared cookie does not authenticate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionSurvivesRestart is the core of ADR-029: a cookie issued by one
|
||||||
|
// process is accepted by a FRESH instance with the same session secret — so a
|
||||||
|
// deploy/pod-restart no longer logs users out (the old in-memory store did).
|
||||||
|
func TestSessionSurvivesRestart(t *testing.T) {
|
||||||
|
f := newFakeIssuer(t)
|
||||||
|
auth1 := newAuth(t, f)
|
||||||
|
cookie := authenticate(t, auth1, f)
|
||||||
|
|
||||||
|
auth2 := newAuth(t, f) // simulate a redeploy: new process, same SessionSecret
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
user, ok := auth2.CurrentUser(req)
|
||||||
|
require.True(t, ok, "a session must survive a restart (stateless signed cookie)")
|
||||||
|
require.Equal(t, testSubject, user.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionCookieIsPersistent: the cookie carries a positive Max-Age so it
|
||||||
|
// survives the browser/app being closed (a session cookie was dropped on iOS).
|
||||||
|
func TestSessionCookieIsPersistent(t *testing.T) {
|
||||||
|
f := newFakeIssuer(t)
|
||||||
|
auth := newAuth(t, f)
|
||||||
|
cookie := authenticate(t, auth, f)
|
||||||
|
require.Greater(t, cookie.MaxAge, 0, "session cookie must be persistent (Max-Age set)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpiredSessionRejected(t *testing.T) {
|
func TestExpiredSessionRejected(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -53,56 +54,44 @@ func (d *DexAuth) unsign(signed string) (string, bool) {
|
|||||||
return value, true
|
return value, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionData is the server-side session record.
|
// sessionClaims is the self-contained session payload carried INSIDE the signed
|
||||||
type sessionData struct {
|
// cookie — there is no server-side session table. This is deliberate (ADR-029):
|
||||||
user web.User
|
// an in-memory store was wiped on every pod restart, logging every user out on
|
||||||
expiry time.Time
|
// 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
|
// encodeSession produces the signed cookie value for a user with the given expiry.
|
||||||
// in-process map is sufficient; it is safe for concurrent use.
|
func (d *DexAuth) encodeSession(u web.User, exp time.Time) string {
|
||||||
type sessionStore struct {
|
b, _ := json.Marshal(sessionClaims{Sub: u.Subject, Email: u.Email, Exp: exp.Unix()})
|
||||||
mu sync.Mutex
|
return d.sign(base64.RawURLEncoding.EncodeToString(b))
|
||||||
m map[string]sessionData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSessionStore() *sessionStore { return &sessionStore{m: make(map[string]sessionData)} }
|
// decodeSession verifies the cookie's HMAC, parses the claims, and checks expiry.
|
||||||
|
// It returns the user and the absolute expiry on success.
|
||||||
func (s *sessionStore) put(id string, d sessionData) {
|
func (d *DexAuth) decodeSession(cookieValue string, now time.Time) (web.User, time.Time, bool) {
|
||||||
s.mu.Lock()
|
payload, ok := d.unsign(cookieValue)
|
||||||
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 {
|
if !ok {
|
||||||
return sessionData{}, false
|
return web.User{}, time.Time{}, false
|
||||||
}
|
}
|
||||||
if !now.Before(d.expiry) {
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||||
delete(s.m, id)
|
if err != nil {
|
||||||
return sessionData{}, false
|
return web.User{}, time.Time{}, false
|
||||||
}
|
}
|
||||||
return d, true
|
var c sessionClaims
|
||||||
|
if err := json.Unmarshal(raw, &c); err != nil {
|
||||||
|
return web.User{}, time.Time{}, false
|
||||||
}
|
}
|
||||||
|
exp := time.Unix(c.Exp, 0)
|
||||||
// refresh slides an existing session's expiry forward; a no-op for unknown ids.
|
if !now.Before(exp) {
|
||||||
func (s *sessionStore) refresh(id string, expiry time.Time) {
|
return web.User{}, time.Time{}, false // expired
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if d, ok := s.m[id]; ok {
|
|
||||||
d.expiry = expiry
|
|
||||||
s.m[id] = d
|
|
||||||
}
|
}
|
||||||
}
|
return web.User{Subject: c.Sub, Email: c.Email}, exp, true
|
||||||
|
|
||||||
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.
|
// pendingData holds the nonce bound to an in-flight authorization request.
|
||||||
|
|||||||
Reference in New Issue
Block a user