Skip to content

Commit 20eeaff

Browse files
elliotdauberfalcoagustinclaude
authored
Add Vercel OIDC auth and Presigned URLs (#1056)
* Add Vercel OIDC auth * up * header * up * prioritize token * normalize storeId in OIDC auth resolution BLOB_STORE_ID and the storeId option are accepted in either store_<id> or <id> form (Vercel env pull writes the prefixed form), and may be mixed-case. resolveBlobAuth was passing those through verbatim, so the storeId in API headers and CDN host subdomains could be malformed — e.g. blob.get against a private store with `store_WdsHBk1w9fDO4vPW` built `https://store_WdsHBk1w9fDO4vPW.private.blob.vercel-storage.com/...` and 404'd. The RW path was unaffected because parseStoreIdFromReadWriteToken yields a bare lowercase id from the token's structure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * preserve case when normalizing storeId The first pass also lowercased — that breaks API requests, since the Vercel Blob API is case-sensitive on the storeId (header and bearer parsing). The CDN host accepts either case, so prefix-strip alone is sufficient and works for both consumers. Verified end-to-end against a private store: blob get and blob list both succeed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * add oidcToken option * minor version * Vercel Presigned URLs (#1057) * presigned urls * up * normalize * fix * update to new signing string * [wip] presigned url version of handleUpload (#1059) * [wip] presigned url version of handleUpload * up * up * up * add presigned urls for mpu * update options shape * webhook signature * update example * up * up * up * change to operation * up * up * verify webhook signature * up * callbackUrl * non-null * presigned url opts * new delegation token + url opts * dont lowercase store id * validUntil instead of ttlSeconds * up * up * Add 'delete' to DelegationOperation for presigned DELETE URLs (#1061) Mirrors the API-side change that adds `delete` to the issue_signed_token allowed operations. `presignUrl` now signs canonical `operation=delete\npathname=...` against the public blob object URL (same host shape as `get`/`head`, just used with HTTP DELETE). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * change upload to put * delete * up * presigned url payload * update app * fix * docs * refactor presign url * up * up * up * add head * up * add params to url * add keys first * token payload * add buildPresignedGetUrl * up * presignUrl returns url, not accepted in sdk methods * up * up * feat(blob): presigned HEAD & DELETE URLs (#1064) * feat(blob): presigned DELETE URLs Wires the `'delete'` DelegationOperation (added in #1061, since rebased out of elliot/presigned-urls) through `presignUrl`. A token issued with `operations: ['delete']` can now mint a presigned `DELETE /?pathname=…` against the control-plane API. - `PresignDeleteUrlOptions` accepts `pathname`, optional `validUntil`, optional `ifMatch`. Upload-only fields are rejected at the type level. - `presign()` gates on the delegation scope including `'delete'`. - `buildPresignedDeleteUrl()` mirrors the PUT URL shape; the HTTP method is the discriminator (canonical signing string carries `operation=delete`). - `buildPresignCanonicalQueryEntries()` for `delete` emits only `validUntil` (when below the delegation ceiling) and `ifMatch`. - E2E test route + delete button on the presigned-upload demo page. Based on `elliot/presigned-urls`, not `main`. * feat(blob): presigned HEAD URLs (#1065) `HEAD` mirrors `GET` against the blob object host (`<storeId>.<access>.blob.vercel-storage.com/<pathname>`); the URL shape is identical and the HTTP method is the discriminator. `operation=head` goes into the canonical signing string so a GET-signed URL cannot be replayed as a HEAD (and vice versa). - `'head'` added to `DelegationOperation`. - `PresignHeadUrlOptions` — same shape as `PresignGetUrlOptions` (`pathname`, optional `validUntil`). - `presign()` gates on the delegation scope including `'head'`. - `presignUrl()` reuses `buildPresignedGetUrl()` for `operation: 'head'`. - `buildPresignCanonicalQueryEntries()` for `head` emits only `validUntil` (when below the delegation ceiling) — same as `get`. - E2E test route + HEAD button on the presigned-upload demo page. Stacked on `falcoagustin/presigned-delete-impl` (#1064). * environment error * up * cleanup --------- Co-authored-by: Agustin Falco <agusfalco_11@hotmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * up * rm dead code * throw on manual token * up * up --------- Co-authored-by: Agustin Falco <agusfalco_11@hotmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f23cb89 commit 20eeaff

37 files changed

Lines changed: 3662 additions & 132 deletions

.changeset/empty-things-say.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vercel/blob": minor
3+
---
4+
5+
Add Vercel OIDC auth and presigned URLs

packages/blob/src/api.node.test.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ describe('api', () => {
2121
jest.restoreAllMocks();
2222

2323
process.env = { ...OLD_ENV };
24+
delete process.env.BLOB_STORE_ID;
25+
delete process.env.VERCEL_OIDC_TOKEN;
2426
});
2527

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

3537
process.env.BLOB_READ_WRITE_TOKEN = undefined;
38+
process.env.BLOB_STORE_ID = undefined;
39+
process.env.VERCEL_OIDC_TOKEN = undefined;
3640

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

48+
it('should throw if BLOB_STORE_ID is set without an OIDC token', async () => {
49+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
50+
jest.fn().mockResolvedValue({
51+
status: 200,
52+
ok: true,
53+
json: () => Promise.resolve({ success: true }),
54+
}),
55+
);
56+
57+
process.env.BLOB_READ_WRITE_TOKEN = undefined;
58+
process.env.BLOB_STORE_ID = 'my-store';
59+
process.env.VERCEL_OIDC_TOKEN = undefined;
60+
61+
await expect(
62+
requestApi('/method', { method: 'GET' }, undefined),
63+
).rejects.toThrow(BlobError);
64+
65+
expect(fetchMock).toHaveBeenCalledTimes(0);
66+
});
67+
68+
it('should use BLOB_STORE_ID and VERCEL_OIDC_TOKEN when set', async () => {
69+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
70+
jest.fn().mockResolvedValue({
71+
status: 200,
72+
ok: true,
73+
json: () => Promise.resolve({ success: true }),
74+
}),
75+
);
76+
77+
process.env.BLOB_READ_WRITE_TOKEN = undefined;
78+
process.env.BLOB_STORE_ID = 'oidcStore';
79+
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';
80+
81+
const res = await requestApi<{ success: boolean }>(
82+
'/method',
83+
{ method: 'POST', body: JSON.stringify({ foo: 'bar' }) },
84+
undefined,
85+
);
86+
87+
expect(fetchMock).toHaveBeenCalledTimes(1);
88+
const call = fetchMock.mock.calls[0] as [
89+
string,
90+
{ headers: Record<string, string> },
91+
];
92+
expect(call[1].headers.authorization).toBe('Bearer oidc-jwt');
93+
expect(call[1].headers['x-api-blob-request-id']).toMatch(
94+
/^oidcStore:\d+:[a-f0-9]+$/,
95+
);
96+
expect(res).toEqual({ success: true });
97+
});
98+
99+
it('should prefer OIDC when store id and VERCEL_OIDC_TOKEN are set, even if BLOB_READ_WRITE_TOKEN is set', async () => {
100+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
101+
jest.fn().mockResolvedValue({
102+
status: 200,
103+
ok: true,
104+
json: () => Promise.resolve({ success: true }),
105+
}),
106+
);
107+
108+
process.env.BLOB_READ_WRITE_TOKEN =
109+
'vercel_blob_rw_fromRwToken_30FakeRandomCharacters12345678';
110+
process.env.BLOB_STORE_ID = 'oidcStore';
111+
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';
112+
113+
await requestApi<{ success: boolean }>(
114+
'/method',
115+
{ method: 'POST', body: JSON.stringify({}) },
116+
undefined,
117+
);
118+
119+
expect(fetchMock).toHaveBeenCalledTimes(1);
120+
const call = fetchMock.mock.calls[0] as [
121+
string,
122+
{ headers: Record<string, string> },
123+
];
124+
expect(call[1].headers.authorization).toBe('Bearer oidc-jwt');
125+
expect(call[1].headers['x-api-blob-request-id']).toMatch(
126+
/^oidcStore:\d+:[a-f0-9]+$/,
127+
);
128+
});
129+
130+
it('should use storeId option over BLOB_STORE_ID for OIDC', async () => {
131+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
132+
jest.fn().mockResolvedValue({
133+
status: 200,
134+
ok: true,
135+
json: () => Promise.resolve({ success: true }),
136+
}),
137+
);
138+
139+
process.env.BLOB_READ_WRITE_TOKEN = undefined;
140+
process.env.BLOB_STORE_ID = 'fromEnv';
141+
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';
142+
143+
await requestApi<{ success: boolean }>(
144+
'/method',
145+
{ method: 'POST', body: JSON.stringify({}) },
146+
{ storeId: 'fromOption' },
147+
);
148+
149+
expect(fetchMock).toHaveBeenCalledTimes(1);
150+
const call = fetchMock.mock.calls[0] as [
151+
string,
152+
{ headers: Record<string, string> },
153+
];
154+
expect(call[1].headers.authorization).toBe('Bearer oidc-jwt');
155+
expect(call[1].headers['x-api-blob-request-id']).toMatch(
156+
/^fromOption:\d+:[a-f0-9]+$/,
157+
);
158+
});
159+
160+
it('should use oidcToken option with storeId for OIDC auth', async () => {
161+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
162+
jest.fn().mockResolvedValue({
163+
status: 200,
164+
ok: true,
165+
json: () => Promise.resolve({ success: true }),
166+
}),
167+
);
168+
169+
process.env.BLOB_READ_WRITE_TOKEN = undefined;
170+
delete process.env.BLOB_STORE_ID;
171+
delete process.env.VERCEL_OIDC_TOKEN;
172+
173+
await requestApi<{ success: boolean }>(
174+
'/method',
175+
{ method: 'POST', body: JSON.stringify({}) },
176+
{ storeId: 'fromOption', oidcToken: 'oidc-from-option' },
177+
);
178+
179+
expect(fetchMock).toHaveBeenCalledTimes(1);
180+
const call = fetchMock.mock.calls[0] as [
181+
string,
182+
{ headers: Record<string, string> },
183+
];
184+
expect(call[1].headers.authorization).toBe('Bearer oidc-from-option');
185+
expect(call[1].headers['x-api-blob-request-id']).toMatch(
186+
/^fromOption:\d+:[a-f0-9]+$/,
187+
);
188+
});
189+
190+
it('should prefer oidcToken option over VERCEL_OIDC_TOKEN', async () => {
191+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
192+
jest.fn().mockResolvedValue({
193+
status: 200,
194+
ok: true,
195+
json: () => Promise.resolve({ success: true }),
196+
}),
197+
);
198+
199+
process.env.BLOB_READ_WRITE_TOKEN = undefined;
200+
process.env.BLOB_STORE_ID = 'fromEnv';
201+
process.env.VERCEL_OIDC_TOKEN = 'oidc-from-env';
202+
203+
await requestApi<{ success: boolean }>(
204+
'/method',
205+
{ method: 'POST', body: JSON.stringify({}) },
206+
{ oidcToken: 'oidc-from-option' },
207+
);
208+
209+
expect(fetchMock).toHaveBeenCalledTimes(1);
210+
const call = fetchMock.mock.calls[0] as [
211+
string,
212+
{ headers: Record<string, string> },
213+
];
214+
expect(call[1].headers.authorization).toBe('Bearer oidc-from-option');
215+
expect(call[1].headers['x-api-blob-request-id']).toMatch(
216+
/^fromEnv:\d+:[a-f0-9]+$/,
217+
);
218+
});
219+
220+
it('should strip the "store_" prefix from BLOB_STORE_ID for OIDC', async () => {
221+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
222+
jest.fn().mockResolvedValue({
223+
status: 200,
224+
ok: true,
225+
json: () => Promise.resolve({ success: true }),
226+
}),
227+
);
228+
229+
process.env.BLOB_READ_WRITE_TOKEN = undefined;
230+
// What `vercel env pull` writes: prefixed, original case.
231+
process.env.BLOB_STORE_ID = 'store_WdsHBk1w9fDO4vPW';
232+
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';
233+
234+
await requestApi<{ success: boolean }>(
235+
'/method',
236+
{ method: 'POST', body: JSON.stringify({}) },
237+
undefined,
238+
);
239+
240+
expect(fetchMock).toHaveBeenCalledTimes(1);
241+
const call = fetchMock.mock.calls[0] as [
242+
string,
243+
{ headers: Record<string, string> },
244+
];
245+
// Bare id with original case preserved — the API is case-sensitive.
246+
expect(call[1].headers['x-vercel-blob-store-id']).toBe(
247+
'WdsHBk1w9fDO4vPW',
248+
);
249+
expect(call[1].headers['x-api-blob-request-id']).toMatch(
250+
/^WdsHBk1w9fDO4vPW:\d+:[a-f0-9]+$/,
251+
);
252+
});
253+
254+
it('should strip the "store_" prefix from the OIDC storeId option', async () => {
255+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
256+
jest.fn().mockResolvedValue({
257+
status: 200,
258+
ok: true,
259+
json: () => Promise.resolve({ success: true }),
260+
}),
261+
);
262+
263+
process.env.BLOB_READ_WRITE_TOKEN = undefined;
264+
delete process.env.BLOB_STORE_ID;
265+
process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt';
266+
267+
await requestApi<{ success: boolean }>(
268+
'/method',
269+
{ method: 'POST', body: JSON.stringify({}) },
270+
{ storeId: 'store_AbCDef' },
271+
);
272+
273+
expect(fetchMock).toHaveBeenCalledTimes(1);
274+
const call = fetchMock.mock.calls[0] as [
275+
string,
276+
{ headers: Record<string, string> },
277+
];
278+
expect(call[1].headers['x-vercel-blob-store-id']).toBe('AbCDef');
279+
});
280+
281+
it('should fall back to BLOB_READ_WRITE_TOKEN when OIDC is set but store id is missing', async () => {
282+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
283+
jest.fn().mockResolvedValue({
284+
status: 200,
285+
ok: true,
286+
json: () => Promise.resolve({ success: true }),
287+
}),
288+
);
289+
290+
process.env.BLOB_READ_WRITE_TOKEN =
291+
'vercel_blob_rw_fallbackStore_30FakeRandomCharacters12345678';
292+
delete process.env.BLOB_STORE_ID;
293+
process.env.VERCEL_OIDC_TOKEN = 'orphan-oidc';
294+
295+
await requestApi<{ success: boolean }>(
296+
'/method',
297+
{ method: 'POST', body: JSON.stringify({}) },
298+
undefined,
299+
);
300+
301+
expect(fetchMock).toHaveBeenCalledTimes(1);
302+
const call = fetchMock.mock.calls[0] as [
303+
string,
304+
{ headers: Record<string, string> },
305+
];
306+
expect(call[1].headers.authorization).toBe(
307+
'Bearer vercel_blob_rw_fallbackStore_30FakeRandomCharacters12345678',
308+
);
309+
expect(call[1].headers['x-api-blob-request-id']).toMatch(
310+
/^fallbackStore:\d+:[a-f0-9]+$/,
311+
);
312+
});
313+
44314
it('should not retry successful request', async () => {
45315
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
46316
jest.fn().mockResolvedValue({
@@ -67,6 +337,7 @@ describe('api', () => {
67337
authorization: 'Bearer 123',
68338
'x-api-blob-request-attempt': '0',
69339
'x-api-blob-request-id': expect.any(String) as string,
340+
'x-vercel-blob-store-id': '',
70341
'x-api-version': '12',
71342
},
72343
method: 'POST',

0 commit comments

Comments
 (0)