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 @@
1import { importPKCS8, SignJWT } from "jose";1import { importPKCS8, SignJWT } from "jose";
2import { z } from "zod";2import { z } from "zod";
3import { appendDeleteCookie, appendSetCookie, readCookie } from "@/lib/http/cookies";
3 4
4const anonymousAuthAudience = "executor-anonymous";5const anonymousAuthAudience = "executor-anonymous";
5const anonymousAuthKeyId = "executor-anonymous-es256";6const anonymousAuthKeyId = "executor-anonymous-es256";
6const anonymousAuthTokenTtlSeconds = 60 * 60 * 24 * 7;7const anonymousAuthTokenTtlSeconds = 60 * 60 * 24 * 7;
8const anonymousAuthCookieName = "executor_anonymous_auth";
9const anonymousAuthCookieVersion = 1;
10const tokenRefreshSkewMs = 60_000;
7const configQueryTimeoutMs = 2_000;11const configQueryTimeoutMs = 2_000;
8 12
13const issueAnonymousTokenRequestSchema = z.object({
14 accountId: z.string().trim().min(1).optional(),
15 forceRefresh: z.boolean().optional(),
16}).partial();
17
18const 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
25type AnonymousAuthCookiePayload = z.infer<typeof anonymousAuthCookieSchema>;
26
9function trimOrNull(value: string | undefined): string | null {27function trimOrNull(value: string | undefined): string | null {
10 const trimmed = value?.trim();28 const trimmed = value?.trim();
11 return trimmed && trimmed.length > 0 ? trimmed : null;29 return trimmed && trimmed.length > 0 ? trimmed : null;
@@ -109,6 +127,75 @@ function parseTokenTtlSeconds(raw: string | undefined): number { · 97 unmodified lines
109 return Number.isFinite(parsed) && parsed > 0 ? parsed : anonymousAuthTokenTtlSeconds;127 return Number.isFinite(parsed) && parsed > 0 ? parsed : anonymousAuthTokenTtlSeconds;
110}128}
111 129
130function 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
138function encodeAnonymousAuthCookie(payload: AnonymousAuthCookiePayload): string {
139 return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
140}
141
142function 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
153function isAnonymousAuthCookieUsable(payload: AnonymousAuthCookiePayload): boolean {
154 return Date.now() + tokenRefreshSkewMs < payload.expiresAtMs;
155}
156
157function 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
171function 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
179function 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
190function appendAnonymousAuthCookieDeletion(request: Request, headers: Headers) {
191 appendDeleteCookie(headers, anonymousAuthCookieName, {
192 path: "/",
193 httpOnly: true,
194 secure: shouldUseSecureCookies(request),
195 sameSite: "lax",
196 });
197}
198
112function noStoreJson(payload: unknown, status: number): Response {199function noStoreJson(payload: unknown, status: number): Response {
113 return Response.json(payload, {200 return Response.json(payload, {
114 status,201 status,
@@ -131,14 +218,39 @@ function createAccountId(): string { · 16 unmodified lines
131 return `anon_${crypto.randomUUID().replace(/-/g, "")}`;218 return `anon_${crypto.randomUUID().replace(/-/g, "")}`;
132}219}
133 220
134export async function POST(): Promise<Response> {221export 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
135 const issuer = await resolveIssuerFromBackend() ?? resolveIssuer();247 const issuer = await resolveIssuerFromBackend() ?? resolveIssuer();
136 const privateKeyPem = normalizePem(process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM);248 const privateKeyPem = normalizePem(process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM);
137 if (!issuer || !privateKeyPem) {249 if (!issuer || !privateKeyPem) {
138 return noStoreJson({ error: "Anonymous auth is not configured" }, 503);250 return noStoreJson({ error: "Anonymous auth is not configured" }, 503);
139 }251 }
140 252
141 const accountId = createAccountId();253 const accountId = parsedBody.accountId ?? createAccountId();
142 254
143 try {255 try {
144 const nowSeconds = Math.floor(Date.now() / 1000);256 const nowSeconds = Math.floor(Date.now() / 1000);
@@ -155,15 +267,20 @@ export async function POST(): Promise<Response> { · 10 unmodified lines
155 .setExpirationTime(expiresAtSeconds)267 .setExpirationTime(expiresAtSeconds)
156 .sign(signingKey);268 .sign(signingKey);
157 269
158 return noStoreJson(270 const payload = {
159 {271 tokenType: "Bearer",
160 tokenType: "Bearer",272 accessToken,
161 accessToken,273 accountId,
162 accountId,274 expiresAtMs: expiresAtSeconds * 1000,
163 expiresAtMs: expiresAtSeconds * 1000,275 };
164 },276 const response = noStoreJson(payload, 200);
165 200,277 appendAnonymousAuthCookie(request, response.headers, {
166 );278 version: anonymousAuthCookieVersion,
279 accessToken,
280 accountId,
281 expiresAtMs: payload.expiresAtMs,
282 });
283 return response;
167 } catch (error) {284 } catch (error) {
168 return noStoreJson(285 return noStoreJson(
169 { error: error instanceof Error ? error.message : "Failed to issue anonymous token" },286 { error: error instanceof Error ? error.message : "Failed to issue anonymous token" },
@@ -171,3 +288,9 @@ export async function POST(): Promise<Response> { · 1 unmodified line
171 );288 );
172 }289 }
173}290}
291
292export 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
4 expiresAtMs: number;4 expiresAtMs: number;
5};5};
6 6
7const TOKEN_STORAGE_KEY = "executor_anonymous_access_token";
8const ACCOUNT_STORAGE_KEY = "executor_anonymous_account_id";
9const EXPIRES_STORAGE_KEY = "executor_anonymous_token_expires_at";
10const TOKEN_EXPIRY_SKEW_MS = 60_000;7const TOKEN_EXPIRY_SKEW_MS = 60_000;
8let cachedToken: AnonymousAuthToken | null = null;
9let cachedAccountId: string | null = null;
11 10
12function canUseStorage(): boolean {11function canUseStorage(): boolean {
13 return typeof window !== "undefined";12 return typeof window !== "undefined";
14}13}
15 14
16function readStorageValue(key: string): string | null {15function isTokenUsable(token: AnonymousAuthToken): boolean {
17 if (!canUseStorage()) {16 return Date.now() + TOKEN_EXPIRY_SKEW_MS < token.expiresAtMs;
18 return null;
19 }
20 return localStorage.getItem(key);
21}17}
22 18
23function writeStorageValue(key: string, value: string) {19function clearTokenCache() {
24 if (!canUseStorage()) {20 cachedToken = null;
25 return;
26 }
27 localStorage.setItem(key, value);
28}21}
29 22
30export function clearAnonymousAuth(options?: { clearAccount?: boolean }) {23export function clearAnonymousAuth(options?: { clearAccount?: boolean }) {
31 if (!canUseStorage()) {24 clearTokenCache();
32 return;25 if (options?.clearAccount) {
26 cachedAccountId = null;
33 }27 }
34 28
35 localStorage.removeItem(TOKEN_STORAGE_KEY);29 if (canUseStorage()) {
36 if (options?.clearAccount) {30 void fetch("/api/auth/anonymous-token", {
37 localStorage.removeItem(ACCOUNT_STORAGE_KEY);31 method: "DELETE",
32 cache: "no-store",
33 keepalive: true,
34 }).catch(() => {});
38 }35 }
39 localStorage.removeItem(EXPIRES_STORAGE_KEY);
40}36}
41 37
42function persistAnonymousAuth(token: AnonymousAuthToken) {38function persistAnonymousAuth(token: AnonymousAuthToken) {
43 writeStorageValue(TOKEN_STORAGE_KEY, token.accessToken);39 cachedToken = token;
44 writeStorageValue(ACCOUNT_STORAGE_KEY, token.accountId);40 cachedAccountId = token.accountId;
45 writeStorageValue(EXPIRES_STORAGE_KEY, String(token.expiresAtMs));
46}
47
48function readStoredAccountId(): string | null {
49 const raw = readStorageValue(ACCOUNT_STORAGE_KEY);
50 return raw && raw.trim().length > 0 ? raw.trim() : null;
51}41}
52 42
53export function readStoredAnonymousAuthToken(): AnonymousAuthToken | null {43export function readStoredAnonymousAuthToken(): AnonymousAuthToken | null {
54 const accessToken = readStorageValue(TOKEN_STORAGE_KEY);44 if (!cachedToken) {
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();
65 return null;45 return null;
66 }46 }
67 47
68 if (Date.now() + TOKEN_EXPIRY_SKEW_MS >= expiresAtMs) {48 if (!isTokenUsable(cachedToken)) {
69 clearAnonymousAuth();49 clearTokenCache();
70 return null;50 return null;
71 }51 }
72 52
73 return {53 return cachedToken;
74 accessToken,
75 accountId,
76 expiresAtMs,
77 };
78}54}
79 55
80async function requestAnonymousAuthToken(accountId?: string): Promise<AnonymousAuthToken> {56async function requestAnonymousAuthToken(options?: {
57 accountId?: string;
58 forceRefresh?: boolean;
59}): Promise<AnonymousAuthToken> {
81 const response = await fetch("/api/auth/anonymous-token", {60 const response = await fetch("/api/auth/anonymous-token", {
82 method: "POST",61 method: "POST",
83 headers: {62 headers: {
84 "content-type": "application/json",63 "content-type": "application/json",
85 },64 },
86 body: JSON.stringify({ accountId }),65 body: JSON.stringify({
66 accountId: options?.accountId,
67 forceRefresh: options?.forceRefresh,
68 }),
69 cache: "no-store",
87 });70 });
88 71
89 if (!response.ok) {72 if (!response.ok) {
@@ -123,8 +106,8 @@ export async function getAnonymousAuthToken( · 33 unmodified lines
123 }106 }
124 }107 }
125 108
126 const accountId = requestedAccountId ?? readStoredAccountId() ?? undefined;109 const accountId = requestedAccountId ?? cachedAccountId ?? undefined;
127 const fresh = await requestAnonymousAuthToken(accountId);110 const fresh = await requestAnonymousAuthToken({ accountId, forceRefresh });
128 persistAnonymousAuth(fresh);111 persistAnonymousAuth(fresh);
129 return fresh;112 return fresh;
130}113}