Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/empty-things-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vercel/blob": minor
---

Add Vercel OIDC auth and presigned URLs
271 changes: 271 additions & 0 deletions packages/blob/src/api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ describe('api', () => {
jest.restoreAllMocks();

process.env = { ...OLD_ENV };
delete process.env.BLOB_STORE_ID;
delete process.env.VERCEL_OIDC_TOKEN;
});

it('should throw if no token is provided', async () => {
Expand All @@ -33,6 +35,8 @@ describe('api', () => {
);

process.env.BLOB_READ_WRITE_TOKEN = undefined;
process.env.BLOB_STORE_ID = undefined;
process.env.VERCEL_OIDC_TOKEN = undefined;

await expect(
requestApi('/method', { method: 'GET' }, undefined),
Expand All @@ -41,6 +45,272 @@ describe('api', () => {
expect(fetchMock).toHaveBeenCalledTimes(0);
});

it('should throw if BLOB_STORE_ID is set without an OIDC token', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN = undefined;
process.env.BLOB_STORE_ID = 'my-store';
process.env.VERCEL_OIDC_TOKEN = undefined;

await expect(
requestApi('/method', { method: 'GET' }, undefined),
).rejects.toThrow(BlobError);

expect(fetchMock).toHaveBeenCalledTimes(0);
});

it('should use BLOB_STORE_ID and VERCEL_OIDC_TOKEN when set', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN = undefined;
process.env.BLOB_STORE_ID = 'oidcStore';
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';

const res = await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({ foo: 'bar' }) },
undefined,
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(call[1].headers.authorization).toBe('Bearer oidc-jwt');
expect(call[1].headers['x-api-blob-request-id']).toMatch(
/^oidcStore:\d+:[a-f0-9]+$/,
);
expect(res).toEqual({ success: true });
});

it('should prefer OIDC when store id and VERCEL_OIDC_TOKEN are set, even if BLOB_READ_WRITE_TOKEN is set', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN =
'vercel_blob_rw_fromRwToken_30FakeRandomCharacters12345678';
process.env.BLOB_STORE_ID = 'oidcStore';
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';

await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({}) },
undefined,
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(call[1].headers.authorization).toBe('Bearer oidc-jwt');
expect(call[1].headers['x-api-blob-request-id']).toMatch(
/^oidcStore:\d+:[a-f0-9]+$/,
);
});

it('should use storeId option over BLOB_STORE_ID for OIDC', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN = undefined;
process.env.BLOB_STORE_ID = 'fromEnv';
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';

await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({}) },
{ storeId: 'fromOption' },
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(call[1].headers.authorization).toBe('Bearer oidc-jwt');
expect(call[1].headers['x-api-blob-request-id']).toMatch(
/^fromOption:\d+:[a-f0-9]+$/,
);
});

it('should use oidcToken option with storeId for OIDC auth', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN = undefined;
delete process.env.BLOB_STORE_ID;
delete process.env.VERCEL_OIDC_TOKEN;

await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({}) },
{ storeId: 'fromOption', oidcToken: 'oidc-from-option' },
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(call[1].headers.authorization).toBe('Bearer oidc-from-option');
expect(call[1].headers['x-api-blob-request-id']).toMatch(
/^fromOption:\d+:[a-f0-9]+$/,
);
});

it('should prefer oidcToken option over VERCEL_OIDC_TOKEN', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN = undefined;
process.env.BLOB_STORE_ID = 'fromEnv';
process.env.VERCEL_OIDC_TOKEN = 'oidc-from-env';

await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({}) },
{ oidcToken: 'oidc-from-option' },
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(call[1].headers.authorization).toBe('Bearer oidc-from-option');
expect(call[1].headers['x-api-blob-request-id']).toMatch(
/^fromEnv:\d+:[a-f0-9]+$/,
);
});

it('should strip the "store_" prefix from BLOB_STORE_ID for OIDC', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

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';

await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({}) },
undefined,
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
// Bare id with original case preserved — the API is case-sensitive.
expect(call[1].headers['x-vercel-blob-store-id']).toBe(
'WdsHBk1w9fDO4vPW',
);
expect(call[1].headers['x-api-blob-request-id']).toMatch(
/^WdsHBk1w9fDO4vPW:\d+:[a-f0-9]+$/,
);
});

it('should strip the "store_" prefix from the OIDC storeId option', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN = undefined;
delete process.env.BLOB_STORE_ID;
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';

await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({}) },
{ storeId: 'store_AbCDef' },
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(call[1].headers['x-vercel-blob-store-id']).toBe('AbCDef');
});

it('should fall back to BLOB_READ_WRITE_TOKEN when OIDC is set but store id is missing', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ success: true }),
}),
);

process.env.BLOB_READ_WRITE_TOKEN =
'vercel_blob_rw_fallbackStore_30FakeRandomCharacters12345678';
delete process.env.BLOB_STORE_ID;
process.env.VERCEL_OIDC_TOKEN = 'orphan-oidc';

await requestApi<{ success: boolean }>(
'/method',
{ method: 'POST', body: JSON.stringify({}) },
undefined,
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(call[1].headers.authorization).toBe(
'Bearer vercel_blob_rw_fallbackStore_30FakeRandomCharacters12345678',
);
expect(call[1].headers['x-api-blob-request-id']).toMatch(
/^fallbackStore:\d+:[a-f0-9]+$/,
);
});

it('should not retry successful request', async () => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
Expand All @@ -67,6 +337,7 @@ describe('api', () => {
authorization: 'Bearer 123',
'x-api-blob-request-attempt': '0',
'x-api-blob-request-id': expect.any(String) as string,
'x-vercel-blob-store-id': '',
'x-api-version': '12',
},
method: 'POST',
Expand Down
Loading
Loading