diff --git a/.env.example b/.env.example index 2776f80629..719a185f20 100644 --- a/.env.example +++ b/.env.example @@ -225,4 +225,21 @@ HINDSIGHT_API_LOG_LEVEL=info # Optional: Require a shared access key to view the Control Plane UI. # When set, visitors see a login page and must enter the key before # accessing the dashboard or any /api/* routes (except /api/health). +# This key is the admin scope: it sees every bank (prefix ""). # HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key + +# Optional: Additional scoped tokens, each mapped to a bank-id prefix, so the +# Control Plane can be embedded per-user. JSON array of {token, prefix, label?}. +# A scoped token only sees banks equal to its prefix or namespaced under it +# ("u2" matches "u2" and "u2--*", never "u20"). The admin key above stays all-banks. +# HINDSIGHT_CP_TOKENS=[{"token":"user-2-token","prefix":"u2","label":"user 2"}] + +# Optional: SameSite policy for the CP session cookie. Set to "none" so the +# cookie survives inside a cross-site iframe (adds Secure + Partitioned/CHIPS; +# requires HTTPS). Leave unset for local http dev to keep SameSite=Lax. +# HINDSIGHT_CP_COOKIE_SAMESITE=none + +# Optional: Origins allowed to embed the Control Plane in an iframe +# (Content-Security-Policy: frame-ancestors). Space- or comma-separated list, +# e.g. the tokengate origin. Falls back to 'self' when unset. +# HINDSIGHT_CP_FRAME_ANCESTORS=https://tokengate.example.com diff --git a/hindsight-control-plane/src/app/api/auth/embed-login/route.ts b/hindsight-control-plane/src/app/api/auth/embed-login/route.ts new file mode 100644 index 0000000000..0d59b8f724 --- /dev/null +++ b/hindsight-control-plane/src/app/api/auth/embed-login/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; + +import { + ACCESS_KEY_COOKIE, + SESSION_MAX_AGE_SECONDS, + createSessionToken, + sessionCookieOptions, +} from "@/lib/auth/session"; +import { resolveToken } from "@/lib/auth/tokens"; +import { sanitizeReturnTo, withBasePath } from "@/lib/base-path"; + +const DEFAULT_RETURN_TO = "/dashboard"; + +/** + * Cross-site auto-login for the tokengate iframe. A hidden `
` + * POSTs the token here (form-encoded or JSON); we force-recreate the session + * cookie with the resolved prefix and 302 to the sanitized `returnTo` so the + * iframe lands directly on the scoped dashboard. The 302 + Set-Cookie is what + * makes a single cross-site POST render the dashboard. + */ +export async function POST(request: NextRequest) { + const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY; + const contentType = request.headers.get("content-type") ?? ""; + const isJson = contentType.includes("application/json"); + + if (!accessKey) { + return isJson + ? NextResponse.json( + localizeApiErrorPayload(request, { + error: "Access key not configured", + errorKey: "api.errors.auth.accessKeyNotConfigured", + }), + { status: 503 } + ) + : htmlError(503, "Access key not configured"); + } + + let token: string | undefined; + let returnTo: string | null | undefined; + + if (isJson) { + try { + const body = (await request.json()) as { token?: string; returnTo?: string }; + token = body.token; + returnTo = body.returnTo; + } catch { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Invalid request body", + errorKey: "api.errors.auth.invalidRequestBody", + }), + { status: 400 } + ); + } + } else { + const form = await request.formData(); + const rawToken = form.get("token"); + const rawReturnTo = form.get("returnTo"); + token = typeof rawToken === "string" ? rawToken : undefined; + returnTo = typeof rawReturnTo === "string" ? rawReturnTo : undefined; + } + + const resolved = resolveToken(token); + if (!resolved) { + return isJson + ? NextResponse.json( + localizeApiErrorPayload(request, { + error: "Invalid access key", + errorKey: "api.errors.auth.invalidAccessKey", + }), + { status: 401 } + ) + : htmlError(401, "Invalid access key"); + } + + // Relative (path-only) Location so the browser resolves it against the public + // origin it actually loaded, not the internal upstream host (request.url is + // 0.0.0.0:9999 behind nginx). sanitizeReturnTo already forbids off-origin + // targets, so a relative path can never become an open redirect. + const target = withBasePath(sanitizeReturnTo(returnTo, DEFAULT_RETURN_TO)); + const response = new NextResponse(null, { + status: 302, + headers: { Location: target }, + }); + + response.cookies.set({ + name: ACCESS_KEY_COOKIE, + value: await createSessionToken(accessKey, resolved.prefix), + ...sessionCookieOptions(request), + maxAge: SESSION_MAX_AGE_SECONDS, + }); + + return response; +} + +function htmlError(status: number, message: string): NextResponse { + return new NextResponse(`

${message}

`, { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} diff --git a/hindsight-control-plane/src/app/api/auth/login/route.ts b/hindsight-control-plane/src/app/api/auth/login/route.ts index 0251aedfaf..912155da8d 100644 --- a/hindsight-control-plane/src/app/api/auth/login/route.ts +++ b/hindsight-control-plane/src/app/api/auth/login/route.ts @@ -7,6 +7,7 @@ import { createSessionToken, sessionCookieOptions, } from "@/lib/auth/session"; +import { resolveToken } from "@/lib/auth/tokens"; export async function POST(request: NextRequest) { const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY; @@ -35,12 +36,9 @@ export async function POST(request: NextRequest) { ); } - const providedKey = body.key; + const resolved = resolveToken(body.key); - // Constant-time comparison to prevent timing attacks - const isValid = providedKey && constantTimeCompare(providedKey, accessKey); - - if (!isValid) { + if (!resolved) { return NextResponse.json( localizeApiErrorPayload(request, { error: "Invalid access key", @@ -54,26 +52,10 @@ export async function POST(request: NextRequest) { response.cookies.set({ name: ACCESS_KEY_COOKIE, - value: await createSessionToken(accessKey), + value: await createSessionToken(accessKey, resolved.prefix), ...sessionCookieOptions(request), maxAge: SESSION_MAX_AGE_SECONDS, }); return response; } - -/** - * Constant-time string comparison to prevent timing attacks. - */ -function constantTimeCompare(a: string, b: string): boolean { - if (a.length !== b.length) { - return false; - } - - let result = 0; - for (let i = 0; i < a.length; i++) { - result |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - - return result === 0; -} diff --git a/hindsight-control-plane/src/app/api/auth/whoami/route.ts b/hindsight-control-plane/src/app/api/auth/whoami/route.ts new file mode 100644 index 0000000000..436766b5e3 --- /dev/null +++ b/hindsight-control-plane/src/app/api/auth/whoami/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getSessionPrefix } from "@/lib/auth/session"; +import { labelForPrefix } from "@/lib/auth/tokens"; + +/** + * Reports the current session's bank scope so the client can tailor the UI + * (hide admin-only actions, foreign chrome). Requires a valid session: it lives + * under `/api/` and is not in PUBLIC_PATTERNS, so middleware already gates it. + */ +export async function GET(request: NextRequest) { + const prefix = (await getSessionPrefix(request)) ?? ""; + return NextResponse.json({ + isAdmin: prefix === "", + prefix, + label: labelForPrefix(prefix), + }); +} diff --git a/hindsight-control-plane/src/app/api/banks/route.ts b/hindsight-control-plane/src/app/api/banks/route.ts index d0a8d46ef4..66ec4ca08e 100644 --- a/hindsight-control-plane/src/app/api/banks/route.ts +++ b/hindsight-control-plane/src/app/api/banks/route.ts @@ -1,16 +1,34 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; +import { getSessionPrefix } from "@/lib/auth/session"; +import { bankAllowed } from "@/lib/auth/tokens"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; const HTTP_CREATED = 201; -export async function GET(request: Request) { +type BankListEntry = { bank_id?: string }; +type BankListData = { banks?: BankListEntry[] }; + +export async function GET(request: NextRequest) { const response = await sdk.listBanks({ client: lowLevelClient }); + + const prefix = await getSessionPrefix(request); + if (prefix && response.data) { + const data = response.data as BankListData; + if (Array.isArray(data.banks)) { + response.data = { + ...data, + banks: data.banks.filter((bank) => bankAllowed(prefix, bank.bank_id ?? "")), + } as typeof response.data; + } + } + return respondWithSdk(response, "Failed to fetch banks", { request }); } -export async function POST(request: Request) { +export async function POST(request: NextRequest) { let body; try { body = await request.json(); @@ -35,6 +53,9 @@ export async function POST(request: Request) { ); } + const forbidden = await assertBankAllowed(request, bank_id); + if (forbidden) return forbidden; + const response = await sdk.createOrUpdateBank({ client: lowLevelClient, path: { bank_id }, diff --git a/hindsight-control-plane/src/app/api/extract/route.ts b/hindsight-control-plane/src/app/api/extract/route.ts index aae274757a..3120ad70ee 100644 --- a/hindsight-control-plane/src/app/api/extract/route.ts +++ b/hindsight-control-plane/src/app/api/extract/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; /** * Proxy for the dataplane dry-run extraction endpoint: extract facts from text with a candidate @@ -10,6 +11,10 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); const bankId = body.bank_id || "default"; + + const forbidden = await assertBankAllowed(request, bankId); + if (forbidden) return forbidden; + const { content, retain_mission, diff --git a/hindsight-control-plane/src/app/api/files/retain/route.ts b/hindsight-control-plane/src/app/api/files/retain/route.ts index 2b6bcfe772..b74c383a46 100644 --- a/hindsight-control-plane/src/app/api/files/retain/route.ts +++ b/hindsight-control-plane/src/app/api/files/retain/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; export async function POST(request: NextRequest) { try { @@ -44,6 +45,9 @@ export async function POST(request: NextRequest) { ); } + const forbidden = await assertBankAllowed(request, bankId); + if (forbidden) return forbidden; + // Use the shared dataplane URL configuration const url = dataplaneBankUrl(bankId, "/files/retain"); diff --git a/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts b/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts index a09f0802de..afbe00fca9 100644 --- a/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts +++ b/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; export async function GET( request: NextRequest, @@ -75,6 +76,9 @@ export async function PATCH( ); } + const forbidden = await assertBankAllowed(request, bankId); + if (forbidden) return forbidden; + // Curation fields only; bank_id is a routing param, not part of the body. const { text, context, occurred_start, occurred_end, fact_type, entities, state, reason } = body; diff --git a/hindsight-control-plane/src/app/api/memories/retain/route.ts b/hindsight-control-plane/src/app/api/memories/retain/route.ts index 11b0db1a64..780f714f1b 100644 --- a/hindsight-control-plane/src/app/api/memories/retain/route.ts +++ b/hindsight-control-plane/src/app/api/memories/retain/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import { hindsightClient } from "@/lib/hindsight-client"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; export async function POST(request: NextRequest) { try { @@ -17,6 +18,9 @@ export async function POST(request: NextRequest) { ); } + const forbidden = await assertBankAllowed(request, bankId); + if (forbidden) return forbidden; + const { items, document_id, document_tags, observation_scopes } = body; // Map observation_scopes into each item if provided at request level diff --git a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts b/hindsight-control-plane/src/app/api/memories/retain_async/route.ts index fb23feeedf..8d19afdf17 100644 --- a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts +++ b/hindsight-control-plane/src/app/api/memories/retain_async/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; export async function POST(request: NextRequest) { let body; @@ -28,6 +29,9 @@ export async function POST(request: NextRequest) { ); } + const forbidden = await assertBankAllowed(request, bankId); + if (forbidden) return forbidden; + const { items } = body; const response = await sdk.retainMemories({ diff --git a/hindsight-control-plane/src/app/api/recall/route.ts b/hindsight-control-plane/src/app/api/recall/route.ts index 2908e7acb6..abb28669a5 100644 --- a/hindsight-control-plane/src/app/api/recall/route.ts +++ b/hindsight-control-plane/src/app/api/recall/route.ts @@ -1,11 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import { lowLevelClient, sdk } from "@/lib/hindsight-client"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; export async function POST(request: NextRequest) { try { const body = await request.json(); const bankId = body.bank_id || body.agent_id || "default"; + + const forbidden = await assertBankAllowed(request, bankId); + if (forbidden) return forbidden; + const { query, types, diff --git a/hindsight-control-plane/src/app/api/reflect/route.ts b/hindsight-control-plane/src/app/api/reflect/route.ts index b6e3a091dd..91c8ac32ba 100644 --- a/hindsight-control-plane/src/app/api/reflect/route.ts +++ b/hindsight-control-plane/src/app/api/reflect/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; +import { assertBankAllowed } from "@/lib/auth/bank-guard"; export async function POST(request: NextRequest) { let body; @@ -17,6 +18,10 @@ export async function POST(request: NextRequest) { ); } const bankId = body.bank_id || body.agent_id || "default"; + + const forbidden = await assertBankAllowed(request, bankId); + if (forbidden) return forbidden; + const { query, budget, diff --git a/hindsight-control-plane/src/components/bank-selector.tsx b/hindsight-control-plane/src/components/bank-selector.tsx index aecb860e5d..9d8a8c1920 100644 --- a/hindsight-control-plane/src/components/bank-selector.tsx +++ b/hindsight-control-plane/src/components/bank-selector.tsx @@ -160,6 +160,10 @@ function BankSelectorInner() { // Feature flags const [fileUploadEnabled, setFileUploadEnabled] = React.useState(null); + // Session scope: admin sees all banks + admin-only chrome; a scoped token is + // limited to its prefix, so hide create-bank and foreign links for it. + const [isAdmin, setIsAdmin] = React.useState(true); + // Load feature flags React.useEffect(() => { client @@ -172,6 +176,13 @@ function BankSelectorInner() { }); }, []); + React.useEffect(() => { + client + .whoami() + .then((info) => setIsAdmin(info.isAdmin)) + .catch(() => setIsAdmin(true)); + }, []); + const sortedBanks = React.useMemo(() => { // Sort by last document inserted descending, then by created_at return [...bankInfos].sort((a, b) => { @@ -598,19 +609,21 @@ function BankSelectorInner() { })} - {/* Footer: Create new bank */} -
- -
+ {/* Footer: Create new bank (admin only) */} + {isAdmin && ( +
+ +
+ )} @@ -636,17 +649,19 @@ function BankSelectorInner() { {/* Spacer */}
- {/* GitHub Link */} - - - GitHub - + {/* GitHub Link (admin only) */} + {isAdmin && ( + + + GitHub + + )} {/* Separator */}
diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 2591045f7d..ff99217e59 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -310,6 +310,15 @@ export class ControlPlaneClient { return this.fetchApi<{ banks: any[] }>("/api/banks", { cache: "no-store" as RequestCache }); } + /** + * Current session's bank scope, for tailoring the UI to scoped tokens. + */ + async whoami() { + return this.fetchApi<{ isAdmin: boolean; prefix: string; label?: string }>("/api/auth/whoami", { + cache: "no-store" as RequestCache, + }); + } + /** * Create a new bank */ diff --git a/hindsight-control-plane/src/lib/auth/bank-guard.ts b/hindsight-control-plane/src/lib/auth/bank-guard.ts new file mode 100644 index 0000000000..82576ec31f --- /dev/null +++ b/hindsight-control-plane/src/lib/auth/bank-guard.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; +import { getSessionPrefix } from "@/lib/auth/session"; +import { bankAllowed } from "@/lib/auth/tokens"; + +export function forbiddenResponse(request: NextRequest): NextResponse { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Forbidden", + errorKey: "api.errors.auth.forbidden", + }), + { status: 403 } + ); +} + +/** + * For routes that read the bank id from the request body (which middleware + * cannot inspect without consuming it): returns a 403 response when the session + * is scoped to a prefix that does not cover `bankId`, else null. Admin sessions + * (empty prefix) and setups without an access key resolve to null and pass. + */ +export async function assertBankAllowed( + request: NextRequest, + bankId: string +): Promise { + const prefix = await getSessionPrefix(request); + if (prefix === null) return null; + if (bankAllowed(prefix, bankId)) return null; + return forbiddenResponse(request); +} diff --git a/hindsight-control-plane/src/lib/auth/session.ts b/hindsight-control-plane/src/lib/auth/session.ts index 36fa052bc2..bb74a183ec 100644 --- a/hindsight-control-plane/src/lib/auth/session.ts +++ b/hindsight-control-plane/src/lib/auth/session.ts @@ -5,39 +5,68 @@ export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; const CLOCK_SKEW_TOLERANCE_SECONDS = 60; +export type SessionVerification = { + valid: boolean; + prefix: string; +}; + /** - * Session token format: `.`. + * Session token format: `..`. * - * The HMAC is computed over `issuedAtSeconds` using the access key as the - * secret, so the token cannot be forged without knowing the key, and rotating - * the key invalidates every outstanding session. No server-side state needed. + * `prefixB64url` is the base64url of the (possibly empty) bank prefix the + * session is scoped to. The HMAC is computed over `issuedAt + "." + prefixB64url` + * using the access key as the secret, so neither the timestamp nor the prefix + * can be forged without the key, and rotating the key invalidates every + * outstanding session. No server-side state needed. */ -export async function createSessionToken(accessKey: string): Promise { +export async function createSessionToken(accessKey: string, prefix = ""): Promise { const issuedAt = Math.floor(Date.now() / 1000).toString(); - const signature = await hmacSha256Base64Url(accessKey, issuedAt); - return `${issuedAt}.${signature}`; + const prefixB64 = base64UrlEncode(new TextEncoder().encode(prefix)); + const payload = `${issuedAt}.${prefixB64}`; + const signature = await hmacSha256Base64Url(accessKey, payload); + return `${payload}.${signature}`; } export async function verifySessionToken( token: string | undefined, accessKey: string -): Promise { - if (!token) return false; - const separator = token.indexOf("."); - if (separator <= 0 || separator === token.length - 1) return false; +): Promise { + const invalid: SessionVerification = { valid: false, prefix: "" }; + if (!token) return invalid; + + const parts = token.split("."); + // A legacy 2-part token (`.`) no longer verifies — the user + // simply re-logs in. Only the 3-part shape is accepted. + if (parts.length !== 3) return invalid; - const payload = token.slice(0, separator); - const providedSignature = token.slice(separator + 1); + const [issuedAtRaw, prefixB64, providedSignature] = parts; + if (!issuedAtRaw || !providedSignature) return invalid; - const issuedAt = Number(payload); - if (!Number.isInteger(issuedAt) || issuedAt <= 0) return false; + const issuedAt = Number(issuedAtRaw); + if (!Number.isInteger(issuedAt) || issuedAt <= 0) return invalid; const nowSeconds = Math.floor(Date.now() / 1000); - if (issuedAt > nowSeconds + CLOCK_SKEW_TOLERANCE_SECONDS) return false; - if (nowSeconds - issuedAt > SESSION_MAX_AGE_SECONDS) return false; + if (issuedAt > nowSeconds + CLOCK_SKEW_TOLERANCE_SECONDS) return invalid; + if (nowSeconds - issuedAt > SESSION_MAX_AGE_SECONDS) return invalid; + const payload = `${issuedAtRaw}.${prefixB64}`; const expectedSignature = await hmacSha256Base64Url(accessKey, payload); - return constantTimeEqual(expectedSignature, providedSignature); + if (!constantTimeEqual(expectedSignature, providedSignature)) return invalid; + + return { valid: true, prefix: base64UrlDecodeToString(prefixB64) }; +} + +/** + * Convenience for callers (e.g. middleware) that need the session's bank prefix: + * reads the session cookie, verifies it, and returns the prefix when valid, + * else null. + */ +export async function getSessionPrefix(request: NextRequest): Promise { + const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY; + if (!accessKey) return null; + const token = request.cookies.get(ACCESS_KEY_COOKIE)?.value; + const result = await verifySessionToken(token, accessKey); + return result.valid ? result.prefix : null; } /** @@ -55,11 +84,36 @@ export function isSecureRequest(request: NextRequest): boolean { return request.nextUrl.protocol === "https:"; } -export function sessionCookieOptions(request: NextRequest) { +/** + * Cookie attributes for the session. When `HINDSIGHT_CP_COOKIE_SAMESITE=none` + * the cookie is emitted as `SameSite=None; Secure; Partitioned` so it survives + * inside a cross-site iframe (CHIPS). Otherwise it stays `SameSite=Lax` with + * `Secure` derived from the request protocol, keeping plain-http dev working. + * + * `partitioned` is not yet in the Next.js cookie option types, so the return is + * loosely typed to carry it through to `cookies().set(...)`. + */ +export function sessionCookieOptions(request: NextRequest): { + httpOnly: boolean; + secure: boolean; + sameSite: "lax" | "none"; + path: string; + partitioned?: boolean; +} { + if (process.env.HINDSIGHT_CP_COOKIE_SAMESITE === "none") { + return { + httpOnly: true, + secure: true, + sameSite: "none", + path: "/", + partitioned: true, + }; + } + return { httpOnly: true, secure: isSecureRequest(request), - sameSite: "lax" as const, + sameSite: "lax", path: "/", }; } @@ -83,6 +137,15 @@ function base64UrlEncode(bytes: Uint8Array): string { return btoa(binary).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); } +function base64UrlDecodeToString(value: string): string { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} + function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let result = 0; diff --git a/hindsight-control-plane/src/lib/auth/tokens.ts b/hindsight-control-plane/src/lib/auth/tokens.ts new file mode 100644 index 0000000000..b21e2e1f15 --- /dev/null +++ b/hindsight-control-plane/src/lib/auth/tokens.ts @@ -0,0 +1,120 @@ +export type ScopedToken = { + token: string; + prefix: string; + label?: string; +}; + +export type ResolvedToken = { + prefix: string; + label?: string; +}; + +let cachedRaw: string | undefined; +let cachedTokens: ScopedToken[] = []; +let warnedInvalid = false; + +/** + * Parse `HINDSIGHT_CP_TOKENS` (JSON array of `{token, prefix, label?}`). Any + * malformed configuration (unset, empty, invalid JSON, not an array) resolves + * to no scoped tokens, logging a single warning on invalid JSON/shape so a + * broken env doesn't spam logs on every request. Entries missing `token` or + * `prefix` are ignored. + */ +function loadScopedTokens(): ScopedToken[] { + const raw = process.env.HINDSIGHT_CP_TOKENS; + if (raw === cachedRaw) return cachedTokens; + + cachedRaw = raw; + cachedTokens = []; + + if (!raw || raw.trim().length === 0) return cachedTokens; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + if (!warnedInvalid) { + console.warn("HINDSIGHT_CP_TOKENS is not valid JSON; ignoring scoped tokens."); + warnedInvalid = true; + } + return cachedTokens; + } + + if (!Array.isArray(parsed)) { + if (!warnedInvalid) { + console.warn("HINDSIGHT_CP_TOKENS must be a JSON array; ignoring scoped tokens."); + warnedInvalid = true; + } + return cachedTokens; + } + + cachedTokens = parsed.flatMap((entry): ScopedToken[] => { + if (!entry || typeof entry !== "object") return []; + const { token, prefix, label } = entry as Record; + if (typeof token !== "string" || token.length === 0) return []; + if (typeof prefix !== "string") return []; + return [{ token, prefix, label: typeof label === "string" ? label : undefined }]; + }); + + return cachedTokens; +} + +/** + * Resolve a provided key to its bank prefix scope. The admin key + * (`HINDSIGHT_CP_ACCESS_KEY`) maps to the empty prefix (all banks). Scoped + * tokens map to their configured prefix. Returns null when nothing matches. + * + * Comparison is constant-time and every candidate is checked (no early return + * on the first match) so response timing does not leak which token matched. + */ +export function resolveToken(provided: string | undefined): ResolvedToken | null { + if (!provided) return null; + + let match: ResolvedToken | null = null; + + const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY; + if (accessKey && constantTimeEqual(provided, accessKey)) { + match = { prefix: "", label: "admin" }; + } + + for (const entry of loadScopedTokens()) { + if (constantTimeEqual(provided, entry.token)) { + match = match ?? { prefix: entry.prefix, label: entry.label }; + } + } + + return match; +} + +/** + * Human-readable label for a resolved prefix, for UI display. Empty prefix is + * the admin scope; otherwise the first configured token entry with that prefix + * supplies the label (may be undefined). + */ +export function labelForPrefix(prefix: string): string | undefined { + if (prefix === "") return "admin"; + for (const entry of loadScopedTokens()) { + if (entry.prefix === prefix) return entry.label; + } + return undefined; +} + +/** + * Prefix-scope predicate reused by the list filter, middleware, and body guard. + * Empty prefix (admin) always passes. Otherwise the bank must equal the prefix + * exactly or be a namespaced child (`--...`), so "u2" does not match + * "u20". + */ +export function bankAllowed(prefix: string, bankId: string): boolean { + if (prefix === "") return true; + return bankId === prefix || bankId.startsWith(`${prefix}--`); +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} diff --git a/hindsight-control-plane/src/messages/de.json b/hindsight-control-plane/src/messages/de.json index 8fe694355c..ae0fff3280 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "Zugriffsschlüssel ist nicht konfiguriert", "invalidRequestBody": "Ungültiger Anfrageinhalt", "invalidAccessKey": "Ungültiger Zugriffsschlüssel", - "unauthorized": "Nicht autorisiert" + "unauthorized": "Nicht autorisiert", + "forbidden": "Verboten" }, "validation": { "bankIdRequired": "bank_id ist erforderlich", diff --git a/hindsight-control-plane/src/messages/en.json b/hindsight-control-plane/src/messages/en.json index 7b30140c8d..830a4f17b6 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "Access key not configured", "invalidRequestBody": "Invalid request body", "invalidAccessKey": "Invalid access key", - "unauthorized": "Unauthorized" + "unauthorized": "Unauthorized", + "forbidden": "Forbidden" }, "validation": { "bankIdRequired": "bank_id is required", diff --git a/hindsight-control-plane/src/messages/es.json b/hindsight-control-plane/src/messages/es.json index 9ff37b9a61..3db57bc821 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "La clave de acceso no está configurada", "invalidRequestBody": "El cuerpo de la solicitud no es válido", "invalidAccessKey": "La clave de acceso no es válida", - "unauthorized": "No autorizado" + "unauthorized": "No autorizado", + "forbidden": "Prohibido" }, "validation": { "bankIdRequired": "bank_id es obligatorio", diff --git a/hindsight-control-plane/src/messages/fr.json b/hindsight-control-plane/src/messages/fr.json index 574df1886c..9dca20d6f9 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "La clé d'accès n'est pas configurée", "invalidRequestBody": "Corps de requête invalide", "invalidAccessKey": "Clé d'accès invalide", - "unauthorized": "Non autorisé" + "unauthorized": "Non autorisé", + "forbidden": "Interdit" }, "validation": { "bankIdRequired": "bank_id est obligatoire", diff --git a/hindsight-control-plane/src/messages/ja.json b/hindsight-control-plane/src/messages/ja.json index 704ce07ad5..849e8287f7 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "アクセスキーが設定されていません", "invalidRequestBody": "リクエスト本文が無効です", "invalidAccessKey": "アクセスキーが無効です", - "unauthorized": "認証されていません" + "unauthorized": "認証されていません", + "forbidden": "禁止されています" }, "validation": { "bankIdRequired": "bank_id は必須です", diff --git a/hindsight-control-plane/src/messages/ko.json b/hindsight-control-plane/src/messages/ko.json index 14fa436c56..cbf363f288 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "액세스 키가 구성되지 않았습니다", "invalidRequestBody": "요청 본문이 유효하지 않습니다", "invalidAccessKey": "액세스 키가 유효하지 않습니다", - "unauthorized": "인증되지 않았습니다" + "unauthorized": "인증되지 않았습니다", + "forbidden": "금지됨" }, "validation": { "bankIdRequired": "bank_id는 필수입니다", diff --git a/hindsight-control-plane/src/messages/pt.json b/hindsight-control-plane/src/messages/pt.json index df8f889cb3..afd2015da4 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "A chave de acesso não está configurada", "invalidRequestBody": "O corpo da solicitação é inválido", "invalidAccessKey": "A chave de acesso é inválida", - "unauthorized": "Não autorizado" + "unauthorized": "Não autorizado", + "forbidden": "Proibido" }, "validation": { "bankIdRequired": "bank_id é obrigatório", diff --git a/hindsight-control-plane/src/messages/yue-Hant.json b/hindsight-control-plane/src/messages/yue-Hant.json index bf85538169..a17857bd42 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "尚未設定存取金鑰", "invalidRequestBody": "請求內容無效", "invalidAccessKey": "存取金鑰無效", - "unauthorized": "未獲授權" + "unauthorized": "未獲授權", + "forbidden": "禁止存取" }, "validation": { "bankIdRequired": "必須提供 bank_id", diff --git a/hindsight-control-plane/src/messages/zh-CN.json b/hindsight-control-plane/src/messages/zh-CN.json index 129417e5bf..fc7cf4efc4 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "未配置访问密钥", "invalidRequestBody": "请求体无效", "invalidAccessKey": "访问密钥无效", - "unauthorized": "未授权" + "unauthorized": "未授权", + "forbidden": "禁止访问" }, "validation": { "bankIdRequired": "bank_id 为必填项", diff --git a/hindsight-control-plane/src/messages/zh-TW.json b/hindsight-control-plane/src/messages/zh-TW.json index 7c31b7b66f..2adb5c3640 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -1557,7 +1557,8 @@ "accessKeyNotConfigured": "尚未設定存取金鑰", "invalidRequestBody": "請求內容無效", "invalidAccessKey": "存取金鑰無效", - "unauthorized": "未授權" + "unauthorized": "未授權", + "forbidden": "禁止存取" }, "validation": { "bankIdRequired": "bank_id 為必填欄位", diff --git a/hindsight-control-plane/src/middleware.ts b/hindsight-control-plane/src/middleware.ts index 231fa7e9b8..a977ce7f29 100644 --- a/hindsight-control-plane/src/middleware.ts +++ b/hindsight-control-plane/src/middleware.ts @@ -4,6 +4,7 @@ import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; import createIntlMiddleware from "next-intl/middleware"; import { ACCESS_KEY_COOKIE, verifySessionToken } from "@/lib/auth/session"; +import { bankAllowed } from "@/lib/auth/tokens"; import { stripBasePath, withBasePath } from "@/lib/base-path"; import { routing } from "@/i18n/routing"; @@ -20,9 +21,78 @@ const PUBLIC_PATTERNS = [ "/static", ]; +// API collections whose first path segment after the collection is a bank id. +const BANK_PATH_COLLECTIONS = new Set(["banks", "operations", "stats", "profile"]); + const intlMiddleware = createIntlMiddleware(routing); -export async function middleware(request: NextRequest) { +/** + * Resolve the target bank id an authenticated `/api/*` request addresses, from + * either the path (`/api///...`) or a `?bank_id=`/`?agent_id=` + * query param. Returns null when no bank id is present (e.g. `/api/banks` list, + * `/api/version`). Path ids are URL-decoded before comparison. + */ +function apiTargetBankId(appPathname: string, request: NextRequest): string | null { + const segments = appPathname.split("/").filter(Boolean); // ["api", "", "", ...] + if (segments.length >= 3 && segments[0] === "api" && BANK_PATH_COLLECTIONS.has(segments[1])) { + return decodeURIComponent(segments[2]); + } + + const query = request.nextUrl.searchParams; + return query.get("bank_id") ?? query.get("agent_id"); +} + +function forbidden(request: NextRequest): NextResponse { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Forbidden", + errorKey: "api.errors.auth.forbidden", + }), + { status: 403 } + ); +} + +/** + * Origins allowed to embed the Control Plane, read per-request so the runtime + * env controls framing (Next bakes next.config `headers()` at build time, which + * would ignore a container-time env). Space- or comma-separated; falls back to + * `'self'` when unset, never `*`. + */ +function frameAncestorsValue(): string { + return (process.env.HINDSIGHT_CP_FRAME_ANCESTORS || "'self'") + .split(/[\s,]+/) + .filter(Boolean) + .join(" "); +} + +function withFrameAncestors(response: NextResponse): NextResponse { + response.headers.set("Content-Security-Policy", `frame-ancestors ${frameAncestorsValue()};`); + return response; +} + +/** + * Public origin the client actually loaded, honoring the nginx proxy. Middleware + * redirects must be absolute (a relative Location makes Next throw), and + * request.url behind nginx is the internal upstream host (0.0.0.0:9999). Prefer + * x-forwarded-proto/x-forwarded-host, fall back to host, then request.nextUrl. + */ +function publicRedirect(request: NextRequest, path: string): NextResponse { + const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); + const forwardedHost = + request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() || + request.headers.get("host")?.trim(); + + const proto = forwardedProto || request.nextUrl.protocol.replace(/:$/, ""); + const host = forwardedHost || request.nextUrl.host; + + return NextResponse.redirect(new URL(path, `${proto}://${host}`)); +} + +export async function middleware(request: NextRequest): Promise { + return withFrameAncestors(await handle(request)); +} + +async function handle(request: NextRequest): Promise { const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY; const { pathname } = request.nextUrl; const appPathname = stripBasePath(pathname); @@ -39,9 +109,9 @@ export async function middleware(request: NextRequest) { } const sessionCookie = request.cookies.get(ACCESS_KEY_COOKIE)?.value; - const isAuthenticated = await verifySessionToken(sessionCookie, accessKey); + const session = await verifySessionToken(sessionCookie, accessKey); - if (!isAuthenticated) { + if (!session.valid) { return NextResponse.json( localizeApiErrorPayload(request, { error: "Unauthorized", @@ -51,6 +121,15 @@ export async function middleware(request: NextRequest) { ); } + // Scoped sessions may only reach banks under their prefix. Body-only bank + // ids are enforced in-route via bank-guard (middleware can't read the body). + if (session.prefix) { + const targetBankId = apiTargetBankId(appPathname, request); + if (targetBankId !== null && !bankAllowed(session.prefix, targetBankId)) { + return forbidden(request); + } + } + return NextResponse.next(); } @@ -62,15 +141,23 @@ export async function middleware(request: NextRequest) { if (!isPublic) { const sessionCookie = request.cookies.get(ACCESS_KEY_COOKIE)?.value; - const isAuthenticated = await verifySessionToken(sessionCookie, accessKey); + const session = await verifySessionToken(sessionCookie, accessKey); - if (!isAuthenticated) { + if (!session.valid) { // Next.js middleware redirects do not automatically inherit next.config basePath. // Prefix the target explicitly, but keep returnTo as the app-relative path so // client-side router.push() does not double-prefix after login. - const loginUrl = new URL(withBasePath("/login"), request.url); - loginUrl.searchParams.set("returnTo", appPathname); - return NextResponse.redirect(loginUrl); + const loginPath = `${withBasePath("/login")}?returnTo=${encodeURIComponent(appPathname)}`; + return publicRedirect(request, loginPath); + } + + // Block scoped sessions from navigating to a foreign bank page; send them + // back to the dashboard rather than exposing another prefix's chrome. + if (session.prefix) { + const bankMatch = appPathname.match(/^\/banks\/([^/?]+)/); + if (bankMatch && !bankAllowed(session.prefix, decodeURIComponent(bankMatch[1]))) { + return publicRedirect(request, withBasePath("/dashboard")); + } } } } diff --git a/hindsight-control-plane/tests/lib/auth/session.test.ts b/hindsight-control-plane/tests/lib/auth/session.test.ts index 9cbb4f7ca3..964d5cf1b9 100644 --- a/hindsight-control-plane/tests/lib/auth/session.test.ts +++ b/hindsight-control-plane/tests/lib/auth/session.test.ts @@ -28,48 +28,78 @@ function fakeRequest({ } describe("createSessionToken / verifySessionToken", () => { - it("round-trips: a freshly issued token verifies", async () => { + it("round-trips an admin (empty-prefix) token", async () => { const token = await createSessionToken(ACCESS_KEY); - expect(await verifySessionToken(token, ACCESS_KEY)).toBe(true); + expect(await verifySessionToken(token, ACCESS_KEY)).toEqual({ valid: true, prefix: "" }); }); - it("emits the documented `.` shape", async () => { - const token = await createSessionToken(ACCESS_KEY); - const [issuedAt, sig, ...rest] = token.split("."); + it("round-trips a scoped prefix", async () => { + const token = await createSessionToken(ACCESS_KEY, "u2"); + expect(await verifySessionToken(token, ACCESS_KEY)).toEqual({ valid: true, prefix: "u2" }); + }); + + it("round-trips a prefix containing base64-sensitive characters", async () => { + const token = await createSessionToken(ACCESS_KEY, "u2--a/b+c"); + expect(await verifySessionToken(token, ACCESS_KEY)).toEqual({ + valid: true, + prefix: "u2--a/b+c", + }); + }); + + it("emits the documented `..` shape", async () => { + const token = await createSessionToken(ACCESS_KEY, "u2"); + const [issuedAt, prefixB64, sig, ...rest] = token.split("."); expect(rest).toHaveLength(0); expect(Number.isInteger(Number(issuedAt))).toBe(true); + expect(prefixB64.length).toBeGreaterThan(0); expect(sig.length).toBeGreaterThan(0); }); it("rejects when the signature is tampered", async () => { - const token = await createSessionToken(ACCESS_KEY); - const [issuedAt] = token.split("."); - const forged = `${issuedAt}.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`; - expect(await verifySessionToken(forged, ACCESS_KEY)).toBe(false); + const token = await createSessionToken(ACCESS_KEY, "u2"); + const [issuedAt, prefixB64] = token.split("."); + const forged = `${issuedAt}.${prefixB64}.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`; + expect(await verifySessionToken(forged, ACCESS_KEY)).toEqual({ valid: false, prefix: "" }); }); - it("rejects when the payload is swapped (signature no longer matches)", async () => { - const token = await createSessionToken(ACCESS_KEY); - const [, sig] = token.split("."); - const futureTime = (Math.floor(Date.now() / 1000) - 10).toString(); - const forged = `${futureTime}.${sig}`; - expect(await verifySessionToken(forged, ACCESS_KEY)).toBe(false); + it("rejects when the prefix is swapped (signature no longer matches)", async () => { + const token = await createSessionToken(ACCESS_KEY, "u2"); + const [issuedAt, , sig] = token.split("."); + const forgedPrefix = base64UrlEncode("u5"); + const forged = `${issuedAt}.${forgedPrefix}.${sig}`; + expect(await verifySessionToken(forged, ACCESS_KEY)).toEqual({ valid: false, prefix: "" }); + }); + + it("rejects a legacy 2-part token", async () => { + const issuedAt = Math.floor(Date.now() / 1000).toString(); + const { hmacSha256 } = await loadHmacHelper(); + const legacySig = await hmacSha256(ACCESS_KEY, issuedAt); + expect(await verifySessionToken(`${issuedAt}.${legacySig}`, ACCESS_KEY)).toEqual({ + valid: false, + prefix: "", + }); }); it("rejects the bare-string cookie value that the old impl accepted", async () => { - expect(await verifySessionToken("authenticated", ACCESS_KEY)).toBe(false); + expect(await verifySessionToken("authenticated", ACCESS_KEY)).toEqual({ + valid: false, + prefix: "", + }); }); - it.each(["", undefined, ".", "abc", "abc.", ".abc", "notanumber.sig"])( + it.each(["", undefined, ".", "..", "abc", "abc.", ".abc", "notanumber.cHJlZml4.sig"])( "rejects malformed token: %j", async (bad) => { - expect(await verifySessionToken(bad, ACCESS_KEY)).toBe(false); + expect(await verifySessionToken(bad, ACCESS_KEY)).toEqual({ valid: false, prefix: "" }); } ); it("rejects when the access key has rotated since the token was issued", async () => { - const token = await createSessionToken(ACCESS_KEY); - expect(await verifySessionToken(token, "different-access-key")).toBe(false); + const token = await createSessionToken(ACCESS_KEY, "u2"); + expect(await verifySessionToken(token, "different-access-key")).toEqual({ + valid: false, + prefix: "", + }); }); describe("expiry", () => { @@ -83,16 +113,16 @@ describe("createSessionToken / verifySessionToken", () => { it("accepts a token issued just inside the max-age window", async () => { vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); - const token = await createSessionToken(ACCESS_KEY); + const token = await createSessionToken(ACCESS_KEY, "u2"); vi.setSystemTime(new Date(Date.now() + (SESSION_MAX_AGE_SECONDS - 5) * 1000)); - expect(await verifySessionToken(token, ACCESS_KEY)).toBe(true); + expect(await verifySessionToken(token, ACCESS_KEY)).toEqual({ valid: true, prefix: "u2" }); }); it("rejects a token issued past the max-age window", async () => { vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); - const token = await createSessionToken(ACCESS_KEY); + const token = await createSessionToken(ACCESS_KEY, "u2"); vi.setSystemTime(new Date(Date.now() + (SESSION_MAX_AGE_SECONDS + 5) * 1000)); - expect(await verifySessionToken(token, ACCESS_KEY)).toBe(false); + expect(await verifySessionToken(token, ACCESS_KEY)).toEqual({ valid: false, prefix: "" }); }); it("rejects a token whose issuedAt is implausibly in the future", async () => { @@ -100,9 +130,12 @@ describe("createSessionToken / verifySessionToken", () => { const farFuture = Math.floor(Date.now() / 1000) + 3600; // Forge a properly-signed token with a future iat to isolate the iat // check (a tampered iat alone would also fail the signature check). + const prefixB64 = base64UrlEncode("u2"); const { hmacSha256 } = await loadHmacHelper(); - const futureSig = await hmacSha256(ACCESS_KEY, farFuture.toString()); - expect(await verifySessionToken(`${farFuture}.${futureSig}`, ACCESS_KEY)).toBe(false); + const futureSig = await hmacSha256(ACCESS_KEY, `${farFuture}.${prefixB64}`); + expect( + await verifySessionToken(`${farFuture}.${prefixB64}.${futureSig}`, ACCESS_KEY) + ).toEqual({ valid: false, prefix: "" }); }); }); }); @@ -131,8 +164,15 @@ describe("isSecureRequest", () => { }); }); -// Inlined HMAC helper for the future-iat test — re-derives a signature without -// reaching into private module internals. +function base64UrlEncode(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +// Inlined HMAC helper for the forged-token tests — re-derives a signature +// without reaching into private module internals. async function loadHmacHelper() { async function hmacSha256(secret: string, message: string): Promise { const enc = new TextEncoder();