Skip to content

Commit 76f4efa

Browse files
Merge pull request #250 from CodeForPhilly/fix/sticky-sessions
fix(auth): keep members signed in for months
2 parents 2c79750 + d6b13d9 commit 76f4efa

11 files changed

Lines changed: 233 additions & 25 deletions

File tree

‎apps/api/src/auth/cookies.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
* per specs/behaviors/authorization.md.
77
*/
88
import type { FastifyReply } from 'fastify';
9+
import { REFRESH_TTL_SECONDS } from './jwt.js';
910

1011
const COOKIE_OPTS_BASE = {
1112
httpOnly: true,
1213
sameSite: 'lax' as const,
1314
};
1415

1516
const ACCESS_TTL_MS = 15 * 60 * 1000;
16-
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
17+
const REFRESH_TTL_MS = REFRESH_TTL_SECONDS * 1000;
1718
const CLAIM_TTL_MS = 5 * 60 * 1000;
1819
const OAUTH_TTL_MS = 10 * 60 * 1000;
1920

@@ -41,6 +42,15 @@ export function setSessionCookies(
4142
path: '/api/auth/refresh',
4243
maxAge: REFRESH_TTL_MS / 1000,
4344
});
45+
46+
// Not HttpOnly and not a secret: tells the SPA a refresh is worth trying,
47+
// so anonymous visitors never call /api/auth/refresh.
48+
reply.setCookie('cfp_has_session', '1', {
49+
sameSite: 'lax',
50+
secure,
51+
path: '/',
52+
maxAge: REFRESH_TTL_MS / 1000,
53+
});
4454
}
4555

