Skip to content

Commit 28973c6

Browse files
feat(anonymous-sessions): add anonymous sessions support (EA)
Add server-side anonymous sessions: mint a session before login, carry set-once metadata, read it from Server Components, Route Handlers, and a client hook, and link it to the real user at callback under a fixation-safe binding. Additive and off by default (anonymousSession.enabled). Public API: createAnonymousSession(), getAnonymousSession(), useAnonymousSession() hook, Auth0Provider props (anonymousSession, anonymousSessionRoute). New config anonymousSession { enabled, audience, scope, cookie } and routes /auth/anonymous-session[/logout]. Encrypted auth0_anon cookie (chunked when large), error-driven silent renewal, set-once metadata with a 1KB cap. New AnonymousSessionError plus getStatusForAnonymousError. Three-layer session-fixation mitigation: strip caller session_token before authorize, inject only from the SDK's own encrypted cookie, bind a digest to the login transaction and re-verify at callback (anonymousSessionLinked). Read path never throws; create throws so callers can surface failures. Includes docs/anonymous-sessions.md and a README link. 122 dedicated unit and integration tests; tsc and eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f5683ab commit 28973c6

23 files changed

Lines changed: 6156 additions & 33 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The Auth0 Next.js SDK is a library for implementing user authentication in Next.
1313

