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.
Unified · Split
| @@ -1,11 +1,29 @@ | ||
| 1 | 1 | import { importPKCS8, SignJWT } from "jose"; |
| 2 | 2 | import { z } from "zod"; |
| 3 | +import { appendDeleteCookie, appendSetCookie, readCookie } from "@/lib/http/cookies"; | |
| 3 | 4 | |
| 4 | 5 | const anonymousAuthAudience = "executor-anonymous"; |
| 5 | 6 | const anonymousAuthKeyId = "executor-anonymous-es256"; |
| 6 | 7 | const anonymousAuthTokenTtlSeconds = 60 * 60 * 24 * 7; |
| 8 | +const anonymousAuthCookieName = "executor_anonymous_auth"; | |
| 9 | +const anonymousAuthCookieVersion = 1; | |
| 10 | +const tokenRefreshSkewMs = 60_000; | |
| 7 | 11 | const configQueryTimeoutMs = 2_000; |
| 8 | 12 | |
| 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 | + | |
| 9 | 27 | function trimOrNull(value: string | undefined): string | null { |
| 10 | 28 | const trimmed = value?.trim(); |
| 11 | 29 | return trimmed && trimmed.length > 0 ? trimmed : null; |
| @@ -109,6 +127,75 @@ function parseTokenTtlSeconds(raw: string | undefined): number { · 97 unmodified lines | ||
| 109 | 127 | return Number.isFinite(parsed) && parsed > 0 ? parsed : anonymousAuthTokenTtlSeconds; |
| 110 | 128 | } |
| 111 | 129 | |
| 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 | + | |
| 112 | 199 | function noStoreJson(payload: unknown, status: number): Response { |
| 113 | 200 | return Response.json(payload, { |
| 114 | 201 | status, |
| @@ -131,14 +218,39 @@ function createAccountId(): string { · 16 unmodified lines | ||
| 131 | 218 | return `anon_${crypto.randomUUID().replace(/-/g, "")}`; |
| 132 | 219 | } |
| 133 | 220 | |
| 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 | + | |
| 135 | 247 | const issuer = await resolveIssuerFromBackend() ?? resolveIssuer(); |
| 136 | 248 | const privateKeyPem = normalizePem(process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM); |
| 137 | 249 | if (!issuer || !privateKeyPem) { |
| 138 | 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 | 255 | try { |
| 144 | 256 | const nowSeconds = Math.floor(Date.now() / 1000); |
| @@ -155,15 +267,20 @@ export async function POST(): Promise<Response> { · 10 unmodified lines | ||
| 155 | 267 | .setExpirationTime(expiresAtSeconds) |
| 156 | 268 | .sign(signingKey); |
| 157 | 269 | |
| 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; | |
| 167 | 284 | } catch (error) { |
| 168 | 285 | return noStoreJson( |
| 169 | 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 | + | |
| 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 | +} | |
| @@ -4,86 +4,69 @@ export type AnonymousAuthToken = { · 3 unmodified lines | ||
| 4 | 4 | expiresAtMs: number; |
| 5 | 5 | }; |
| 6 | 6 | |
| 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"; | |
| 10 | 7 | const TOKEN_EXPIRY_SKEW_MS = 60_000; |
| 8 | +let cachedToken: AnonymousAuthToken | null = null; | |
| 9 | +let cachedAccountId: string | null = null; | |
| 11 | 10 | |
| 12 | 11 | function canUseStorage(): boolean { |
| 13 | 12 | return typeof window !== "undefined"; |
| 14 | 13 | } |
| 15 | 14 | |
| 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; | |
| 21 | 17 | } |
| 22 | 18 | |
| 23 | −function writeStorageValue(key: string, value: string) { | |
| 24 | − if (!canUseStorage()) { | |
| 25 | − return; | |
| 26 | − } | |
| 27 | − localStorage.setItem(key, value); | |
| 19 | +function clearTokenCache() { | |
| 20 | + cachedToken = null; | |
| 28 | 21 | } |
| 29 | 22 | |
| 30 | 23 | export function clearAnonymousAuth(options?: { clearAccount?: boolean }) { |
| 31 | − if (!canUseStorage()) { | |
| 32 | − return; | |
| 24 | + clearTokenCache(); | |
| 25 | + if (options?.clearAccount) { | |
| 26 | + cachedAccountId = null; | |
| 33 | 27 | } |
| 34 | 28 | |
| 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(() => {}); | |
| 38 | 35 | } |
| 39 | − localStorage.removeItem(EXPIRES_STORAGE_KEY); | |
| 40 | 36 | } |
| 41 | 37 | |
| 42 | 38 | 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; | |
| 51 | 41 | } |
| 52 | 42 | |
| 53 | 43 | 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) { | |
| 65 | 45 | return null; |
| 66 | 46 | } |
| 67 | 47 | |
| 68 | − if (Date.now() + TOKEN_EXPIRY_SKEW_MS >= expiresAtMs) { | |
| 69 | − clearAnonymousAuth(); | |
| 48 | + if (!isTokenUsable(cachedToken)) { | |
| 49 | + clearTokenCache(); | |
| 70 | 50 | return null; |
| 71 | 51 | } |
| 72 | 52 | |
| 73 | − return { | |
| 74 | − accessToken, | |
| 75 | − accountId, | |
| 76 | − expiresAtMs, | |
| 77 | − }; | |
| 53 | + return cachedToken; | |
| 78 | 54 | } |
| 79 | 55 | |
| 80 | −async function requestAnonymousAuthToken(accountId?: string): Promise<AnonymousAuthToken> { | |
| 56 | +async function requestAnonymousAuthToken(options?: { | |
| 57 | + accountId?: string; | |
| 58 | + forceRefresh?: boolean; | |
| 59 | +}): Promise<AnonymousAuthToken> { | |
| 81 | 60 | const response = await fetch("/api/auth/anonymous-token", { |
| 82 | 61 | method: "POST", |
| 83 | 62 | headers: { |
| 84 | 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 | 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; | |
| 127 | − const fresh = await requestAnonymousAuthToken(accountId); | |
| 109 | + const accountId = requestedAccountId ?? cachedAccountId ?? undefined; | |
| 110 | + const fresh = await requestAnonymousAuthToken({ accountId, forceRefresh }); | |
| 128 | 111 | persistAnonymousAuth(fresh); |
| 129 | 112 | return fresh; |
| 130 | 113 | } |