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 | import { importPKCS8, SignJWT } from "jose"; | 1 | import { importPKCS8, SignJWT } from "jose"; |
| 2 | import { z } from "zod"; | 2 | import { z } from "zod"; |
| 3 | import { appendDeleteCookie, appendSetCookie, readCookie } from "@/lib/http/cookies"; | ||
| 3 | 4 | ||
| 4 | const anonymousAuthAudience = "executor-anonymous"; | 5 | const anonymousAuthAudience = "executor-anonymous"; |
| 5 | const anonymousAuthKeyId = "executor-anonymous-es256"; | 6 | const anonymousAuthKeyId = "executor-anonymous-es256"; |
| 6 | const anonymousAuthTokenTtlSeconds = 60 * 60 * 24 * 7; | 7 | const anonymousAuthTokenTtlSeconds = 60 * 60 * 24 * 7; |
| 8 | const anonymousAuthCookieName = "executor_anonymous_auth"; | ||
| 9 | const anonymousAuthCookieVersion = 1; | ||
| 10 | const tokenRefreshSkewMs = 60_000; | ||
| 7 | const configQueryTimeoutMs = 2_000; | 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 | function trimOrNull(value: string | undefined): string | null { | 27 | function 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 | ||
| 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 | function noStoreJson(payload: unknown, status: number): Response { | 199 | function 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 | ||
| 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 | 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 | |||
| 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 | expiresAtMs: number; | 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 | const TOKEN_EXPIRY_SKEW_MS = 60_000; | 7 | const TOKEN_EXPIRY_SKEW_MS = 60_000; |
| 8 | let cachedToken: AnonymousAuthToken | null = null; | ||
| 9 | let cachedAccountId: string | null = null; | ||
| 11 | 10 | ||
| 12 | function canUseStorage(): boolean { | 11 | function canUseStorage(): boolean { |
| 13 | return typeof window !== "undefined"; | 12 | return typeof window !== "undefined"; |
| 14 | } | 13 | } |
| 15 | 14 | ||
| 16 | function readStorageValue(key: string): string | null { | 15 | function 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 | ||
| 23 | function writeStorageValue(key: string, value: string) { | 19 | function clearTokenCache() { |
| 24 | if (!canUseStorage()) { | 20 | cachedToken = null; |
| 25 | return; | ||
| 26 | } | ||
| 27 | localStorage.setItem(key, value); | ||
| 28 | } | 21 | } |
| 29 | 22 | ||
| 30 | export function clearAnonymousAuth(options?: { clearAccount?: boolean }) { | 23 | export 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 | ||
| 42 | function persistAnonymousAuth(token: AnonymousAuthToken) { | 38 | function 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 | |||
| 48 | function readStoredAccountId(): string | null { | ||
| 49 | const raw = readStorageValue(ACCOUNT_STORAGE_KEY); | ||
| 50 | return raw && raw.trim().length > 0 ? raw.trim() : null; | ||
| 51 | } | 41 | } |
| 52 | 42 | ||
| 53 | export function readStoredAnonymousAuthToken(): AnonymousAuthToken | null { | 43 | export 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 | ||
| 80 | async function requestAnonymousAuthToken(accountId?: string): Promise<AnonymousAuthToken> { | 56 | async 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 | } |