1414
- [QuickStart](https://auth0.com/docs/quickstart/webapp/nextjs) - our guide for adding Auth0 to your Next.js app.
1515
- [Examples](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md) - lots of examples for your different use cases.
16+
- [Anonymous Sessions](./docs/anonymous-sessions.md) - enable pre-login identity with access tokens and metadata.
1617
- [Security](https://github.com/auth0/nextjs-auth0/blob/main/SECURITY.md) - Some important security notices that you should check.
1718
- [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0.
1819

docs/anonymous-sessions.md

Lines changed: 794 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
8+
import type { AnonymousSession } from "../../types/index.js";
9+
import { useAnonymousSession } from "./use-anonymous-session.js";
10+
11+
describe("M4 BLOCKER: FR-3 useAnonymousSession Hook REAL execution", () => {
12+
const mockSession: AnonymousSession = {
13+
id: "anon@uuid-1234",
14+
accessToken: "bearer-token-xyz",
15+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
16+
metadata: { cart: { qty: 5 } }
17+
};
18+
19+
beforeEach(() => {
20+
vi.clearAllMocks();
21+
// Clear global fetch mock before each test
22+
global.fetch = vi.fn();
23+
});
24+
25+
afterEach(() => {
26+
// Clean up React components and SWR cache after each test
27+
cleanup();
28+
});
29+
30+
describe("Hook with REAL SWR execution", () => {
31+
it("M4: Hook returns 200 → {anonymous: session, isLoading: false}", async () => {
32+
// Mock fetch to return 200 with session
33+
global.fetch = vi.fn().mockResolvedValue({
34+
ok: true,
35+
status: 200,
36+
json: async () => mockSession
37+
});
38+
39+
// Use unique route per test to avoid SWR cache collision
40+
const { result } = renderHook(() =>
41+
useAnonymousSession({ route: "/test/anon-200" })
42+
);
43+
44+
// Wait for SWR to fetch (SWR may skip loading state if data arrives fast)
45+
await waitFor(() => {
46+
expect(result.current.anonymous).toEqual(mockSession);
47+
});
48+
49+
// ASSERT: session loaded
50+
expect(result.current.error).toBeNull();
51+
expect(global.fetch).toHaveBeenCalled();
52+
});
53+
54+
it("M4: Hook returns 204 → {anonymous: null, isLoading: false}", async () => {
55+
// Mock fetch to return 204 (no session)
56+
global.fetch = vi.fn().mockResolvedValue({
57+
ok: true,
58+
status: 204
59+
});
60+
61+
const { result } = renderHook(() =>
62+
useAnonymousSession({ route: "/test/anon-204" })
63+
);
64+
65+
await waitFor(() => {
66+
expect(result.current.isLoading).toBe(false);
67+
});
68+
69+
// ASSERT: no session (null)
70+
expect(result.current.anonymous).toBeNull();
71+
expect(result.current.error).toBeNull();
72+
});
73+
74+
it("M4: Hook fetch error → {error: Error, anonymous: null, isLoading: false}", async () => {
75+
// Mock fetch to return error
76+
global.fetch = vi.fn().mockResolvedValue({
77+
ok: false,
78+
status: 500
79+
});
80+
81+
const { result } = renderHook(() =>
82+
useAnonymousSession({ route: "/test/anon-error" })
83+
);
84+
85+
await waitFor(
86+
() => {
87+
expect(result.current.error).toBeTruthy();
88+
},
89+
{ timeout: 3000 }
90+
);
91+
92+
// ASSERT: error state
93+
expect(result.current.error).toBeInstanceOf(Error);
94+
expect(result.current.anonymous).toBeNull();
95+
expect(result.current.isLoading).toBe(false);
96+
});
97+
98+
it("M4: isLoading false after data loads", async () => {
99+
global.fetch = vi.fn().mockResolvedValue({
100+
ok: true,
101+
status: 200,
102+
json: async () => mockSession
103+
});
104+
105+
const { result } = renderHook(() =>
106+
useAnonymousSession({ route: "/test/anon-loading" })
107+
);
108+
109+
// Wait for load to complete
110+
await waitFor(() => {
111+
expect(result.current.isLoading).toBe(false);
112+
});
113+
114+
expect(result.current.anonymous).toEqual(mockSession);
115+
});
116+
117+
it("M4: invalidate() triggers SWR revalidation", async () => {
118+
let callCount = 0;
119+
global.fetch = vi.fn().mockImplementation(async () => {
120+
callCount++;
121+
return {
122+
ok: true,
123+
status: 200,
124+
json: async () => ({
125+
...mockSession,
126+
id: `anon@call-${callCount}`
127+
})
128+
};
129+
});
130+
131+
const { result } = renderHook(() =>
132+
useAnonymousSession({ route: "/test/anon-invalidate" })
133+
);
134+
135+
// Wait for initial fetch
136+
await waitFor(() => {
137+
expect(callCount).toBeGreaterThanOrEqual(1);
138+
});
139+
140+
const firstId = result.current.anonymous?.id;
141+
142+
// Call invalidate to trigger refetch
143+
await act(async () => {
144+
result.current.invalidate();
145+
});
146+
147+
// Wait for ID to change (React render commit)
148+
await waitFor(() => {
149+
expect(result.current.anonymous?.id).not.toBe(firstId);
150+
});
151+
152+
// Verify refetch happened
153+
expect(callCount).toBeGreaterThanOrEqual(2);
154+
});
155+
156+
it("M4: Hook uses custom route from options", async () => {
157+
global.fetch = vi.fn().mockResolvedValue({
158+
ok: true,
159+
status: 200,
160+
json: async () => mockSession
161+
});
162+
163+
renderHook(() => useAnonymousSession({ route: "/custom/anon-route" }));
164+
165+
await waitFor(() => {
166+
expect(global.fetch).toHaveBeenCalled();
167+
});
168+
169+
// Verify fetch was called with custom route
170+
const fetchCall = (global.fetch as any).mock.calls[0];
171+
expect(fetchCall[0]).toContain("/custom/anon-route");
172+
});
173+
174+
it("M4: Hook returns correct shape {anonymous, isLoading, error, invalidate}", async () => {
175+
global.fetch = vi.fn().mockResolvedValue({
176+
ok: true,
177+
status: 200,
178+
json: async () => mockSession
179+
});
180+
181+
const { result } = renderHook(() => useAnonymousSession());
182+
183+
await waitFor(() => {
184+
expect(result.current.isLoading).toBe(false);
185+
});
186+
187+
// ASSERT: shape matches contract
188+
expect(result.current).toHaveProperty("anonymous");
189+
expect(result.current).toHaveProperty("isLoading");
190+
expect(result.current).toHaveProperty("error");
191+
expect(result.current).toHaveProperty("invalidate");
192+
expect(typeof result.current.invalidate).toBe("function");
193+
});
194+
});
195+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"use client";
2+
3+
import useSWR from "swr";
4+
5+
import type {
6+
AnonymousSession,
7+
UseAnonymousSessionOptions
8+
} from "../../types/index.js";
9+
import { normalizeWithBasePath } from "../../utils/pathUtils.js";
10+
11+
/**
12+
* Fetch the anonymous session from the read route.
13+
*
14+
* The route answers 204 with an empty body when there is no session, which maps
15+
* to null, and 200 with the session object otherwise. Any other status is a
16+
* failure the hook surfaces through `error`.
17+
*
18+
* The return type is declared rather than inferred so the session case is a
19+
* concrete value rather than the `any` that `Response.json()` produces. That is
20+
* what keeps `null` from being the only value a reader (human or static analysis)
21+
* can see the fetcher resolve to.
22+
*/
23+
async function fetchAnonymousSession(
24+
route: string
25+
): Promise<AnonymousSession | null> {
26+
const res = await fetch(route);
27+
28+
if (!res.ok) {
29+
throw new Error("Failed to load anonymous session");
30+
}
31+
32+
// 204 No Content → null (no session)
33+
if (res.status === 204) {
34+
return null;
35+
}
36+
37+
// 200 + JSON → return session object
38+
return (await res.json()) as AnonymousSession;
39+
}
40+
41+
/**
42+
* Client hook: fetch and cache anonymous session via SWR.
43+
* Mirrors the useUser() pattern.
44+
*
45+
* Returns:
46+
* - anonymous: AnonymousSession | null (null if no session or fetch error)
47+
* - isLoading: boolean (false when error or data loaded)
48+
* - error: Error | null (populated on fetch error)
49+
* - invalidate: () => void (trigger SWR revalidate)
50+
*
51+
* Uses SWR key: resolved route (option, env var, or default "/auth/anonymous-session")
52+
* Fetcher: standard fetch, returns null on 204 status, throws on !ok
53+
*
54+
* Test: T7 (hook + provider)
55+
*/
56+
export function useAnonymousSession(options: UseAnonymousSessionOptions = {}): {
57+
anonymous: AnonymousSession | null;
58+
isLoading: boolean;
59+
error: Error | null;
60+
invalidate: () => void;
61+
} {
62+
// Resolve SWR key (route path, normalized with basePath)
63+
const route = normalizeWithBasePath(
64+
options.route ||
65+
process.env.NEXT_PUBLIC_ANONYMOUS_SESSION_ROUTE ||
66+
"/auth/anonymous-session"
67+
);
68+
69+
// Fetch via SWR with standard error handling
70+
const { data, error, isLoading, mutate } = useSWR<
71+
AnonymousSession | null,
72+
Error,
73+
string
74+
>(route, fetchAnonymousSession);
75+
76+
// Return shape matching useUser() pattern
77+
if (error) {
78+
return {
79+
anonymous: null,
80+
isLoading: false,
81+
error,
82+
invalidate: () => mutate()
83+
};
84+
}
85+
86+
// The fetcher resolves to null for a 204, so `data` carries three distinct
87+
// states: undefined while the first request is in flight, null once the route
88+
// has answered that there is no session, and the session object otherwise. A
89+
// truthiness test cannot tell the first two apart, and it reads as a test that
90+
// can never succeed to a reader that only sees the null-returning branch of the
91+
// fetcher. Comparing against undefined names the state that is actually being
92+
// asked about, and every branch below is reachable for some value of `data`.
93+
const hasLoaded = data !== undefined;
94+
95+
return {
96+
anonymous: data ?? null,
97+
isLoading: hasLoaded ? false : isLoading,
98+
error: null,
99+
invalidate: () => mutate()
100+
};
101+
}

src/client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { useUser, type UseUserOptions } from "./hooks/use-user.js";
2+
export { useAnonymousSession } from "./hooks/use-anonymous-session.js";
23
export {
34
getAccessToken,
45
type AccessTokenOptions
@@ -17,3 +18,4 @@ export type { ChallengeWithPopupOptions } from "./mfa/index.js";
1718
export type { AccessTokenResponse } from "./helpers/get-access-token.js";
1819
export { passwordless } from "./passwordless/index.js";
1920
export { passkey, serializeCredential } from "./passkey/index.js";
21+
export type { UseAnonymousSessionOptions } from "../types/index.js";

0 commit comments

Comments
 (0)