Skip to content

Commit d7eff17

Browse files
Auth review fixes: persist rotated tokens from polling, harden returnTo
Review feedback on #718: - /auth/token now proxies Auth.js's own /auth/session handler and forwards its Set-Cookie headers: a bare auth() in the route refreshed the access token but discarded the rotated refresh-token cookie, so an actively-used session still died when the ORIGINAL 14-day refresh token expired. - sanitizeReturnTo also rejects backslashes: browsers normalize \ to / in special-scheme URLs, so /\evil.com was a post-login open redirect despite the // check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fadead0 commit d7eff17

3 files changed

Lines changed: 54 additions & 13 deletions

File tree

app/src/app/auth/token/route.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1-
import {getServerSession} from '@/app/lib/auth';
1+
import {NextRequest} from 'next/server';
2+
import {handlers} from '@/auth';
3+
import {toClientSession} from '@/app/lib/auth';
24

35
/**
46
* Returns the current client session (fresh access token included) for client
5-
* polling. getServerSession -> auth() runs the NextAuth jwt callback, which
6-
* silently refreshes an expiring access token — and unlike server components,
7-
* route handlers CAN write cookies, so the rotated refresh token is persisted
8-
* back to the session cookie here.
7+
* polling.
8+
*
9+
* This proxies Auth.js's own /auth/session handler rather than calling auth()
10+
* bare: a bare auth() in a route handler runs the jwt callback (refreshing an
11+
* expiring token) but DISCARDS the Set-Cookie carrying the rotated pair, so
12+
* the browser would keep polling with the original refresh token until it
13+
* expired mid-session. The proxied handler's Set-Cookie headers are forwarded
14+
* so the rotation is persisted.
915
*
1016
* Responds with JSON `null` when unauthenticated or the refresh failed.
1117
*/
1218
export const dynamic = 'force-dynamic';
1319

14-
export async function GET() {
15-
const session = await getServerSession();
16-
return Response.json(session, {headers: {'Cache-Control': 'no-store'}});
20+
export async function GET(request: NextRequest) {
21+
const sessionRequest = new NextRequest(new URL('/auth/session', request.url), {
22+
headers: request.headers,
23+
});
24+
const upstream = await handlers.GET(sessionRequest);
25+
const session = upstream.ok ? await upstream.json() : null;
26+
27+
const response = Response.json(toClientSession(session), {
28+
headers: {'Cache-Control': 'no-store'},
29+
});
30+
for (const cookie of upstream.headers.getSetCookie()) {
31+
response.headers.append('Set-Cookie', cookie);
32+
}
33+
return response;
1734
}

app/src/app/lib/auth.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ export type ClientSession = {
1818
};
1919

2020
/**
21-
* Server-side session helper. Returns null when unauthenticated or when the
21+
* Map an Auth.js session (from auth() or the /auth/session endpoint's JSON)
22+
* to the serializable client shape. Null when unauthenticated or when the
2223
* silent token refresh has failed (forcing a re-login).
2324
*/
24-
export const getServerSession = async (): Promise<ClientSession | null> => {
25-
const session = await auth();
25+
export const toClientSession = (
26+
session: {
27+
user?: SessionUser;
28+
accessToken?: string;
29+
error?: string;
30+
} | null
31+
): ClientSession | null => {
2632
if (!session?.user || session.error === 'RefreshTokenError') {
2733
return null;
2834
}
@@ -35,3 +41,14 @@ export const getServerSession = async (): Promise<ClientSession | null> => {
3541
tokenSet: session.accessToken ? {accessToken: session.accessToken} : undefined,
3642
};
3743
};
44+
45+
/**
46+
* Server-side session helper. Returns null when unauthenticated or when the
47+
* silent token refresh has failed (forcing a re-login).
48+
*
49+
* NOTE: server components cannot write cookies, so a refresh that happens
50+
* here is not persisted — that is fine for rendering, but polling endpoints
51+
* must go through Auth.js's own handler instead (see app/auth/token/route.ts).
52+
*/
53+
export const getServerSession = async (): Promise<ClientSession | null> =>
54+
toClientSession(await auth());
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
/** Only allow same-origin relative redirect targets; anything else falls back to /. */
1+
/**
2+
* Only allow same-origin relative redirect targets; anything else falls back
3+
* to /. Rejects `//host` AND any backslash: browsers normalize `\` to `/`
4+
* when resolving special-scheme URLs, so `/\evil.com` is scheme-relative too.
5+
*/
26
export const sanitizeReturnTo = (returnTo: unknown): string =>
3-
typeof returnTo === 'string' && returnTo.startsWith('/') && !returnTo.startsWith('//')
7+
typeof returnTo === 'string' &&
8+
returnTo.startsWith('/') &&
9+
!returnTo.startsWith('//') &&
10+
!returnTo.includes('\\')
411
? returnTo
512
: '/';

0 commit comments

Comments
 (0)