From 59bc90c1a2b0ed3329185879b3b3042db2c2d8d2 Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Thu, 25 Jun 2026 07:06:08 -0300 Subject: [PATCH 1/2] [@vercel/blob] Use refreshing getVercelOidcToken from @vercel/oidc Switch the blob OIDC reader from @vercel/oidc's non-refreshing getVercelOidcTokenSync to the async getVercelOidcToken, which refreshes an expired token in development. The wrapper, resolveBlobAuth, and its two call sites (requestApi, get) become async accordingly. Co-Authored-By: Claude Opus 4.6 --- packages/blob/src/api.node.test.ts | 34 ++++++++++++++----- packages/blob/src/api.ts | 2 +- packages/blob/src/get.ts | 2 +- packages/blob/src/helpers.ts | 6 ++-- .../blob/src/vercel-oidc-token.node.test.ts | 31 ++++++++++++----- packages/blob/src/vercel-oidc-token.ts | 15 ++++---- 6 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/blob/src/api.node.test.ts b/packages/blob/src/api.node.test.ts index 2ca6f06e0..0760b7ed0 100644 --- a/packages/blob/src/api.node.test.ts +++ b/packages/blob/src/api.node.test.ts @@ -11,9 +11,25 @@ import { } from './api'; import { BlobError, createChunkTransformStream } from './helpers'; +// `@vercel/oidc`'s refreshing `getVercelOidcToken` lazily `import()`s its +// token-refresh helpers, which jest's CJS runner cannot execute (it needs +// --experimental-vm-modules). Refreshing only matters in dev/prod with an +// expired token; for these tests we delegate to the synchronous reader, which +// resolves the token from the same request-context header / env var sources. +jest.mock('@vercel/oidc', () => { + const actual = jest.requireActual('@vercel/oidc'); + return { + ...actual, + // A plain function (not jest.fn) so `jest.resetAllMocks()` in beforeEach + // doesn't wipe this implementation. + getVercelOidcToken: () => Promise.resolve(actual.getVercelOidcTokenSync()), + }; +}); + describe('api', () => { describe('request api', () => { const OLD_ENV = process.env; + const OIDC_TOKEN = 'oidc-jwt'; beforeEach(() => { jest.useFakeTimers({ advanceTimers: true }); @@ -76,7 +92,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = undefined; process.env.BLOB_STORE_ID = 'oidcStore'; - process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; + process.env.VERCEL_OIDC_TOKEN = OIDC_TOKEN; const res = await requestApi<{ success: boolean }>( '/method', @@ -89,7 +105,7 @@ describe('api', () => { string, { headers: Record }, ]; - expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); + expect(call[1].headers.authorization).toBe(`Bearer ${OIDC_TOKEN}`); expect(call[1].headers['x-api-blob-request-id']).toMatch( /^oidcStore:\d+:[a-f0-9]+$/, ); @@ -108,7 +124,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_fromRwToken_30FakeRandomCharacters12345678'; process.env.BLOB_STORE_ID = 'oidcStore'; - process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; + process.env.VERCEL_OIDC_TOKEN = OIDC_TOKEN; await requestApi<{ success: boolean }>( '/method', @@ -121,7 +137,7 @@ describe('api', () => { string, { headers: Record }, ]; - expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); + expect(call[1].headers.authorization).toBe(`Bearer ${OIDC_TOKEN}`); expect(call[1].headers['x-api-blob-request-id']).toMatch( /^oidcStore:\d+:[a-f0-9]+$/, ); @@ -138,7 +154,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = undefined; process.env.BLOB_STORE_ID = 'fromEnv'; - process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; + process.env.VERCEL_OIDC_TOKEN = OIDC_TOKEN; await requestApi<{ success: boolean }>( '/method', @@ -151,7 +167,7 @@ describe('api', () => { string, { headers: Record }, ]; - expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); + expect(call[1].headers.authorization).toBe(`Bearer ${OIDC_TOKEN}`); expect(call[1].headers['x-api-blob-request-id']).toMatch( /^fromOption:\d+:[a-f0-9]+$/, ); @@ -229,7 +245,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = undefined; // What `vercel env pull` writes: prefixed, original case. process.env.BLOB_STORE_ID = 'store_WdsHBk1w9fDO4vPW'; - process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; + process.env.VERCEL_OIDC_TOKEN = OIDC_TOKEN; await requestApi<{ success: boolean }>( '/method', @@ -262,7 +278,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = undefined; delete process.env.BLOB_STORE_ID; - process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; + process.env.VERCEL_OIDC_TOKEN = OIDC_TOKEN; await requestApi<{ success: boolean }>( '/method', @@ -290,7 +306,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_fallbackStore_30FakeRandomCharacters12345678'; delete process.env.BLOB_STORE_ID; - process.env.VERCEL_OIDC_TOKEN = 'orphan-oidc'; + process.env.VERCEL_OIDC_TOKEN = OIDC_TOKEN; await requestApi<{ success: boolean }>( '/method', diff --git a/packages/blob/src/api.ts b/packages/blob/src/api.ts index 9fc6fac17..c1f843d18 100644 --- a/packages/blob/src/api.ts +++ b/packages/blob/src/api.ts @@ -291,7 +291,7 @@ export async function requestApi( | undefined, ): Promise { const apiVersion = getApiVersion(); - const auth = resolveBlobAuth(commandOptions); + const auth = await resolveBlobAuth(commandOptions); const bearerToken = auth.kind === 'presigned' ? undefined : auth.token; const extraHeaders = getProxyThroughAlternativeApiHeaderFromEnv(); diff --git a/packages/blob/src/get.ts b/packages/blob/src/get.ts index df7cc25dd..4354a5fd2 100644 --- a/packages/blob/src/get.ts +++ b/packages/blob/src/get.ts @@ -136,7 +136,7 @@ export async function get( ); } - const auth = resolveBlobAuth(options); + const auth = await resolveBlobAuth(options); if (auth.kind === 'presigned') { throw new BlobError('Presigned URLs are not supported for the get method'); diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 1506b94db..8986a3aed 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -254,9 +254,9 @@ export function normalizeStoreId(storeId: string): string { * 2. An explicit `oidcToken` (or `VERCEL_OIDC_TOKEN`) paired with `storeId` option (or `BLOB_STORE_ID`). * 3. `BLOB_READ_WRITE_TOKEN` from the environment. */ -export function resolveBlobAuth( +export async function resolveBlobAuth( options?: BlobCommandOptions & BlobPresignedCommandOptions, -): ResolvedBlobAuth { +): Promise { if (options?.presignedUrlPayload) { const storeId = parseStoreIdFromDelegationToken( options.presignedUrlPayload.delegationToken, @@ -270,7 +270,7 @@ export function resolveBlobAuth( } const manualOidcToken = options?.oidcToken?.trim(); - const oidcToken = manualOidcToken || getVercelOidcToken(); + const oidcToken = manualOidcToken || (await getVercelOidcToken()); if (oidcToken) { // Try to get storeId from the supplied options const manualStoreId = options?.storeId?.trim(); diff --git a/packages/blob/src/vercel-oidc-token.node.test.ts b/packages/blob/src/vercel-oidc-token.node.test.ts index d354cd1f4..4c9e076a1 100644 --- a/packages/blob/src/vercel-oidc-token.node.test.ts +++ b/packages/blob/src/vercel-oidc-token.node.test.ts @@ -1,5 +1,18 @@ import { getVercelOidcToken } from './vercel-oidc-token'; +// `@vercel/oidc`'s refreshing `getVercelOidcToken` lazily `import()`s its +// token-refresh helpers, which jest's CJS runner cannot execute (it needs +// --experimental-vm-modules). Refreshing only matters in dev/prod with an +// expired token; for these tests we delegate to the synchronous reader, which +// resolves the token from the same request-context header / env var sources. +jest.mock('@vercel/oidc', () => { + const actual = jest.requireActual('@vercel/oidc'); + return { + ...actual, + getVercelOidcToken: () => Promise.resolve(actual.getVercelOidcTokenSync()), + }; +}); + describe('vercel-oidc-token', () => { const OLD_ENV = process.env; const REQUEST_CONTEXT_SYMBOL = Symbol.for('@vercel/request-context'); @@ -16,17 +29,17 @@ describe('vercel-oidc-token', () => { process.env = OLD_ENV; }); - it('getVercelOidcToken returns token from env', () => { + it('getVercelOidcToken returns token from env', async () => { process.env.VERCEL_OIDC_TOKEN = 'jwt-from-env'; - expect(getVercelOidcToken()).toBe('jwt-from-env'); + await expect(getVercelOidcToken()).resolves.toBe('jwt-from-env'); }); - it('getVercelOidcToken returns undefined when no token is available', () => { + it('getVercelOidcToken returns undefined when no token is available', async () => { delete process.env.VERCEL_OIDC_TOKEN; - expect(getVercelOidcToken()).toBeUndefined(); + await expect(getVercelOidcToken()).resolves.toBeUndefined(); }); - it('getVercelOidcToken prioritizes request context headers over env', () => { + it('getVercelOidcToken prioritizes request context headers over env', async () => { process.env.VERCEL_OIDC_TOKEN = 'jwt-from-env'; (globalThis as typeof globalThis & Record unknown }>)[ REQUEST_CONTEXT_SYMBOL @@ -38,10 +51,12 @@ describe('vercel-oidc-token', () => { }), }; - expect(getVercelOidcToken()).toBe('jwt-from-request-context'); + await expect(getVercelOidcToken()).resolves.toBe( + 'jwt-from-request-context', + ); }); - it('getVercelOidcToken returns undefined for a blank request context header', () => { + it('getVercelOidcToken returns undefined for a blank request context header', async () => { // @vercel/oidc selects the header over the env var as long as the header // key is present, so a blank header resolves to undefined here (it does // not fall back to VERCEL_OIDC_TOKEN). @@ -56,6 +71,6 @@ describe('vercel-oidc-token', () => { }), }; - expect(getVercelOidcToken()).toBeUndefined(); + await expect(getVercelOidcToken()).resolves.toBeUndefined(); }); }); diff --git a/packages/blob/src/vercel-oidc-token.ts b/packages/blob/src/vercel-oidc-token.ts index 1e1b95626..774c95c83 100644 --- a/packages/blob/src/vercel-oidc-token.ts +++ b/packages/blob/src/vercel-oidc-token.ts @@ -1,20 +1,21 @@ -import { getVercelOidcTokenSync } from '@vercel/oidc'; +import { getVercelOidcToken as getVercelOidcTokenWithRefresh } from '@vercel/oidc'; /** * Gets the current OIDC token from the request context header or the * `VERCEL_OIDC_TOKEN` env var, or `undefined` when none is set. * - * Delegates to `@vercel/oidc`'s `getVercelOidcTokenSync`, which reads the + * Delegates to `@vercel/oidc`'s `getVercelOidcToken`, which reads the * `x-vercel-oidc-token` request-context header (falling back to - * `VERCEL_OIDC_TOKEN`) and throws when neither is present. We convert that - * throw to `undefined` so callers (see `resolveBlobAuth`) can fall through to - * a `BLOB_READ_WRITE_TOKEN`, and treat a blank token as absent. + * `VERCEL_OIDC_TOKEN`) and throws when neither is present. Unlike the sync + * variant, it also refreshes an expired token in a development environment. We + * convert any throw to `undefined` so callers (see `resolveBlobAuth`) can fall + * through to a `BLOB_READ_WRITE_TOKEN`, and treat a blank token as absent. * * Do not cache this value, as it is subject to change in production! */ -export function getVercelOidcToken(): string | undefined { +export async function getVercelOidcToken(): Promise { try { - const token = getVercelOidcTokenSync().trim(); + const token = (await getVercelOidcTokenWithRefresh()).trim(); return token === '' ? undefined : token; } catch { return undefined; From 4f3596e9127efa3e8f8cf1e97dbfed8f9bf96fe2 Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Thu, 25 Jun 2026 07:06:42 -0300 Subject: [PATCH 2/2] Add changeset for @vercel/blob OIDC token refresh Co-Authored-By: Claude Opus 4.6 --- .changeset/blob-oidc-token-refresh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/blob-oidc-token-refresh.md diff --git a/.changeset/blob-oidc-token-refresh.md b/.changeset/blob-oidc-token-refresh.md new file mode 100644 index 000000000..45ab7ae6d --- /dev/null +++ b/.changeset/blob-oidc-token-refresh.md @@ -0,0 +1,5 @@ +--- +'@vercel/blob': patch +--- + +Read the Vercel OIDC token via `@vercel/oidc`'s refreshing `getVercelOidcToken` instead of the non-refreshing `getVercelOidcTokenSync`. This refreshes an expired token in development environments. In production with a valid token, behavior is unchanged. If a refresh is needed but fails, the token is treated as absent so callers still fall back to `BLOB_READ_WRITE_TOKEN`.