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:
+36
-44
@@ -7,10 +7,11 @@
|
||||
// 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).
|
||||
// Authorization/registration is layered on top in internal/web (an authenticated
|
||||
// subject with no tapir user is routed to registration). Sessions are server-side
|
||||
// (in-memory, fine for the single Stage-1 replica) addressed by an HMAC-signed
|
||||
// (HS256) HttpOnly Secure SameSite=Lax cookie with a short TTL and sliding
|
||||
// refresh. Tokens are never logged.
|
||||
// subject with no tapir user is routed to registration). Sessions are STATELESS
|
||||
// (ADR-029): the identity + expiry live inside an HMAC-signed (HS256) HttpOnly
|
||||
// Secure SameSite=Lax persistent cookie with a long sliding TTL — no server-side
|
||||
// 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
|
||||
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
||||
@@ -47,7 +48,11 @@ type Config struct {
|
||||
}
|
||||
|
||||
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
|
||||
sessionCookie = "tapir_session"
|
||||
loginPath = "/auth/login"
|
||||
@@ -59,7 +64,6 @@ type DexAuth struct {
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
|
||||
sessions *sessionStore
|
||||
pending *pendingStore
|
||||
secret []byte
|
||||
sessionTTL time.Duration
|
||||
@@ -127,7 +131,6 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
sessions: newSessionStore(),
|
||||
pending: newPendingStore(),
|
||||
secret: []byte(cfg.SessionSecret),
|
||||
sessionTTL: defaultSessionTTL,
|
||||
@@ -159,31 +162,31 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
|
||||
h.ServeHTTP(w, r)
|
||||
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 {
|
||||
d.redirectUnauthenticated(w, r)
|
||||
return
|
||||
}
|
||||
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
||||
d.redirectUnauthenticated(w, r)
|
||||
return
|
||||
}
|
||||
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
||||
// Sliding refresh: re-issue the cookie with a fresh expiry so an active
|
||||
// user never lapses (the expiry lives in the cookie, so sliding = re-sign).
|
||||
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||
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) {
|
||||
sid, ok := d.sessionID(r)
|
||||
if !ok {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return web.User{}, false
|
||||
}
|
||||
data, ok := d.sessions.get(sid, d.now())
|
||||
if !ok {
|
||||
return web.User{}, false
|
||||
}
|
||||
return data.user, true
|
||||
user, _, ok := d.decodeSession(c.Value, d.now())
|
||||
return user, ok
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
sid, err := randToken()
|
||||
if err != nil {
|
||||
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)
|
||||
user := web.User{Subject: idToken.Subject, Email: claims.Email}
|
||||
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if sid, ok := d.sessionID(r); ok {
|
||||
d.sessions.delete(sid)
|
||||
}
|
||||
// Stateless sessions: clearing the cookie logs the browser out. There is no
|
||||
// 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)
|
||||
// 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.
|
||||
@@ -287,22 +282,19 @@ func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
}
|
||||
|
||||
func (d *DexAuth) sessionID(r *http.Request) (string, bool) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return d.unsign(c.Value)
|
||||
}
|
||||
|
||||
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, sid string) {
|
||||
// setSessionCookie writes the signed session value as a PERSISTENT cookie
|
||||
// (Max-Age set), so it survives the browser/app being closed — a session cookie
|
||||
// (no Max-Age) was dropped on iPhone Safari close, forcing re-login. value is the
|
||||
// already-signed payload from encodeSession.
|
||||
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, value string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: d.sign(sid),
|
||||
Value: value,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: !d.insecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(d.sessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user