diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index 12bfb0ead..cda2e7813 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -76,6 +76,10 @@ An upgrade validates the runtime, applies supported pending migrations, deploys Before starting, Void checks access to the installation's Cloudflare account, Workers, and existing R2 buckets. If credentials are rejected, set `CLOUDFLARE_API_TOKEN` to a management token scoped to that account and retry. This overrides the installation's saved management token. +::: warning Projects deployed before credential rotation +Current releases no longer accept the original project credentials. After upgrading, the platform gives each project new credentials by itself. Until a project's turn comes (minutes to hours depending on how many projects you have), its pages keep working but AI, email, ISR revalidation, prerendering, and remote D1, KV, and R2 can fail. Check progress with `void platform system rotate-proxy-tokens --plan` until `legacy`, `pending` and `failed` are all 0; a project leaves `legacy` when its rotation starts, before it is finished. Redeploying a project fixes it right away; if that deploy fails, run `void platform system rotate-proxy-tokens --project --yes`. +::: + ::: details Migration safety, credential rotation, and interrupted upgrades Some upgrades briefly put the platform into maintenance mode. Traffic resumes after the new version passes health checks. If an upgrade is interrupted during maintenance, rerun the same command to complete it. diff --git a/docs/reference/cli/platform-operations.md b/docs/reference/cli/platform-operations.md index 5d7a09f61..b3a11d782 100644 --- a/docs/reference/cli/platform-operations.md +++ b/docs/reference/cli/platform-operations.md @@ -65,7 +65,7 @@ void platform system rotate-proxy-tokens [--project ] [--all] `sandbox-drain` removes legacy tenant-owned Sandbox resources. Use `--plan` to preview and `--yes` to apply, then pass `nextCursor` as `--cursor` to continue. Application traffic stays paused during cleanup. -`rotate-proxy-tokens` gives projects new platform credentials on all of their Workers, without a redeploy. The platform also rotates projects that still use their original credentials by itself after an upgrade; use this command to check progress, rotate one project, or rotate every project with `--all`. Without `--project` it covers every project that still uses its original credentials; `--all` covers every project. Use `--plan` to preview and `--yes` to apply. Each run does a bounded batch and the platform finishes the rest in the background; a rotation takes about 6 minutes per project, longer while a deploy is running. Use `--plan` to check progress until nothing is pending or failed. Running `--yes` again with `--project` or `--all` while a rotation is still running starts one more rotation for that project. When a rotation is finished, the previous credentials no longer work. Each rotation starts a new version of the project's live Worker, so WebSocket connections reconnect once. +`rotate-proxy-tokens` gives projects new platform credentials on all of their Workers, without a redeploy. The platform also rotates projects that still use their original credentials by itself after an upgrade; use this command to check progress, rotate one project, or rotate every project with `--all`. Without `--project` it covers every project that still uses its original credentials; `--all` covers every project. Use `--plan` to preview and `--yes` to apply. Each run does a bounded batch and the platform finishes the rest in the background; a rotation takes about 6 minutes per project, longer while a deploy is running. Use `--plan` to check progress until nothing is pending or failed. Running `--yes` again with `--project` or `--all` while a rotation is still running starts one more rotation for that project. When a rotation is finished, the previous credentials no longer work. Each rotation starts a new version of the project's live Worker, so WebSocket connections reconnect once. After an upgrade, projects still on their original credentials can see errors until they are rotated; see [Upgrade the platform](../../guide/platform/installation/maintenance.md#upgrade-the-platform). Use the [platform lifecycle commands](./platform-installation.md#lifecycle-commands) to maintain your installation's Workers. diff --git a/platform/meta/operations.md b/platform/meta/operations.md index 19ccc1e07..d8fce81e1 100644 --- a/platform/meta/operations.md +++ b/platform/meta/operations.md @@ -41,7 +41,7 @@ These deployment and smoke-test workflows target Void Cloud, from `prod` for pro Both deploy workflows serialize deployments without cancelling an active rollout. Advancing `prod` requests a production deployment. A failed or interrupted deployment needs inspection before another rollout, particularly when database migrations already ran. -Hosted API and email gateway deploys report the release version from `packages/platform/package.json` as `PLATFORM_VERSION`. The `void` CLI refuses platforms older than its minimum platform version, so release in this order: advance `prod` and wait for the production deployment to succeed, then push the release tag that publishes the npm packages. +Hosted API and email gateway deploys report the release version from `packages/platform/package.json` as `PLATFORM_VERSION`. The `void` CLI refuses platforms older than its minimum platform version, so release in this order: advance `prod` and wait for the production deployment to succeed, then push the release tag that publishes the npm packages. Before the release that removes the legacy proxy token reaches staging or production, `rotate-proxy-tokens --plan` there must show 0 legacy, 0 pending and 0 failed; `legacy: 0` alone is not enough, because a project leaves it when its rotation starts or a deploy moves it to generation 1, while its older Workers still hold the legacy token until the rotation finishes. If that environment was rolled back below the rotation release after it shipped, run `rotate-proxy-tokens --all --yes` there first, since `--plan` cannot see a Worker redeployed with the legacy token during the rollback. See [Proxy token rotation](#proxy-token-rotation). Consult the workflow files for their current inputs. Store credentials in the selected GitHub environment and the deployment owner's secret manager. Keep @@ -84,6 +84,10 @@ The API also requests a rotation when a collaborator or admin loses deploy acces Accept floors live in the ROUTING namespace. If it is ever recreated, every floor reads as 0 and old tokens work again: run `rotate-proxy-tokens --all --yes`. Before rolling the platform back, let running rotations finish (`--plan` shows nothing pending); after a rollback to the release before rotation, a project in the middle of a rotation gets 403 on remote D1/KV/R2 until the platform is upgraded again. So does a project deployed since the upgrade, until it is redeployed or the platform is upgraded again. A rollback to an older release drops rotations that have not finished, and Workers deployed while it runs carry the legacy token, which a rotated project rejects. After a rollback to the release just before rotation, a project first deployed since the upgrade is on generation 1 with no floor, so a redeploy during the rollback bakes the legacy token; after upgrading again, `--plan` does not list it, and the release that stops accepting the legacy token breaks it. After upgrading again from a rollback to either release, run `rotate-proxy-tokens --all --yes` and check with `--plan` until nothing is pending or failed: every Worker gets a new token and every floor rises again. +The proxy and API no longer accept or send the legacy token. Rotation in the previous release moves every project off it by itself; the scheduled run keeps queuing any project still on it. Wait until `--plan` shows 0 legacy, 0 pending and 0 failed (see the release order above). Redeploy the Workers listed by the query above before this release. A project still on the legacy token moves to generation 1 at its next deploy, and its older Workers get a rotation. Static projects are rotated too: they can keep earlier Worker deployments for rollback. + +Do not roll back only the API to a release that does not check `x-void-proxy-internal` while the proxy runs a release without the legacy token: metadata calls fail until both match. A self-hosted upgrade from such a release fails these calls for the seconds until the new proxy is active. + ## Secrets Use separate credentials for CI deployment and platform runtime access. Runtime permissions cover tenant resource provisioning; the deploy credential also needs the permissions required to upload the configured Workers and build-container images. diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index d87f7b3e2..50a9af276 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -144,9 +144,9 @@ HMAC. See [Platform Development](../../../docs/guide/platform/development/runtim - `POST /internal/verify-owner/:projectId` — legacy URL that live-checks the caller's project deploy capability `resolve-resource`, `resolve-worker` and `verify-prerender-build` authenticate -the proxy with its own key (`x-void-proxy-internal`); a request without it is -checked like a project token. `revalidate` checks the project token forwarded -by the proxy against the project's accept floor (see the proxy README). +the proxy with its own key (`x-void-proxy-internal`) and do not accept project +tokens. `revalidate` checks the project token forwarded by the proxy against +the project's accept floor (see the proxy README). ### Operator API diff --git a/platform/packages/api/src/admin/backfill-queue-tokens.ts b/platform/packages/api/src/admin/backfill-queue-tokens.ts index a4698b788..5e8d788ca 100644 --- a/platform/packages/api/src/admin/backfill-queue-tokens.ts +++ b/platform/packages/api/src/admin/backfill-queue-tokens.ts @@ -1,4 +1,3 @@ -import { hmacSignState } from '../auth/hmac.ts'; import { getDispatchWorkerProxyToken, type ProxyTokenLookup, @@ -8,7 +7,6 @@ export type BackfillQueueTokensOptions = Readonly<{ accountId: string; apiToken: string; dispatchNamespace: string; - jwtSecret: string; /** Validate every candidate without writing to KV. */ dryRun?: boolean; /** @@ -52,9 +50,8 @@ type QueueRoutingEntry = Readonly<{ * Iterate every `queue:*` KV entry and stamp `proxyToken` on any entry * written before the dispatch relay started forwarding * `x-void-internal`. The stamped token is read from the target worker's - * `__VOID_PROXY_TOKEN` binding so it survives `JWT_SECRET` rotation — - * a freshly `hmacSignState(projectId, currentSecret)` value would 401 - * any worker deployed before the rotation. + * `__VOID_PROXY_TOKEN` binding: the worker accepts only the value it + * holds. A worker without that binding is skipped; it needs a redeploy. * * Concurrency-safe-ish: each entry is re-read immediately before the * put; if the value changed we skip the write so a concurrent deploy @@ -123,12 +120,10 @@ export async function backfillQueueTokens( ); errored++; continue; - } else if (entry.projectId) { - // lookup.kind === 'missing' — legacy worker, no baked binding. - // Fall back to re-derivation. Only safe while JWT_SECRET has - // not rotated since this worker was deployed. - proxyToken = await hmacSignState(entry.projectId, opts.jwtSecret); } else { + console.warn( + `[backfill-queue-tokens] worker "${entry.workerName}" has no __VOID_PROXY_TOKEN binding. Skipping "${name}" — redeploy the project.`, + ); skipped++; continue; } diff --git a/platform/packages/api/src/deploy/recovery.ts b/platform/packages/api/src/deploy/recovery.ts index ba79fbd94..8bec5c26a 100644 --- a/platform/packages/api/src/deploy/recovery.ts +++ b/platform/packages/api/src/deploy/recovery.ts @@ -1,5 +1,4 @@ import { sql as drizzleSql } from 'drizzle-orm'; -import { hmacSignState } from '../auth/hmac.ts'; import { DEFAULT_DISPATCH_NAMESPACE } from '../constants.ts'; import { clearCronScheduler, syncCronScheduler } from '../cron-scheduler-client.ts'; import { @@ -172,11 +171,11 @@ export function resolvePersistedDeploymentType( return manifest.assetConfig?.not_found_handling === 'single-page-application' ? 'spa' : 'static'; } +/** The Worker's own token, or `undefined` when it has no token binding. */ async function resolveCutoverProxyToken( env: ApiEnv['Bindings'], workerName: string, - projectId: string, -): Promise { +): Promise { const lookup = await getDispatchWorkerProxyToken( env.CF_ACCOUNT_ID, env.CF_API_TOKEN, @@ -189,7 +188,10 @@ async function resolveCutoverProxyToken( if (lookup.kind === 'error') { throw lookup.error; } - return hmacSignState(projectId, env.JWT_SECRET); + console.warn( + `[cutover] worker "${workerName}" has no __VOID_PROXY_TOKEN binding; its cron and queue relays get no token. Redeploy the project.`, + ); + return undefined; } /** Make every persisted custom-domain key match one authoritative route. */ @@ -340,7 +342,7 @@ export async function replayDeploymentCutover( ? await proxyTokenCopies( env.JWT_SECRET, project, - await resolveCutoverProxyToken(env, workerName, project.id), + await resolveCutoverProxyToken(env, workerName), ) : {}; diff --git a/platform/packages/api/src/id.ts b/platform/packages/api/src/id.ts index 107bea262..9645821c1 100644 --- a/platform/packages/api/src/id.ts +++ b/platform/packages/api/src/id.ts @@ -18,10 +18,9 @@ export function generateId(prefix: string): string { const PROJECT_ID = new RegExp(`^proj_[${ALPHABET}]{${ID_LENGTH}}$`); /** - * Whether `value` has the shape `generateId('proj')` produces. The project - * token is an HMAC over the project id under the same key that signs login - * JWTs and OAuth state, so callers check this before verifying the token: - * any other string (such as a JWT's `header.body`) must not authenticate. + * Whether `value` has the shape `generateId('proj')` produces. Callers check + * this before verifying a token bound to a project id, so no other string + * reaches the MAC check. */ export function isProjectId(value: unknown): value is string { return typeof value === 'string' && PROJECT_ID.test(value); diff --git a/platform/packages/api/src/internal-proxy-auth.ts b/platform/packages/api/src/internal-proxy-auth.ts index 6b14b1acb..b272d2859 100644 --- a/platform/packages/api/src/internal-proxy-auth.ts +++ b/platform/packages/api/src/internal-proxy-auth.ts @@ -44,9 +44,7 @@ export async function checkTenantProxyToken( /** * Authenticates the proxy on an internal metadata route. The proxy signs * `x-void-proxy-internal` with its own key, bound to the route and project. - * A request without that header is checked like a tenant project token in - * `x-void-internal`, so a token below the project's accept floor is refused - * here as it is at the proxy. + * Tenant project tokens are not accepted here. * * Returns null when the call may proceed, else the error response. */ @@ -56,23 +54,13 @@ export async function requireProxyInternalAuth( projectId: string, ): Promise { const proxyAuth = context.req.header(PROXY_INTERNAL_AUTH_HEADER); - const token = context.req.header('x-void-internal'); - if (!proxyAuth && !token) { + if (!proxyAuth) { return context.json({ error: 'missing internal auth' }, 401); } - if (!isProjectId(projectId)) { - return context.json({ error: 'invalid internal auth' }, 403); - } - if (proxyAuth) { - return (await verifyProxyInternalAuth(context.env.JWT_SECRET, route, projectId, proxyAuth)) - ? null - : context.json({ error: 'invalid internal auth' }, 403); - } - const tokenCheck = await checkTenantProxyToken(context.env, projectId, token ?? ''); - if (tokenCheck === 'unavailable') { - return context.json({ error: 'project token state unavailable' }, 503); - } - if (tokenCheck === 'invalid') { + if ( + !isProjectId(projectId) || + !(await verifyProxyInternalAuth(context.env.JWT_SECRET, route, projectId, proxyAuth)) + ) { return context.json({ error: 'invalid internal auth' }, 403); } return null; diff --git a/platform/packages/api/src/operator/system-operations.ts b/platform/packages/api/src/operator/system-operations.ts index 137deac95..2a911557f 100644 --- a/platform/packages/api/src/operator/system-operations.ts +++ b/platform/packages/api/src/operator/system-operations.ts @@ -247,7 +247,6 @@ export async function backfillPlatformQueueTokens(env: ApiEnv['Bindings'], dryRu accountId: env.CF_ACCOUNT_ID, apiToken: env.CF_API_TOKEN, dispatchNamespace: env.DISPATCH_NAMESPACE ?? DEFAULT_DISPATCH_NAMESPACE, - jwtSecret: env.JWT_SECRET, dryRun, }); } diff --git a/platform/packages/api/src/provisioning/cloudflare-api.ts b/platform/packages/api/src/provisioning/cloudflare-api.ts index 49bac05f1..97b4fd8e5 100644 --- a/platform/packages/api/src/provisioning/cloudflare-api.ts +++ b/platform/packages/api/src/provisioning/cloudflare-api.ts @@ -409,8 +409,8 @@ export type ProxyTokenLookup = * Returns a typed result so callers can distinguish: * - `found`: use the baked token as-is. * - `missing`: bindings list returned but no `__VOID_PROXY_TOKEN` — - * a legacy worker. Callers may fall back to re-derivation with a - * visible warning. + * a legacy worker. Callers must not re-derive a token; the Worker + * needs a redeploy. * - `error`: CF API failure (rate limit, transport, auth, 5xx). * Callers MUST NOT fall back silently — a transient error must * not end up writing a mismatched token that 401s after a diff --git a/platform/packages/api/src/proxy-token-rotation.ts b/platform/packages/api/src/proxy-token-rotation.ts index 1df3e89f3..75177fe6d 100644 --- a/platform/packages/api/src/proxy-token-rotation.ts +++ b/platform/packages/api/src/proxy-token-rotation.ts @@ -37,10 +37,12 @@ export { * Proxy token generations per project. * * `projects.proxy_token_gen` is the highest generation the platform has issued - * for the project (0 = the legacy token). Deploys bake the token for that - * generation into the Worker, and the proxy sends it. A Worker deploy moves 0 - * to 1 (`issueProjectProxyToken`); after that only a rotation raises it. The - * KV accept floor (`proxy-token:`) never exceeds it. + * for the project. 0 means nothing has been issued yet: the project's Workers + * still hold the legacy token, which is no longer accepted, and the proxy + * sends nothing. From 1 up, deploys bake the token for that generation into + * the Worker and the proxy sends it. A Worker deploy moves 0 to 1 + * (`issueProjectProxyToken`); after that only a rotation raises it. The KV + * accept floor (`proxy-token:`) never exceeds it. */ /** How long a rotation waits for KV copies and old Worker versions to drain. */ @@ -139,8 +141,10 @@ export async function proxyTokenCopies( // deployment is in flight (the same fence as deploys, rollback and secret // writes). A wait is stored in the payload as `waitUntil`, taken from // `clock_timestamp()` after the step's last outside write, so a kick of -// `next_attempt_at` never cuts it short. Until the floor rises both tokens are -// accepted, so a failed or unfinished rotation never breaks a tenant. +// `next_attempt_at` never cuts it short. When the old generation is 1 or +// higher, both tokens are accepted until the floor rises, so a failed or +// unfinished rotation never breaks a tenant. The legacy token (generation 0) +// is never accepted. export type ProxyTokenRotationPhase = 'requested' | 'copies' | 'patch' | 'revoke' | 'settle'; diff --git a/platform/packages/api/src/proxy-token.ts b/platform/packages/api/src/proxy-token.ts index 30ead727e..432ad3934 100644 --- a/platform/packages/api/src/proxy-token.ts +++ b/platform/packages/api/src/proxy-token.ts @@ -3,17 +3,15 @@ import { isProjectId } from './id.ts'; /** * Tenant proxy tokens. * - * A tenant Worker authenticates to the platform with `__VOID_PROXY_TOKEN`. - * Two forms exist: - * - * - legacy (generation 0): base64url(HMAC-SHA256(JWT_SECRET, projectId)), - * 43 characters, the bytes `hmacSignState` produces; - * - `v2..`: the MAC is HMAC-SHA256 under a key derived from - * JWT_SECRET with HKDF, over the project id and the generation. + * A tenant Worker authenticates to the platform with `__VOID_PROXY_TOKEN`, + * `v2..` with `gen >= 1`: the MAC is HMAC-SHA256 under a key + * derived from JWT_SECRET with HKDF, over the project id and the generation. + * The original token, an HMAC of the project id under JWT_SECRET itself, is + * never accepted. * * ROUTING `proxy-token:` holds the accept floor `{"gen": F}`. A - * token is accepted when its MAC is valid and its generation is at least F. - * A missing key is floor 0, which accepts the legacy token. + * token is accepted when its MAC is valid and its generation is at least + * max(F, 1). A missing key is floor 0: no floor. * * Every check runs `isProjectId` and the syntax check before any crypto, and * the MAC before any KV read, so a forged token never costs a KV read. @@ -25,10 +23,10 @@ export function proxyTokenStateKey(projectId: string): string { return `${PROXY_TOKEN_STATE_KEY_PREFIX}${projectId}`; } -/** Accept floor. Generation 0 accepts the legacy token. */ +/** Accept floor. Generation 0 is no floor: every generation from 1 is accepted. */ export type ProxyTokenState = Readonly<{ gen: number }>; -export const LEGACY_PROXY_TOKEN_STATE: ProxyTokenState = Object.freeze({ gen: 0 }); +export const NO_PROXY_TOKEN_FLOOR: ProxyTokenState = Object.freeze({ gen: 0 }); /** KV `cacheTtl` for floor reads on request paths (the KV minimum). */ export const PROXY_TOKEN_KV_CACHE_TTL_SECONDS = 30; @@ -55,7 +53,7 @@ function isStateGeneration(gen: unknown): gen is number { /** Missing state is floor 0. Anything other than `{"gen": = 0>}` throws. */ export function parseProxyTokenState(raw: string | null | undefined): ProxyTokenState { if (raw === null || raw === undefined) { - return LEGACY_PROXY_TOKEN_STATE; + return NO_PROXY_TOKEN_FLOOR; } let value: unknown; try { @@ -81,31 +79,25 @@ export function serializeProxyTokenState(state: ProxyTokenState): string { } export function isAcceptedGeneration(state: ProxyTokenState, gen: number): boolean { - return gen >= state.gen; + return gen >= Math.max(state.gen, 1); } /** * Generations a sender tries, in order, for the issued generation: the * current one, then the one before it while Workers may still hold it. - * Generation 2 also tries the legacy token last: a Worker deploy moves a - * project from 0 to 1 and queues a rotation to 2 for its older Workers, which - * hold the legacy token until that rotation patches them. + * Empty for generation 0: nothing has been issued. */ export function sendGenerations(issueGen: number): ReadonlyArray { if (!Number.isSafeInteger(issueGen) || issueGen < 0) { throw new RangeError(`invalid proxy token generation: ${issueGen}`); } - if (issueGen === 0) { - return [0]; - } - return issueGen === 2 ? [2, 1, 0] : [issueGen, issueGen - 1]; + return [issueGen, issueGen - 1].filter((gen) => gen >= 1); } // --- Keys --- const encoder = new TextEncoder(); -const legacyKeys = new Map>(); const proxyTokenKeys = new Map>(); const proxyInternalKeys = new Map>(); @@ -123,18 +115,6 @@ function cachedKey( return key; } -function legacyKey(secret: string): Promise { - return cachedKey(legacyKeys, secret, () => - crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign', 'verify'], - ), - ); -} - async function hkdfHmacKey(secret: string, salt: string, info: string): Promise { const base = await crypto.subtle.importKey('raw', encoder.encode(secret), 'HKDF', false, [ 'deriveKey', @@ -192,7 +172,7 @@ function proxyTokenMessage(projectId: string, gen: number): string { // --- Tenant tokens --- -/** Generation 0 is the legacy token. */ +/** `gen` must be a safe integer of at least 1. */ export async function deriveProxyToken( jwtSecret: string, projectId: string, @@ -201,12 +181,9 @@ export async function deriveProxyToken( if (!isProjectId(projectId)) { throw new Error('invalid project id'); } - if (!Number.isSafeInteger(gen) || gen < 0) { + if (!Number.isSafeInteger(gen) || gen < 1) { throw new RangeError(`invalid proxy token generation: ${gen}`); } - if (gen === 0) { - return sign(legacyKey(jwtSecret), projectId); - } return `v2.${gen}.${await sign(proxyTokenKey(jwtSecret), proxyTokenMessage(projectId, gen))}`; } @@ -219,9 +196,6 @@ export async function proxyTokenGeneration( if (!isProjectId(projectId)) { return null; } - if (MAC.test(token)) { - return (await verify(legacyKey(jwtSecret), projectId, token)) ? 0 : null; - } const match = V2_TOKEN.exec(token); if (!match) { return null; diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 307ece873..bfdca08bb 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -4,7 +4,6 @@ import { PLATFORM_HEALTH_HOSTNAME_LABEL } from '@void/platform'; import { sql as drizzleSql } from 'drizzle-orm'; import { Hono, type Context } from 'hono'; import { deployTokenTtlSecondsForBuildMinutes, signPrerenderToken } from '../auth/deploy-token.ts'; -import { hmacSignState } from '../auth/hmac.ts'; import { buildManagerNamespace } from '../build-namespace.ts'; import { isTerminalBuildStatus } from '../build-status.ts'; import { getPlatformCapabilities } from '../capabilities.ts'; @@ -356,17 +355,13 @@ function sortProjectList(a: ProjectListItem, b: ProjectListItem): number { * Resolve the proxy token to use when re-targeting routing/cron at a * historical deployment. * - * Preferred path: fetch the EXACT `__VOID_PROXY_TOKEN` plain_text - * binding baked into the target worker. Rotating `JWT_SECRET` after - * that worker was deployed would make a fresh `hmacSignState` value - * mismatch the worker and 401 every /__void/queue and /__void/scheduled - * relay. + * Fetch the EXACT `__VOID_PROXY_TOKEN` plain_text binding baked into the + * target worker: the /__void/queue and /__void/scheduled relays only work + * with the value that worker holds. * - * Legacy workers (deployed before the binding was introduced) report - * `{kind: "missing"}`. Falling back to the current-secret derivation is - * safe only if `JWT_SECRET` has not rotated since the worker was - * deployed; the warning surfaces the risk to operators so they can - * redeploy instead of relying on the fallback. + * A worker without that binding returns `undefined`: no token value it + * accepts can be derived, so its cron and queue copies carry no token and + * the project needs a redeploy. * * Transient CF API failures MUST propagate. A silent null that becomes * a fallback can write a mismatched token into KV that will 401 the @@ -375,8 +370,7 @@ function sortProjectList(a: ProjectListItem, b: ProjectListItem): number { async function resolveRollbackProxyToken( env: ApiEnv['Bindings'], workerName: string, - projectId: string, -): Promise { +): Promise { const dispatchNamespace = env.DISPATCH_NAMESPACE ?? DEFAULT_DISPATCH_NAMESPACE; const lookup = await getDispatchWorkerProxyToken( env.CF_ACCOUNT_ID, @@ -392,11 +386,10 @@ async function resolveRollbackProxyToken( `rollback: could not read __VOID_PROXY_TOKEN from worker "${workerName}": ${lookup.error.message}`, ); } - // lookup.kind === 'missing' — legacy worker. console.warn( - `[rollback] worker "${workerName}" has no __VOID_PROXY_TOKEN binding; falling back to current-secret derivation. If JWT_SECRET has rotated since this deployment was live, you must redeploy the project instead.`, + `[rollback] worker "${workerName}" has no __VOID_PROXY_TOKEN binding; its cron and queue relays get no token. Redeploy the project.`, ); - return hmacSignState(projectId, env.JWT_SECRET); + return undefined; } /** @@ -1537,7 +1530,7 @@ const app = new Hono() ? await proxyTokenCopies( context.env.JWT_SECRET, lockedProject, - await resolveRollbackProxyToken(context.env, workerName, projectId), + await resolveRollbackProxyToken(context.env, workerName), ) : {}; diff --git a/platform/packages/api/test/integration/api-internal-project-token.test.ts b/platform/packages/api/test/integration/api-internal-project-token.test.ts index bb4db0b69..dd17778d8 100644 --- a/platform/packages/api/test/integration/api-internal-project-token.test.ts +++ b/platform/packages/api/test/integration/api-internal-project-token.test.ts @@ -127,44 +127,66 @@ describe('per-project token checks', () => { const project = await createTestProject(env.db, user.id); const { response, run } = await proxyRequest( project.id, - await hmacSignState(project.id, JWT_SECRET), + await deriveProxyToken(JWT_SECRET, project.id, 1), ); expect(response.status).toBe(200); expect(run).toHaveBeenCalledOnce(); }); - it.each(['revalidate', 'resolve-resource', 'resolve-worker', 'verify-prerender-build'])( - 'rejects a login JWT presented as a project id and token at /internal/%s', + it('rejects a login JWT presented as a project id and token at /internal/revalidate', async () => { + const forged = await loginJwtAsProjectToken(); + const response = await internalRequest('revalidate', forged.projectId, forged.token); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'invalid internal auth' }); + }); + + it.each(['resolve-resource', 'resolve-worker', 'verify-prerender-build'])( + 'rejects a login JWT presented as a project id and credential at /internal/%s', async (route) => { const forged = await loginJwtAsProjectToken(); - const response = await internalRequest(route, forged.projectId, forged.token); - expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'invalid internal auth' }); + const asProxy = await internalRequest(route, forged.projectId, { + 'x-void-proxy-internal': forged.token, + }); + expect(asProxy.status).toBe(403); + expect(await asProxy.json()).toEqual({ error: 'invalid internal auth' }); + + const asProject = await internalRequest(route, forged.projectId, forged.token); + expect(asProject.status).toBe(401); + expect(await asProject.json()).toEqual({ error: 'missing internal auth' }); }, ); - it('accepts a generated project id with its token at every internal route', async () => { + it('accepts a generated project id at every internal route', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); const deployment = await createTestDeployment(env.db, project.id, 'live'); await createResource(env.db, project.id, 'kv', 'cf-kv-id', 'CACHE'); await env.ROUTING.put(project.slug, JSON.stringify({ version: deployment.id })); mockCfApi(); - const token = await hmacSignState(project.id, JWT_SECRET); + const token = await deriveProxyToken(JWT_SECRET, project.id, 1); + const proxyAuth = async (route: ProxyInternalRoute) => ({ + 'x-void-proxy-internal': await deriveProxyInternalAuth(JWT_SECRET, route, project.id), + }); const revalidated = await internalRequest('revalidate', project.id, token, { paths: ['/about'], }); expect(revalidated.status).toBe(200); - const resource = await internalRequest('resolve-resource', project.id, token, { - type: 'kv', - binding: 'CACHE', - }); + const resource = await internalRequest( + 'resolve-resource', + project.id, + await proxyAuth('resolve-resource'), + { type: 'kv', binding: 'CACHE' }, + ); expect(resource.status).toBe(200); expect(await resource.json()).toEqual({ cf_id: 'cf-kv-id' }); - const worker = await internalRequest('resolve-worker', project.id, token); + const worker = await internalRequest( + 'resolve-worker', + project.id, + await proxyAuth('resolve-worker'), + ); expect(worker.status).toBe(200); expect(await worker.json()).toEqual({ workerName: `user-${project.slug}-${deployment.id}`, @@ -172,9 +194,12 @@ describe('per-project token checks', () => { proxyTokenGen: 0, }); - const prerender = await internalRequest('verify-prerender-build', project.id, token, { - userId: user.id, - }); + const prerender = await internalRequest( + 'verify-prerender-build', + project.id, + await proxyAuth('verify-prerender-build'), + { userId: user.id }, + ); expect(prerender.status).toBe(200); expect(await prerender.json()).toEqual({ ok: true }); }); @@ -219,23 +244,30 @@ describe('project token generations', () => { expect(run).not.toHaveBeenCalled(); }); - it('accepts the legacy and v2 tokens at /internal/revalidate while no floor is set', async () => { + it('accepts a v2 token and rejects the legacy token at /internal/revalidate while no floor is set', async () => { const project = await liveProject(); - for (const gen of [0, 1]) { - const response = await internalRequest( - 'revalidate', - project.id, - await deriveProxyToken(JWT_SECRET, project.id, gen), - { paths: ['/about'] }, - ); - expect(response.status).toBe(200); - } + const accepted = await internalRequest( + 'revalidate', + project.id, + await deriveProxyToken(JWT_SECRET, project.id, 1), + { paths: ['/about'] }, + ); + expect(accepted.status).toBe(200); + + const legacy = await internalRequest( + 'revalidate', + project.id, + await hmacSignState(project.id, JWT_SECRET), + { paths: ['/about'] }, + ); + expect(legacy.status).toBe(403); + expect(await legacy.json()).toEqual({ error: 'invalid internal auth' }); }); it('applies the floor at /internal/revalidate', async () => { const project = await liveProject(); - await setFloor(project.id, '{"gen":1}'); - for (const gen of [1, 2]) { + await setFloor(project.id, '{"gen":2}'); + for (const gen of [2, 3]) { const response = await internalRequest( 'revalidate', project.id, @@ -244,14 +276,14 @@ describe('project token generations', () => { ); expect(response.status).toBe(200); } - const legacy = await internalRequest( + const below = await internalRequest( 'revalidate', project.id, - await hmacSignState(project.id, JWT_SECRET), + await deriveProxyToken(JWT_SECRET, project.id, 1), { paths: ['/about'] }, ); - expect(legacy.status).toBe(403); - expect(await legacy.json()).toEqual({ error: 'invalid internal auth' }); + expect(below.status).toBe(403); + expect(await below.json()).toEqual({ error: 'invalid internal auth' }); }); it('fails closed at /internal/revalidate when the floor is malformed', async () => { @@ -260,7 +292,7 @@ describe('project token generations', () => { const response = await internalRequest( 'revalidate', project.id, - await hmacSignState(project.id, JWT_SECRET), + await deriveProxyToken(JWT_SECRET, project.id, 1), { paths: ['/about'] }, ); expect(response.status).toBe(503); @@ -380,40 +412,21 @@ describe('proxy credential on the metadata routes', () => { }, ); - it.each(routes)('applies the floor to a project token at /internal/%s', async (route) => { - const { project, bodies, expected } = await liveProject(); - const legacy = await hmacSignState(project.id, JWT_SECRET); - - const accepted = await internalRequest(route, project.id, legacy, bodies[route]); - expect(accepted.status).toBe(200); - expect(await accepted.json()).toEqual(expected[route]); - - await env.ROUTING.put(`proxy-token:${project.id}`, '{"gen":1}'); - const rejected = await internalRequest(route, project.id, legacy, bodies[route]); - expect(rejected.status).toBe(403); - expect(await rejected.json()).toEqual({ error: 'invalid internal auth' }); - - await env.ROUTING.put(`proxy-token:${project.id}`, 'not json'); - const unavailable = await internalRequest(route, project.id, legacy, bodies[route]); - expect(unavailable.status).toBe(503); - expect(await unavailable.json()).toEqual({ error: 'project token state unavailable' }); - }); - - it.each(routes)('requires internal auth at /internal/%s', async (route) => { + it.each(routes)('requires the proxy credential at /internal/%s', async (route) => { const { project, bodies } = await liveProject(); const missing = await internalRequest(route, project.id, {}, bodies[route]); expect(missing.status).toBe(401); expect(await missing.json()).toEqual({ error: 'missing internal auth' }); const get = vi.spyOn(env.ROUTING, 'get'); - const forged = await internalRequest( - route, - project.id, - await hmacSignState('proj_000000000999', JWT_SECRET), - bodies[route], - ); - expect(forged.status).toBe(403); - expect(await forged.json()).toEqual({ error: 'invalid internal auth' }); + for (const token of [ + await hmacSignState(project.id, JWT_SECRET), + await deriveProxyToken(JWT_SECRET, project.id, 1), + ]) { + const projectToken = await internalRequest(route, project.id, token, bodies[route]); + expect(projectToken.status).toBe(401); + expect(await projectToken.json()).toEqual({ error: 'missing internal auth' }); + } expect(floorReads(get)).toEqual([]); }); }); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 2db3807e9..01d923674 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -7,6 +7,7 @@ import { verifyDeployToken, verifyPrerenderToken, } from '../../src/auth/deploy-token.ts'; +import { hmacSignState } from '../../src/auth/hmac.ts'; import { revokeConnectionSessions } from '../../src/auth/sessions.ts'; import { createDeployment, @@ -3249,7 +3250,10 @@ describe('POST /projects/:id/rollback', () => { await markDeploymentRetained(env.db, d2.id, true); await setActiveDeployment(env.db, project.id, d2.id); - const heldToken = await deriveProxyToken(env.JWT_SECRET, project.id, held); + const heldToken = + held === 0 + ? await hmacSignState(project.id, env.JWT_SECRET) + : await deriveProxyToken(env.JWT_SECRET, project.id, held); mockCfApi({ listWorkerBindings: () => cfSuccess([{ type: 'plain_text', name: '__VOID_PROXY_TOKEN', text: heldToken }]), @@ -3297,6 +3301,75 @@ describe('POST /projects/:id/rollback', () => { expect(sync.proxyTokenFallback).toBe(expected.proxyTokenFallback); }, ); + + it('rollback to a Worker without a token binding writes no token to cron or queues', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'roll-no-token'); + await env.db.update(projects).set({ proxy_token_gen: 1 }).where(eq(projects.id, project.id)); + const queueRoutingKey = `queue:${project.id.replaceAll('_', '-')}-emails`; + await env.db.insert(resources).values({ + id: 'res_roll_no_token', + project_id: project.id, + type: 'queue', + cf_id: '25832cb0ce3d4f60b1e538911a4629f4', + binding_name: 'emails', + }); + const manifest = JSON.stringify({ + version: 3, + bindings: {}, + schedules: [{ cron: '*/5 * * * *', filePath: 'crons/tick.ts' }], + queues: [{ name: 'emails', bindingName: 'QUEUE_EMAILS' }], + }); + const d1 = await createDeployment(env.db, project.id, manifest); + await updateDeploymentStatus(env.db, d1.id, 'live'); + await markDeploymentRetained(env.db, d1.id, true); + const d2 = await createDeployment(env.db, project.id, manifest); + await updateDeploymentStatus(env.db, d2.id, 'live'); + await markDeploymentRetained(env.db, d2.id, true); + await setActiveDeployment(env.db, project.id, d2.id); + + mockCfApi({ listWorkerBindings: () => cfSuccess([]) }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const syncBodies: Array = []; + const originalGet = env.CRON_SCHEDULER.get.bind(env.CRON_SCHEDULER); + vi.spyOn(env.CRON_SCHEDULER, 'get').mockImplementation((id) => { + const stub = originalGet(id); + return { + ...stub, + fetch: async (req: Request) => { + if (new URL(req.url).pathname === '/sync') { + syncBodies.push(await req.text()); + } + return new Response('{}', { status: 200 }); + }, + } as unknown as DurableObjectStub; + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}/rollback`, { + method: 'POST', + headers: { Authorization: `Bearer ${user.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ deploymentId: d1.id }), + }), + env, + ); + expect(res.status).toBe(200); + + const workerName = `user-roll-no-token-${d1.id}`; + expect(await env.ROUTING.get(queueRoutingKey, 'json')).toEqual({ + workerName, + projectId: project.id, + queueName: 'emails', + }); + expect(syncBodies).toHaveLength(1); + const sync = JSON.parse(syncBodies[0]!) as Record; + expect(sync.workerName).toBe(workerName); + expect(sync).not.toHaveProperty('proxyToken'); + expect(sync).not.toHaveProperty('proxyTokenFallback'); + expect(warn.mock.calls.flat().join(' ')).toContain( + `worker "${workerName}" has no __VOID_PROXY_TOKEN binding`, + ); + }); }); describe('GET /projects/:id/logs', () => { diff --git a/platform/packages/api/test/integration/api-revalidate.test.ts b/platform/packages/api/test/integration/api-revalidate.test.ts index 6b80cb28e..fa228ac9c 100644 --- a/platform/packages/api/test/integration/api-revalidate.test.ts +++ b/platform/packages/api/test/integration/api-revalidate.test.ts @@ -10,8 +10,8 @@ import { afterEach, vi, } from 'vite-plus/test'; -import { hmacSignState } from '../../src/auth/hmac.ts'; import app from '../../src/index.ts'; +import { deriveProxyToken } from '../../src/proxy-token.ts'; import { domains, projectMembers } from '../../src/schema.ts'; import { setupEnv, @@ -1368,7 +1368,7 @@ describe('POST /internal/revalidate/:id', () => { ); await env.ISR_CACHE.put(isrKey(project.id, oldDeployment.id, 'example.com', 'about'), 'old'); - const token = await hmacSignState(project.id, env.JWT_SECRET); + const token = await deriveProxyToken(env.JWT_SECRET, project.id, 1); const res = await app.fetch( new Request(`http://localhost/internal/revalidate/${project.id}`, { method: 'POST', diff --git a/platform/packages/api/test/integration/deployment-recovery.test.ts b/platform/packages/api/test/integration/deployment-recovery.test.ts index 96cdb4c3e..ba0a266d1 100644 --- a/platform/packages/api/test/integration/deployment-recovery.test.ts +++ b/platform/packages/api/test/integration/deployment-recovery.test.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm'; import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from 'vite-plus/test'; +import { hmacSignState } from '../../src/auth/hmac.ts'; import { getDeployment, getProject, type Db } from '../../src/db.ts'; import { replayDeploymentCutover } from '../../src/deploy/recovery.ts'; import { settleExpiredDeployment } from '../../src/deployment-recovery.ts'; @@ -325,7 +326,10 @@ test.each([ cf_id: '25832cb0ce3d4f60b1e538911a4629f4', binding_name: 'emails', }); - const heldToken = await deriveProxyToken(env.JWT_SECRET, project.id, held); + const heldToken = + held === 0 + ? await hmacSignState(project.id, env.JWT_SECRET) + : await deriveProxyToken(env.JWT_SECRET, project.id, held); mockCfApi({ listWorkerBindings: () => cfSuccess([{ type: 'plain_text', name: '__VOID_PROXY_TOKEN', text: heldToken }]), @@ -375,3 +379,72 @@ test.each([ expect(sync.proxyTokenFallback).toBe(expected.proxyTokenFallback); }, ); + +test('compensates an expired cutover to a committed Worker without a token binding and writes no token', async () => { + await env.db.update(projects).set({ proxy_token_gen: 1 }).where(eq(projects.id, project.id)); + const committed = await createTestDeployment(env.db, project.id); + await env.db + .update(deployments) + .set({ + manifest: JSON.stringify({ + version: 3, + type: 'void', + bindings: {}, + schedules: [{ cron: '*/5 * * * *', filePath: 'crons/tick.ts' }], + queues: [{ name: 'emails', bindingName: 'QUEUE_EMAILS' }], + }), + }) + .where(eq(deployments.id, committed.id)); + await env.db.insert(resources).values({ + id: 'resource-missing-token-queue', + project_id: project.id, + type: 'queue', + cf_id: '25832cb0ce3d4f60b1e538911a4629f4', + binding_name: 'emails', + }); + const candidate = await createTestDeployment(env.db, project.id, 'uploading'); + await env.db + .update(deployments) + .set({ status: 'cutover' }) + .where(eq(deployments.id, candidate.id)); + await env.ROUTING.put( + `cutover-intent:${candidate.id}`, + JSON.stringify({ projectId: project.id, ownerId: 'expired-owner', buildId: null }), + ); + await env.ROUTING.put(project.slug, JSON.stringify({ deploymentId: candidate.id })); + mockCfApi({ listWorkerBindings: () => cfSuccess([]) }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const syncBodies: Array = []; + const originalGet = env.CRON_SCHEDULER.get.bind(env.CRON_SCHEDULER); + vi.spyOn(env.CRON_SCHEDULER, 'get').mockImplementation((id) => { + const stub = originalGet(id); + return { + ...stub, + fetch: async (request: Request) => { + if (new URL(request.url).pathname === '/sync') { + syncBodies.push(await request.text()); + } + return new Response('{}', { status: 200 }); + }, + } as unknown as DurableObjectStub; + }); + + expect(await settle(candidate.id)).toMatchObject({ terminal: true, event: { event: 'error' } }); + + const workerName = `user-${project.slug}-${committed.id}`; + expect(await env.ROUTING.get(project.slug, 'json')).toMatchObject({ workerName }); + expect(await env.ROUTING.get(`queue:${project.id.replaceAll('_', '-')}-emails`, 'json')).toEqual({ + workerName, + projectId: project.id, + queueName: 'emails', + }); + expect(syncBodies).toHaveLength(1); + const sync = JSON.parse(syncBodies[0]!) as Record; + expect(sync.workerName).toBe(workerName); + expect(sync).not.toHaveProperty('proxyToken'); + expect(sync).not.toHaveProperty('proxyTokenFallback'); + expect(warn.mock.calls.flat().join(' ')).toContain( + `worker "${workerName}" has no __VOID_PROXY_TOKEN binding`, + ); + expect(await getDeployment(env.db, candidate.id)).toMatchObject({ status: 'failed' }); +}); diff --git a/platform/packages/api/test/integration/operator-operations.test.ts b/platform/packages/api/test/integration/operator-operations.test.ts index 9a6c97920..b69272296 100644 --- a/platform/packages/api/test/integration/operator-operations.test.ts +++ b/platform/packages/api/test/integration/operator-operations.test.ts @@ -10,6 +10,7 @@ import { test, vi, } from 'vite-plus/test'; +import { hmacSignState } from '../../src/auth/hmac.ts'; import { getDeployment } from '../../src/db.ts'; import { app } from '../../src/index.ts'; import { getProjectDeploymentWorkerName } from '../../src/naming.ts'; @@ -1461,7 +1462,9 @@ describe('operator installation-aware system and worker operations', () => { describe('proxy token rotation', () => { const rotationTarget = (projectId: string) => `proxy-token-rotation:${projectId}`; - const tok = (projectId: string, gen: number) => deriveProxyToken(JWT_SECRET, projectId, gen); + // Workers on generation 0 hold the original token. + const tok = (projectId: string, gen: number) => + gen === 0 ? hmacSignState(projectId, JWT_SECRET) : deriveProxyToken(JWT_SECRET, projectId, gen); /** Cloudflare mock whose script bindings come from seeded scripts; a missing script is a 404. */ function cloudflare() { diff --git a/platform/packages/api/test/integration/proxy-token-rotation.test.ts b/platform/packages/api/test/integration/proxy-token-rotation.test.ts index 17e4045fe..21dc0b214 100644 --- a/platform/packages/api/test/integration/proxy-token-rotation.test.ts +++ b/platform/packages/api/test/integration/proxy-token-rotation.test.ts @@ -235,7 +235,7 @@ async function setupProject( if (options.type) { await env.db.update(projects).set({ type: options.type }).where(eq(projects.id, projectId)); } - const legacy = await tok(projectId, 0); + const legacy = await hmacSignState(projectId, JWT_SECRET); const worker = (deploymentId: string) => getProjectDeploymentWorkerName(slug, deploymentId); const retained: Array = []; for (let index = 0; index < (options.retained ?? 2); index++) { @@ -434,7 +434,7 @@ describe('proxy token rotation', () => { expect(await job(fx.projectId)).toBeUndefined(); }); - it('turns old tokens off on the proxy metadata routes', async () => { + it('keeps the proxy metadata routes on the proxy credential through a rotation', async () => { const cf = cfApi(); const fx = await setupProject(cf); await env.db.insert(resources).values({ @@ -475,7 +475,7 @@ describe('proxy token rotation', () => { [PROXY_INTERNAL_AUTH_HEADER]: await deriveProxyInternalAuth(JWT_SECRET, route, fx.projectId), }); - expect(await statuses(legacy)).toEqual([200, 200, 200]); + expect(await statuses(proxy)).toEqual([200, 200, 200]); await request(fx.projectId); await advance(fx.projectId, { waitMs: 60_000 }); @@ -487,7 +487,7 @@ describe('proxy token rotation', () => { expect((await job(fx.projectId))!.payload.phase).toBe('settle'); expect(await floor(fx.projectId)).toEqual({ gen: 1 }); - expect(await statuses(legacy)).toEqual([403, 403, 403]); + expect(await statuses(legacy)).toEqual([401, 401, 401]); expect(await statuses(proxy)).toEqual([200, 200, 200]); for (let index = 0; index < 4 && (await job(fx.projectId)); index++) { @@ -495,8 +495,8 @@ describe('proxy token rotation', () => { } expect(await job(fx.projectId)).toBeUndefined(); expect(await issued(fx.projectId)).toBe(2); - expect(await statuses(tenant(1))).toEqual([403, 403, 403]); - expect(await statuses(tenant(2))).toEqual([200, 200, 200]); + expect(await statuses(tenant(1))).toEqual([401, 401, 401]); + expect(await statuses(tenant(2))).toEqual([401, 401, 401]); expect(await statuses(proxy)).toEqual([200, 200, 200]); }); @@ -1082,6 +1082,21 @@ describe('proxy token rotation', () => { expect(await floor(fx.projectId)).toBeNull(); }); + it('moves Workers on the legacy token to generation 1 through a bulk request', async () => { + const cf = cfApi(); + const fx = await setupProject(cf); + expect(await floor(fx.projectId)).toBeNull(); + + expect(await requestBulkProxyTokenRotations(env.db, 'legacy')).toBe(1); + expect(await advance(fx.projectId)).toEqual({ status: 'done', gen: 1 }); + + const token = await tok(fx.projectId, 1); + for (const id of [...fx.retained, fx.active]) { + expect(scriptToken(cf, fx.worker(id))).toBe(token); + } + expect(await floor(fx.projectId)).toEqual({ gen: 1 }); + }); + it('raises the floor of a project without deployments', async () => { cfApi(); const owner = await createTestUser(env.db); diff --git a/platform/packages/api/test/unit/backfill-queue-tokens.test.ts b/platform/packages/api/test/unit/backfill-queue-tokens.test.ts index ccfc23a43..d96612bdd 100644 --- a/platform/packages/api/test/unit/backfill-queue-tokens.test.ts +++ b/platform/packages/api/test/unit/backfill-queue-tokens.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi } from 'vite-plus/test'; import { backfillQueueTokens } from '../../src/admin/backfill-queue-tokens.ts'; -import { hmacSignState } from '../../src/auth/hmac.ts'; import type { ProxyTokenLookup } from '../../src/provisioning/cloudflare-api.ts'; function createMockKV(initial: Record = {}) { @@ -40,7 +39,6 @@ function createMockKV(initial: Record = {}) { } as unknown as KVNamespace & { store: Map }; } -const JWT_SECRET = 'test-jwt-secret'; const ACCOUNT = 'acc_test'; const TOKEN = 'token_test'; const NAMESPACE = 'void-users'; @@ -54,7 +52,6 @@ function makeOpts(overrides: FetchTokenOptions = {}) { accountId: ACCOUNT, apiToken: TOKEN, dispatchNamespace: NAMESPACE, - jwtSecret: JWT_SECRET, fetchToken: overrides.fetchToken ?? (async (workerName: string): Promise => ({ @@ -81,24 +78,28 @@ describe('backfillQueueTokens', () => { expect(patched.proxyToken).toBe('baked-token-for-user-a-dpl_1'); }); - it('falls back to hmacSignState re-derivation when the worker has no baked token', async () => { - const kv = createMockKV({ - 'queue:proj-legacy-emails': JSON.stringify({ + it('skips an entry whose worker has no baked token and leaves KV unchanged', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const original = JSON.stringify({ workerName: 'user-legacy', projectId: 'proj_legacy', queueName: 'emails', - }), - }); + }); + const kv = createMockKV({ 'queue:proj-legacy-emails': original }); - const result = await backfillQueueTokens( - kv, - makeOpts({ fetchToken: async () => ({ kind: 'missing' }) }), - ); + const result = await backfillQueueTokens( + kv, + makeOpts({ fetchToken: async () => ({ kind: 'missing' }) }), + ); - expect(result).toEqual({ scanned: 1, updated: 1, skipped: 0, raced: 0, errored: 0 }); - const expected = await hmacSignState('proj_legacy', JWT_SECRET); - const patched = JSON.parse(kv.store.get('queue:proj-legacy-emails')!); - expect(patched.proxyToken).toBe(expected); + expect(result).toEqual({ scanned: 1, updated: 0, skipped: 1, raced: 0, errored: 0 }); + expect(kv.store.get('queue:proj-legacy-emails')).toBe(original); + expect(kv.put).not.toHaveBeenCalled(); + expect(warnSpy.mock.calls.flat().join(' ')).toMatch(/user-legacy/); + } finally { + warnSpy.mockRestore(); + } }); it('skips entries that already have a proxyToken', async () => { diff --git a/platform/packages/api/test/unit/proxy-token.test.ts b/platform/packages/api/test/unit/proxy-token.test.ts index 04caec13c..9121741ff 100644 --- a/platform/packages/api/test/unit/proxy-token.test.ts +++ b/platform/packages/api/test/unit/proxy-token.test.ts @@ -7,7 +7,7 @@ import { deriveProxyInternalAuth, deriveProxyToken, isAcceptedGeneration, - LEGACY_PROXY_TOKEN_STATE, + NO_PROXY_TOKEN_FLOOR, parseProxyTokenState, PROXY_INTERNAL_AUTH_HEADER, PROXY_TOKEN_ISOLATE_TTL_MS, @@ -28,7 +28,8 @@ const SECRET = 'test-secret-key'; const PROJECT = 'proj_000000000001'; const OTHER_PROJECT = 'proj_000000000002'; -const TOK_0 = 'ZMRzpVRsaOJSG_unVlA0oqSaz2kApOohvao_XGR8Q0c'; +/** The original project token, HMAC-SHA256(JWT_SECRET, projectId). Never accepted. */ +const LEGACY = 'ZMRzpVRsaOJSG_unVlA0oqSaz2kApOohvao_XGR8Q0c'; const TOK_1 = 'v2.1.3W5_i2g79pUCXyzxWPQWmTqscSNtC_2vuHC84z775Y8'; const TOK_2 = 'v2.2.Vx_qycCHPx2BoYvQrbQ7JrhbONKU7f0jNK23B0YUhOs'; const INTERNAL: Record = { @@ -43,9 +44,6 @@ function base64url(bytes: Uint8Array): string { } function nodeTok(gen: number, projectId = PROJECT): string { - if (gen === 0) { - return base64url(createHmac('sha256', SECRET).update(projectId).digest()); - } const key = Buffer.from(hkdfSync('sha256', SECRET, 'void-proxy-token', 'v2', 32)); const mac = createHmac('sha256', key).update(`void-proxy-token:v2:${projectId}:${gen}`).digest(); return `v2.${gen}.${base64url(mac)}`; @@ -70,7 +68,7 @@ function throwingKv() { return { routing: { get } as unknown as Pick, get }; } -function verify(token: string, state: ProxyTokenState | Error = LEGACY_PROXY_TOKEN_STATE) { +function verify(token: string, state: ProxyTokenState | Error = NO_PROXY_TOKEN_FLOOR) { const readState = vi.fn(async () => { if (state instanceof Error) { throw state; @@ -89,7 +87,6 @@ afterEach(() => { describe('vectors', () => { it.each([ - [0, TOK_0], [1, TOK_1], [2, TOK_2], ])('derives tok(%i)', async (gen, expected) => { @@ -98,11 +95,6 @@ describe('vectors', () => { expect(token).toBe(nodeTok(gen)); }); - it('keeps the legacy token byte-identical to hmacSignState', async () => { - expect(await deriveProxyToken(SECRET, PROJECT, 0)).toBe(await hmacSignState(PROJECT, SECRET)); - expect(TOK_0).toMatch(/^[A-Za-z0-9_-]{43}$/); - }); - it.each(ROUTES)('derives the internal value for %s', async (route) => { const value = await deriveProxyInternalAuth(SECRET, route, PROJECT); expect(value).toBe(INTERNAL[route]); @@ -110,7 +102,7 @@ describe('vectors', () => { }); it('keeps internal values distinct from each other and from tenant tokens', () => { - const values = [...Object.values(INTERNAL), TOK_0, TOK_1.slice('v2.1.'.length)]; + const values = [...Object.values(INTERNAL), LEGACY, TOK_1.slice('v2.1.'.length)]; expect(new Set(values).size).toBe(values.length); }); @@ -156,11 +148,11 @@ describe('parseProxyTokenState', () => { describe('state table', () => { it.each([ - { raw: null, accepted: [0, 1, 5], rejected: [], send: [0] }, - { raw: '{"gen":0}', accepted: [0, 1, 5], rejected: [], send: [0] }, - { raw: '{"gen":1}', accepted: [1, 2], rejected: [0], send: [1, 0] }, - { raw: '{"gen":2}', accepted: [2, 3], rejected: [0, 1], send: [2, 1, 0] }, - { raw: '{"gen":3}', accepted: [3, 4], rejected: [0, 1, 2], send: [3, 2] }, + { raw: null, accepted: [1, 5], rejected: [], send: [] }, + { raw: '{"gen":0}', accepted: [1, 5], rejected: [], send: [] }, + { raw: '{"gen":1}', accepted: [1, 2], rejected: [], send: [1] }, + { raw: '{"gen":2}', accepted: [2, 3], rejected: [1], send: [2, 1] }, + { raw: '{"gen":3}', accepted: [3, 4], rejected: [1, 2], send: [3, 2] }, ])('floor $raw', async ({ raw, accepted, rejected, send }) => { const { routing } = kvStub(raw === null ? {} : { [proxyTokenStateKey(PROJECT)]: raw }); const state = await readProxyTokenState(routing, PROJECT); @@ -172,6 +164,10 @@ describe('state table', () => { expect(isAcceptedGeneration(state, gen)).toBe(false); expect(await verify(await deriveProxyToken(SECRET, PROJECT, gen), state).result).toBe(false); } + expect(isAcceptedGeneration(state, 0)).toBe(false); + const { result, readState } = verify(LEGACY, state); + expect(await result).toBe(false); + expect(readState).not.toHaveBeenCalled(); expect(sendGenerations(state.gen)).toEqual(send); }); @@ -180,18 +176,19 @@ describe('state table', () => { await expect(readProxyTokenState(routing, PROJECT)).rejects.toThrow(ProxyTokenStateError); }); - it('sends the issued generation, then the one before it', () => { - expect(sendGenerations(0)).toEqual([0]); - expect(sendGenerations(1)).toEqual([1, 0]); - expect(sendGenerations(2)).toEqual([2, 1, 0]); - expect(sendGenerations(5)).toEqual([5, 4]); + it('sends the issued generation, then the one before it, never generation 0', () => { + expect(sendGenerations(0)).toEqual([]); + expect(sendGenerations(1)).toEqual([1]); + expect(sendGenerations(4)).toEqual([4, 3]); }); }); describe('verifyProxyToken', () => { - it('accepts legacy and v2 tokens when no floor is set', async () => { - expect(await verify(TOK_0).result).toBe(true); + it('accepts v2 tokens and rejects the original token when no floor is set', async () => { expect(await verify(TOK_1).result).toBe(true); + const { result, readState } = verify(LEGACY); + expect(await result).toBe(false); + expect(readState).not.toHaveBeenCalled(); }); it('accepts generations at or above the floor only', async () => { @@ -199,7 +196,7 @@ describe('verifyProxyToken', () => { expect(await verify(TOK_2, { gen: 2 }).result).toBe(true); expect(await verify(await deriveProxyToken(SECRET, PROJECT, 3), { gen: 2 }).result).toBe(true); expect(await verify(TOK_1, { gen: 2 }).result).toBe(false); - expect(await verify(TOK_0, { gen: 2 }).result).toBe(false); + expect(await verify(LEGACY, { gen: 2 }).result).toBe(false); expect(warn).toHaveBeenCalledWith('[proxy-token] rejected generation', { projectId: PROJECT, gen: 1, @@ -210,7 +207,7 @@ describe('verifyProxyToken', () => { it("rejects another project's token", async () => { expect(await verify(await deriveProxyToken(SECRET, OTHER_PROJECT, 1)).result).toBe(false); - expect(await verify(await deriveProxyToken(SECRET, OTHER_PROJECT, 0)).result).toBe(false); + expect(await verify(await hmacSignState(OTHER_PROJECT, SECRET)).result).toBe(false); }); it('rejects a MAC relabelled with another generation', async () => { @@ -232,10 +229,10 @@ describe('verifyProxyToken', () => { it.each([ ['leading zero generation', `v2.01.${TOK_1.slice('v2.1.'.length)}`], ['leading zero and short MAC', 'v2.01.x'], - ['42-char MAC', TOK_0.slice(0, 42)], - ['44-char MAC', `${TOK_0}A`], - ['trailing padding', `${TOK_0}=`], - ['standard base64 alphabet', `+${TOK_0.slice(1)}`], + ['42-char MAC', LEGACY.slice(0, 42)], + ['44-char MAC', `${LEGACY}A`], + ['trailing padding', `${LEGACY}=`], + ['standard base64 alphabet', `+${LEGACY.slice(1)}`], ['empty token', ''], ['unsafe generation', `v2.9999999999999999.${TOK_1.slice('v2.1.'.length)}`], ])('rejects a malformed token (%s) without reading the floor', async (_name, token) => { @@ -257,7 +254,7 @@ describe('verifyProxyToken', () => { // The JWT signature is the legacy MAC over this string. expect(await hmacSignState(projectId, SECRET)).toBe(signature); expect(await proxyTokenGeneration(SECRET, projectId, signature)).toBeNull(); - const readState = vi.fn(async () => LEGACY_PROXY_TOKEN_STATE); + const readState = vi.fn(async () => NO_PROXY_TOKEN_FLOOR); expect( await verifyProxyToken({ jwtSecret: SECRET, projectId, token: signature, readState }), ).toBe(false); @@ -267,15 +264,22 @@ describe('verifyProxyToken', () => { describe('proxyTokenGeneration', () => { it('returns the generation of a valid token', async () => { - expect(await proxyTokenGeneration(SECRET, PROJECT, TOK_0)).toBe(0); expect(await proxyTokenGeneration(SECRET, PROJECT, TOK_2)).toBe(2); expect(await proxyTokenGeneration('other-secret', PROJECT, TOK_2)).toBeNull(); }); + + it('returns null for the original token without computing a MAC', async () => { + expect(await hmacSignState(PROJECT, SECRET)).toBe(LEGACY); + const verifyMac = vi.spyOn(crypto.subtle, 'verify'); + expect(await proxyTokenGeneration(SECRET, PROJECT, LEGACY)).toBeNull(); + expect(verifyMac).not.toHaveBeenCalled(); + }); }); describe('deriveProxyToken', () => { it.each([ ['a non-project id', 'proj_1', 1], + ['generation 0', PROJECT, 0], ['a negative generation', PROJECT, -1], ['a fractional generation', PROJECT, 1.5], ])('throws for %s', async (_name, projectId, gen) => { @@ -414,7 +418,7 @@ describe('verifyProxyInternalAuth', () => { ['44 chars', `${INTERNAL['resolve-worker']}A`], ['a v2 token shape', 'v2.1.x'], ['a tenant token', TOK_1], - ['the legacy token', TOK_0], + ['the original token', LEGACY], ['empty', ''], ])('rejects %s without throwing', async (_name, value) => { await expect(verifyProxyInternalAuth(SECRET, 'resolve-worker', PROJECT, value)).resolves.toBe( diff --git a/platform/packages/proxy/README.md b/platform/packages/proxy/README.md index e484d680c..5960adc08 100644 --- a/platform/packages/proxy/README.md +++ b/platform/packages/proxy/README.md @@ -24,11 +24,11 @@ Deployed user worker Proxy Downstream Project requests use these authentication paths: -| Path | When | Auth | -| --------------- | ------------------ | ------------------------------------------------------------------------------------------------------------ | -| Service binding | Deployed Void apps | Project token (`aiToken`), checked against the project's accept floor; see [Project tokens](#project-tokens) | -| HTTPS | Local development | `Authorization: Bearer ` | -| HTTPS | Managed prerender | Five-minute, project-scoped prerender JWT | +| Path | When | Auth | +| --------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Service binding | Deployed Void apps | `v2..` project token (`aiToken`), checked against the project's accept floor; see [Project tokens](#project-tokens) | +| HTTPS | Local development | `Authorization: Bearer ` | +| HTTPS | Managed prerender | Five-minute, project-scoped prerender JWT | Project-scoped POST routes require `projectId` in the JSON body. The public `GET /health` route has no project body, and `/email/verify-receipt` checks a @@ -45,22 +45,19 @@ seconds; non-email routes return 413 for oversized bodies and 408 for timeouts. ### Project tokens Each deployed Worker holds its project token in the `__VOID_PROXY_TOKEN` -binding. Both forms are derived from `JWT_SECRET`: - -| Form | Generation | Value | -| ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| Legacy | 0 | base64url `HMAC-SHA256(JWT_SECRET, projectId)` | -| `v2..` | `` | `` = base64url `HMAC-SHA256(K, "void-proxy-token:v2:" + projectId + ":" + gen)`, `K` = HKDF of `JWT_SECRET` (salt `void-proxy-token`, info `v2`) | +binding: `v2..` with `gen >= 1`, where `` = base64url +`HMAC-SHA256(K, "void-proxy-token:v2:" + projectId + ":" + gen)` and `K` = +HKDF of `JWT_SECRET` (salt `void-proxy-token`, info `v2`). Legacy tokens +(base64url `HMAC-SHA256(JWT_SECRET, projectId)`) are rejected. The ROUTING key `proxy-token:{projectId}` holds the project's accept floor, `{"gen": N}`. A token is accepted when its MAC is valid and its generation is at -least `N`. A missing key means floor 0, so the legacy token is accepted. The -`projectId` must be a generated project id, and the MAC is checked before the -floor is read, so an invalid token never reads KV. A floor that cannot be read -or does not parse fails closed with 503 `project token state unavailable`. Each -isolate caches a floor for 15 seconds and reads KV with `cacheTtl: 30`, so with -KV propagation a raised floor rejects older tokens everywhere within about 105 -seconds. +least `max(N, 1)`. A missing key means floor 0: no floor. The `projectId` must +be a generated project id, and the MAC is checked before the floor is read, so +an invalid token never reads KV. A floor that cannot be read or does not parse +fails closed with 503 `project token state unavailable`. Each isolate caches a +floor for 15 seconds and reads KV with `cacheTtl: 30`, so with KV propagation a +raised floor rejects older tokens everywhere within about 105 seconds. Service-binding auth and `/email/verify-receipt` use this check. `/revalidate` forwards the token, and the API checks it against the same floor. @@ -68,16 +65,16 @@ forwards the token, and the API checks it against the same floor. When the proxy calls a tenant Worker's `/__void/*` handler, it sends the token for the generation `resolve-worker` reports (`proxyTokenGen`), or for the floor when the API does not report one. If that Worker answers 403 -`{"error":"unauthorized"}`, the proxy retries with the previous generation. -At generation 2 it then also tries the legacy token: a Worker deploy that -moves a project from 0 to 1 queues a rotation to 2 for its older Workers. It -never sends a generation above the issued one. +`{"error":"unauthorized"}`, the proxy retries once with the previous +generation. It never sends a generation above the issued one, or generation 0: +when that generation is 0, it sends nothing and the caller handles the request +as it does for a Worker without the handler, with the REST fallback where the +route has one. The proxy authenticates its own `resolve-worker`, `resolve-resource`, and `verify-prerender-build` calls with `x-void-proxy-internal`, an HMAC under a separate HKDF key (salt `void-proxy-internal`, info `v1`) bound to the route and -project. That value never leaves the platform. The proxy also sends the legacy -`x-void-internal` header so an older API keeps working. +project. That value never leaves the platform. ## Endpoints diff --git a/platform/packages/proxy/src/dispatch.ts b/platform/packages/proxy/src/dispatch.ts index db7ddb60d..df1e882f6 100644 --- a/platform/packages/proxy/src/dispatch.ts +++ b/platform/packages/proxy/src/dispatch.ts @@ -3,7 +3,6 @@ import { PROXY_INTERNAL_AUTH_HEADER, ProxyTokenStateError, } from '@voidcloud/api/proxy-token'; -import { hmacSign } from './hmac.ts'; import { tenantSendTokens } from './proxy-token.ts'; import type { Env } from './types.ts'; @@ -34,8 +33,6 @@ async function resolveWorker( method: 'POST', headers: { 'content-type': 'application/json', - // Legacy header for an API that predates the proxy credential. - 'x-void-internal': await hmacSign(projectId, jwtSecret), [PROXY_INTERNAL_AUTH_HEADER]: await deriveProxyInternalAuth( jwtSecret, 'resolve-worker', @@ -113,7 +110,8 @@ async function isTokenRejection(res: Response): Promise { * The user worker handles it via native D1/KV/R2 bindings at the `/__void/*` route. * * Returns the Response if successful, or null if the worker doesn't support /__void - * (404 = old deploy without the handler → caller should fall back to REST API). + * (404 = old deploy without the handler → caller should fall back to REST API), or when + * no token has been issued for the project yet. */ export async function dispatchToWorker( env: Env, @@ -146,6 +144,12 @@ export async function dispatchToWorker( ? null : Response.json({ error: 'project token state unavailable' }, { status: 502 }); } + if (tokens.length === 0) { + // The Worker can only hold the original token, which is no longer sent. + // Every caller here is already authorized for the project. + console.warn('[proxy-token] no token to send', { projectId }); + return null; + } let worker: Fetcher; try { diff --git a/platform/packages/proxy/src/proxy-token.ts b/platform/packages/proxy/src/proxy-token.ts index 874d31fd0..8b0edce8b 100644 --- a/platform/packages/proxy/src/proxy-token.ts +++ b/platform/packages/proxy/src/proxy-token.ts @@ -44,7 +44,8 @@ export async function verifyTenantProxyToken( /** * Tokens to present to a tenant Worker, in order. Without the issued * generation, sends for the cached floor, which throws `ProxyTokenStateError` - * when the floor cannot be read. + * when the floor cannot be read. Empty when generation 0 is all there is: + * no token has been issued for the project yet. */ export async function tenantSendTokens( env: Pick, diff --git a/platform/packages/proxy/src/resolve.ts b/platform/packages/proxy/src/resolve.ts index 65978f06e..1b1351aeb 100644 --- a/platform/packages/proxy/src/resolve.ts +++ b/platform/packages/proxy/src/resolve.ts @@ -32,8 +32,6 @@ export async function resolveResource( method: 'POST', headers: { 'content-type': 'application/json', - // Legacy header for an API that predates the proxy credential. - 'x-void-internal': await hmacSign(projectId, jwtSecret), [PROXY_INTERNAL_AUTH_HEADER]: await deriveProxyInternalAuth( jwtSecret, 'resolve-resource', @@ -107,8 +105,6 @@ export async function verifyPrerenderAuthority( method: 'POST', headers: { 'content-type': 'application/json', - // Legacy header for an API that predates the proxy credential. - 'x-void-internal': await hmacSign(projectId, jwtSecret), [PROXY_INTERNAL_AUTH_HEADER]: await deriveProxyInternalAuth( jwtSecret, 'verify-prerender-build', diff --git a/platform/packages/proxy/test/unit/d1.test.ts b/platform/packages/proxy/test/unit/d1.test.ts index 62b66686b..1532054dc 100644 --- a/platform/packages/proxy/test/unit/d1.test.ts +++ b/platform/packages/proxy/test/unit/d1.test.ts @@ -1,32 +1,10 @@ +import { deriveProxyToken } from '@voidcloud/api/proxy-token'; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vite-plus/test'; import worker from '../../src/index.ts'; import { clearProxyTokenStateCache } from '../../src/proxy-token.ts'; const JWT_SECRET = 'test-secret-key'; -// --- HMAC helper --- - -function base64urlEncode(data: Uint8Array): string { - let binary = ''; - for (const byte of data) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -async function signAiToken(projectId: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(projectId))); - return base64urlEncode(sig); -} - // --- Mock factories --- function createMockApi(resources: Record = {}) { @@ -98,7 +76,7 @@ describe('D1 proxy endpoints', () => { const originalFetch = globalThis.fetch; beforeAll(async () => { - token = await signAiToken('proj_000000000001', JWT_SECRET); + token = await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 1); }); beforeEach(clearProxyTokenStateCache); diff --git a/platform/packages/proxy/test/unit/dispatch.test.ts b/platform/packages/proxy/test/unit/dispatch.test.ts index a9389a5dc..1dc0a6572 100644 --- a/platform/packages/proxy/test/unit/dispatch.test.ts +++ b/platform/packages/proxy/test/unit/dispatch.test.ts @@ -2,35 +2,13 @@ import { deriveProxyInternalAuth, deriveProxyToken } from '@voidcloud/api/proxy- import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vite-plus/test'; import { createBindingHandler } from '../../../../../packages/void/src/runtime/remote/binding-handler.ts'; import { dispatchToWorker } from '../../src/dispatch.ts'; +import { hmacSign } from '../../src/hmac.ts'; import worker from '../../src/index.ts'; import { clearProxyTokenStateCache } from '../../src/proxy-token.ts'; import type { Env } from '../../src/types.ts'; const JWT_SECRET = 'test-secret-key'; -// --- HMAC helper --- - -function base64urlEncode(data: Uint8Array): string { - let binary = ''; - for (const byte of data) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -async function signAiToken(projectId: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(projectId))); - return base64urlEncode(sig); -} - // --- Mock factories --- function createMockApi(workerNames: Record = {}) { @@ -42,7 +20,7 @@ function createMockApi(workerNames: Record = {}) { const projectId = match[1]; const workerName = workerNames[projectId]; if (workerName) { - return Response.json({ workerName, deploymentId: 'dpl_active' }); + return Response.json({ workerName, deploymentId: 'dpl_active', proxyTokenGen: 1 }); } return Response.json({ error: 'not found' }, { status: 404 }); } @@ -160,7 +138,7 @@ describe('WfP dispatch path', () => { let token: string; beforeAll(async () => { - token = await signAiToken('proj_000000000001', JWT_SECRET); + token = await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 1); }); beforeEach(clearProxyTokenStateCache); @@ -372,7 +350,11 @@ describe('WfP dispatch path', () => { env.API.fetch = vi.fn(async (url: string) => { const path = new URL(url).pathname; return path.includes('/internal/resolve-worker/') - ? Response.json({ workerName: 'user-my-app', deploymentId: 'dpl_active' }) + ? Response.json({ + workerName: 'user-my-app', + deploymentId: 'dpl_active', + proxyTokenGen: 1, + }) : Response.json({ cf_id: 'kv-namespace' }); }) as unknown as typeof env.API.fetch; const restFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('from-rest')); @@ -525,7 +507,11 @@ describe('WfP dispatch path', () => { env.API.fetch = vi.fn(async (url: string, init?: RequestInit) => { const path = new URL(url).pathname; if (path.includes('/internal/resolve-worker/')) { - return Response.json({ workerName: 'user-my-app', deploymentId: 'dpl_active' }); + return Response.json({ + workerName: 'user-my-app', + deploymentId: 'dpl_active', + proxyTokenGen: 1, + }); } const body = JSON.parse(init?.body as string) as { type?: string; binding?: string }; if (body.type === 'd1' && body.binding === 'DB') { @@ -563,6 +549,7 @@ describe('WfP dispatch path', () => { return Response.json({ workerName, deploymentId: workerName?.split('-').at(-1), + proxyTokenGen: 1, }); }) as unknown as typeof env.API.fetch; @@ -619,7 +606,7 @@ describe('WfP dispatch path', () => { expect(capturedHeaders['x-void-proxy-internal']).toBeUndefined(); }); - it('authenticates worker resolution with the proxy credential and the legacy header', async () => { + it('authenticates worker resolution with the proxy credential only', async () => { const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => Response.json({ results: [], success: true, meta: {} }), ); @@ -632,7 +619,7 @@ describe('WfP dispatch path', () => { expect(headers['x-void-proxy-internal']).toBe( await deriveProxyInternalAuth(JWT_SECRET, 'resolve-worker', 'proj_000000000001'), ); - expect(headers['x-void-internal']).toBe(token); + expect(headers['x-void-internal']).toBeUndefined(); }); }); }); @@ -650,6 +637,11 @@ describe('proxy token generations', () => { return deriveProxyToken(JWT_SECRET, PROJECT_ID, gen); } + /** The original project token, which a never-rotated Worker still holds. */ + function legacyToken(): Promise { + return hmacSign(PROJECT_ID, JWT_SECRET); + } + type Sent = { token: string | null; body: unknown }; /** @@ -703,15 +695,20 @@ describe('proxy token generations', () => { return { env, sent, floorReads }; } - it('sends the legacy token once when there is no issued generation and no floor', async () => { - const legacy = await tok(0); - const { env, sent } = setup({ held: legacy }); - - const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY); - - expect(res?.status).toBe(200); - expect(await res?.json()).toBe('hello'); - expect(sent.map((s) => s.token)).toEqual([legacy]); + it.each([ + [ + 'issued generation 0', + { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 0 }, + ], + ['no issued generation and no floor', undefined], + ])('falls back without dispatching for %s', async (_name, resolution) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { env, sent } = setup({ resolution, held: await legacyToken() }); + + expect(await dispatchToWorker(env, PROJECT_ID, '/kv/put', KV_BODY)).toBeNull(); + expect(env.DISPATCH.get).not.toHaveBeenCalled(); + expect(sent).toHaveLength(0); + expect(warn).toHaveBeenCalledWith('[proxy-token] no token to send', { projectId: PROJECT_ID }); }); it('sends the floor token, then the previous one, when there is no issued generation', async () => { @@ -732,25 +729,36 @@ describe('proxy token generations', () => { expect(sent.map((s) => s.token)).toEqual([await tok(1)]); }); - it('retries generation 1 with the legacy token and the same body', async () => { - const legacy = await tok(0); + it('sends only generation 1 to a Worker that still holds the original token', async () => { const { env, sent, floorReads } = setup({ resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 1 }, - held: legacy, + held: await legacyToken(), + }); + + const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY); + + expect(res?.status).toBe(403); + expect(await res?.json()).toEqual({ error: 'unauthorized' }); + expect(sent.map((s) => s.token)).toEqual([await tok(1)]); + expect(floorReads()).toBe(0); + }); + + it('retries issued generation 2 with generation 1 and the same body', async () => { + const { env, sent } = setup({ + resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 2 }, + held: await tok(1), }); const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY); expect(res?.status).toBe(200); expect(await res?.json()).toBe('hello'); - expect(sent.map((s) => s.token)).toEqual([await tok(1), legacy]); + expect(sent.map((s) => s.token)).toEqual([await tok(2), await tok(1)]); expect(sent[1].body).toEqual(sent[0].body); expect(sent[0].body).toEqual(KV_BODY); - expect(floorReads()).toBe(0); }); it.each([ - { held: 0, tokens: [2, 1, 0] }, { held: 1, tokens: [2, 1] }, { held: 2, tokens: [2] }, ])('sends issued generation 2 to a Worker holding $held', async ({ held, tokens }) => { @@ -793,11 +801,11 @@ describe('proxy token generations', () => { }); it('retries a reject that is streamed without a declared length', async () => { - const legacy = await tok(0); + const previous = await tok(1); const { env, sent } = setup({ - resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 1 }, + resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 2 }, respond: (token) => - token === legacy + token === previous ? Response.json('hello') : new Response(new Blob(['{"error":"unauthorized"}']).stream(), { status: 403 }), }); @@ -805,7 +813,7 @@ describe('proxy token generations', () => { const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY); expect(res?.status).toBe(200); - expect(sent.map((s) => s.token)).toEqual([await tok(1), legacy]); + expect(sent.map((s) => s.token)).toEqual([await tok(2), previous]); }); it('does not read a 403 that declares a large body', async () => { @@ -853,15 +861,15 @@ describe('proxy token generations', () => { it('returns the last reject when the Worker holds none of the tokens', async () => { const { env, sent } = setup({ - resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 3 }, - held: await tok(1), + resolution: { workerName: 'user-my-app', deploymentId: 'dpl_active', proxyTokenGen: 2 }, + held: await legacyToken(), }); const res = await dispatchToWorker(env, PROJECT_ID, '/kv/get', KV_BODY); expect(res?.status).toBe(403); expect(await res?.json()).toEqual({ error: 'unauthorized' }); - expect(sent.map((s) => s.token)).toEqual([await tok(3), await tok(2)]); + expect(sent.map((s) => s.token)).toEqual([await tok(2), await tok(1)]); }); it('does not retry after a thrown fetch', async () => { diff --git a/platform/packages/proxy/test/unit/email-custom-domain.test.ts b/platform/packages/proxy/test/unit/email-custom-domain.test.ts index 6d3c26b09..21f19a08c 100644 --- a/platform/packages/proxy/test/unit/email-custom-domain.test.ts +++ b/platform/packages/proxy/test/unit/email-custom-domain.test.ts @@ -1,5 +1,6 @@ /** Custom-domain sends use SQL admission and a customer-account send_raw credential. */ +import { deriveProxyToken } from '@voidcloud/api/proxy-token'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; // `cloudflare:email` is a workerd built-in with no npm shim — same mock as @@ -26,7 +27,7 @@ const CUSTOM_FROM = `support@${CUSTOM_DOMAIN}`; /** Dummy fixture value for the leased credential — not a real token. */ const LEASED = 'leased-credential-fixture-value'; -// --- HMAC helper (mirrors the worker's project token signing) --- +// --- HMAC helper --- function base64urlEncode(data: Uint8Array): string { let binary = ''; @@ -166,7 +167,7 @@ async function postSend( async function sendBody(overrides: Record = {}) { return { projectId: 'proj_000000000001', - aiToken: await signHmac('proj_000000000001', JWT_SECRET), + aiToken: await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 1), from: CUSTOM_FROM, recipients: ['dev@example.com'], mimeBase64: mimeBase64(), diff --git a/platform/packages/proxy/test/unit/email.test.ts b/platform/packages/proxy/test/unit/email.test.ts index e7656b0be..d4bed2ea6 100644 --- a/platform/packages/proxy/test/unit/email.test.ts +++ b/platform/packages/proxy/test/unit/email.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { deriveProxyToken } from '@voidcloud/api/proxy-token'; import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; // `cloudflare:email` is a workerd built-in with no npm shim. The root @@ -30,30 +31,6 @@ const EMAIL_SIGNING_SECRET = 'test-email-signing-key-independent'; const PROD_DOMAIN = 'mail.void.cloud'; const STAGING_DOMAIN = 'mail.voidcloud.dev'; -// --- HMAC helper (mirrors the worker's project token signing) --- - -function base64urlEncode(data: Uint8Array): string { - let binary = ''; - for (const byte of data) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -async function signHmac(data: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - return base64urlEncode( - new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(data))), - ); -} - // --- Mock env --- type MockKv = ReturnType; @@ -193,7 +170,7 @@ async function sendBody(overrides: Record = {}) { const from = typeof overrides.from === 'string' ? overrides.from : DEFAULT_FROM; return { projectId: 'proj_000000000001', - aiToken: await signHmac('proj_000000000001', JWT_SECRET), + aiToken: await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 1), from, recipients: ['dev@example.com'], mimeBase64: mimeBase64(mimeFor(from)), diff --git a/platform/packages/proxy/test/unit/kv.test.ts b/platform/packages/proxy/test/unit/kv.test.ts index 023f2efa9..9350a6214 100644 --- a/platform/packages/proxy/test/unit/kv.test.ts +++ b/platform/packages/proxy/test/unit/kv.test.ts @@ -1,32 +1,10 @@ +import { deriveProxyToken } from '@voidcloud/api/proxy-token'; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vite-plus/test'; import worker from '../../src/index.ts'; import { clearProxyTokenStateCache } from '../../src/proxy-token.ts'; const JWT_SECRET = 'test-secret-key'; -// --- HMAC helper --- - -function base64urlEncode(data: Uint8Array): string { - let binary = ''; - for (const byte of data) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -async function signAiToken(projectId: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(projectId))); - return base64urlEncode(sig); -} - // --- Mock factories --- function createMockApi(resources: Record = {}) { @@ -87,7 +65,7 @@ describe('KV proxy endpoints', () => { const originalFetch = globalThis.fetch; beforeAll(async () => { - token = await signAiToken('proj_000000000001', JWT_SECRET); + token = await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 1); }); beforeEach(clearProxyTokenStateCache); diff --git a/platform/packages/proxy/test/unit/proxy.test.ts b/platform/packages/proxy/test/unit/proxy.test.ts index 5af5b4699..78379bcd3 100644 --- a/platform/packages/proxy/test/unit/proxy.test.ts +++ b/platform/packages/proxy/test/unit/proxy.test.ts @@ -56,9 +56,9 @@ async function signJwt( return `${header}.${body}.${base64urlEncode(sig)}`; } -// --- HMAC helper (mirrors the worker's project token signing) --- +// --- HMAC helper (the original project token, which is no longer accepted) --- -async function signAiToken(projectId: string, secret: string): Promise { +async function signLegacyToken(projectId: string, secret: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', @@ -194,7 +194,7 @@ describe('proxy worker', () => { 'proj_000000000099', 'proj_000000000000', ]) { - aiTokens[pid] = await signAiToken(pid, JWT_SECRET); + aiTokens[pid] = await deriveProxyToken(JWT_SECRET, pid, 1); } }); @@ -518,7 +518,7 @@ describe('proxy worker', () => { recipient: 'support@example.com', expiresAt: Date.now() + 30_000, }; - const projectToken = await signAiToken(claims.projectId, JWT_SECRET); + const projectToken = await deriveProxyToken(JWT_SECRET, claims.projectId, 1); const call = async (receiptKey: string, auth = projectToken, env = createEnv()) => worker.fetch( post( @@ -534,8 +534,12 @@ describe('proxy worker', () => { }); expect(await (await call(JWT_SECRET)).json()).toEqual({ valid: false }); expect( - (await call(EMAIL_SIGNING_SECRET, await signAiToken(claims.projectId, EMAIL_SIGNING_SECRET))) - .status, + ( + await call( + EMAIL_SIGNING_SECRET, + await deriveProxyToken(EMAIL_SIGNING_SECRET, claims.projectId, 1), + ) + ).status, ).toBe(403); const missing = { ...createEnv(), EMAIL_SIGNING_SECRET: '' }; expect((await call(JWT_SECRET, projectToken, missing)).status).toBe(503); @@ -555,7 +559,7 @@ describe('proxy worker', () => { post( '/email/verify-receipt', { ...claims, receipt: await signEmailReceipt(claims, EMAIL_SIGNING_SECRET) }, - { 'x-void-internal': await signAiToken(projectId, JWT_SECRET) }, + { 'x-void-internal': await signLegacyToken(projectId, JWT_SECRET) }, ), createEnv(), ); @@ -1093,7 +1097,7 @@ describe('proxy worker', () => { it('rejects a login JWT split into projectId and aiToken', async () => { const [header, body, signature] = validToken.split('.'); // The JWT signature is the same HMAC a project token would carry for this string. - expect(await signAiToken(`${header}.${body}`, JWT_SECRET)).toBe(signature); + expect(await signLegacyToken(`${header}.${body}`, JWT_SECRET)).toBe(signature); const env = createEnv(); const res = await worker.fetch( post('/ai/run', { @@ -1117,7 +1121,7 @@ describe('proxy worker', () => { model: 'test-model', inputs: {}, projectId: 'proj_1', - aiToken: await signAiToken('proj_1', JWT_SECRET), + aiToken: await signLegacyToken('proj_1', JWT_SECRET), }), env, ); @@ -1153,34 +1157,103 @@ describe('proxy worker', () => { vi.restoreAllMocks(); }); - it('accepts the legacy and v2 tokens while no floor is set', async () => { - for (const gen of [0, 1]) { - clearProxyTokenStateCache(); - const env = createEnv(); - const res = await aiRun(env, await deriveProxyToken(JWT_SECRET, projectId, gen)); - expect(res.status).toBe(200); - expect(env.AI.run).toHaveBeenCalledOnce(); - } + it('accepts a v2 token and rejects the original token while no floor is set', async () => { + const accepted = createEnv(); + expect((await aiRun(accepted, await deriveProxyToken(JWT_SECRET, projectId, 1))).status).toBe( + 200, + ); + expect(accepted.AI.run).toHaveBeenCalledOnce(); + + const env = createEnv(); + const res = await aiRun(env, await signLegacyToken(projectId, JWT_SECRET)); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'invalid project token' }); + expect(env.AI.run).not.toHaveBeenCalled(); + expect(floorReads(env)).toBe(0); }); - it('accepts generations at or above the floor and rejects the legacy token', async () => { - for (const gen of [1, 2]) { + it('accepts generations at or above the floor only', async () => { + for (const gen of [2, 3]) { clearProxyTokenStateCache(); const res = await aiRun( - createEnv({}, { [floorKey]: '{"gen":1}' }), + createEnv({}, { [floorKey]: '{"gen":2}' }), await deriveProxyToken(JWT_SECRET, projectId, gen), ); expect(res.status).toBe(200); } clearProxyTokenStateCache(); - const env = createEnv({}, { [floorKey]: '{"gen":1}' }); - const res = await aiRun(env, aiTokens[projectId]); + const env = createEnv({}, { [floorKey]: '{"gen":2}' }); + const res = await aiRun(env, await deriveProxyToken(JWT_SECRET, projectId, 1)); expect(res.status).toBe(403); expect(await res.json()).toEqual({ error: 'invalid project token' }); expect(env.AI.run).not.toHaveBeenCalled(); }); + it('rejects the original token on a binding route before any lookup', async () => { + const env = createEnv(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const api = vi.fn(createMockApi().fetch); + env.API = { fetch: api }; + const res = await worker.fetch( + post('/d1/query', { + binding: 'DB', + sql: 'SELECT 1', + method: 'all', + projectId, + aiToken: await signLegacyToken(projectId, JWT_SECRET), + }), + env, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'invalid project token' }); + expect(api).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('falls back to the REST API for a user when no token has been issued', async () => { + const api = createMockApi(async (input, init) => { + const url = typeof input === 'string' ? input : input.url; + if (url.includes('/internal/verify-owner/')) { + return Response.json({ ok: true }); + } + if (url.includes('/internal/resolve-worker/')) { + return Response.json({ + workerName: 'user-my-app', + deploymentId: 'dpl_active', + proxyTokenGen: 0, + }); + } + if (url.includes('/internal/resolve-resource/')) { + return Response.json({ cf_id: 'd1-uuid-abc' }); + } + throw new Error(`unexpected API request: ${url}`); + }); + const env = createEnv(undefined, undefined, api); + const rest = vi.fn(async () => + Response.json({ + success: true, + result: [{ results: [{ id: 1 }], success: true, meta: {} }], + }), + ); + vi.stubGlobal('fetch', rest); + + const res = await worker.fetch( + post( + '/d1/query', + { binding: 'DB', sql: 'SELECT 1', method: 'all', projectId }, + { authorization: `Bearer ${validToken}` }, + ), + env, + ); + + expect(res.status).toBe(200); + expect(env.DISPATCH.get).not.toHaveBeenCalled(); + expect(rest).toHaveBeenCalledOnce(); + expect(console.warn).toHaveBeenCalledWith('[proxy-token] no token to send', { projectId }); + }); + it('rejects a wrong MAC without reading the floor', async () => { const other = await deriveProxyToken(JWT_SECRET, 'proj_000000000042', 1); const env = createEnv(); @@ -1229,16 +1302,24 @@ describe('proxy worker', () => { ); } - it('accepts a token at the floor and rejects the legacy token below it', async () => { - const accepted = await verifyReceipt(createEnv(undefined, { [floorKey]: '{"gen":1}' }), { + it('accepts a v2 token and rejects the original token', async () => { + const accepted = await verifyReceipt(createEnv(), { 'x-void-internal': await deriveProxyToken(JWT_SECRET, projectId, 1), }); expect(accepted.status).toBe(200); expect(await accepted.json()).toEqual({ valid: true, receiptId: claims.receiptId }); clearProxyTokenStateCache(); - const rejected = await verifyReceipt(createEnv(undefined, { [floorKey]: '{"gen":1}' }), { - 'x-void-internal': aiTokens[projectId], + const rejected = await verifyReceipt(createEnv(), { + 'x-void-internal': await signLegacyToken(projectId, JWT_SECRET), + }); + expect(rejected.status).toBe(403); + expect(await rejected.json()).toEqual({ valid: false }); + }); + + it('rejects a token below the floor', async () => { + const rejected = await verifyReceipt(createEnv(undefined, { [floorKey]: '{"gen":2}' }), { + 'x-void-internal': await deriveProxyToken(JWT_SECRET, projectId, 1), }); expect(rejected.status).toBe(403); expect(await rejected.json()).toEqual({ valid: false }); diff --git a/platform/packages/proxy/test/unit/r2.test.ts b/platform/packages/proxy/test/unit/r2.test.ts index c2962aea4..e0419495a 100644 --- a/platform/packages/proxy/test/unit/r2.test.ts +++ b/platform/packages/proxy/test/unit/r2.test.ts @@ -1,32 +1,10 @@ +import { deriveProxyToken } from '@voidcloud/api/proxy-token'; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vite-plus/test'; import worker from '../../src/index.ts'; import { clearProxyTokenStateCache } from '../../src/proxy-token.ts'; const JWT_SECRET = 'test-secret-key'; -// --- HMAC helper --- - -function base64urlEncode(data: Uint8Array): string { - let binary = ''; - for (const byte of data) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -async function signAiToken(projectId: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(projectId))); - return base64urlEncode(sig); -} - // --- Mock factories --- function createMockApi(resources: Record = {}) { @@ -87,7 +65,7 @@ describe('R2 proxy endpoints', () => { const originalFetch = globalThis.fetch; beforeAll(async () => { - token = await signAiToken('proj_000000000001', JWT_SECRET); + token = await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 1); }); beforeEach(clearProxyTokenStateCache); @@ -184,7 +162,7 @@ describe('R2 proxy endpoints', () => { it('does not replay a conditional write through REST when an old worker returns 404', async () => { const env = createEnv(); env.API.fetch = vi.fn(async () => - Response.json({ workerName: 'old-worker', deploymentId: 'old' }), + Response.json({ workerName: 'old-worker', deploymentId: 'old', proxyTokenGen: 1 }), ); const dispatchFetch = vi.fn(async () => new Response('Not Found', { status: 404 })); env.DISPATCH.get = vi.fn(() => ({ fetch: dispatchFetch })) as never; diff --git a/platform/packages/proxy/test/unit/resolve.test.ts b/platform/packages/proxy/test/unit/resolve.test.ts index 0d310bc12..70ab51dfb 100644 --- a/platform/packages/proxy/test/unit/resolve.test.ts +++ b/platform/packages/proxy/test/unit/resolve.test.ts @@ -1,4 +1,4 @@ -import { deriveProxyInternalAuth, deriveProxyToken } from '@voidcloud/api/proxy-token'; +import { deriveProxyInternalAuth } from '@voidcloud/api/proxy-token'; import { describe, it, expect, vi, beforeEach } from 'vite-plus/test'; import { hmacSign } from '../../src/hmac.ts'; import { @@ -65,7 +65,7 @@ describe('resolveResource', () => { expect((api.fetch as ReturnType).mock.calls).toHaveLength(1); }); - it('authenticates with the proxy credential and the legacy header', async () => { + it('authenticates with the proxy credential only', async () => { const api = createMockApi({ 'proj_000000000001:d1:DB': 'd1-uuid-123' }); await resolveResource(api, JWT_SECRET, 'proj_000000000001', 'd1', 'DB'); @@ -74,14 +74,12 @@ describe('resolveResource', () => { expect(headers['x-void-proxy-internal']).toBe( await deriveProxyInternalAuth(JWT_SECRET, 'resolve-resource', 'proj_000000000001'), ); - expect(headers['x-void-internal']).toBe( - await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 0), - ); + expect(headers['x-void-internal']).toBeUndefined(); }); }); describe('verifyPrerenderAuthority', () => { - it('authenticates with the proxy credential and the legacy header', async () => { + it('authenticates with the proxy credential only', async () => { const api = { fetch: vi.fn(async () => Response.json({ ok: true })) } as unknown as Fetcher; await expect( verifyPrerenderAuthority(api, JWT_SECRET, 'proj_000000000001', 'user_1', { @@ -95,9 +93,7 @@ describe('verifyPrerenderAuthority', () => { expect(headers['x-void-proxy-internal']).toBe( await deriveProxyInternalAuth(JWT_SECRET, 'verify-prerender-build', 'proj_000000000001'), ); - expect(headers['x-void-internal']).toBe( - await deriveProxyToken(JWT_SECRET, 'proj_000000000001', 0), - ); + expect(headers['x-void-internal']).toBeUndefined(); }); });