4656
export function setClaimCookie(reply: FastifyReply, token: string, nodeEnv: string): void {
@@ -55,6 +65,7 @@ export function setClaimCookie(reply: FastifyReply, token: string, nodeEnv: stri
5565
export function clearSessionCookies(reply: FastifyReply): void {
5666
reply.clearCookie('cfp_session', { path: '/' });
5767
reply.clearCookie('cfp_refresh', { path: '/api/auth/refresh' });
68+
reply.clearCookie('cfp_has_session', { path: '/' });
5869
}
5970

6071
export function clearClaimCookie(reply: FastifyReply): void {

‎apps/api/src/auth/jwt.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export interface ClaimPendingClaims {
6565

6666
const CLOCK_SKEW_SECONDS = 60;
6767
const ACCESS_TTL_SECONDS = 15 * 60;
68-
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60;
68+
/** Refresh-token lifetime: 90 days from the last refresh (specs/behaviors/authorization.md). */
69+
export const REFRESH_TTL_SECONDS = 90 * 24 * 60 * 60;
6970
const CLAIM_TTL_SECONDS = 5 * 60;
7071

7172
function keyBytes(signingKey: string): Uint8Array {

‎apps/api/src/routes/account-claim.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
UnauthenticatedError,
2929
} from '../lib/errors.js';
3030
import { requireAuth } from '../auth/guards.js';
31-
import { verifyClaimPending } from '../auth/jwt.js';
31+
import { REFRESH_TTL_SECONDS, verifyClaimPending } from '../auth/jwt.js';
3232
import {
3333
clearClaimCookie,
3434
setSessionCookies,
@@ -60,7 +60,7 @@ async function persistSession(
6060
userAgent: String(request.headers['user-agent'] ?? ''),
6161
ipAddress: clientIp(request),
6262
issuedAt: new Date(now).toISOString(),
63-
expiresAt: new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString(),
63+
expiresAt: new Date(now + REFRESH_TTL_SECONDS * 1000).toISOString(),
6464
};
6565
await fastify.sessionMetadata.add(meta, fastify.store.private);
6666
}

‎apps/api/src/routes/auth.ts‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
1515
import { errors as JoseErrors } from 'jose';
1616
import { ok } from '../lib/response.js';
1717
import { UnauthenticatedError, ConflictError, ApiNotFoundError } from '../lib/errors.js';
18-
import { verifyRefresh, issueSession } from '../auth/jwt.js';
18+
import { REFRESH_TTL_SECONDS, verifyRefresh, issueSession } from '../auth/jwt.js';
1919
import {
2020
setSessionCookies,
2121
clearSessionCookies,
@@ -95,7 +95,7 @@ async function persistSessionMetadata(
9595
userAgent: String(request.headers['user-agent'] ?? ''),
9696
ipAddress: clientIp(request),
9797
issuedAt: new Date(now).toISOString(),
98-
expiresAt: new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString(),
98+
expiresAt: new Date(now + REFRESH_TTL_SECONDS * 1000).toISOString(),
9999
};
100100
await fastify.sessionMetadata.add(meta, fastify.store.private);
101101
}
@@ -691,14 +691,12 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
691691
claims.loginMethod ? { loginMethod: claims.loginMethod } : undefined,
692692
);
693693

694-
const oldExpiresAt = new Date(claims.exp * 1000).toISOString();
695-
await fastify.revocations.revoke(
696-
{ jti: claims.jti, personId: claims.sub, expiresAt: oldExpiresAt },
697-
fastify.store.public,
698-
);
694+
// No revocation of the old refresh token: refresh writes nothing to the
695+
// public store (specs/behaviors/authorization.md → "Refresh writes
696+
// nothing to the public store"). It stays valid until it expires.
699697
await fastify.sessionMetadata.remove(claims.jti, fastify.store.private);
700698

701-
const newExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
699+
const newExpiresAt = new Date(Date.now() + REFRESH_TTL_SECONDS * 1000).toISOString();
702700
const newMeta: SessionMeta = {
703701
refreshJti: newTokens.refreshJti,
704702
personId: claims.sub,

‎apps/api/tests/auth.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,16 @@ describe('POST /api/auth/refresh', () => {
293293
expect(hasSession).toBe(true);
294294
expect(hasRefresh).toBe(true);
295295

296-
// Old refresh jti should now be revoked
297-
expect(app.revocations.isRevoked(refreshJti)).toBe(true);
296+
// 90 days from now, and the hint cookie that tells the SPA to refresh.
297+
const refreshCookie = cookiesArr.find((c) => c.startsWith('cfp_refresh='))!;
298+
expect(refreshCookie).toContain(`Max-Age=${90 * 24 * 60 * 60}`);
299+
const hint = cookiesArr.find((c) => c.startsWith('cfp_has_session=1'));
300+
expect(hint).toBeDefined();
301+
expect(hint).not.toMatch(/HttpOnly/i);
302+
303+
// Refresh writes nothing to the public store: the old jti is not revoked
304+
// (specs/behaviors/authorization.md).
305+
expect(app.revocations.isRevoked(refreshJti)).toBe(false);
298306
});
299307
});
300308

@@ -338,6 +346,7 @@ describe('POST /api/auth/logout', () => {
338346
: String(setCookieHeaders ?? '');
339347
expect(cookieStr).toContain('cfp_session=;');
340348
expect(cookieStr).toContain('cfp_refresh=;');
349+
expect(cookieStr).toContain('cfp_has_session=;');
341350
});
342351

343352
it('subsequent /api/auth/me after logout returns anonymous', async () => {

‎apps/web/src/hooks/useAuth.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useState,
88
type ReactNode,
99
} from 'react';
10+
import { hasSessionHint, refreshSession } from '@/lib/api';
1011

1112
export type AccountLevel = 'anonymous' | 'user' | 'staff' | 'administrator';
1213

@@ -60,7 +61,7 @@ const ANON_SNAPSHOT: MeSnapshot = {
6061
lastLoginMethod: null,
6162
};
6263

63-
async function fetchMe(): Promise<MeSnapshot> {
64+
async function fetchMeOnce(): Promise<MeSnapshot> {
6465
try {
6566
const res = await fetch('/api/auth/me', { credentials: 'include' });
6667
if (!res.ok) {
@@ -79,6 +80,17 @@ async function fetchMe(): Promise<MeSnapshot> {
7980
}
8081
}
8182

83+
/**
84+
* The signed-in state, renewing an expired access cookie first when the
85+
* session hint says there is a refresh cookie to renew it from
86+
* (specs/behaviors/authorization.md → "The SPA keeps the session alive").
87+
*/
88+
async function fetchMe(): Promise<MeSnapshot> {
89+
const snap = await fetchMeOnce();
90+
if (snap.person || !hasSessionHint()) return snap;
91+
return (await refreshSession()) ? fetchMeOnce() : snap;
92+
}
93+
8294
export function AuthProvider({ children }: { children: ReactNode }) {
8395
const [snapshot, setSnapshot] = useState<MeSnapshot>(ANON_SNAPSHOT);
8496
const [loading, setLoading] = useState(true);

‎apps/web/src/lib/api.ts‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,30 @@ function buildQuery(params: object): string {
502502
return s ? `?${s}` : '';
503503
}
504504

505+
// ---------------------------------------------------------------------------
506+
// Session refresh — specs/behaviors/authorization.md → "The SPA keeps the
507+
// session alive". The access cookie lives 15 minutes; the refresh cookie
508+
// 90 days. Concurrent callers share one in-flight refresh.
509+
// ---------------------------------------------------------------------------
510+
511+
let refreshInFlight: Promise<boolean> | null = null;
512+
513+
/** Whether the server left the session hint cookie (set with the refresh cookie). */
514+
export function hasSessionHint(): boolean {
515+
return typeof document !== 'undefined' && /(?:^|;\s*)cfp_has_session=1(?:;|$)/.test(document.cookie);
516+
}
517+
518+
/** Mint a fresh session from the refresh cookie. Resolves true on success. */
519+
export function refreshSession(): Promise<boolean> {
520+
refreshInFlight ??= fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
521+
.then((res) => res.ok)
522+
.catch(() => false)
523+
.finally(() => {
524+
refreshInFlight = null;
525+
});
526+
return refreshInFlight;
527+
}
528+
505529
async function request<T>(path: string, init?: RequestInit): Promise<T> {
506530
const headers: Record<string, string> = {
507531
Accept: 'application/json',
@@ -517,11 +541,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
517541
) {
518542
headers['Content-Type'] = 'application/json';
519543
}
520-
const res = await fetch(path, {
521-
credentials: 'include',
522-
...init,
523-
headers,
524-
});
544+
const send = () => fetch(path, { credentials: 'include', ...init, headers });
545+
let res = await send();
546+
// An expired access cookie: refresh once and retry. Auth endpoints answer
547+
// 401 for their own reasons and are never retried.
548+
if (res.status === 401 && !path.startsWith('/api/auth/') && (await refreshSession())) {
549+
res = await send();
550+
}
525551

526552
// 204 No Content
527553
if (res.status === 204) {

‎apps/web/tests/useAuth.test.tsx‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,71 @@ describe('useAuth', () => {
100100
});
101101
});
102102
});
103+
104+
describe('session refresh (specs/behaviors/authorization.md → "The SPA keeps the session alive")', () => {
105+
const me = (person: unknown) =>
106+
new Response(JSON.stringify({ success: true, data: { person, accountLevel: person ? 'user' : 'anonymous' } }), {
107+
status: 200,
108+
headers: { 'content-type': 'application/json' },
109+
});
110+
const PERSON = { id: 'p1', slug: 'jane', fullName: 'Jane Doe', avatarUrl: null, accountLevel: 'user' };
111+
112+
afterEach(() => {
113+
document.cookie = 'cfp_has_session=; max-age=0; path=/';
114+
vi.restoreAllMocks();
115+
});
116+
117+
it('refreshes on load when the hint cookie says a session exists', async () => {
118+
document.cookie = 'cfp_has_session=1; path=/';
119+
let signedIn = false;
120+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
121+
const url = String(input);
122+
if (url === '/api/auth/refresh') {
123+
signedIn = true;
124+
return new Response(JSON.stringify({ success: true, data: null }), { status: 200 });
125+
}
126+
return me(signedIn ? PERSON : null);
127+
});
128+
129+
renderWithRouter(<Wrapped />);
130+
expect(await screen.findByTestId('user')).toHaveTextContent('Jane Doe');
131+
expect(fetchMock.mock.calls.filter(([u]) => String(u) === '/api/auth/refresh')).toHaveLength(1);
132+
});
133+
134+
it('never refreshes for an anonymous visitor without the hint', async () => {
135+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => me(null));
136+
137+
renderWithRouter(<Wrapped />);
138+
expect(await screen.findByTestId('anon')).toBeInTheDocument();
139+
expect(fetchMock.mock.calls.some(([u]) => String(u) === '/api/auth/refresh')).toBe(false);
140+
});
141+
142+
it('retries an API call once after refreshing on a 401', async () => {
143+
const { api } = await import('../src/lib/api.js');
144+
let refreshed = false;
145+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
146+
const url = String(input);
147+
if (url === '/api/auth/refresh') {
148+
refreshed = true;
149+
return new Response(JSON.stringify({ success: true, data: null }), { status: 200 });
150+
}
151+
if (!refreshed) {
152+
return new Response(JSON.stringify({ success: false, error: { code: 'unauthenticated', message: 'no' } }), {
153+
status: 401,
154+
headers: { 'content-type': 'application/json' },
155+
});
156+
}
157+
return new Response(JSON.stringify({ success: true, data: [], metadata: { totalItems: 0 } }), {
158+
status: 200,
159+
headers: { 'content-type': 'application/json' },
160+
});
161+
});
162+
163+
await api.projects.list({ perPage: 1 });
164+
expect(fetchMock.mock.calls.map(([u]) => String(u))).toEqual([
165+
'/api/projects?perPage=1',
166+
'/api/auth/refresh',
167+
'/api/projects?perPage=1',
168+
]);
169+
});
170+
});

