History

refactor: move anonymous auth tokens from localStorage to httpOnly cookies

Replace client-side localStorage token persistence with server-managed httpOnly cookies. The token route now accepts accountId/forceRefresh, returns cached tokens from cookies when valid, and supports DELETE for cookie cleanup. Client-side code uses in-memory cache only.

Rhys Sullivan committed 8 months ago

Commit 56b73f91 parent aadcaef2 files · +166 −60Browse files

Unified · Split

2 files
  1. executor/apps/web/src/app/api/auth/anonymous-token/route.ts +134 −11
  2. executor/apps/web/src/lib/anonymous-auth.ts +32 −49
executor/apps/web/src/app/api/auth/anonymous-token/route.ts+134−11
@@ -1,11 +1,29 @@
11 import { importPKCS8, SignJWT } from "jose";
22 import { z } from "zod";
3+import { appendDeleteCookie, appendSetCookie, readCookie } from "@/lib/http/cookies";
34
45 const anonymousAuthAudience = "executor-anonymous";
56 const anonymousAuthKeyId = "executor-anonymous-es256";
67 const anonymousAuthTokenTtlSeconds = 60 * 60 * 24 * 7;
8+const anonymousAuthCookieName = "executor_anonymous_auth";
9+const anonymousAuthCookieVersion = 1;
10+const tokenRefreshSkewMs = 60_000;
711 const configQueryTimeoutMs = 2_000;
812
13+const issueAnonymousTokenRequestSchema = z.object({
14+ accountId: z.string().trim().min(1).optional(),
15+ forceRefresh: z.boolean().optional(),
16+}).partial();
17+
18+const anonymousAuthCookieSchema = z.object({
19+ version: z.literal(anonymousAuthCookieVersion),
20+ accessToken: z.string().min(1),
21+ accountId: z.string().min(1),
22+ expiresAtMs: z.number().finite(),
23+});
24+
25+type AnonymousAuthCookiePayload = z.infer<typeof anonymousAuthCookieSchema>;
26+
927 function trimOrNull(value: string | undefined): string | null {
1028 const trimmed = value?.trim();
1129 return trimmed && trimmed.length > 0 ? trimmed : null;
@@ -109,6 +127,75 @@ function parseTokenTtlSeconds(raw: string | undefined): number { · 97 unmodified lines
109127 return Number.isFinite(parsed) && parsed > 0 ? parsed : anonymousAuthTokenTtlSeconds;
110128 }
111129
130+function parseIssueRequestBody(raw: unknown): z.infer<typeof issueAnonymousTokenRequestSchema> {
131+ const parsed = issueAnonymousTokenRequestSchema.safeParse(raw);
132+ if (!parsed.success) {
133+ throw new Error("Invalid anonymous token request payload");
134+ }
135+ return parsed.data;
136+}
137+
138+function encodeAnonymousAuthCookie(payload: AnonymousAuthCookiePayload): string {
139+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
140+}
141+
142+function decodeAnonymousAuthCookie(raw: string): AnonymousAuthCookiePayload | null {
143+ try {
144+ const decoded = Buffer.from(raw, "base64url").toString("utf8");
145+ const parsed = JSON.parse(decoded);
146+ const payload = anonymousAuthCookieSchema.safeParse(parsed);
147+ return payload.success ? payload.data : null;
148+ } catch {
149+ return null;
150+ }
151+}
152+
153+function isAnonymousAuthCookieUsable(payload: AnonymousAuthCookiePayload): boolean {
154+ return Date.now() + tokenRefreshSkewMs < payload.expiresAtMs;
155+}
156+
157+function readAnonymousAuthCookie(request: Request): AnonymousAuthCookiePayload | null {
158+ const rawCookie = readCookie(request, anonymousAuthCookieName);
159+ if (!rawCookie) {
160+ return null;
161+ }
162+
163+ const decoded = decodeAnonymousAuthCookie(rawCookie);
164+ if (!decoded || !isAnonymousAuthCookieUsable(decoded)) {
165+ return null;
166+ }
167+
168+ return decoded;
169+}
170+
171+function shouldUseSecureCookies(request: Request): boolean {
172+ const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase();
173+ if (forwardedProto) {
174+ return forwardedProto === "https";
175+ }
176+ return new URL(request.url).protocol === "https:";
177+}
178+
179+function appendAnonymousAuthCookie(request: Request, headers: Headers, payload: AnonymousAuthCookiePayload) {
180+ const ttlSeconds = Math.max(0, Math.floor((payload.expiresAtMs - Date.now()) / 1000));
181+ appendSetCookie(headers, anonymousAuthCookieName, encodeAnonymousAuthCookie(payload), {
182+ path: "/",
183+ maxAge: ttlSeconds,
184+ httpOnly: true,
185+ secure: shouldUseSecureCookies(request),
186+ sameSite: "lax",
187+ });
188+}
189+
190+function appendAnonymousAuthCookieDeletion(request: Request, headers: Headers) {
191+ appendDeleteCookie(headers, anonymousAuthCookieName, {
192+ path: "/",
193+ httpOnly: true,
194+ secure: shouldUseSecureCookies(request),
195+ sameSite: "lax",
196+ });
197+}
198+
112199 function noStoreJson(payload: unknown, status: number): Response {
113200 return Response.json(payload, {
114201 status,
@@ -131,14 +218,39 @@ function createAccountId(): string { · 16 unmodified lines
131218 return `anon_${crypto.randomUUID().replace(/-/g, "")}`;
132219 }
133220
134−export async function POST(): Promise<Response> {
221+export async function POST(request: Request): Promise<Response> {
222+ let parsedBody: z.infer<typeof issueAnonymousTokenRequestSchema>;
223+ try {
224+ parsedBody = parseIssueRequestBody(await request.json().catch(() => ({})));
225+ } catch (error) {
226+ return noStoreJson(
227+ { error: error instanceof Error ? error.message : "Invalid anonymous token request payload" },
228+ 400,
229+ );
230+ }
231+
232+ if (!parsedBody.forceRefresh) {
233+ const existing = readAnonymousAuthCookie(request);
234+ if (existing && (!parsedBody.accountId || parsedBody.accountId === existing.accountId)) {
235+ return noStoreJson(
236+ {
237+ tokenType: "Bearer",
238+ accessToken: existing.accessToken,
239+ accountId: existing.accountId,
240+ expiresAtMs: existing.expiresAtMs,
241+ },
242+ 200,
243+ );
244+ }
245+ }
246+
135247 const issuer = await resolveIssuerFromBackend() ?? resolveIssuer();
136248 const privateKeyPem = normalizePem(process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM);
137249 if (!issuer || !privateKeyPem) {
138250 return noStoreJson({ error: "Anonymous auth is not configured" }, 503);
139251 }
140252
141− const accountId = createAccountId();
253+ const accountId = parsedBody.accountId ?? createAccountId();
142254
143255 try {
144256 const nowSeconds = Math.floor(Date.now() / 1000);
@@ -155,15 +267,20 @@ export async function POST(): Promise<Response> { · 10 unmodified lines
155267 .setExpirationTime(expiresAtSeconds)
156268 .sign(signingKey);
157269
158− return noStoreJson(
159− {
160− tokenType: "Bearer",
161− accessToken,
162− accountId,
163− expiresAtMs: expiresAtSeconds * 1000,
164− },
165− 200,
166− );
270+ const payload = {
271+ tokenType: "Bearer",
272+ accessToken,
273+ accountId,
274+ expiresAtMs: expiresAtSeconds * 1000,
275+ };
276+ const response = noStoreJson(payload, 200);
277+ appendAnonymousAuthCookie(request, response.headers, {
278+ version: anonymousAuthCookieVersion,
279+ accessToken,
280+ accountId,
281+ expiresAtMs: payload.expiresAtMs,
282+ });
283+ return response;
167284 } catch (error) {
168285 return noStoreJson(
169286 { error: error instanceof Error ? error.message : "Failed to issue anonymous token" },
@@ -171,3 +288,9 @@ export async function POST(): Promise<Response> { · 1 unmodified line
171288 );
172289 }
173290 }
291+
292+export async function DELETE(request: Request): Promise<Response> {
293+ const response = noStoreJson({ ok: true }, 200);
294+ appendAnonymousAuthCookieDeletion(request, response.headers);
295+ return response;
296+}
executor/apps/web/src/lib/anonymous-auth.ts+32−49
@@ -4,86 +4,69 @@ export type AnonymousAuthToken = { · 3 unmodified lines
44 expiresAtMs: number;
55 };
66
7−const TOKEN_STORAGE_KEY = "executor_anonymous_access_token";
8−const ACCOUNT_STORAGE_KEY = "executor_anonymous_account_id";
9−const EXPIRES_STORAGE_KEY = "executor_anonymous_token_expires_at";
107 const TOKEN_EXPIRY_SKEW_MS = 60_000;
8+let cachedToken: AnonymousAuthToken | null = null;
9+let cachedAccountId: string | null = null;
1110
1211 function canUseStorage(): boolean {
1312 return typeof window !== "undefined";
1413 }
1514
16−function readStorageValue(key: string): string | null {
17− if (!canUseStorage()) {
18− return null;
19− }
20− return localStorage.getItem(key);
15+function isTokenUsable(token: AnonymousAuthToken): boolean {
16+ return Date.now() + TOKEN_EXPIRY_SKEW_MS < token.expiresAtMs;
2117 }
2218
23−function writeStorageValue(key: string, value: string) {
24− if (!canUseStorage()) {
25− return;
26− }
27− localStorage.setItem(key, value);
19+function clearTokenCache() {
20+ cachedToken = null;
2821 }
2922
3023 export function clearAnonymousAuth(options?: { clearAccount?: boolean }) {
31− if (!canUseStorage()) {
32− return;
24+ clearTokenCache();
25+ if (options?.clearAccount) {
26+ cachedAccountId = null;
3327 }
3428
35− localStorage.removeItem(TOKEN_STORAGE_KEY);
36− if (options?.clearAccount) {
37− localStorage.removeItem(ACCOUNT_STORAGE_KEY);
29+ if (canUseStorage()) {
30+ void fetch("/api/auth/anonymous-token", {
31+ method: "DELETE",
32+ cache: "no-store",
33+ keepalive: true,
34+ }).catch(() => {});
3835 }
39− localStorage.removeItem(EXPIRES_STORAGE_KEY);
4036 }
4137
4238 function persistAnonymousAuth(token: AnonymousAuthToken) {
43− writeStorageValue(TOKEN_STORAGE_KEY, token.accessToken);
44− writeStorageValue(ACCOUNT_STORAGE_KEY, token.accountId);
45− writeStorageValue(EXPIRES_STORAGE_KEY, String(token.expiresAtMs));
46−}
47−
48−function readStoredAccountId(): string | null {
49− const raw = readStorageValue(ACCOUNT_STORAGE_KEY);
50− return raw && raw.trim().length > 0 ? raw.trim() : null;
39+ cachedToken = token;
40+ cachedAccountId = token.accountId;
5141 }
5242
5343 export function readStoredAnonymousAuthToken(): AnonymousAuthToken | null {
54− const accessToken = readStorageValue(TOKEN_STORAGE_KEY);
55− const accountId = readStoredAccountId();
56− const expiresAtRaw = readStorageValue(EXPIRES_STORAGE_KEY);
57−
58− if (!accessToken || !accountId || !expiresAtRaw) {
59− return null;
60− }
61−
62− const expiresAtMs = Number.parseInt(expiresAtRaw, 10);
63− if (!Number.isFinite(expiresAtMs)) {
64− clearAnonymousAuth();
44+ if (!cachedToken) {
6545 return null;
6646 }
6747
68− if (Date.now() + TOKEN_EXPIRY_SKEW_MS >= expiresAtMs) {
69− clearAnonymousAuth();
48+ if (!isTokenUsable(cachedToken)) {
49+ clearTokenCache();
7050 return null;
7151 }
7252
73− return {
74− accessToken,
75− accountId,
76− expiresAtMs,
77− };
53+ return cachedToken;
7854 }
7955
80−async function requestAnonymousAuthToken(accountId?: string): Promise<AnonymousAuthToken> {
56+async function requestAnonymousAuthToken(options?: {
57+ accountId?: string;
58+ forceRefresh?: boolean;
59+}): Promise<AnonymousAuthToken> {
8160 const response = await fetch("/api/auth/anonymous-token", {
8261 method: "POST",
8362 headers: {
8463 "content-type": "application/json",
8564 },
86− body: JSON.stringify({ accountId }),
65+ body: JSON.stringify({
66+ accountId: options?.accountId,
67+ forceRefresh: options?.forceRefresh,
68+ }),
69+ cache: "no-store",
8770 });
8871
8972 if (!response.ok) {
@@ -123,8 +106,8 @@ export async function getAnonymousAuthToken( · 33 unmodified lines
123106 }
124107 }
125108
126− const accountId = requestedAccountId ?? readStoredAccountId() ?? undefined;
127− const fresh = await requestAnonymousAuthToken(accountId);
109+ const accountId = requestedAccountId ?? cachedAccountId ?? undefined;
110+ const fresh = await requestAnonymousAuthToken({ accountId, forceRefresh });
128111 persistAnonymousAuth(fresh);
129112 return fresh;
130113 }