Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blob-oidc-token-refresh.md
Original file line number Diff line number Diff line change
@@ -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`.
34 changes: 25 additions & 9 deletions packages/blob/src/api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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',
Expand All @@ -89,7 +105,7 @@ describe('api', () => {
string,
{ headers: Record<string, string> },
];
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]+$/,
);
Expand All @@ -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',
Expand All @@ -121,7 +137,7 @@ describe('api', () => {
string,
{ headers: Record<string, string> },
];
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]+$/,
);
Expand All @@ -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',
Expand All @@ -151,7 +167,7 @@ describe('api', () => {
string,
{ headers: Record<string, string> },
];
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]+$/,
);
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/blob/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export async function requestApi<TResponse>(
| undefined,
): Promise<TResponse> {
const apiVersion = getApiVersion();
const auth = resolveBlobAuth(commandOptions);
const auth = await resolveBlobAuth(commandOptions);
const bearerToken = auth.kind === 'presigned' ? undefined : auth.token;
const extraHeaders = getProxyThroughAlternativeApiHeaderFromEnv();

Expand Down
2 changes: 1 addition & 1 deletion packages/blob/src/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,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');
Expand Down
6 changes: 3 additions & 3 deletions packages/blob/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedBlobAuth> {
if (options?.presignedUrlPayload) {
const storeId = parseStoreIdFromDelegationToken(
options.presignedUrlPayload.delegationToken,
Expand All @@ -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();
Expand Down
31 changes: 23 additions & 8 deletions packages/blob/src/vercel-oidc-token.node.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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<symbol, { get: () => unknown }>)[
REQUEST_CONTEXT_SYMBOL
Expand All @@ -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).
Expand All @@ -56,6 +71,6 @@ describe('vercel-oidc-token', () => {
}),
};

expect(getVercelOidcToken()).toBeUndefined();
await expect(getVercelOidcToken()).resolves.toBeUndefined();
});
});
15 changes: 8 additions & 7 deletions packages/blob/src/vercel-oidc-token.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
try {
const token = getVercelOidcTokenSync().trim();
const token = (await getVercelOidcTokenWithRefresh()).trim();
return token === '' ? undefined : token;
} catch {
return undefined;
Expand Down
Loading