‎plans/sticky-sessions.md‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
status: done
3+
depends: []
4+
specs:
5+
- specs/behaviors/authorization.md
6+
- specs/api/conventions.md
7+
issues: []
8+
pr: 250
9+
---
10+
11+
# Plan: Sessions that stick for months
12+
13+
## Scope
14+
15+
Chris (2026-10-15): "I get logged out soooo quick. I want the login to be
16+
super sticky like weeks/months." The spec already promised a 30-day
17+
sliding session, but the SPA never called `POST /api/auth/refresh`, so
18+
every session ended when the 15-minute access cookie expired. And the
19+
refresh endpoint revoked the old refresh token with a public commit,
20+
which the spec rules out ("no per-request writes anywhere").
21+
22+
**In:** refresh TTL 90 days, sliding; a non-secret `cfp_has_session` hint
23+
cookie set and cleared with the refresh cookie; the SPA refreshes on load
24+
when the hint is present and `/me` says anonymous, and on any 401 (once,
25+
single-flight, then retries); refresh no longer writes a revocation.
26+
27+
**Out:** refresh-token reuse detection (still deferred per the spec);
28+
longer access tokens.
29+
30+
## Implements
31+
32+
- [behaviors/authorization.md](../specs/behaviors/authorization.md#session-model--stateless-jwts)
33+
- [api/conventions.md](../specs/api/conventions.md) — cookie list.
34+
35+
## Approach
36+
37+
1. `REFRESH_TTL_SECONDS` (90 days) exported from `auth/jwt.ts`, used by the
38+
JWT, the cookie and the session metadata.
39+
2. `setSessionCookies` / `clearSessionCookies` handle `cfp_has_session`.
40+
3. `refreshSession()` + `hasSessionHint()` in `lib/api.ts`; `request()`
41+
retries after a refresh on 401 (not for `/api/auth/*`); `useAuth`
42+
refreshes on load when hinted.
43+
44+
## Validation
45+
46+
- [x] `npm run type-check && npm run lint && npm test` clean.
47+
- [x] API test: refresh sets a 90-day refresh cookie and the hint, writes no
48+
revocation; logout clears the hint.
49+
- [x] Web tests: load-time refresh only with the hint; a 401 refreshes once
50+
and retries.
51+
- [x] Devbox: with the access cookie gone and the refresh cookie kept, a
52+
reload comes back signed in.
53+
54+
## Risks / unknowns
55+
56+
- Members signed in before this ships have no hint cookie: their next
57+
signed-in action still recovers through the 401 path, and the first
58+
successful refresh sets the hint.
59+
60+
## Notes
61+
62+
- Devbox check (curl, the SPA's sequence): password sign-in set
63+
`cfp_session`, `cfp_refresh` (90 days) and `cfp_has_session`; with the
64+
access cookie dropped `/me` was anonymous, `POST /api/auth/refresh`
65+
returned 200, and `/me` was signed in again. No data-repo commit came
66+
from the refresh. The SPA's side is covered by the web tests.
67+
- Before this, the refresh endpoint revoked the old token with a public
68+
commit on every refresh; it never fired in practice because nothing
69+
called it.
70+
71+
## Follow-ups
72+
73+
- **None.**

‎specs/api/conventions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ For paginated lists, the `data` is an array and `metadata` includes pagination:
7171

7272
## Authentication
7373

74-
- Two cookies carry **stateless JWTs**: `cfp_session` (15-minute access JWT) and `cfp_refresh` (30-day refresh JWT, path-scoped to `/api/auth/refresh`). Cleared by `POST /api/auth/logout`. See [behaviors/authorization.md](../behaviors/authorization.md) for the full token model and [api/auth.md](auth.md) for the surviving session endpoints.
74+
- Two cookies carry **stateless JWTs**: `cfp_session` (15-minute access JWT) and `cfp_refresh` (90-day refresh JWT, path-scoped to `/api/auth/refresh`), plus the non-secret `cfp_has_session` hint. Cleared by `POST /api/auth/logout`. See [behaviors/authorization.md](../behaviors/authorization.md) for the full token model and [api/auth.md](auth.md) for the surviving session endpoints.
7575
- Both cookies: `HttpOnly`, `Secure`, `SameSite=Lax`. In development, `Secure` is dropped when the host is `localhost`.
7676
- There is no `sessions` table or sheet. Revocation is tracked in a small `revocations` sheet plus an in-memory `Set` of revoked `jti`s.
7777
- The endpoints that *issue* JWTs (GitHub OAuth callback, account-claim flow) are not yet specified. The endpoints that *manage* JWTs once issued are in [api/auth.md](auth.md).

0 commit comments

Comments
 (0)