From 21ce2604253c3c6311fe4e4d4c1a0fa787505b0c Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 20 Apr 2026 12:17:58 -0700 Subject: [PATCH 01/15] Add Vercel OIDC auth --- packages/blob/package.json | 8 + packages/blob/src/api.node.test.ts | 150 ++++++++++++++++++ packages/blob/src/api.ts | 12 +- packages/blob/src/client.browser.test.ts | 14 ++ packages/blob/src/client.ts | 18 ++- packages/blob/src/get.ts | 27 ++-- packages/blob/src/helpers.ts | 82 +++++++++- packages/blob/src/index.node.test.ts | 10 +- packages/blob/src/index.ts | 15 +- packages/blob/src/list.ts | 3 +- .../blob/src/vercel-oidc-token.node.test.ts | 24 +++ packages/blob/src/vercel-oidc-token.ts | 49 ++++++ 12 files changed, 368 insertions(+), 44 deletions(-) create mode 100644 packages/blob/src/vercel-oidc-token.node.test.ts create mode 100644 packages/blob/src/vercel-oidc-token.ts diff --git a/packages/blob/package.json b/packages/blob/package.json index 09c7d8dc4..38042f336 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -65,6 +65,14 @@ "throttleit": "^2.1.0", "undici": "^6.23.0" }, + "peerDependencies": { + "@vercel/oidc": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@vercel/oidc": { + "optional": true + } + }, "devDependencies": { "@edge-runtime/jest-environment": "4.0.0", "@edge-runtime/types": "4.0.0", diff --git a/packages/blob/src/api.node.test.ts b/packages/blob/src/api.node.test.ts index 9f3795c48..a564404ff 100644 --- a/packages/blob/src/api.node.test.ts +++ b/packages/blob/src/api.node.test.ts @@ -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 () => { @@ -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), @@ -41,6 +45,151 @@ 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 }, + ]; + 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 }, + ]; + 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 }, + ]; + 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 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 }, + ]; + 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({ @@ -67,6 +216,7 @@ describe('api', () => { authorization: 'Bearer 123', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': expect.any(String) as string, + 'x-api-blob-store-id': '', 'x-api-version': '12', }, method: 'POST', diff --git a/packages/blob/src/api.ts b/packages/blob/src/api.ts index f0fe26c52..a7ffe4ac0 100644 --- a/packages/blob/src/api.ts +++ b/packages/blob/src/api.ts @@ -11,7 +11,7 @@ import { BlobError, computeBodyLength, getApiUrl, - getTokenFromOptionsOrEnv, + resolveBlobAuth, } from './helpers'; import isNetworkError from './is-network-error'; import { blobRequest } from './request'; @@ -261,11 +261,11 @@ export async function requestApi( commandOptions: (BlobCommandOptions & WithUploadProgress) | undefined, ): Promise { const apiVersion = getApiVersion(); - const token = getTokenFromOptionsOrEnv(commandOptions); + const auth = resolveBlobAuth(commandOptions); + const bearerToken = auth.kind === 'readWrite' ? auth.token : auth.oidcToken; const extraHeaders = getProxyThroughAlternativeApiHeaderFromEnv(); - const [, , , storeId = ''] = token.split('_'); - const requestId = `${storeId}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const requestId = `${auth.storeId}:${Date.now()}:${Math.random().toString(16).slice(2)}`; let retryCount = 0; let bodyLength = 0; let totalLoaded = 0; @@ -301,12 +301,14 @@ export async function requestApi( ...init, headers: { 'x-api-blob-request-id': requestId, + // Store ID is not encoded in OIDC token, so pass it separately as a header + 'x-api-blob-store-id': auth.storeId, 'x-api-blob-request-attempt': String(retryCount), 'x-api-version': apiVersion, ...(sendBodyLength ? { 'x-content-length': String(bodyLength) } : {}), - authorization: `Bearer ${token}`, + authorization: `Bearer ${bearerToken}`, ...extraHeaders, ...init.headers, }, diff --git a/packages/blob/src/client.browser.test.ts b/packages/blob/src/client.browser.test.ts index d86f29c9d..cb8edf8fd 100644 --- a/packages/blob/src/client.browser.test.ts +++ b/packages/blob/src/client.browser.test.ts @@ -90,14 +90,17 @@ describe('client', () => { 'https://vercel.com/api/blob/?pathname=foo.txt', { body: 'Test file data', + duplex: undefined, headers: { authorization: 'Bearer vercel_blob_client_fake_123', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-vercel-blob-access': 'public', }, method: 'PUT', + signal: undefined, }, ); }); @@ -152,14 +155,17 @@ describe('client', () => { 'https://vercel.com/api/blob/?pathname=foo.txt', { body: 'Test file data', + duplex: undefined, headers: { authorization: 'Bearer vercel_blob_client_fake_123', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-vercel-blob-access': 'private', }, method: 'PUT', + signal: undefined, }, ); }); @@ -273,6 +279,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'create', 'x-vercel-blob-access': 'public', @@ -292,6 +299,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -313,6 +321,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -338,6 +347,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'complete', 'x-mpu-key': 'key', @@ -423,6 +433,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'create', 'x-vercel-blob-access': 'public', @@ -442,6 +453,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -463,6 +475,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -488,6 +501,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, + 'x-api-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'complete', 'x-mpu-key': 'key', diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 8c7eb12f1..2a5a135c6 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -9,7 +9,11 @@ import type { BlobCommandOptions, WithUploadProgress, } from './helpers'; -import { BlobError, getTokenFromOptionsOrEnv } from './helpers'; +import { + BlobError, + getReadWriteBlobTokenFromOptionsOrEnv, + parseStoreIdFromReadWriteToken, +} from './helpers'; import type { CommonCompleteMultipartUploadOptions } from './multipart/complete'; import { createCompleteMultipartUploadMethod } from './multipart/complete'; import { createCreateMultipartUploadMethod } from './multipart/create'; @@ -547,7 +551,7 @@ export interface HandleUploadOptions { /** * A string specifying the read-write token to use when making requests. - * It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * Defaults to `BLOB_READ_WRITE_TOKEN`. When using `BLOB_STORE_ID` with OIDC, pass this explicitly for client-token flows. */ token?: string; @@ -567,7 +571,7 @@ export interface HandleUploadOptions { * - body - (Required) The request body containing upload information. * - onBeforeGenerateToken - (Required) Function called before generating the client token for uploads. * - onUploadCompleted - (Optional) Function called by Vercel Blob when the client upload finishes. - * - token - (Optional) A string specifying the read-write token to use when making requests. Defaults to process.env.BLOB_READ_WRITE_TOKEN. + * - token - (Optional) Read-write token. Defaults to `BLOB_READ_WRITE_TOKEN`; required explicitly when only `BLOB_STORE_ID` + OIDC is configured. * @returns A promise that resolves to either a client token generation result or an upload completion result */ export async function handleUpload({ @@ -580,7 +584,7 @@ export async function handleUpload({ | { type: 'blob.generate-client-token'; clientToken: string } | { type: 'blob.upload-completed'; response: 'ok' } > { - const resolvedToken = getTokenFromOptionsOrEnv({ token }); + const resolvedToken = getReadWriteBlobTokenFromOptionsOrEnv({ token }); const type = body.type; switch (type) { @@ -734,7 +738,7 @@ function isAbsoluteUrl(url: string): boolean { * * @param options - Options for generating the client token * - pathname - (Required) The destination path for the blob. - * - token - (Optional) A string specifying the read-write token to use. Defaults to process.env.BLOB_READ_WRITE_TOKEN. + * - token - (Optional) Read-write token. Defaults to `BLOB_READ_WRITE_TOKEN`; required explicitly when only `BLOB_STORE_ID` + OIDC is configured. * - onUploadCompleted - (Optional) Configuration for upload completion callback. * - maximumSizeInBytes - (Optional) A number specifying the maximum size in bytes that can be uploaded (max 5TB). * - allowedContentTypes - (Optional) An array of media types that are allowed to be uploaded. Wildcards are supported (text/*). @@ -775,9 +779,9 @@ export async function generateClientTokenFromReadWriteToken({ const timestamp = new Date(); timestamp.setSeconds(timestamp.getSeconds() + 30); - const readWriteToken = getTokenFromOptionsOrEnv({ token }); + const readWriteToken = getReadWriteBlobTokenFromOptionsOrEnv({ token }); - const [, , , storeId = null] = readWriteToken.split('_'); + const storeId = parseStoreIdFromReadWriteToken(readWriteToken) || null; if (!storeId) { throw new BlobError( diff --git a/packages/blob/src/get.ts b/packages/blob/src/get.ts index 976af0651..2aa624788 100644 --- a/packages/blob/src/get.ts +++ b/packages/blob/src/get.ts @@ -1,6 +1,6 @@ import { fetch, type Headers } from 'undici'; import type { BlobAccessType, BlobCommandOptions } from './helpers'; -import { BlobError, getTokenFromOptionsOrEnv } from './helpers'; +import { BlobError, resolveBlobAuth } from './helpers'; /** * Options for the get method. @@ -98,15 +98,6 @@ function extractPathnameFromUrl(url: string): string { } } -/** - * Extracts the store ID from a blob token. - * Token format: vercel_blob_rw__ - */ -function getStoreIdFromToken(token: string): string { - const [, , , storeId = ''] = token.split('_'); - return storeId; -} - /** * Constructs the blob URL from storeId and pathname. */ @@ -121,7 +112,7 @@ function constructBlobUrl( /** * Fetches blob content by URL or pathname. * - If a URL is provided, fetches the blob directly. - * - If a pathname is provided, constructs the URL from the token's store ID. + * - If a pathname is provided, constructs the URL from the resolved store ID (from the read-write token or `BLOB_STORE_ID`). * * Returns a stream (no automatic buffering) and blob metadata. * @@ -140,7 +131,8 @@ function constructBlobUrl( * @param options - Configuration options including: * - access - (Required) Must be 'public' or 'private'. Determines the access level of the blob. * - useCache - (Optional) When false, fetches directly from origin storage instead of CDN cache. Only effective for private blobs. Defaults to true. - * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * - storeId - (Optional) Store id when using Vercel OIDC token for authentication; overrides `BLOB_STORE_ID`. + * - token - (Optional) Read-write token when not using Vercel OIDC token for authentication, or set `BLOB_READ_WRITE_TOKEN`. * - abortSignal - (Optional) AbortSignal to cancel the operation. * - headers - (Optional, advanced) Additional headers to include in the fetch request. You probably don't need this. * @returns A promise that resolves to { stream, blob } or null if not found. @@ -163,7 +155,8 @@ export async function get( ); } - const token = getTokenFromOptionsOrEnv(options); + const auth = resolveBlobAuth(options); + const bearerToken = auth.kind === 'readWrite' ? auth.token : auth.oidcToken; let blobUrl: string; let pathname: string; @@ -186,19 +179,17 @@ export async function get( throw new BlobError('Invalid URL: unable to parse the provided URL'); } } else { - // Construct the URL from the token's storeId and the pathname - const storeId = getStoreIdFromToken(token); - if (!storeId) { + if (!auth.storeId) { throw new BlobError('Invalid token: unable to extract store ID'); } pathname = urlOrPathname; - blobUrl = constructBlobUrl(storeId, pathname, access); + blobUrl = constructBlobUrl(auth.storeId, pathname, access); } // Fetch the blob content with authentication headers const requestHeaders: HeadersInit = { ...(options.ifNoneMatch ? { 'If-None-Match': options.ifNoneMatch } : {}), - authorization: `Bearer ${token}`, + authorization: `Bearer ${bearerToken}`, ...options.headers, // low-level escape hatch, applied last to override anything }; diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index c399a084f..eebaf7a01 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -6,6 +6,7 @@ import { isNodeProcess } from 'is-node-process'; import type { RequestInit, Response } from 'undici'; import { isNodeJsReadableStream } from './multipart/helpers'; import type { PutBody } from './put-helpers'; +import { getVercelOidcToken } from './vercel-oidc-token'; export { bytes } from './bytes'; @@ -13,10 +14,13 @@ const defaultVercelBlobApiUrl = 'https://vercel.com/api/blob'; export interface BlobCommandOptions { /** - * Define your blob API token. - * @defaultvalue process.env.BLOB_READ_WRITE_TOKEN + * Read-write token (`vercel_blob_rw_...`) for the legacy auth path when OIDC is not used. */ token?: string; + /** + * Store id for OIDC (`VERCEL_OIDC_TOKEN`); overrides `BLOB_STORE_ID` when both apply. + */ + storeId?: string; /** * `AbortSignal` to cancel the running request. See https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal */ @@ -128,17 +132,83 @@ export interface WithUploadProgress { onUploadProgress?: OnUploadProgressCallback; } -export function getTokenFromOptionsOrEnv(options?: BlobCommandOptions): string { +function readEnv(name: string): string | undefined { + try { + const value = process.env[name]; + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : undefined; + } catch { + return undefined; + } +} + +export type ResolvedBlobAuth = + | { kind: 'readWrite'; token: string; storeId: string } + | { kind: 'oidc'; oidcToken: string; storeId: string }; + +export function parseStoreIdFromReadWriteToken(token: string): string { + const [, , , storeId = ''] = token.split('_'); + return storeId; +} + +/** + * Resolves credentials: with `VERCEL_OIDC_TOKEN`, use `storeId` option if set, + * else `BLOB_STORE_ID` if set. Otherwise use read-write `token` option or + * `BLOB_READ_WRITE_TOKEN`. + */ +export function resolveBlobAuth( + options?: BlobCommandOptions, +): ResolvedBlobAuth { + const oidcToken = getVercelOidcToken(); + if (oidcToken) { + // Try to get storeId from the supplied options + const manualStoreId = options?.storeId?.trim(); + if (manualStoreId) { + return { kind: 'oidc', oidcToken, storeId: manualStoreId }; + } + + // If not supplied manually, try to get storeId from the environment variable + const blobStoreId = readEnv('BLOB_STORE_ID'); + if (blobStoreId) { + return { kind: 'oidc', oidcToken, storeId: blobStoreId }; + } + } + + if (options?.token) { + const storeId = parseStoreIdFromReadWriteToken(options.token); + return { kind: 'readWrite', token: options.token, storeId }; + } + + const readWrite = readEnv('BLOB_READ_WRITE_TOKEN'); + if (readWrite) { + const storeId = parseStoreIdFromReadWriteToken(readWrite); + return { kind: 'readWrite', token: readWrite, storeId }; + } + + throw new BlobError( + 'No blob credentials found. With `VERCEL_OIDC_TOKEN`, set `storeId` or `BLOB_STORE_ID`. Otherwise set `BLOB_READ_WRITE_TOKEN` or pass a `token` option.', + ); +} + +/** + * Returns the read-write token for signing and callback verification. + * OIDC-only configuration is not sufficient; pass `token` or set `BLOB_READ_WRITE_TOKEN`. + */ +export function getReadWriteBlobTokenFromOptionsOrEnv( + options?: Pick, +): string { if (options?.token) { return options.token; } - if (process.env.BLOB_READ_WRITE_TOKEN) { - return process.env.BLOB_READ_WRITE_TOKEN; + const readWrite = readEnv('BLOB_READ_WRITE_TOKEN'); + if (readWrite) { + return readWrite; } throw new BlobError( - 'No token found. Either configure the `BLOB_READ_WRITE_TOKEN` environment variable, or pass a `token` option to your calls.', + 'No read-write token found. Either configure the `BLOB_READ_WRITE_TOKEN` environment variable, or pass a `token` option to your calls.', ); } diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index c51ab9312..9e0ce356b 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -34,6 +34,8 @@ describe('blob client', () => { let mockClient: Interceptable; beforeEach(() => { + delete process.env.BLOB_STORE_ID; + delete process.env.VERCEL_OIDC_TOKEN; process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; const mockAgent = new MockAgent(); @@ -128,10 +130,12 @@ describe('blob client', () => { it('should throw when the token is not set', async () => { process.env.BLOB_READ_WRITE_TOKEN = ''; + delete process.env.BLOB_STORE_ID; + delete process.env.VERCEL_OIDC_TOKEN; await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( - 'Vercel Blob: No token found. Either configure the `BLOB_READ_WRITE_TOKEN` environment variable, or pass a `token` option to your calls.', + 'Vercel Blob: No blob credentials found. With `VERCEL_OIDC_TOKEN`, set `storeId` or `BLOB_STORE_ID`. Otherwise set `BLOB_READ_WRITE_TOKEN` or pass a `token` option.', ), ); }); @@ -1290,6 +1294,8 @@ describe('blob client', () => { it('should throw when token is not set', async () => { process.env.BLOB_READ_WRITE_TOKEN = ''; + delete process.env.BLOB_STORE_ID; + delete process.env.VERCEL_OIDC_TOKEN; await expect( get('foo.txt', { @@ -1297,7 +1303,7 @@ describe('blob client', () => { }), ).rejects.toThrow( new Error( - 'Vercel Blob: No token found. Either configure the `BLOB_READ_WRITE_TOKEN` environment variable, or pass a `token` option to your calls.', + 'Vercel Blob: No blob credentials found. With `VERCEL_OIDC_TOKEN`, set `storeId` or `BLOB_STORE_ID`. Otherwise set `BLOB_READ_WRITE_TOKEN` or pass a `token` option.', ), ); }); diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index cc72f8a7e..3218a6374 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -52,7 +52,8 @@ export type { PutCommandOptions }; * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) A string indicating the media type. By default, it's extracted from the pathname's extension. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. Defaults to one month. Cannot be set to a value lower than 1 minute. - * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. + * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. * - multipart - (Optional) Whether to use multipart upload for large files. It will split the file into multiple parts, upload them in parallel and retry failed parts. * - abortSignal - (Optional) AbortSignal to cancel the operation. * - onUploadProgress - (Optional) Callback to track upload progress: onUploadProgress(\{loaded: number, total: number, percentage: number\}) @@ -113,7 +114,8 @@ export { copy } from './copy'; * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. - * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. + * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an object containing: * - key: A string that identifies the blob object. @@ -141,7 +143,8 @@ export const createMultipartUpload = * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. - * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. + * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an uploader object with the following properties and methods: * - key: A string that identifies the blob object. @@ -174,7 +177,8 @@ export type { UploadPartCommandOptions }; * - key - (Required) A string returned from createMultipartUpload which identifies the blob object. * - partNumber - (Required) A number identifying which part is uploaded (1-based index). * - contentType - (Optional) The media type for the blob. By default, it's derived from the pathname. - * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. + * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. @@ -204,7 +208,8 @@ export type { CompleteMultipartUploadCommandOptions }; * - uploadId - (Required) A string returned from createMultipartUpload which identifies the multipart upload. * - key - (Required) A string returned from createMultipartUpload which identifies the blob object. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. - * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. + * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. It defaults to true. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. diff --git a/packages/blob/src/list.ts b/packages/blob/src/list.ts index 4135685de..b23f0653a 100644 --- a/packages/blob/src/list.ts +++ b/packages/blob/src/list.ts @@ -126,7 +126,8 @@ type ListCommandResult< * Fetches a paginated list of blob objects from your store. * * @param options - Configuration options including: - * - token - (Optional) A string specifying the read-write token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. + * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. + * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. * - limit - (Optional) The maximum number of blobs to return. Defaults to 1000. * - prefix - (Optional) Filters the result to only include blobs that start with this prefix. If used with mode: 'folded', include a trailing slash after the folder name. * - cursor - (Optional) The cursor to use for pagination. Can be obtained from the response of a previous list request. diff --git a/packages/blob/src/vercel-oidc-token.node.test.ts b/packages/blob/src/vercel-oidc-token.node.test.ts new file mode 100644 index 000000000..1ba142449 --- /dev/null +++ b/packages/blob/src/vercel-oidc-token.node.test.ts @@ -0,0 +1,24 @@ +import { getVercelOidcToken } from './vercel-oidc-token'; + +describe('vercel-oidc-token', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + delete process.env.VERCEL_OIDC_TOKEN; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it('getVercelOidcToken returns token from env', () => { + process.env.VERCEL_OIDC_TOKEN = 'jwt-from-env'; + expect(getVercelOidcToken()).toBe('jwt-from-env'); + }); + + it('getVercelOidcToken returns undefined when no token is available', () => { + delete process.env.VERCEL_OIDC_TOKEN; + expect(getVercelOidcToken()).toBeUndefined(); + }); +}); diff --git a/packages/blob/src/vercel-oidc-token.ts b/packages/blob/src/vercel-oidc-token.ts new file mode 100644 index 000000000..f31e85ec7 --- /dev/null +++ b/packages/blob/src/vercel-oidc-token.ts @@ -0,0 +1,49 @@ +import { createRequire } from 'node:module'; +import { isNodeProcess } from 'is-node-process'; + +type MutateResponseHeadersBeforeFlushHandler = (headers: Headers) => void; + +type Context = { + waitUntil?: (promise: Promise) => void; + // TODO change this to __unstable_ + mutateResponseHeadersBeforeFlush?: ( + callback: MutateResponseHeadersBeforeFlushHandler, + ) => void; + headers?: Record; + url?: string; +}; + +const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); + +const getContext = (): Context => { + const fromSymbol: typeof globalThis & { + [SYMBOL_FOR_REQ_CONTEXT]?: { get?: () => Context }; + } = globalThis; + + return fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.() ?? {}; +}; + +/** + * Gets the current OIDC token from the request context or the environment variable. + * + * Do not cache this value, as it is subject to change in production! + * + * This function is used to retrieve the OIDC token from the request context or the environment variable. + * It checks for the `x-vercel-oidc-token` header in the request context and falls back to the `VERCEL_OIDC_TOKEN` environment variable if the header is not present. + * + * @returns {string} The OIDC token, or undefined if not found + * + * @example + * + * ```js + * // Using the OIDC token + * const token = getVercelOidcToken(); + * console.log('OIDC Token:', token); + * ``` + */ +export function getVercelOidcToken(): string | undefined { + return ( + getContext().headers?.['x-vercel-oidc-token'] ?? + process.env.VERCEL_OIDC_TOKEN + ); +} From f63ecc19e677b3e2302d994a7815886ef131ffa2 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 20 Apr 2026 13:27:40 -0700 Subject: [PATCH 02/15] up --- .changeset/empty-things-say.md | 5 +++++ packages/blob/package.json | 8 -------- packages/blob/src/client.ts | 6 +++--- packages/blob/src/helpers.ts | 7 +++++-- packages/blob/src/index.ts | 20 ++++++++++---------- packages/blob/src/list.ts | 4 ++-- 6 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 .changeset/empty-things-say.md diff --git a/.changeset/empty-things-say.md b/.changeset/empty-things-say.md new file mode 100644 index 000000000..dcb1ff333 --- /dev/null +++ b/.changeset/empty-things-say.md @@ -0,0 +1,5 @@ +--- +"@vercel/blob": patch +--- + +Add Vercel OIDC auth diff --git a/packages/blob/package.json b/packages/blob/package.json index 38042f336..09c7d8dc4 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -65,14 +65,6 @@ "throttleit": "^2.1.0", "undici": "^6.23.0" }, - "peerDependencies": { - "@vercel/oidc": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@vercel/oidc": { - "optional": true - } - }, "devDependencies": { "@edge-runtime/jest-environment": "4.0.0", "@edge-runtime/types": "4.0.0", diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 2a5a135c6..7f11fe1c6 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -551,7 +551,7 @@ export interface HandleUploadOptions { /** * A string specifying the read-write token to use when making requests. - * Defaults to `BLOB_READ_WRITE_TOKEN`. When using `BLOB_STORE_ID` with OIDC, pass this explicitly for client-token flows. + * It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. */ token?: string; @@ -571,7 +571,7 @@ export interface HandleUploadOptions { * - body - (Required) The request body containing upload information. * - onBeforeGenerateToken - (Required) Function called before generating the client token for uploads. * - onUploadCompleted - (Optional) Function called by Vercel Blob when the client upload finishes. - * - token - (Optional) Read-write token. Defaults to `BLOB_READ_WRITE_TOKEN`; required explicitly when only `BLOB_STORE_ID` + OIDC is configured. + * - token - (Optional) A string specifying the read-write token to use when making requests. Defaults to process.env.BLOB_READ_WRITE_TOKEN. * @returns A promise that resolves to either a client token generation result or an upload completion result */ export async function handleUpload({ @@ -738,7 +738,7 @@ function isAbsoluteUrl(url: string): boolean { * * @param options - Options for generating the client token * - pathname - (Required) The destination path for the blob. - * - token - (Optional) Read-write token. Defaults to `BLOB_READ_WRITE_TOKEN`; required explicitly when only `BLOB_STORE_ID` + OIDC is configured. + * - token - (Optional) A string specifying the read-write token to use. Defaults to process.env.BLOB_READ_WRITE_TOKEN. * - onUploadCompleted - (Optional) Configuration for upload completion callback. * - maximumSizeInBytes - (Optional) A number specifying the maximum size in bytes that can be uploaded (max 5TB). * - allowedContentTypes - (Optional) An array of media types that are allowed to be uploaded. Wildcards are supported (text/*). diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index eebaf7a01..bb7e7c1ac 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -14,11 +14,14 @@ const defaultVercelBlobApiUrl = 'https://vercel.com/api/blob'; export interface BlobCommandOptions { /** - * Read-write token (`vercel_blob_rw_...`) for the legacy auth path when OIDC is not used. + * Define your blob API token. + * If process.env.VERCEL_OIDC_TOKEN is set and either process.env.BLOB_STORE_ID or options.storeId is set, this will be ignored + * @defaultvalue process.env.BLOB_READ_WRITE_TOKEN */ token?: string; /** - * Store id for OIDC (`VERCEL_OIDC_TOKEN`); overrides `BLOB_STORE_ID` when both apply. + * Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. + * @defaultvalue process.env.BLOB_STORE_ID */ storeId?: string; /** diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index 3218a6374..2a280de58 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -52,8 +52,8 @@ export type { PutCommandOptions }; * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) A string indicating the media type. By default, it's extracted from the pathname's extension. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. Defaults to one month. Cannot be set to a value lower than 1 minute. - * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. - * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. + * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - multipart - (Optional) Whether to use multipart upload for large files. It will split the file into multiple parts, upload them in parallel and retry failed parts. * - abortSignal - (Optional) AbortSignal to cancel the operation. * - onUploadProgress - (Optional) Callback to track upload progress: onUploadProgress(\{loaded: number, total: number, percentage: number\}) @@ -114,8 +114,8 @@ export { copy } from './copy'; * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. - * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. - * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. + * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an object containing: * - key: A string that identifies the blob object. @@ -143,8 +143,8 @@ export const createMultipartUpload = * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if you try to overwrite a blob by using the same pathname for multiple blobs. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. - * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. - * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. + * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an uploader object with the following properties and methods: * - key: A string that identifies the blob object. @@ -177,8 +177,8 @@ export type { UploadPartCommandOptions }; * - key - (Required) A string returned from createMultipartUpload which identifies the blob object. * - partNumber - (Required) A number identifying which part is uploaded (1-based index). * - contentType - (Optional) The media type for the blob. By default, it's derived from the pathname. - * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. - * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. + * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. @@ -208,8 +208,8 @@ export type { CompleteMultipartUploadCommandOptions }; * - uploadId - (Required) A string returned from createMultipartUpload which identifies the multipart upload. * - key - (Required) A string returned from createMultipartUpload which identifies the blob object. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. - * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. - * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. + * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. It defaults to true. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. diff --git a/packages/blob/src/list.ts b/packages/blob/src/list.ts index b23f0653a..5344b5a25 100644 --- a/packages/blob/src/list.ts +++ b/packages/blob/src/list.ts @@ -126,8 +126,8 @@ type ListCommandResult< * Fetches a paginated list of blob objects from your store. * * @param options - Configuration options including: - * - storeId - (Optional) Store id when using `VERCEL_OIDC_TOKEN`; overrides `BLOB_STORE_ID`. - * - token - (Optional) Read-write token when not using OIDC with a store id, or set `BLOB_READ_WRITE_TOKEN`. + * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - limit - (Optional) The maximum number of blobs to return. Defaults to 1000. * - prefix - (Optional) Filters the result to only include blobs that start with this prefix. If used with mode: 'folded', include a trailing slash after the folder name. * - cursor - (Optional) The cursor to use for pagination. Can be obtained from the response of a previous list request. From f107c39539b508f6a082c4dc43e68f9edf7ac414 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 20 Apr 2026 14:17:31 -0700 Subject: [PATCH 03/15] header --- packages/blob/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blob/src/api.ts b/packages/blob/src/api.ts index a7ffe4ac0..9fb725b08 100644 --- a/packages/blob/src/api.ts +++ b/packages/blob/src/api.ts @@ -302,7 +302,7 @@ export async function requestApi( headers: { 'x-api-blob-request-id': requestId, // Store ID is not encoded in OIDC token, so pass it separately as a header - 'x-api-blob-store-id': auth.storeId, + 'x-vercel-blob-store-id': auth.storeId, 'x-api-blob-request-attempt': String(retryCount), 'x-api-version': apiVersion, ...(sendBodyLength From 179061a010babd6f7f8ed2682e0ab8c31d97a838 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 20 Apr 2026 14:44:13 -0700 Subject: [PATCH 04/15] up --- packages/blob/src/api.node.test.ts | 2 +- packages/blob/src/client.browser.test.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/blob/src/api.node.test.ts b/packages/blob/src/api.node.test.ts index a564404ff..1cc050248 100644 --- a/packages/blob/src/api.node.test.ts +++ b/packages/blob/src/api.node.test.ts @@ -216,7 +216,7 @@ describe('api', () => { authorization: 'Bearer 123', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': expect.any(String) as string, - 'x-api-blob-store-id': '', + 'x-vercel-blob-store-id': '', 'x-api-version': '12', }, method: 'POST', diff --git a/packages/blob/src/client.browser.test.ts b/packages/blob/src/client.browser.test.ts index cb8edf8fd..6a06b4fc4 100644 --- a/packages/blob/src/client.browser.test.ts +++ b/packages/blob/src/client.browser.test.ts @@ -95,7 +95,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_123', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-vercel-blob-access': 'public', }, @@ -160,7 +160,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_123', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-vercel-blob-access': 'private', }, @@ -279,7 +279,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'create', 'x-vercel-blob-access': 'public', @@ -299,7 +299,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -321,7 +321,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -347,7 +347,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'complete', 'x-mpu-key': 'key', @@ -433,7 +433,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'create', 'x-vercel-blob-access': 'public', @@ -453,7 +453,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -475,7 +475,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'upload', 'x-mpu-key': 'key', @@ -501,7 +501,7 @@ describe('client', () => { authorization: 'Bearer vercel_blob_client_fake_token_for_test', 'x-api-blob-request-attempt': '0', 'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`, - 'x-api-blob-store-id': 'fake', + 'x-vercel-blob-store-id': 'fake', 'x-api-version': '12', 'x-mpu-action': 'complete', 'x-mpu-key': 'key', From 5d06fa0f23e559110c62bc55b36163e416d0b83e Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Tue, 28 Apr 2026 14:51:05 -0700 Subject: [PATCH 05/15] prioritize token --- packages/blob/src/helpers.ts | 22 ++++++++++++---------- packages/blob/src/index.node.test.ts | 4 ++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index bb7e7c1ac..7c04b3f3d 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -15,7 +15,7 @@ const defaultVercelBlobApiUrl = 'https://vercel.com/api/blob'; export interface BlobCommandOptions { /** * Define your blob API token. - * If process.env.VERCEL_OIDC_TOKEN is set and either process.env.BLOB_STORE_ID or options.storeId is set, this will be ignored + * When supplied, this takes priority over process.env.VERCEL_OIDC_TOKEN and process.env.BLOB_READ_WRITE_TOKEN. * @defaultvalue process.env.BLOB_READ_WRITE_TOKEN */ token?: string; @@ -156,13 +156,20 @@ export function parseStoreIdFromReadWriteToken(token: string): string { } /** - * Resolves credentials: with `VERCEL_OIDC_TOKEN`, use `storeId` option if set, - * else `BLOB_STORE_ID` if set. Otherwise use read-write `token` option or - * `BLOB_READ_WRITE_TOKEN`. + * Resolves credentials in the following priority order: + * 1. An explicit read-write `token` passed via options. + * 2. `VERCEL_OIDC_TOKEN` paired with `storeId` option (or `BLOB_STORE_ID`). + * 3. `BLOB_READ_WRITE_TOKEN` from the environment. */ export function resolveBlobAuth( options?: BlobCommandOptions, ): ResolvedBlobAuth { + // An explicitly supplied token always wins over OIDC and env-based tokens. + if (options?.token) { + const storeId = parseStoreIdFromReadWriteToken(options.token); + return { kind: 'readWrite', token: options.token, storeId }; + } + const oidcToken = getVercelOidcToken(); if (oidcToken) { // Try to get storeId from the supplied options @@ -178,11 +185,6 @@ export function resolveBlobAuth( } } - if (options?.token) { - const storeId = parseStoreIdFromReadWriteToken(options.token); - return { kind: 'readWrite', token: options.token, storeId }; - } - const readWrite = readEnv('BLOB_READ_WRITE_TOKEN'); if (readWrite) { const storeId = parseStoreIdFromReadWriteToken(readWrite); @@ -190,7 +192,7 @@ export function resolveBlobAuth( } throw new BlobError( - 'No blob credentials found. With `VERCEL_OIDC_TOKEN`, set `storeId` or `BLOB_STORE_ID`. Otherwise set `BLOB_READ_WRITE_TOKEN` or pass a `token` option.', + 'No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `VERCEL_OIDC_TOKEN` with `storeId` or `BLOB_STORE_ID`.', ); } diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index 9e0ce356b..f4382a4cb 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -135,7 +135,7 @@ describe('blob client', () => { await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( - 'Vercel Blob: No blob credentials found. With `VERCEL_OIDC_TOKEN`, set `storeId` or `BLOB_STORE_ID`. Otherwise set `BLOB_READ_WRITE_TOKEN` or pass a `token` option.', + 'Vercel Blob: No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `VERCEL_OIDC_TOKEN` with `storeId` or `BLOB_STORE_ID`.', ), ); }); @@ -1303,7 +1303,7 @@ describe('blob client', () => { }), ).rejects.toThrow( new Error( - 'Vercel Blob: No blob credentials found. With `VERCEL_OIDC_TOKEN`, set `storeId` or `BLOB_STORE_ID`. Otherwise set `BLOB_READ_WRITE_TOKEN` or pass a `token` option.', + 'Vercel Blob: No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `VERCEL_OIDC_TOKEN` with `storeId` or `BLOB_STORE_ID`.', ), ); }); From 8104aa7181674361a9fd37cd49bf2d4c94fdf399 Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Mon, 4 May 2026 12:27:16 -0300 Subject: [PATCH 06/15] normalize storeId in OIDC auth resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOB_STORE_ID and the storeId option are accepted in either store_ or 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) --- packages/blob/src/api.node.test.ts | 72 +++++++++++++++++++++++++++--- packages/blob/src/helpers.ts | 25 ++++++++++- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/blob/src/api.node.test.ts b/packages/blob/src/api.node.test.ts index 1cc050248..483f8b96d 100644 --- a/packages/blob/src/api.node.test.ts +++ b/packages/blob/src/api.node.test.ts @@ -75,7 +75,7 @@ describe('api', () => { ); process.env.BLOB_READ_WRITE_TOKEN = undefined; - process.env.BLOB_STORE_ID = 'oidcStore'; + process.env.BLOB_STORE_ID = 'oidcstore'; process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; const res = await requestApi<{ success: boolean }>( @@ -91,7 +91,7 @@ describe('api', () => { ]; expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); expect(call[1].headers['x-api-blob-request-id']).toMatch( - /^oidcStore:\d+:[a-f0-9]+$/, + /^oidcstore:\d+:[a-f0-9]+$/, ); expect(res).toEqual({ success: true }); }); @@ -107,7 +107,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_fromRwToken_30FakeRandomCharacters12345678'; - process.env.BLOB_STORE_ID = 'oidcStore'; + process.env.BLOB_STORE_ID = 'oidcstore'; process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; await requestApi<{ success: boolean }>( @@ -123,7 +123,7 @@ describe('api', () => { ]; expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); expect(call[1].headers['x-api-blob-request-id']).toMatch( - /^oidcStore:\d+:[a-f0-9]+$/, + /^oidcstore:\d+:[a-f0-9]+$/, ); }); @@ -143,7 +143,7 @@ describe('api', () => { await requestApi<{ success: boolean }>( '/method', { method: 'POST', body: JSON.stringify({}) }, - { storeId: 'fromOption' }, + { storeId: 'fromoption' }, ); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -153,10 +153,70 @@ describe('api', () => { ]; expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); expect(call[1].headers['x-api-blob-request-id']).toMatch( - /^fromOption:\d+:[a-f0-9]+$/, + /^fromoption:\d+:[a-f0-9]+$/, ); }); + it('should strip "store_" prefix and lowercase the OIDC store id', 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; + // Vercel-issued env value: prefixed and mixed 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 }, + ]; + 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 normalize 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 }, + ]; + 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({ diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 7c04b3f3d..281c90bfc 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -155,6 +155,19 @@ export function parseStoreIdFromReadWriteToken(token: string): string { return storeId; } +/** + * Returns the canonical store id form used in API request headers and blob + * URLs (host subdomain): bare id, lowercase. `BLOB_STORE_ID` and the + * `storeId` option are accepted in either `store_` or `` form, and + * Vercel-issued values may be mixed case — both are normalized here. + */ +export function normalizeStoreId(storeId: string): string { + const lowercase = storeId.toLowerCase(); + return lowercase.startsWith('store_') + ? lowercase.slice('store_'.length) + : lowercase; +} + /** * Resolves credentials in the following priority order: * 1. An explicit read-write `token` passed via options. @@ -175,13 +188,21 @@ export function resolveBlobAuth( // Try to get storeId from the supplied options const manualStoreId = options?.storeId?.trim(); if (manualStoreId) { - return { kind: 'oidc', oidcToken, storeId: manualStoreId }; + return { + kind: 'oidc', + oidcToken, + storeId: normalizeStoreId(manualStoreId), + }; } // If not supplied manually, try to get storeId from the environment variable const blobStoreId = readEnv('BLOB_STORE_ID'); if (blobStoreId) { - return { kind: 'oidc', oidcToken, storeId: blobStoreId }; + return { + kind: 'oidc', + oidcToken, + storeId: normalizeStoreId(blobStoreId), + }; } } From 1a02707f414d33854d024354236e8d0d710dd141 Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Mon, 4 May 2026 12:33:34 -0300 Subject: [PATCH 07/15] preserve case when normalizing storeId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/blob/src/api.node.test.ts | 27 ++++++++++++++------------- packages/blob/src/helpers.ts | 16 ++++++++-------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/blob/src/api.node.test.ts b/packages/blob/src/api.node.test.ts index 483f8b96d..7105b3e09 100644 --- a/packages/blob/src/api.node.test.ts +++ b/packages/blob/src/api.node.test.ts @@ -75,7 +75,7 @@ describe('api', () => { ); process.env.BLOB_READ_WRITE_TOKEN = undefined; - process.env.BLOB_STORE_ID = 'oidcstore'; + process.env.BLOB_STORE_ID = 'oidcStore'; process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; const res = await requestApi<{ success: boolean }>( @@ -91,7 +91,7 @@ describe('api', () => { ]; expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); expect(call[1].headers['x-api-blob-request-id']).toMatch( - /^oidcstore:\d+:[a-f0-9]+$/, + /^oidcStore:\d+:[a-f0-9]+$/, ); expect(res).toEqual({ success: true }); }); @@ -107,7 +107,7 @@ describe('api', () => { process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_fromRwToken_30FakeRandomCharacters12345678'; - process.env.BLOB_STORE_ID = 'oidcstore'; + process.env.BLOB_STORE_ID = 'oidcStore'; process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; await requestApi<{ success: boolean }>( @@ -123,7 +123,7 @@ describe('api', () => { ]; expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); expect(call[1].headers['x-api-blob-request-id']).toMatch( - /^oidcstore:\d+:[a-f0-9]+$/, + /^oidcStore:\d+:[a-f0-9]+$/, ); }); @@ -143,7 +143,7 @@ describe('api', () => { await requestApi<{ success: boolean }>( '/method', { method: 'POST', body: JSON.stringify({}) }, - { storeId: 'fromoption' }, + { storeId: 'fromOption' }, ); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -153,11 +153,11 @@ describe('api', () => { ]; expect(call[1].headers.authorization).toBe('Bearer oidc-jwt'); expect(call[1].headers['x-api-blob-request-id']).toMatch( - /^fromoption:\d+:[a-f0-9]+$/, + /^fromOption:\d+:[a-f0-9]+$/, ); }); - it('should strip "store_" prefix and lowercase the OIDC store id', async () => { + 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, @@ -167,7 +167,7 @@ describe('api', () => { ); process.env.BLOB_READ_WRITE_TOKEN = undefined; - // Vercel-issued env value: prefixed and mixed case. + // What `vercel env pull` writes: prefixed, original case. process.env.BLOB_STORE_ID = 'store_WdsHBk1w9fDO4vPW'; process.env.VERCEL_OIDC_TOKEN = 'oidc-jwt'; @@ -182,15 +182,16 @@ describe('api', () => { string, { headers: Record }, ]; + // Bare id with original case preserved — the API is case-sensitive. expect(call[1].headers['x-vercel-blob-store-id']).toBe( - 'wdshbk1w9fdo4vpw', + 'WdsHBk1w9fDO4vPW', ); expect(call[1].headers['x-api-blob-request-id']).toMatch( - /^wdshbk1w9fdo4vpw:\d+:[a-f0-9]+$/, + /^WdsHBk1w9fDO4vPW:\d+:[a-f0-9]+$/, ); }); - it('should normalize the OIDC storeId option', async () => { + it('should strip the "store_" prefix from the OIDC storeId option', async () => { const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation( jest.fn().mockResolvedValue({ status: 200, @@ -206,7 +207,7 @@ describe('api', () => { await requestApi<{ success: boolean }>( '/method', { method: 'POST', body: JSON.stringify({}) }, - { storeId: 'Store_AbCDef' }, + { storeId: 'store_AbCDef' }, ); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -214,7 +215,7 @@ describe('api', () => { string, { headers: Record }, ]; - expect(call[1].headers['x-vercel-blob-store-id']).toBe('abcdef'); + 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 () => { diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 281c90bfc..8fe000de9 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -156,16 +156,16 @@ export function parseStoreIdFromReadWriteToken(token: string): string { } /** - * Returns the canonical store id form used in API request headers and blob - * URLs (host subdomain): bare id, lowercase. `BLOB_STORE_ID` and the - * `storeId` option are accepted in either `store_` or `` form, and - * Vercel-issued values may be mixed case — both are normalized here. + * Strips the optional `store_` prefix from a store id. `BLOB_STORE_ID` (what + * `vercel env pull` writes) and the `storeId` option are accepted in either + * `store_` or `` form; downstream consumers (CDN host subdomain, + * `x-vercel-blob-store-id` header) want the bare form. Case is preserved + * because the API is case-sensitive on this value. */ export function normalizeStoreId(storeId: string): string { - const lowercase = storeId.toLowerCase(); - return lowercase.startsWith('store_') - ? lowercase.slice('store_'.length) - : lowercase; + return storeId.startsWith('store_') + ? storeId.slice('store_'.length) + : storeId; } /** From 011e3cabd5e3bd0f2f55935907f816a5a2eaedd4 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Fri, 15 May 2026 10:33:02 -0700 Subject: [PATCH 08/15] add oidcToken option --- packages/blob/src/api.node.test.ts | 60 ++++++++++++++++++++++++++++ packages/blob/src/get.ts | 1 + packages/blob/src/helpers.ts | 12 ++++-- packages/blob/src/index.node.test.ts | 33 ++++++++++++++- packages/blob/src/index.ts | 5 +++ packages/blob/src/list.ts | 1 + 6 files changed, 107 insertions(+), 5 deletions(-) diff --git a/packages/blob/src/api.node.test.ts b/packages/blob/src/api.node.test.ts index 7105b3e09..2ca6f06e0 100644 --- a/packages/blob/src/api.node.test.ts +++ b/packages/blob/src/api.node.test.ts @@ -157,6 +157,66 @@ describe('api', () => { ); }); + 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 }, + ]; + 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 }, + ]; + 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({ diff --git a/packages/blob/src/get.ts b/packages/blob/src/get.ts index 2aa624788..590b2240d 100644 --- a/packages/blob/src/get.ts +++ b/packages/blob/src/get.ts @@ -131,6 +131,7 @@ function constructBlobUrl( * @param options - Configuration options including: * - access - (Required) Must be 'public' or 'private'. Determines the access level of the blob. * - useCache - (Optional) When false, fetches directly from origin storage instead of CDN cache. Only effective for private blobs. Defaults to true. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Store id when using Vercel OIDC token for authentication; overrides `BLOB_STORE_ID`. * - token - (Optional) Read-write token when not using Vercel OIDC token for authentication, or set `BLOB_READ_WRITE_TOKEN`. * - abortSignal - (Optional) AbortSignal to cancel the operation. diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 8fe000de9..75bb47970 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -19,6 +19,12 @@ export interface BlobCommandOptions { * @defaultvalue process.env.BLOB_READ_WRITE_TOKEN */ token?: string; + /** + * Define your Vercel OIDC token for store-scoped blob operations. + * Use this together with `storeId` (or `BLOB_STORE_ID`) when you want to pass OIDC credentials explicitly. + * @defaultvalue process.env.VERCEL_OIDC_TOKEN + */ + oidcToken?: string; /** * Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * @defaultvalue process.env.BLOB_STORE_ID @@ -171,7 +177,7 @@ export function normalizeStoreId(storeId: string): string { /** * Resolves credentials in the following priority order: * 1. An explicit read-write `token` passed via options. - * 2. `VERCEL_OIDC_TOKEN` paired with `storeId` option (or `BLOB_STORE_ID`). + * 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( @@ -183,7 +189,7 @@ export function resolveBlobAuth( return { kind: 'readWrite', token: options.token, storeId }; } - const oidcToken = getVercelOidcToken(); + const oidcToken = options?.oidcToken?.trim() || getVercelOidcToken(); if (oidcToken) { // Try to get storeId from the supplied options const manualStoreId = options?.storeId?.trim(); @@ -213,7 +219,7 @@ export function resolveBlobAuth( } throw new BlobError( - 'No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `VERCEL_OIDC_TOKEN` with `storeId` or `BLOB_STORE_ID`.', + 'No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `oidcToken` (or `VERCEL_OIDC_TOKEN`) with `storeId` or `BLOB_STORE_ID`.', ); } diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index f4382a4cb..6aaab87ee 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -85,6 +85,35 @@ describe('blob client', () => { ); }); + it('should use oidcToken and storeId options when calling `head()`', async () => { + let headers: Record = {}; + mockClient + .intercept({ + path: () => true, + method: 'GET', + }) + .reply(200, (req) => { + headers = req.headers as Record; + return mockedFileMeta; + }); + + process.env.BLOB_READ_WRITE_TOKEN = ''; + delete process.env.BLOB_STORE_ID; + delete process.env.VERCEL_OIDC_TOKEN; + + await expect( + head(`${BLOB_STORE_BASE_URL}/foo-id.txt`, { + storeId: 'store_customStore', + oidcToken: 'oidc-from-option', + }), + ).resolves.toMatchObject({ + url: `${BLOB_STORE_BASE_URL}/foo-id.txt`, + }); + + expect(headers.authorization).toEqual('Bearer oidc-from-option'); + expect(headers['x-vercel-blob-store-id']).toEqual('customStore'); + }); + it('should return null when calling `head()` with an url that does not exist', async () => { mockClient .intercept({ @@ -135,7 +164,7 @@ describe('blob client', () => { await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( - 'Vercel Blob: No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `VERCEL_OIDC_TOKEN` with `storeId` or `BLOB_STORE_ID`.', + 'Vercel Blob: No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `oidcToken` (or `VERCEL_OIDC_TOKEN`) with `storeId` or `BLOB_STORE_ID`.', ), ); }); @@ -1303,7 +1332,7 @@ describe('blob client', () => { }), ).rejects.toThrow( new Error( - 'Vercel Blob: No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `VERCEL_OIDC_TOKEN` with `storeId` or `BLOB_STORE_ID`.', + 'Vercel Blob: No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use `oidcToken` (or `VERCEL_OIDC_TOKEN`) with `storeId` or `BLOB_STORE_ID`.', ), ); }); diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index 2a280de58..da89d3f1b 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -53,6 +53,7 @@ export type { PutCommandOptions }; * - contentType - (Optional) A string indicating the media type. By default, it's extracted from the pathname's extension. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. Defaults to one month. Cannot be set to a value lower than 1 minute. * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - multipart - (Optional) Whether to use multipart upload for large files. It will split the file into multiple parts, upload them in parallel and retry failed parts. * - abortSignal - (Optional) AbortSignal to cancel the operation. @@ -115,6 +116,7 @@ export { copy } from './copy'; * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an object containing: @@ -144,6 +146,7 @@ export const createMultipartUpload = * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. Falls back to application/octet-stream when no extension exists or can't be matched. * - cacheControlMaxAge - (Optional) A number in seconds to configure the edge and browser cache. Defaults to one month. * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - abortSignal - (Optional) AbortSignal to cancel the operation. * @returns A promise that resolves to an uploader object with the following properties and methods: @@ -178,6 +181,7 @@ export type { UploadPartCommandOptions }; * - partNumber - (Required) A number identifying which part is uploaded (1-based index). * - contentType - (Optional) The media type for the blob. By default, it's derived from the pathname. * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. @@ -209,6 +213,7 @@ export type { CompleteMultipartUploadCommandOptions }; * - key - (Required) A string returned from createMultipartUpload which identifies the blob object. * - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension. * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. It defaults to true. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. diff --git a/packages/blob/src/list.ts b/packages/blob/src/list.ts index 5344b5a25..e344a47c9 100644 --- a/packages/blob/src/list.ts +++ b/packages/blob/src/list.ts @@ -127,6 +127,7 @@ type ListCommandResult< * * @param options - Configuration options including: * - token - (Optional) A string specifying the token to use when making requests. It defaults to process.env.BLOB_READ_WRITE_TOKEN when deployed on Vercel. Ignored when Vercel OIDC token is available and either process.env.BLOB_STORE_ID or options.storeId is set. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - limit - (Optional) The maximum number of blobs to return. Defaults to 1000. * - prefix - (Optional) Filters the result to only include blobs that start with this prefix. If used with mode: 'folded', include a trailing slash after the folder name. From dcfac573eee3e044d32d442a38a443456aa68c11 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 18 May 2026 10:46:01 -0700 Subject: [PATCH 09/15] minor version --- .changeset/empty-things-say.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/empty-things-say.md b/.changeset/empty-things-say.md index dcb1ff333..10e98afe3 100644 --- a/.changeset/empty-things-say.md +++ b/.changeset/empty-things-say.md @@ -1,5 +1,5 @@ --- -"@vercel/blob": patch +"@vercel/blob": minor --- -Add Vercel OIDC auth +Add Vercel OIDC auth and presigned URLs From e527fad39edc446c9f42c42883f241a1c6a869f1 Mon Sep 17 00:00:00 2001 From: Elliot Dauber <67391073+elliotdauber@users.noreply.github.com> Date: Mon, 18 May 2026 10:54:06 -0700 Subject: [PATCH 10/15] Vercel Presigned URLs (#1057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 (`..blob.vercel-storage.com/`); 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 Co-authored-by: Claude Opus 4.6 --- packages/blob/src/api.ts | 52 +- packages/blob/src/client-token-constraints.ts | 55 ++ packages/blob/src/client.node.test.ts | 179 ++++++ packages/blob/src/client.ts | 469 +++++++++++++-- packages/blob/src/crypto-browser.js | 8 + packages/blob/src/del.ts | 2 +- packages/blob/src/get.node.test.ts | 125 ++++ packages/blob/src/get.ts | 29 +- packages/blob/src/helpers.ts | 135 ++++- packages/blob/src/index.node.test.ts | 22 + packages/blob/src/index.ts | 19 + packages/blob/src/multipart/complete.ts | 4 +- packages/blob/src/multipart/create.ts | 4 +- packages/blob/src/multipart/upload.ts | 10 +- packages/blob/src/presign-query-params.ts | 383 ++++++++++++ packages/blob/src/put-helpers.ts | 6 +- packages/blob/src/put.ts | 20 +- .../blob/src/signed-token.browser.test.ts | 21 + packages/blob/src/signed-token.node.test.ts | 3 + .../signed-token.presignurl.shared-spec.ts | 328 ++++++++++ .../signed-token.presignurl.test-helpers.ts | 41 ++ packages/blob/src/signed-token.ts | 558 ++++++++++++++++++ .../serverless/route.ts | 3 + .../blob/api/app/presigned-delete/route.ts | 67 +++ .../blob/api/app/presigned-head/route.ts | 67 +++ .../blob/api/app/presigned-read/route.ts | 69 +++ .../src/app/vercel/blob/app/list/page.tsx | 146 ++++- .../blob/app/presigned-upload/client/page.tsx | 289 +++++++++ .../blob/handle-blob-upload-presigned.ts | 100 ++++ .../src/app/vercel/blob/handle-blob-upload.ts | 1 + test/next/src/app/vercel/blob/page.tsx | 6 + 31 files changed, 3119 insertions(+), 102 deletions(-) create mode 100644 packages/blob/src/client-token-constraints.ts create mode 100644 packages/blob/src/get.node.test.ts create mode 100644 packages/blob/src/presign-query-params.ts create mode 100644 packages/blob/src/signed-token.browser.test.ts create mode 100644 packages/blob/src/signed-token.node.test.ts create mode 100644 packages/blob/src/signed-token.presignurl.shared-spec.ts create mode 100644 packages/blob/src/signed-token.presignurl.test-helpers.ts create mode 100644 packages/blob/src/signed-token.ts create mode 100644 test/next/src/app/vercel/blob/api/app/handle-blob-upload-presigned/serverless/route.ts create mode 100644 test/next/src/app/vercel/blob/api/app/presigned-delete/route.ts create mode 100644 test/next/src/app/vercel/blob/api/app/presigned-head/route.ts create mode 100644 test/next/src/app/vercel/blob/api/app/presigned-read/route.ts create mode 100644 test/next/src/app/vercel/blob/app/presigned-upload/client/page.tsx create mode 100644 test/next/src/app/vercel/blob/handle-blob-upload-presigned.ts diff --git a/packages/blob/src/api.ts b/packages/blob/src/api.ts index 9fb725b08..9fc6fac17 100644 --- a/packages/blob/src/api.ts +++ b/packages/blob/src/api.ts @@ -3,11 +3,12 @@ import type { Response } from 'undici'; import { debug } from './debug'; import { DOMException } from './dom-exception'; import type { - BlobCommandOptions, + BlobPresignedCommandOptions, BlobRequestInit, WithUploadProgress, } from './helpers'; import { + addPresignedParams, BlobError, computeBodyLength, getApiUrl, @@ -28,6 +29,15 @@ export class BlobAccessError extends BlobError { } } +export class BlobOidcEnvironmentNotAllowedError extends BlobError { + constructor(message?: string) { + super( + message ?? + "OIDC is enabled for this project, but not for this token's environment.", + ); + } +} + export class BlobContentTypeNotAllowedError extends BlobError { constructor(message: string) { super(`Content type mismatch, ${message}.`); @@ -113,11 +123,13 @@ export class BlobPreconditionFailedError extends BlobError { type BlobApiErrorCodes = | 'store_suspended' | 'forbidden' + | 'oidc_environment_not_allowed' | 'not_found' | 'unknown_error' | 'bad_request' | 'store_not_found' | 'not_allowed' + | 'client_token_not_allowed' | 'service_unavailable' | 'rate_limited' | 'content_type_not_allowed' @@ -171,7 +183,7 @@ function createBlobServiceRateLimited( } // reads the body of a error response -async function getBlobError( +export async function getBlobError( response: Response, ): Promise<{ code: string; error: BlobError }> { let code: BlobApiErrorCodes; @@ -207,6 +219,13 @@ async function getBlobError( code = 'file_too_large'; } + if ( + message?.startsWith('OIDC is enabled for this project, but not for the') && + message.includes('environment.') + ) { + code = 'oidc_environment_not_allowed'; + } + let error: BlobError; switch (code) { case 'store_suspended': @@ -215,6 +234,9 @@ async function getBlobError( case 'forbidden': error = new BlobAccessError(); break; + case 'oidc_environment_not_allowed': + error = new BlobOidcEnvironmentNotAllowedError(message); + break; case 'content_type_not_allowed': error = new BlobContentTypeNotAllowedError(message!); break; @@ -230,6 +252,12 @@ async function getBlobError( case 'not_found': error = new BlobNotFoundError(); break; + case 'client_token_not_allowed': + error = new BlobError( + message ?? + 'This operation is not available when using a client token. Use a read–write or OIDC token on the server.', + ); + break; case 'store_not_found': error = new BlobStoreNotFoundError(); break; @@ -258,13 +286,23 @@ async function getBlobError( export async function requestApi( pathname: string, init: BlobRequestInit, - commandOptions: (BlobCommandOptions & WithUploadProgress) | undefined, + commandOptions: + | (BlobPresignedCommandOptions & WithUploadProgress) + | undefined, ): Promise { const apiVersion = getApiVersion(); const auth = resolveBlobAuth(commandOptions); - const bearerToken = auth.kind === 'readWrite' ? auth.token : auth.oidcToken; + const bearerToken = auth.kind === 'presigned' ? undefined : auth.token; const extraHeaders = getProxyThroughAlternativeApiHeaderFromEnv(); + let requestInput = getApiUrl(pathname); + if (commandOptions?.presignedUrlPayload) { + requestInput = addPresignedParams( + requestInput, + commandOptions.presignedUrlPayload, + ); + } + const requestId = `${auth.storeId}:${Date.now()}:${Math.random().toString(16).slice(2)}`; let retryCount = 0; let bodyLength = 0; @@ -296,7 +334,7 @@ export async function requestApi( // try/catch here to treat certain errors as not-retryable try { res = await blobRequest({ - input: getApiUrl(pathname), + input: requestInput, init: { ...init, headers: { @@ -308,7 +346,9 @@ export async function requestApi( ...(sendBodyLength ? { 'x-content-length': String(bodyLength) } : {}), - authorization: `Bearer ${bearerToken}`, + ...(bearerToken !== undefined + ? { authorization: `Bearer ${bearerToken}` } + : {}), ...extraHeaders, ...init.headers, }, diff --git a/packages/blob/src/client-token-constraints.ts b/packages/blob/src/client-token-constraints.ts new file mode 100644 index 000000000..37fb9fd89 --- /dev/null +++ b/packages/blob/src/client-token-constraints.ts @@ -0,0 +1,55 @@ +/** + * Upload / blob constraints shared between `generateClientTokenFromReadWriteToken` and + * `issueSignedToken` (serialized in the JSON body to the control API where supported). + */ +export interface BlobClientTokenConstraintOptions { + /** + * A number specifying the maximum size in bytes that can be uploaded. The maximum is 5TB. + */ + maximumSizeInBytes?: number; + + /** + * An array of strings specifying the media types that are allowed to be uploaded. + * By default, it's all content types. Wildcards are supported (text/*). + */ + allowedContentTypes?: string[]; + + /** + * A number specifying the timestamp in ms when the token will expire. + * For client tokens, defaults to now + 1 hour when omitted. + */ + validUntil?: number; + + /** + * Adds a random suffix to the filename. + * @defaultvalue false + */ + addRandomSuffix?: boolean; + + /** + * Allow overwriting an existing blob. By default this is set to false and will throw an error if the blob already exists. + * @defaultvalue false + */ + allowOverwrite?: boolean; + + /** + * Number in seconds to configure how long Blobs are cached. Defaults to one month. Cannot be set to a value lower than 1 minute. + * @defaultvalue 30 * 24 * 60 * 60 (1 Month) + */ + cacheControlMaxAge?: number; + + /** + * Only write if the ETag matches (optimistic concurrency control). + * Use this for conditional writes to prevent overwriting changes made by others. + * If the ETag doesn't match, a `BlobPreconditionFailedError` will be thrown. + */ + ifMatch?: string; + + /** + * Configuration for upload completion callback. + */ + onUploadCompleted?: { + callbackUrl: string; + tokenPayload?: string | null; + }; +} diff --git a/packages/blob/src/client.node.test.ts b/packages/blob/src/client.node.test.ts index a1d8c9e95..80dbb164c 100644 --- a/packages/blob/src/client.node.test.ts +++ b/packages/blob/src/client.node.test.ts @@ -1,10 +1,13 @@ +import { generateKeyPairSync, sign as nodeCryptoSign } from 'node:crypto'; import type { IncomingMessage } from 'node:http'; import type { PutBlobResult } from '.'; import { generateClientTokenFromReadWriteToken, getPayloadFromClientToken, handleUpload, + handleUploadPresigned, } from './client'; +import * as signedTokenModule from './signed-token'; describe('client uploads', () => { describe('generateClientTokenFromReadWriteToken', () => { @@ -883,4 +886,180 @@ describe('client uploads', () => { process.env = originalEnv; }); }); + + describe('handleUploadPresigned', () => { + const dummyIssuedSignedToken = { + delegationToken: '', + clientSigningToken: '', + validUntil: 0, + }; + const dummyGetSignedTokenResult = { + token: dummyIssuedSignedToken, + urlOptions: {}, + }; + + it('runs onCompleted when Ed25519 x-vercel-signature verifies against BLOB webhook public key', async () => { + const spy = jest.fn(); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const webhookPublicKey = publicKey + .export({ type: 'spki', format: 'pem' }) + .toString(); + + const body = { + type: 'blob.upload-completed' as const, + payload: { + blob: { pathname: 'newfile.txt' } as PutBlobResult, + tokenPayload: 'custom-metadata', + }, + }; + const wireUtf8 = JSON.stringify(body); + + await expect( + handleUploadPresigned({ + webhookPublicKey, + request: { + headers: { + 'x-vercel-signature': Buffer.from( + nodeCryptoSign(null, Buffer.from(wireUtf8, 'utf8'), privateKey), + ).toString('hex'), + }, + } as unknown as IncomingMessage, + body, + getSignedToken: async () => dummyGetSignedTokenResult, + onUploadCompleted: spy, + }), + ).resolves.toEqual({ + response: 'ok', + type: 'blob.upload-completed', + }); + expect(spy).toHaveBeenCalledWith(body.payload); + }); + + it('rejects webhook when Ed25519 signature does not verify', async () => { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const webhookPublicKey = publicKey + .export({ type: 'spki', format: 'pem' }) + .toString(); + + const body = { + type: 'blob.upload-completed' as const, + payload: { + blob: { pathname: 'newfile.txt' } as PutBlobResult, + tokenPayload: 'custom-metadata', + }, + }; + + const wrongBody = JSON.stringify({ + ...body, + payload: { ...body.payload, forged: true }, + }); + const wrongSigHex = Buffer.from( + nodeCryptoSign(null, Buffer.from(wrongBody, 'utf8'), privateKey), + ).toString('hex'); + + await expect( + handleUploadPresigned({ + webhookPublicKey, + request: { + headers: { 'x-vercel-signature': wrongSigHex }, + } as unknown as IncomingMessage, + body, + getSignedToken: async () => dummyGetSignedTokenResult, + onUploadCompleted: async () => { + await Promise.resolve(); + }, + }), + ).rejects.toThrow(/Invalid callback signature/); + }); + + it('rejects HMAC-shaped signatures (must be hex Ed25519, 128 chars)', async () => { + const { publicKey } = generateKeyPairSync('ed25519'); + const webhookPublicKey = publicKey + .export({ type: 'spki', format: 'pem' }) + .toString(); + + await expect( + handleUploadPresigned({ + webhookPublicKey, + request: { + headers: { + 'x-vercel-signature': + 'a4eac582498d4548d701eb8ff3e754f33f078e75298b9a1a0cdbac128981b28d', + }, + } as unknown as IncomingMessage, + body: { + type: 'blob.upload-completed', + payload: { + blob: { pathname: 'x' } as PutBlobResult, + }, + }, + getSignedToken: async () => dummyGetSignedTokenResult, + onUploadCompleted: async () => { + await Promise.resolve(); + }, + }), + ).rejects.toThrow(/Invalid callback signature/); + }); + + it('resolves callback URL and passes it to presign when onUploadCompleted is set', async () => { + const dummyPresignedUrlPayload = { + delegationToken: 'delegation-token', + signature: 'signature', + params: {} as Record, + }; + const presignSpy = jest + .spyOn(signedTokenModule, 'presign') + .mockResolvedValue(dummyPresignedUrlPayload); + + const originalEnv = { ...process.env }; + process.env.VERCEL_BLOB_CALLBACK_URL = + 'https://callback-base.example.com'; + + const getSignedToken = jest + .fn() + .mockResolvedValue(dummyGetSignedTokenResult); + + const result = await handleUploadPresigned({ + webhookPublicKey: 'test-webhook-public-key', + request: new Request( + 'http://localhost:3000/vercel/blob/api/app/handle-blob-upload-presigned', + ), + body: { + type: 'blob.generate-presigned-url', + payload: { + pathname: 'a.png', + clientPayload: 'cp', + multipart: false, + }, + }, + getSignedToken, + onUploadCompleted: async () => { + await Promise.resolve(); + }, + }); + + expect(result).toEqual({ + type: 'blob.generate-presigned-url', + presignedUrlPayload: dummyPresignedUrlPayload, + }); + + expect(getSignedToken).toHaveBeenCalledWith('a.png', 'cp', false); + + expect(presignSpy).toHaveBeenCalledWith( + dummyIssuedSignedToken, + expect.objectContaining({ + operation: 'put', + pathname: 'a.png', + onUploadCompleted: { + callbackUrl: + 'https://callback-base.example.com/vercel/blob/api/app/handle-blob-upload-presigned', + tokenPayload: 'cp', + }, + }), + ); + + presignSpy.mockRestore(); + process.env = originalEnv; + }); + }); }); diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 7f11fe1c6..2b6a2dea2 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -4,9 +4,11 @@ import * as crypto from 'crypto'; // the `undici` module will be replaced with https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API // for browser contexts. See ./undici-browser.js and ./package.json import { fetch } from 'undici'; +import type { BlobClientTokenConstraintOptions } from './client-token-constraints'; import type { BlobAccessType, BlobCommandOptions, + PresignedUrlPayload, WithUploadProgress, } from './helpers'; import { @@ -22,6 +24,12 @@ import type { CommonMultipartUploadOptions } from './multipart/upload'; import { createUploadPartMethod } from './multipart/upload'; import { createPutMethod } from './put'; import type { PutBlobResult } from './put-helpers'; +import { + type IssuedSignedToken, + type IssueSignedTokenOptions, + type PresignPutUrlOptions, + presign, +} from './signed-token'; /** * Interface for put, upload and multipart upload operations. @@ -311,6 +319,60 @@ export const upload = createPutMethod({ }, }); +/** + * Uploads a blob into your store from the client using a presigned URL. + * Detailed documentation can be found here: https://vercel.com/docs/vercel-blob/using-blob-sdk#client-uploads + * + * If you want to upload from your server instead, check out the documentation for the put operation: https://vercel.com/docs/vercel-blob/using-blob-sdk#upload-a-blob + * + * @param pathname - The pathname to upload the blob to. This includes the filename and extension. + * @param body - The contents of your blob. This has to be a supported fetch body type (string, Blob, File, ArrayBuffer, etc). + * @param options - Configuration options including: + * - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication. + * - handleUploadUrl - (Required) A string specifying the route to call for generating client tokens for client uploads. + * - clientPayload - (Optional) A string to be sent to your handleUpload server code. Example use-case: attaching the post id an image relates to. + * - headers - (Optional) An object containing custom headers to be sent with the request to your handleUpload route. Example use-case: sending Authorization headers. + * - contentType - (Optional) A string indicating the media type. By default, it's extracted from the pathname's extension. + * - multipart - (Optional) Whether to use multipart upload for large files. When true, your `handleUploadPresigned` route must return a presigned `POST` URL for `/mpu`. + * - abortSignal - (Optional) AbortSignal to cancel the operation. + * - onUploadProgress - (Optional) Callback to track upload progress: onUploadProgress(\{loaded: number, total: number, percentage: number\}) + * @returns A promise that resolves to the blob information, including pathname, contentType, contentDisposition, url, and downloadUrl. + */ +export const uploadPresigned = createPutMethod({ + allowedOptions: ['contentType'], + extraChecks(options) { + if (options.handleUploadUrl === undefined) { + throw new BlobError( + "client/`upload` requires the 'handleUploadUrl' parameter", + ); + } + + if ( + // @ts-expect-error -- Runtime check for DX. + options.addRandomSuffix !== undefined || + // @ts-expect-error -- Runtime check for DX. + options.createPutExtraChecks !== undefined || + // @ts-expect-error -- Runtime check for DX. + options.cacheControlMaxAge !== undefined || + // @ts-expect-error -- Runtime check for DX. + options.ifMatch !== undefined + ) { + throw new BlobError( + "client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating client tokens.", + ); + } + }, + async getPresignedUrlPayload(pathname, options) { + return retrievePresignedUrlPayload({ + pathname, + handleUploadUrl: options.handleUploadUrl, + clientPayload: options.clientPayload ?? null, + multipart: options.multipart ?? false, + headers: options.headers, + }); + }, +}); + /** * @internal Internal function to import a crypto key. */ @@ -343,6 +405,88 @@ async function signPayload( return Buffer.from(new Uint8Array(signature)).toString('hex'); } +/** + * Decodes PEM `-----BEGIN PUBLIC KEY----- …` (SPKI) to DER bytes. + */ +function publicKeyDerFromPem(pem: string): Buffer | undefined { + const match = pem + .trim() + .match(/-----BEGIN PUBLIC KEY-----([^-]*)-----END PUBLIC KEY-----/s); + const b64 = match?.[1]?.replace(/\s+/g, ''); + if (!b64) { + return undefined; + } + try { + return Buffer.from(b64, 'base64'); + } catch { + return undefined; + } +} + +/** + * @internal Internal function to verify a callback signature for presigned uploads. + */ +async function verifyCallbackSignaturePresigned({ + webhookPublicKey, + signature, + body, +}: { + webhookPublicKey: string; + signature: string; + body: string; +}): Promise { + if ( + typeof signature !== 'string' || + !/^[0-9a-fA-F]+$/.test(signature) || + signature.length !== 128 + ) { + return false; + } + + const signatureBuf = Buffer.from(signature, 'hex'); + + const bodyBuf = Buffer.from(body, 'utf8'); + + const der = publicKeyDerFromPem(webhookPublicKey); + + // Prefer Web Crypto when available so browser bundles (which replace `crypto` + // with a small shim) never take the Node `createPublicKey` branch. + if (globalThis.crypto?.subtle && der) { + try { + const derCopy = Uint8Array.from(der); + const verifyKey = await globalThis.crypto.subtle.importKey( + 'spki', + derCopy, + { name: 'Ed25519' }, + false, + ['verify'], + ); + const sigBytes = new Uint8Array(64); + sigBytes.set(signatureBuf.subarray(0, 64), 0); + const ok = await globalThis.crypto.subtle.verify( + 'Ed25519', + verifyKey, + sigBytes, + new TextEncoder().encode(body), + ); + return ok; + } catch { + return false; + } + } + + if (typeof crypto.createPublicKey === 'function') { + try { + const key = crypto.createPublicKey(webhookPublicKey.trim()); + return crypto.verify(null, bodyBuf, key, signatureBuf); + } catch { + return false; + } + } + + return false; +} + /** * @internal Internal function to verify a callback signature. */ @@ -435,6 +579,7 @@ export function getPayloadFromClientToken( */ const EventTypes = { generateClientToken: 'blob.generate-client-token', + generatePresignedUrl: 'blob.generate-presigned-url', uploadCompleted: 'blob.upload-completed', } as const; @@ -469,6 +614,33 @@ interface GenerateClientTokenEvent { }; } +export interface GeneratePresignedUrlEvent { + /** + * Type identifier for the generate presigned url event. + */ + type: (typeof EventTypes)['generatePresignedUrl']; + + /** + * Payload containing information needed to generate a presigned url. + */ + payload: { + /** + * The destination path for the blob. + */ + pathname: string; + + /** + * Whether the upload will use multipart uploading. + */ + multipart: boolean; + + /** + * Additional data from the client which will be available in onBeforeGenerateToken. + */ + clientPayload: string | null; + }; +} + /** * Event that occurs when a client upload has completed. * @internal This is an internal interface used by the SDK. @@ -500,6 +672,13 @@ interface UploadCompletedEvent { */ export type HandleUploadBody = GenerateClientTokenEvent | UploadCompletedEvent; +/** + * Request body for {@link handleUploadPresigned}: presigned `PUT` URL issuance or upload completion callback. + */ +export type HandleUploadPresignedBody = + | GeneratePresignedUrlEvent + | UploadCompletedEvent; + /** * Type representing either a Node.js IncomingMessage or a web standard Request object. * @internal This is an internal type used by the SDK. @@ -666,6 +845,187 @@ export async function handleUpload({ } } +/** + * Delegation fields accepted by {@link issueSignedToken}, i.e. + * JSON embedded in `vercel-blob-delegation`. Upload-behavior options belong in + * {@link PresignUrlOptions} for `put` (`urlOptions` from {@link HandleUploadPresignedOptions.getSignedToken}), + * not in the delegation body. + */ +export type HandleUploadPresignedSignedTokenPayload = Pick< + IssueSignedTokenOptions, + | 'allowedContentTypes' + | 'maximumSizeInBytes' + | 'operations' + | 'pathname' + | 'validUntil' +>; + +/** + * @deprecated Callback wiring for presigned uploads is merged into {@link PresignUrlOptions} + * by {@link handleUploadPresigned} after `getSignedToken` returns; you no longer receive this + * object as a separate argument. + */ +export interface HandleUploadPresignedIssuanceContext { + onUploadCompleted?: { + callbackUrl: string; + tokenPayload?: string | null; + }; +} + +/** + * Options for {@link handleUploadPresigned} — same upload-completion flow as {@link handleUpload}, + * but `blob.generate-presigned-url` returns a presigned control-plane URL: `PUT` to `/?pathname=…` + * for single upload, or `POST` to `/mpu?pathname=…` when the client requested multipart. + */ +export interface HandleUploadPresignedOptions { + body: HandleUploadPresignedBody; + /** + * Produce signed token (e.g. via `issueSignedToken`) for {@link presign}. + * This must be a token with 'put' priveleges and access to the pathname + */ + getSignedToken: ( + pathname: string, + clientPayload: string | null, + multipart: boolean, + ) => Promise<{ + token: IssuedSignedToken; + urlOptions?: Omit< + PresignPutUrlOptions, + 'operation' | 'pathname' | 'onUploadCompleted' + > & { + callbackUrl?: string; + tokenPayload?: string | null; + }; + }>; + + /** + * Public key for verifying webhook signatures. + * This is used to verify the signature of the webhook request. + * + * @default process.env.BLOB_WEBHOOK_PUBLIC_KEY + */ + webhookPublicKey?: string; + + /** + * Function called by Vercel Blob when the client upload finishes. + * This is useful to update your database with the blob URL that was uploaded. + * + * @param body - Contains information about the completed upload including the blob details + */ + onUploadCompleted?: HandleUploadOptions['onUploadCompleted']; + + /** + * An IncomingMessage or Request object to be used to determine the action to take. + */ + request: RequestType; +} + +/** + * Server route helper for **presigned** client uploads: returns a presigned control-plane + * `PUT` URL for single-object uploads, or a presigned `POST` URL for `/mpu` when multipart. + * Verifies upload-completed callbacks with Ed25519 (`BLOB_WEBHOOK_PUBLIC_KEY`) over `x-vercel-signature`, + * matching outbound `webhook_keypair` signing from api-storage when sending the callback. + * + * If `onUploadCompleted` is set, the callback target URL is resolved like {@link handleUpload} + * (same `VERCEL_BLOB_CALLBACK_URL` / Vercel URL rules) and merged into the options passed to + * {@link presignUrl} after `getSignedToken` returns. + */ +export async function handleUploadPresigned({ + body, + request, + webhookPublicKey, + getSignedToken, + onUploadCompleted, +}: HandleUploadPresignedOptions): Promise< + | { + type: 'blob.generate-presigned-url'; + presignedUrlPayload: PresignedUrlPayload; + } + | { type: 'blob.upload-completed'; response: 'ok' } +> { + const resolvedWebhookPublicKey = + webhookPublicKey ?? process.env.BLOB_WEBHOOK_PUBLIC_KEY; + + if (!resolvedWebhookPublicKey) { + throw new BlobError('Missing webhook public key'); + } + + const type = body.type; + switch (type) { + case 'blob.generate-presigned-url': { + const { pathname, clientPayload, multipart } = body.payload; + + const { token, urlOptions = {} } = await getSignedToken( + pathname, + clientPayload, + multipart, + ); + + const tokenPayload = + urlOptions?.tokenPayload ?? clientPayload ?? undefined; + const { callbackUrl: providedCallbackUrl } = urlOptions; + let callbackUrl = providedCallbackUrl; + + if (onUploadCompleted && !callbackUrl) { + callbackUrl = getCallbackUrl(request); + } + + // If no onUploadCompleted but callbackUrl was provided, warn about it + if (!onUploadCompleted && callbackUrl) { + console.warn( + 'callbackUrl was provided but onUploadCompleted is not defined. The callback will not be handled.', + ); + } + + const urlOptionsWithCallback = { + ...urlOptions, + onUploadCompleted: callbackUrl + ? { + callbackUrl, + tokenPayload, + } + : undefined, + }; + + const presignedUrlPayload = await presign(token, { + ...urlOptionsWithCallback, + operation: 'put', + pathname, + }); + return { type, presignedUrlPayload }; + } + case 'blob.upload-completed': { + const signatureHeader = 'x-vercel-signature'; + const signature = ( + 'credentials' in request + ? (request.headers.get(signatureHeader) ?? '') + : (request.headers[signatureHeader] ?? '') + ) as string; + + if (!signature) { + throw new BlobError('Missing callback signature'); + } + + const isVerified = await verifyCallbackSignaturePresigned({ + webhookPublicKey: resolvedWebhookPublicKey, + signature, + body: JSON.stringify(body), + }); + + if (!isVerified) { + throw new BlobError('Invalid callback signature'); + } + + if (onUploadCompleted) { + await onUploadCompleted(body.payload); + } + return { type, response: 'ok' }; + } + default: + throw new BlobError('Invalid event type'); + } +} + /** * @internal Internal function to retrieve a client token from server. */ @@ -713,6 +1073,61 @@ async function retrieveClientToken(options: { } } +/** + * @internal Internal function to retrieve a presigned URL from server. + */ +async function retrievePresignedUrlPayload(options: { + pathname: string; + handleUploadUrl: string; + clientPayload: string | null; + multipart: boolean; + abortSignal?: AbortSignal; + headers?: Record; +}): Promise { + const { handleUploadUrl, pathname } = options; + const url = isAbsoluteUrl(handleUploadUrl) + ? handleUploadUrl + : toAbsoluteUrl(handleUploadUrl); + + const event: GeneratePresignedUrlEvent = { + type: EventTypes.generatePresignedUrl, + payload: { + pathname, + clientPayload: options.clientPayload, + multipart: options.multipart, + }, + }; + + const res = await fetch(url, { + method: 'POST', + body: JSON.stringify(event), + headers: { + 'content-type': 'application/json', + ...options.headers, + }, + signal: options.abortSignal, + }); + + if (!res.ok) { + throw new BlobError('Failed to retrieve the presigned URL'); + } + + try { + const { presignedUrlPayload } = (await res.json()) as { + presignedUrlPayload: PresignedUrlPayload; + }; + if (presignedUrlPayload) { + return presignedUrlPayload; + } + throw new BlobError('Missing presignedUrlPayload'); + } catch (error) { + if (error instanceof BlobError) { + throw error; + } + throw new BlobError('Failed to retrieve the presigned URL'); + } +} + /** * @internal Internal utility to convert a relative URL to absolute URL. */ @@ -809,61 +1224,13 @@ export async function generateClientTokenFromReadWriteToken({ /** * Options for generating a client token. */ -export interface GenerateClientTokenOptions extends BlobCommandOptions { +export interface GenerateClientTokenOptions + extends BlobCommandOptions, + BlobClientTokenConstraintOptions { /** * The destination path for the blob */ pathname: string; - - /** - * Configuration for upload completion callback - */ - onUploadCompleted?: { - callbackUrl: string; - tokenPayload?: string | null; - }; - - /** - * A number specifying the maximum size in bytes that can be uploaded. The maximum is 5TB. - */ - maximumSizeInBytes?: number; - - /** - * An array of strings specifying the media type that are allowed to be uploaded. - * By default, it's all content types. Wildcards are supported (text/*) - */ - allowedContentTypes?: string[]; - - /** - * A number specifying the timestamp in ms when the token will expire. - * By default, it's now + 1 hour. - */ - validUntil?: number; - - /** - * Adds a random suffix to the filename. - * @defaultvalue false - */ - addRandomSuffix?: boolean; - - /** - * Allow overwriting an existing blob. By default this is set to false and will throw an error if the blob already exists. - * @defaultvalue false - */ - allowOverwrite?: boolean; - - /** - * Number in seconds to configure how long Blobs are cached. Defaults to one month. Cannot be set to a value lower than 1 minute. - * @defaultvalue 30 * 24 * 60 * 60 (1 Month) - */ - cacheControlMaxAge?: number; - - /** - * Only write if the ETag matches (optimistic concurrency control). - * Use this for conditional writes to prevent overwriting changes made by others. - * If the ETag doesn't match, a `BlobPreconditionFailedError` will be thrown. - */ - ifMatch?: string; } /** diff --git a/packages/blob/src/crypto-browser.js b/packages/blob/src/crypto-browser.js index 7e31d5f27..c01649c87 100644 --- a/packages/blob/src/crypto-browser.js +++ b/packages/blob/src/crypto-browser.js @@ -5,3 +5,11 @@ export function createHmac() { export function timingSafeEqual() { throw new Error('Not implemented'); } + +export function createPublicKey() { + throw new Error('Not implemented'); +} + +export function verify() { + throw new Error('Not implemented'); +} diff --git a/packages/blob/src/del.ts b/packages/blob/src/del.ts index 2d9d4a791..ec08b87d8 100644 --- a/packages/blob/src/del.ts +++ b/packages/blob/src/del.ts @@ -13,7 +13,7 @@ export interface DeleteCommandOptions extends BlobCommandOptions { } /** - * Deletes one or multiple blobs from your store. + * Deletes one or multiple blobs from your store (`POST /api/blob/delete` on the Blob API). * Detailed documentation can be found here: https://vercel.com/docs/vercel-blob/using-blob-sdk#delete-a-blob * * @param urlOrPathname - Blob url (or pathname) to delete. You can pass either a single value or an array of values. You can only delete blobs that are located in a store, that your 'BLOB_READ_WRITE_TOKEN' has access to. diff --git a/packages/blob/src/get.node.test.ts b/packages/blob/src/get.node.test.ts new file mode 100644 index 000000000..c9659999e --- /dev/null +++ b/packages/blob/src/get.node.test.ts @@ -0,0 +1,125 @@ +import { BlobError } from './helpers'; +import { presignUrl } from './signed-token'; +import { + createDelegationToken, + deriveClientSigningToken, + randomBytes, +} from './signed-token.presignurl.test-helpers'; + +describe('presignUrl (get)', () => { + const storeId = 's'.repeat(16); + const blobSigningSecret = randomBytes(32).toString('base64'); + const now = Date.now(); + + function makeSignedToken(pathname: string) { + const delegationToken = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'owner_1', + pathname, + operations: ['get'], + validUntil: now + 3600_000, + iat: now, + }, + blobSigningSecret, + ); + const clientSigningToken = deriveClientSigningToken( + blobSigningSecret, + delegationToken, + ); + return { + delegationToken, + clientSigningToken, + validUntil: now + 3600_000, + }; + } + + it('builds a private object URL from pathname using storeId from the delegation token', async () => { + const pathname = 'media/photo.png'; + const token = makeSignedToken(pathname); + const getOpts = { + operation: 'get' as const, + pathname, + access: 'private' as const, + }; + const { presignedUrl: url } = await presignUrl(token, getOpts); + const { presignedUrl: again } = await presignUrl(token, getOpts); + expect(url).toBe(again); + + const parsed = new URL(url); + expect(parsed.hostname).toBe(`${storeId}.private.blob.vercel-storage.com`); + expect(parsed.pathname).toBe(`/${pathname}`); + expect(parsed.searchParams.get('vercel-blob-delegation')).toBe( + token.delegationToken, + ); + expect(parsed.searchParams.get('vercel-blob-signature')).toMatch( + /^[A-Za-z0-9_-]+$/, + ); + }); + + it('builds a public object URL when access is public', async () => { + const pathname = 'a.png'; + const token = makeSignedToken(pathname); + const { presignedUrl: url } = await presignUrl(token, { + operation: 'get', + pathname, + access: 'public', + }); + expect(new URL(url).hostname).toBe( + `${storeId}.public.blob.vercel-storage.com`, + ); + }); + + it('keeps an explicit https blob URL as the base and merges existing query params', async () => { + const logical = 'nested/file.bin'; + const pathnameWithQuery = `https://${storeId}.private.blob.vercel-storage.com/${logical}?existing=1`; + const token = makeSignedToken(pathnameWithQuery); + const getOpts = { + operation: 'get' as const, + pathname: pathnameWithQuery, + access: 'private' as const, + }; + const { presignedUrl: url } = await presignUrl(token, getOpts); + const { presignedUrl: again } = await presignUrl(token, getOpts); + expect(url).toBe(again); + + const parsed = new URL(url); + expect(parsed.searchParams.get('existing')).toBe('1'); + expect(parsed.searchParams.get('vercel-blob-signature')).toMatch( + /^[A-Za-z0-9_-]+$/, + ); + expect(`${parsed.origin}${parsed.pathname}`).toBe( + `https://${storeId}.private.blob.vercel-storage.com/${logical}`, + ); + }); + + it('treats http:// blob URLs as a full URL base', async () => { + const logical = 'x.txt'; + const pathname = `http://${storeId}.private.blob.vercel-storage.com/${logical}`; + const token = makeSignedToken(pathname); + const { presignedUrl: url } = await presignUrl(token, { + operation: 'get', + pathname, + access: 'private', + }); + expect(new URL(url).protocol).toBe('http:'); + expect(new URL(url).searchParams.get('vercel-blob-delegation')).toBe( + token.delegationToken, + ); + }); + + it('rejects an invalid delegation token', async () => { + const token = { + delegationToken: 'not-a-jwt', + clientSigningToken: 'Zm9v', + validUntil: now + 3600_000, + }; + await expect( + presignUrl(token, { + operation: 'get', + pathname: 'a.png', + access: 'private', + }), + ).rejects.toThrow(BlobError); + }); +}); diff --git a/packages/blob/src/get.ts b/packages/blob/src/get.ts index 590b2240d..df7cc25dd 100644 --- a/packages/blob/src/get.ts +++ b/packages/blob/src/get.ts @@ -1,6 +1,6 @@ import { fetch, type Headers } from 'undici'; import type { BlobAccessType, BlobCommandOptions } from './helpers'; -import { BlobError, resolveBlobAuth } from './helpers'; +import { BlobError, constructBlobUrl, isUrl, resolveBlobAuth } from './helpers'; /** * Options for the get method. @@ -76,15 +76,6 @@ export type GetBlobResult = }; }; -/** - * Checks if the input is a URL (starts with http:// or https://). - */ -function isUrl(urlOrPathname: string): boolean { - return ( - urlOrPathname.startsWith('http://') || urlOrPathname.startsWith('https://') - ); -} - /** * Extracts the pathname from a blob URL. */ @@ -98,17 +89,6 @@ function extractPathnameFromUrl(url: string): string { } } -/** - * Constructs the blob URL from storeId and pathname. - */ -function constructBlobUrl( - storeId: string, - pathname: string, - access: BlobAccessType, -): string { - return `https://${storeId}.${access}.blob.vercel-storage.com/${pathname}`; -} - /** * Fetches blob content by URL or pathname. * - If a URL is provided, fetches the blob directly. @@ -157,7 +137,10 @@ export async function get( } const auth = resolveBlobAuth(options); - const bearerToken = auth.kind === 'readWrite' ? auth.token : auth.oidcToken; + + if (auth.kind === 'presigned') { + throw new BlobError('Presigned URLs are not supported for the get method'); + } let blobUrl: string; let pathname: string; @@ -190,7 +173,7 @@ export async function get( // Fetch the blob content with authentication headers const requestHeaders: HeadersInit = { ...(options.ifNoneMatch ? { 'If-None-Match': options.ifNoneMatch } : {}), - authorization: `Bearer ${bearerToken}`, + authorization: `Bearer ${auth.token}`, ...options.headers, // low-level escape hatch, applied last to override anything }; diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 75bb47970..e8641495d 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -36,6 +36,24 @@ export interface BlobCommandOptions { abortSignal?: AbortSignal; } +export interface PresignedUrlPayload { + delegationToken: string; + signature: string; + params: Record; +} + +/** + * Presigned control-plane URL for writes (`put`, `copy`, multipart) and other + * methods built on {@link CommonCreateBlobOptions}. When set, it is used as the + * fetch target instead of composing `getApiUrl` with a bearer token. + */ +export interface BlobPresignedCommandOptions extends BlobCommandOptions { + /** + * Takes precedence over `token` and store credentials for supported calls. + */ + presignedUrlPayload?: PresignedUrlPayload; +} + /** * The access level of a blob. * - 'public': The blob is publicly accessible via its URL. @@ -153,14 +171,76 @@ function readEnv(name: string): string | undefined { } export type ResolvedBlobAuth = - | { kind: 'readWrite'; token: string; storeId: string } - | { kind: 'oidc'; oidcToken: string; storeId: string }; + | { kind: 'readWrite' | 'oidc'; token: string; storeId: string } + | { kind: 'presigned'; storeId: string }; export function parseStoreIdFromReadWriteToken(token: string): string { const [, , , storeId = ''] = token.split('_'); return storeId; } +function normalizeDelegationStoreId(storeId: string): string { + return storeId.startsWith('store_') + ? storeId.slice('store_'.length) + : storeId; +} + +function base64UrlDecodeDelegationSegment(segment: string): string { + let base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padding = 4 - (base64.length % 4); + if (padding !== 4) { + base64 += '='.repeat(padding); + } + if (typeof atob === 'function') { + return atob(base64); + } + if (typeof Buffer !== 'undefined') { + // eslint-disable-next-line no-restricted-globals + return Buffer.from(base64, 'base64').toString('utf8'); + } + throw new BlobError('Cannot decode base64: no atob or Buffer available.'); +} + +/** + * Reads `storeId` from the delegation JWT embedded in a presigned blob URL’s + * `vercel-blob-delegation` query parameter (same payload shape as `issueSignedToken` delegations). + */ +/** + * Reads `storeId` from a delegation JWT’s payload segment (same format as + * embedded in a presigned URL’s `vercel-blob-delegation` query parameter). + */ +export function parseStoreIdFromDelegationToken( + delegationToken: string, +): string { + const dot = delegationToken.indexOf('.'); + if (dot < 0) { + throw new BlobError('Invalid delegation token format.'); + } + + const payloadSeg = delegationToken.slice(0, dot); + let parsed: { storeId?: unknown }; + try { + parsed = JSON.parse(base64UrlDecodeDelegationSegment(payloadSeg)) as { + storeId?: unknown; + }; + } catch { + throw new BlobError('Invalid delegation token payload.'); + } + + if (!parsed.storeId || typeof parsed.storeId !== 'string') { + throw new BlobError('Delegation token payload is missing `storeId`.'); + } + + return normalizeDelegationStoreId(parsed.storeId); +} + +export function parseStoreIdFromPresignedUrl( + presignedUrlPayload: PresignedUrlPayload, +): string { + const delegation = presignedUrlPayload.delegationToken; + return parseStoreIdFromDelegationToken(delegation); +} + /** * Strips the optional `store_` prefix from a store id. `BLOB_STORE_ID` (what * `vercel env pull` writes) and the `storeId` option are accepted in either @@ -181,8 +261,14 @@ export function normalizeStoreId(storeId: string): string { * 3. `BLOB_READ_WRITE_TOKEN` from the environment. */ export function resolveBlobAuth( - options?: BlobCommandOptions, + options?: BlobCommandOptions & BlobPresignedCommandOptions, ): ResolvedBlobAuth { + if (options?.presignedUrlPayload) { + const storeId = parseStoreIdFromDelegationToken( + options.presignedUrlPayload.delegationToken, + ); + return { kind: 'presigned', storeId }; + } // An explicitly supplied token always wins over OIDC and env-based tokens. if (options?.token) { const storeId = parseStoreIdFromReadWriteToken(options.token); @@ -196,7 +282,7 @@ export function resolveBlobAuth( if (manualStoreId) { return { kind: 'oidc', - oidcToken, + token: oidcToken, storeId: normalizeStoreId(manualStoreId), }; } @@ -206,7 +292,7 @@ export function resolveBlobAuth( if (blobStoreId) { return { kind: 'oidc', - oidcToken, + token: oidcToken, storeId: normalizeStoreId(blobStoreId), }; } @@ -414,3 +500,42 @@ export function isStream(value: PutBody): value is ReadableStream | Readable { return false; } + +export const addPresignedParams = ( + url: string, + presignedUrlPayload: PresignedUrlPayload, +): string => { + const urlObj = new URL(url); + for (const [key, value] of Object.entries(presignedUrlPayload.params)) { + urlObj.searchParams.set(key, value); + } + urlObj.searchParams.set( + 'vercel-blob-delegation', + presignedUrlPayload.delegationToken, + ); + urlObj.searchParams.set( + 'vercel-blob-signature', + presignedUrlPayload.signature, + ); + return urlObj.toString(); +}; + +/** + * Checks if the input is a URL (starts with http:// or https://). + */ +export function isUrl(urlOrPathname: string): boolean { + return ( + urlOrPathname.startsWith('http://') || urlOrPathname.startsWith('https://') + ); +} + +/** + * Constructs the blob URL from storeId and pathname. + */ +export function constructBlobUrl( + storeId: string, + pathname: string, + access: BlobAccessType, +): string { + return `https://${storeId}.${access}.blob.vercel-storage.com/${pathname}`; +} diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index 6aaab87ee..22bf05056 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -1,5 +1,6 @@ import { type Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; import { + BlobOidcEnvironmentNotAllowedError, BlobPreconditionFailedError, BlobRequestAbortedError, BlobServiceNotAvailable, @@ -426,6 +427,27 @@ describe('blob client', () => { ); }); + it('should throw a specific error when OIDC environment is not enabled for the project', async () => { + mockClient + .intercept({ + path: () => true, + method: 'GET', + }) + .reply(403, { + error: { + code: 'forbidden', + message: + 'OIDC is enabled for this project, but not for the "production" environment.', + }, + }); + + await expect(list()).rejects.toThrow( + new BlobOidcEnvironmentNotAllowedError( + 'OIDC is enabled for this project, but not for the "production" environment.', + ), + ); + }); + it('should throw a generic error when the worker returns a 500 status code', async () => { mockClient .intercept({ diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index da89d3f1b..7c8785ba5 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -30,6 +30,9 @@ export { BlobError, getDownloadUrl, type OnUploadProgressCallback, + type PresignedUrlPayload, + parseStoreIdFromDelegationToken, + parseStoreIdFromPresignedUrl, type UploadProgressEvent, } from './helpers'; @@ -231,9 +234,25 @@ export const completeMultipartUpload = ], }); +export type { BlobClientTokenConstraintOptions } from './client-token-constraints'; export type { CreateFolderCommandOptions, CreateFolderResult, } from './create-folder'; export { createFolder } from './create-folder'; export type { Part, PartInput } from './multipart/helpers'; +export type { + DelegationOperation, + IssuedSignedToken, + IssueSignedTokenOptions, + PresignDeleteUrlOptions, + PresignGetUrlOptions, + PresignHeadUrlOptions, + PresignPutUrlOptions, + PresignUrlOptions, + PresignUrlResult, +} from './signed-token'; +export { + issueSignedToken, + presignUrl, +} from './signed-token'; diff --git a/packages/blob/src/multipart/complete.ts b/packages/blob/src/multipart/complete.ts index 1a5cf7f2d..b5a6c17e8 100644 --- a/packages/blob/src/multipart/complete.ts +++ b/packages/blob/src/multipart/complete.ts @@ -1,6 +1,6 @@ import { BlobServiceNotAvailable, requestApi } from '../api'; import { debug } from '../debug'; -import type { BlobCommandOptions, CommonCreateBlobOptions } from '../helpers'; +import type { CommonCreateBlobOptions } from '../helpers'; import type { CreatePutMethodOptions, PutBlobApiResponse, @@ -67,7 +67,7 @@ export async function completeMultipartUpload({ pathname: string; parts: Part[]; headers: Record; - options: BlobCommandOptions; + options: CommonCreateBlobOptions; }): Promise { const params = new URLSearchParams({ pathname }); diff --git a/packages/blob/src/multipart/create.ts b/packages/blob/src/multipart/create.ts index cb32433c8..a22e7dee8 100644 --- a/packages/blob/src/multipart/create.ts +++ b/packages/blob/src/multipart/create.ts @@ -1,6 +1,6 @@ import { BlobServiceNotAvailable, requestApi } from '../api'; import { debug } from '../debug'; -import type { BlobCommandOptions, CommonCreateBlobOptions } from '../helpers'; +import type { CommonCreateBlobOptions } from '../helpers'; import type { CreatePutMethodOptions } from '../put-helpers'; import { createPutHeaders, createPutOptions } from '../put-helpers'; @@ -38,7 +38,7 @@ interface CreateMultipartUploadApiResponse { export async function createMultipartUpload( pathname: string, headers: Record, - options: BlobCommandOptions, + options: CommonCreateBlobOptions, ): Promise { debug('mpu: create', 'pathname:', pathname); diff --git a/packages/blob/src/multipart/upload.ts b/packages/blob/src/multipart/upload.ts index e455ae90f..023e18fd4 100644 --- a/packages/blob/src/multipart/upload.ts +++ b/packages/blob/src/multipart/upload.ts @@ -1,11 +1,7 @@ import throttle from 'throttleit'; import { BlobServiceNotAvailable, requestApi } from '../api'; import { debug } from '../debug'; -import type { - BlobCommandOptions, - CommonCreateBlobOptions, - WithUploadProgress, -} from '../helpers'; +import type { CommonCreateBlobOptions, WithUploadProgress } from '../helpers'; import { BlobError, bytes, isPlainObject } from '../helpers'; import type { CreatePutMethodOptions, PutBody } from '../put-helpers'; import { createPutHeaders, createPutOptions } from '../put-helpers'; @@ -91,7 +87,7 @@ export async function uploadPart({ key: string; pathname: string; headers: Record; - options: BlobCommandOptions & WithUploadProgress; + options: CommonCreateBlobOptions & WithUploadProgress; internalAbortController?: AbortController; part: PartInput; }): Promise { @@ -167,7 +163,7 @@ export function uploadAllParts({ pathname: string; stream: ReadableStream; headers: Record; - options: BlobCommandOptions & WithUploadProgress; + options: CommonCreateBlobOptions & WithUploadProgress; totalToLoad: number; }): Promise { debug('mpu: upload init', 'key:', key); diff --git a/packages/blob/src/presign-query-params.ts b/packages/blob/src/presign-query-params.ts new file mode 100644 index 000000000..2ca286f6a --- /dev/null +++ b/packages/blob/src/presign-query-params.ts @@ -0,0 +1,383 @@ +/** + * Per-query presign constraints on signed blob URLs. Must stay aligned with + * api-blob and proxy (`signed_url.go`). + * + * Each listed param participates in the HMAC canonical string as `key=value` + * (decoded query value), sorted with `operation` and `pathname`. + * + * Callback completion uses two params (no nested JSON in the query string): + * `vercel-blob-callback-url` and optional `vercel-blob-callback-token-payload`. + */ + +export const BLOB_PRESIGN_QUERY_VALID_UNTIL = + 'vercel-blob-valid-until' as const; +export const BLOB_PRESIGN_QUERY_MAXIMUM_SIZE = + 'vercel-blob-maximum-size-in-bytes' as const; +export const BLOB_PRESIGN_QUERY_ALLOWED_CONTENT_TYPES = + 'vercel-blob-allowed-content-types' as const; +export const BLOB_PRESIGN_QUERY_ADD_RANDOM_SUFFIX = + 'vercel-blob-add-random-suffix' as const; +export const BLOB_PRESIGN_QUERY_ALLOW_OVERWRITE = + 'vercel-blob-allow-overwrite' as const; +export const BLOB_PRESIGN_QUERY_CACHE_CONTROL_MAX_AGE = + 'vercel-blob-cache-control-max-age' as const; +export const BLOB_PRESIGN_QUERY_IF_MATCH = 'vercel-blob-if-match' as const; +export const BLOB_PRESIGN_QUERY_CALLBACK_URL = + 'vercel-blob-callback-url' as const; +export const BLOB_PRESIGN_QUERY_CALLBACK_TOKEN_PAYLOAD = + 'vercel-blob-callback-token-payload' as const; + +/** + * Presign constraint query names + */ +export const PRESIGN_QUERY = { + validUntil: BLOB_PRESIGN_QUERY_VALID_UNTIL, + maximumSizeInBytes: BLOB_PRESIGN_QUERY_MAXIMUM_SIZE, + allowedContentTypes: BLOB_PRESIGN_QUERY_ALLOWED_CONTENT_TYPES, + addRandomSuffix: BLOB_PRESIGN_QUERY_ADD_RANDOM_SUFFIX, + allowOverwrite: BLOB_PRESIGN_QUERY_ALLOW_OVERWRITE, + cacheControlMaxAge: BLOB_PRESIGN_QUERY_CACHE_CONTROL_MAX_AGE, + ifMatch: BLOB_PRESIGN_QUERY_IF_MATCH, + callbackUrl: BLOB_PRESIGN_QUERY_CALLBACK_URL, + callbackTokenPayload: BLOB_PRESIGN_QUERY_CALLBACK_TOKEN_PAYLOAD, +} as const; + +/** Sorted UTF-8 (lexicographic over param names). */ +export const PRESIGN_CANONICAL_QUERY_KEYS = [ + BLOB_PRESIGN_QUERY_ADD_RANDOM_SUFFIX, + BLOB_PRESIGN_QUERY_ALLOW_OVERWRITE, + BLOB_PRESIGN_QUERY_ALLOWED_CONTENT_TYPES, + BLOB_PRESIGN_QUERY_CACHE_CONTROL_MAX_AGE, + BLOB_PRESIGN_QUERY_CALLBACK_TOKEN_PAYLOAD, + BLOB_PRESIGN_QUERY_CALLBACK_URL, + BLOB_PRESIGN_QUERY_IF_MATCH, + BLOB_PRESIGN_QUERY_MAXIMUM_SIZE, + BLOB_PRESIGN_QUERY_VALID_UNTIL, +] as const; + +export type PresignCanonicalQueryKey = + (typeof PRESIGN_CANONICAL_QUERY_KEYS)[number]; + +export const MAX_PRESIGN_CALLBACK_URL_CHARS = 4096; +export const MAX_PRESIGN_CALLBACK_TOKEN_PAYLOAD_CHARS = 8192; + +export type PresignOptionsOnUploadCompletedWire = { + callbackUrl: string; + tokenPayload?: string | null; +}; + +export type DelegationScopeForPresign = { + validUntil: number; + maximumSizeInBytes?: number; + allowedContentTypes?: string[]; +}; + +function contentTypeAllowedByList( + contentType: string, + allowed: readonly string[], +): boolean { + const [type] = contentType.split('/'); + const wildcard = `${type}/*`; + return ( + allowed.includes(contentType) || (type ? allowed.includes(wildcard) : false) + ); +} + +function assertAllowedContentTypesSubset( + optionsTypes: string[] | undefined, + delegationTypes: string[] | undefined, + label: string, +): void { + if (!optionsTypes?.length) { + return; + } + if (!delegationTypes?.length) { + return; + } + for (const ct of optionsTypes) { + if (!contentTypeAllowedByList(ct, delegationTypes)) { + throw new Error( + `${label}: allowedContentTypes entry "${ct}" is not permitted by the delegation token.`, + ); + } + } +} + +function assertNumberSubset( + name: string, + optionVal: number | undefined, + delegationVal: number | undefined, + label: string, + mode: 'lte' | 'eqIfDelegation', +): void { + if (optionVal === undefined) { + return; + } + if (delegationVal === undefined) { + return; + } + if (mode === 'lte' && optionVal > delegationVal) { + throw new Error( + `${label}: ${name} must be ≤ delegation (${String(delegationVal)}).`, + ); + } +} + +function isPlausibleAbsoluteUrl(s: string): boolean { + if (typeof URL !== 'undefined' && typeof URL.canParse === 'function') { + return URL.canParse(s); + } + try { + // eslint-disable-next-line no-new + new URL(s); + return true; + } catch { + return false; + } +} + +/** Callback is URL-only; delegation never embeds `onUploadCompleted`. */ +function validatePresignUrlOnUploadCompletedWire( + opt: PresignOptionsOnUploadCompletedWire | undefined, + label: string, +): void { + if (!opt) { + return; + } + if (typeof opt.callbackUrl !== 'string' || opt.callbackUrl.length === 0) { + throw new Error( + `${label}: onUploadCompleted.callbackUrl must be a non-empty string.`, + ); + } + if (opt.callbackUrl.length > MAX_PRESIGN_CALLBACK_URL_CHARS) { + throw new Error(`${label}: onUploadCompleted.callbackUrl is too long.`); + } + if (!isPlausibleAbsoluteUrl(opt.callbackUrl)) { + throw new Error( + `${label}: onUploadCompleted.callbackUrl must be a valid URL.`, + ); + } + if (opt.tokenPayload !== undefined && opt.tokenPayload !== null) { + if (typeof opt.tokenPayload !== 'string') { + throw new Error( + `${label}: onUploadCompleted.tokenPayload must be a string.`, + ); + } + if (opt.tokenPayload.length > MAX_PRESIGN_CALLBACK_TOKEN_PAYLOAD_CHARS) { + throw new Error(`${label}: onUploadCompleted.tokenPayload is too long.`); + } + } +} + +export const MAX_PRESIGN_CACHE_CONTROL_MAX_AGE_SECONDS = 365 * 24 * 60 * 60; +const MAX_PRESIGN_IF_MATCH_LENGTH = 256; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally blocking them +const IF_MATCH_CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/; + +type PresignUrlConstraintOptions = { + validUntil?: number; + allowedContentTypes?: string[]; + maximumSizeInBytes?: number; + addRandomSuffix?: boolean; + allowOverwrite?: boolean; + cacheControlMaxAge?: number; + ifMatch?: string; + onUploadCompleted?: PresignOptionsOnUploadCompletedWire; +}; + +/** `addRandomSuffix` / `allowOverwrite` are wire-only; validate numeric / string fields. */ +function validateUrlOnlyPresignUploadOptions( + urlOptions: PresignUrlConstraintOptions, + label: string, +): void { + if (urlOptions.cacheControlMaxAge !== undefined) { + const n = urlOptions.cacheControlMaxAge; + if ( + !Number.isInteger(n) || + n < 0 || + n > MAX_PRESIGN_CACHE_CONTROL_MAX_AGE_SECONDS + ) { + throw new Error( + `${label}: cacheControlMaxAge must be an integer between 0 and ${MAX_PRESIGN_CACHE_CONTROL_MAX_AGE_SECONDS}.`, + ); + } + } + if (urlOptions.ifMatch !== undefined) { + const im = urlOptions.ifMatch; + if (typeof im !== 'string' || im.length === 0) { + throw new Error(`${label}: ifMatch must be a non-empty string.`); + } + if (im.length > MAX_PRESIGN_IF_MATCH_LENGTH) { + throw new Error(`${label}: ifMatch is too long.`); + } + if (IF_MATCH_CONTROL_CHARS_RE.test(im)) { + throw new Error( + `${label}: ifMatch contains disallowed control characters.`, + ); + } + } +} + +function sortedContentTypesCsv(types: readonly string[]): string { + return [...types].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(','); +} + +/** + * Clears presign constraint params (not delegation/signature) from a URL. + */ +export function deletePresignCanonicalParams(url: URL): void { + for (const k of PRESIGN_CANONICAL_QUERY_KEYS) { + url.searchParams.delete(k); + } +} + +/** + * Computes resolved URL expiry (ms). When omitted from the wire, callers treat + * it as the delegation ceiling. + */ +export function resolvePresignUrlValidUntilMs(args: { + delegationValidUntil: number; + urlOptions?: { validUntil?: number }; + nowMs: number; +}): number { + const { delegationValidUntil, urlOptions, nowMs } = args; + let t: number; + if (urlOptions?.validUntil !== undefined) { + if ( + typeof urlOptions.validUntil !== 'number' || + !Number.isFinite(urlOptions.validUntil) + ) { + throw new Error('presignUrl: validUntil must be a finite number (ms).'); + } + t = Math.trunc(urlOptions.validUntil); + } else { + t = Math.trunc(delegationValidUntil); + } + if (Number.isFinite(delegationValidUntil)) { + t = Math.min(t, Math.trunc(delegationValidUntil)); + } + if (t <= nowMs) { + throw new Error( + 'presignUrl: resolved URL expiry is not after the current time; issue a new delegation token or pass a later validUntil.', + ); + } + return t; +} + +/** + * Returns entries to set on the URL before signing. Omits + * `vercel-blob-valid-until` when it equals the delegation ceiling (server + * defaults to delegation expiry). + */ +export function buildPresignCanonicalQueryEntries(args: { + operation: 'get' | 'head' | 'put' | 'delete'; + delegation: DelegationScopeForPresign; + urlOptions?: { + validUntil?: number; + allowedContentTypes?: string[]; + maximumSizeInBytes?: number; + addRandomSuffix?: boolean; + allowOverwrite?: boolean; + cacheControlMaxAge?: number; + ifMatch?: string; + onUploadCompleted?: PresignOptionsOnUploadCompletedWire; + }; + nowMs: number; +}): [string, string][] { + const { operation, delegation, urlOptions, nowMs } = args; + const label = 'presignUrl'; + const resolvedUntil = resolvePresignUrlValidUntilMs({ + delegationValidUntil: delegation.validUntil, + urlOptions, + nowMs, + }); + const delegUntil = Math.trunc(delegation.validUntil); + const entries: [string, string][] = []; + + if (resolvedUntil < delegUntil) { + entries.push([BLOB_PRESIGN_QUERY_VALID_UNTIL, String(resolvedUntil)]); + } + + if (operation === 'delete') { + if (urlOptions?.ifMatch !== undefined) { + entries.push([BLOB_PRESIGN_QUERY_IF_MATCH, urlOptions.ifMatch]); + } + return entries; + } + + if (operation !== 'put' || !urlOptions) { + return entries; + } + + assertAllowedContentTypesSubset( + urlOptions.allowedContentTypes, + delegation.allowedContentTypes, + label, + ); + assertNumberSubset( + 'maximumSizeInBytes', + urlOptions.maximumSizeInBytes, + delegation.maximumSizeInBytes, + label, + 'lte', + ); + validateUrlOnlyPresignUploadOptions(urlOptions, label); + validatePresignUrlOnUploadCompletedWire(urlOptions.onUploadCompleted, label); + + if (urlOptions.allowedContentTypes !== undefined) { + const csv = sortedContentTypesCsv(urlOptions.allowedContentTypes); + if (csv.length > 16_384) { + throw new Error(`${label}: allowedContentTypes query value is too long.`); + } + entries.push([BLOB_PRESIGN_QUERY_ALLOWED_CONTENT_TYPES, csv]); + } + if (urlOptions.maximumSizeInBytes !== undefined) { + entries.push([ + BLOB_PRESIGN_QUERY_MAXIMUM_SIZE, + String(Math.trunc(urlOptions.maximumSizeInBytes)), + ]); + } + if (urlOptions.addRandomSuffix !== undefined) { + entries.push([ + BLOB_PRESIGN_QUERY_ADD_RANDOM_SUFFIX, + urlOptions.addRandomSuffix ? 'true' : 'false', + ]); + } + if (urlOptions.allowOverwrite !== undefined) { + entries.push([ + BLOB_PRESIGN_QUERY_ALLOW_OVERWRITE, + urlOptions.allowOverwrite ? 'true' : 'false', + ]); + } + if (urlOptions.cacheControlMaxAge !== undefined) { + entries.push([ + BLOB_PRESIGN_QUERY_CACHE_CONTROL_MAX_AGE, + String(Math.trunc(urlOptions.cacheControlMaxAge)), + ]); + } + if (urlOptions.ifMatch !== undefined) { + entries.push([BLOB_PRESIGN_QUERY_IF_MATCH, urlOptions.ifMatch]); + } + if (urlOptions.onUploadCompleted !== undefined) { + const { callbackUrl, tokenPayload } = urlOptions.onUploadCompleted; + if (callbackUrl.length > MAX_PRESIGN_CALLBACK_URL_CHARS) { + throw new Error(`${label}: onUploadCompleted.callbackUrl is too long.`); + } + entries.push([BLOB_PRESIGN_QUERY_CALLBACK_URL, callbackUrl]); + if ( + tokenPayload !== undefined && + tokenPayload !== null && + tokenPayload !== '' + ) { + if (tokenPayload.length > MAX_PRESIGN_CALLBACK_TOKEN_PAYLOAD_CHARS) { + throw new Error( + `${label}: onUploadCompleted.tokenPayload is too long.`, + ); + } + entries.push([BLOB_PRESIGN_QUERY_CALLBACK_TOKEN_PAYLOAD, tokenPayload]); + } + } + + return entries; +} diff --git a/packages/blob/src/put-helpers.ts b/packages/blob/src/put-helpers.ts index aa9f265ed..a1423bdf1 100644 --- a/packages/blob/src/put-helpers.ts +++ b/packages/blob/src/put-helpers.ts @@ -5,7 +5,7 @@ import type { Readable } from 'stream'; import type { File } from 'undici'; import { MAXIMUM_PATHNAME_LENGTH } from './api'; import type { ClientCommonCreateBlobOptions } from './client'; -import type { CommonCreateBlobOptions } from './helpers'; +import type { CommonCreateBlobOptions, PresignedUrlPayload } from './helpers'; import { BlobError, disallowedPathnameCharacters } from './helpers'; export const putOptionHeaderMap = { @@ -68,6 +68,10 @@ export type CommonPutCommandOptions = CommonCreateBlobOptions & export interface CreatePutMethodOptions { allowedOptions: (keyof typeof putOptionHeaderMap)[]; getToken?: (pathname: string, options: TOptions) => Promise; + getPresignedUrlPayload?: ( + pathname: string, + options: TOptions, + ) => Promise; extraChecks?: (options: TOptions) => void; } diff --git a/packages/blob/src/put.ts b/packages/blob/src/put.ts index fba85317b..a29f895b0 100644 --- a/packages/blob/src/put.ts +++ b/packages/blob/src/put.ts @@ -24,6 +24,7 @@ export interface PutCommandOptions export function createPutMethod({ allowedOptions, getToken, + getPresignedUrlPayload, extraChecks, }: CreatePutMethodOptions) { return async function put( @@ -48,10 +49,25 @@ export function createPutMethod({ getToken, }); + const presignedUrlPayload = await getPresignedUrlPayload?.( + pathname, + options, + ); + + const optionsWithPresignedUrlPayload = { + ...options, + presignedUrlPayload, + }; + const headers = createPutHeaders(allowedOptions, options); if (options.multipart === true) { - return uncontrolledMultipartUpload(pathname, body, headers, options); + return uncontrolledMultipartUpload( + pathname, + body, + headers, + optionsWithPresignedUrlPayload, + ); } const onUploadProgress = options.onUploadProgress @@ -69,7 +85,7 @@ export function createPutMethod({ signal: options.abortSignal, }, { - ...options, + ...optionsWithPresignedUrlPayload, onUploadProgress, }, ); diff --git a/packages/blob/src/signed-token.browser.test.ts b/packages/blob/src/signed-token.browser.test.ts new file mode 100644 index 000000000..759460a5c --- /dev/null +++ b/packages/blob/src/signed-token.browser.test.ts @@ -0,0 +1,21 @@ +import { webcrypto } from 'node:crypto'; +import { registerPresignUrlTests } from './signed-token.presignurl.shared-spec'; + +// jsdom does not expose Web Crypto. Install Node's so `presignUrl` (HMAC) matches browsers. +beforeAll(() => { + Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, + configurable: true, + writable: true, + }); + if (typeof window !== 'undefined') { + Object.defineProperty(window, 'crypto', { + value: webcrypto, + configurable: true, + writable: true, + }); + } +}); + +// Same suite as `signed-token.node.test.ts`, but under Jest's jsdom environment +registerPresignUrlTests('presignUrl (jsdom)'); diff --git a/packages/blob/src/signed-token.node.test.ts b/packages/blob/src/signed-token.node.test.ts new file mode 100644 index 000000000..dcab2ca03 --- /dev/null +++ b/packages/blob/src/signed-token.node.test.ts @@ -0,0 +1,3 @@ +import { registerPresignUrlTests } from './signed-token.presignurl.shared-spec'; + +registerPresignUrlTests(); diff --git a/packages/blob/src/signed-token.presignurl.shared-spec.ts b/packages/blob/src/signed-token.presignurl.shared-spec.ts new file mode 100644 index 000000000..17f482147 --- /dev/null +++ b/packages/blob/src/signed-token.presignurl.shared-spec.ts @@ -0,0 +1,328 @@ +import { createHmac } from 'node:crypto'; +import { BlobError } from './helpers'; +import { + BLOB_PRESIGN_QUERY_VALID_UNTIL, + buildPresignCanonicalQueryEntries, +} from './presign-query-params'; +import { + canonicalString, + type PresignUrlOptions, + presign, +} from './signed-token'; +import { + createDelegationToken, + deriveClientSigningToken, + randomBytes, +} from './signed-token.presignurl.test-helpers'; + +type DelegationPayload = { + storeId: string; + ownerId: string; + pathname: string; + operations: string[]; + validUntil: number; + iat: number; + maximumSizeInBytes?: number; + allowedContentTypes?: string[]; +}; + +function readDelegationPayload(delegationToken: string): DelegationPayload { + const seg = delegationToken.split('.')[0]!; + return JSON.parse( + Buffer.from(seg, 'base64url').toString('utf8'), + ) as DelegationPayload; +} + +async function expectSignatureMatches( + delegationToken: string, + clientSigningToken: string, + options: PresignUrlOptions, + nowMs: number, +): Promise { + const spy = jest.spyOn(Date, 'now').mockReturnValue(nowMs); + try { + const payload = await presign( + { delegationToken, clientSigningToken }, + options, + ); + const scope = readDelegationPayload(delegationToken); + const { pathname, operation } = options; + const urlOptions = + operation === 'put' + ? { + validUntil: options.validUntil, + allowedContentTypes: options.allowedContentTypes, + maximumSizeInBytes: options.maximumSizeInBytes, + addRandomSuffix: options.addRandomSuffix, + allowOverwrite: options.allowOverwrite, + cacheControlMaxAge: options.cacheControlMaxAge, + ifMatch: options.ifMatch, + onUploadCompleted: options.onUploadCompleted, + } + : { validUntil: options.validUntil }; + const presignEntries = buildPresignCanonicalQueryEntries({ + operation, + delegation: { + validUntil: scope.validUntil, + maximumSizeInBytes: scope.maximumSizeInBytes, + allowedContentTypes: scope.allowedContentTypes, + }, + urlOptions, + nowMs, + }); + const canonical = canonicalString(pathname, presignEntries, operation); + const expected = createHmac('sha256', clientSigningToken) + .update(canonical, 'utf8') + .digest('base64url'); + expect(payload.signature).toBe(expected); + expect(payload.delegationToken).toBe(delegationToken); + expect(payload.params).toEqual(Object.fromEntries(presignEntries)); + } finally { + spy.mockRestore(); + } +} + +/** + * Shared `presignUrl` assertions; run in Node and jsdom to exercise Web Crypto + * the same way browsers do. + */ +export function registerPresignUrlTests(suiteName = 'presignUrl'): void { + describe(suiteName, () => { + const storeId = 's'.repeat(16); + const blobSigningSecret = randomBytes(32).toString('base64'); + const now = Date.now(); + + it('PUT: same signature for pathname whether target is PUT / or POST /mpu', async () => { + const pathname = 'images/a.png'; + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'owner_1', + pathname, + operations: ['put'], + validUntil: now + 3600_000, + iat: now, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + const presignedPut = await presign( + { delegationToken: delegation, clientSigningToken: client }, + { operation: 'put', pathname }, + ); + const presignedMpu = await presign( + { delegationToken: delegation, clientSigningToken: client }, + { operation: 'put', pathname }, + ); + expect(presignedPut.signature).toBe(presignedMpu.signature); + expect(presignedPut.params).toEqual(presignedMpu.params); + await expectSignatureMatches( + delegation, + client, + { operation: 'put', pathname }, + now, + ); + expect( + presignedPut.params[BLOB_PRESIGN_QUERY_VALID_UNTIL], + ).toBeUndefined(); + }); + + it('POST /mpu: matches PUT canonical for the same pathname', async () => { + const pathname = 'images/a.png'; + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'owner_1', + pathname, + operations: ['put'], + validUntil: now + 3600_000, + iat: now, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + // URLs are only for documentation parity with real uploads; presign is pathname-based. + await expectSignatureMatches( + delegation, + client, + { operation: 'put', pathname }, + now, + ); + }); + + it('HMACs the documented canonical string and returns payload fields', async () => { + const pathname = 'images/a.png'; + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'owner_1', + pathname, + operations: ['get'], + validUntil: now + 3600_000, + iat: now, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + await expectSignatureMatches( + delegation, + client, + { operation: 'get', pathname }, + now, + ); + }); + + it('same pathname yields identical presign payloads (deterministic)', async () => { + const pathname = 'x.txt'; + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'owner_1', + pathname, + operations: ['get'], + validUntil: now + 3600_000, + iat: now, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + const first = await presign( + { delegationToken: delegation, clientSigningToken: client }, + { operation: 'get', pathname }, + ); + const second = await presign( + { delegationToken: delegation, clientSigningToken: client }, + { operation: 'get', pathname }, + ); + expect(first).toEqual(second); + }); + + it('accepts logical pathname when the object URL would use percent-encoded segments', async () => { + const logicalName = 'Image Background Removed (1).png'; + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'owner_1', + pathname: logicalName, + operations: ['get'], + validUntil: now + 3600_000, + iat: now, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + await expectSignatureMatches( + delegation, + client, + { + operation: 'get', + pathname: logicalName, + }, + now, + ); + }); + + it('rejects path mismatch for scoped non-wildcard tokens', async () => { + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'o', + pathname: 'a.png', + operations: ['get'], + validUntil: now + 3600_000, + iat: now, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + await expect( + presign( + { + delegationToken: delegation, + clientSigningToken: client, + }, + { operation: 'get', pathname: 'b.png' }, + ), + ).rejects.toThrow(BlobError); + }); + + it('adds signed `vercel-blob-valid-until` when `validUntil` is before delegation ceiling', async () => { + const fixedNow = 1_700_000_000_000; + const pathnameTtl = 'images/a.png'; + const validUntil = fixedNow + 3_600_000; + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'owner_1', + pathname: pathnameTtl, + operations: ['get'], + validUntil, + iat: fixedNow, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + const presignedUntil = fixedNow + 90_000; + const spy = jest.spyOn(Date, 'now').mockReturnValue(fixedNow); + try { + const payload = await presign( + { delegationToken: delegation, clientSigningToken: client }, + { + operation: 'get', + pathname: pathnameTtl, + validUntil: presignedUntil, + }, + ); + expect(payload.params[BLOB_PRESIGN_QUERY_VALID_UNTIL]).toBe( + String(presignedUntil), + ); + await expectSignatureMatches( + delegation, + client, + { + operation: 'get', + pathname: pathnameTtl, + validUntil: presignedUntil, + }, + fixedNow, + ); + } finally { + spy.mockRestore(); + } + }); + + it('omits `vercel-blob-valid-until` when `validUntil` equals delegation ceiling', async () => { + const fixedNow = 2_000_000_000_000; + const validUntil = fixedNow + 120_000; + const pathname = 'a.png'; + const delegation = createDelegationToken( + { + storeId: `store_${storeId}`, + ownerId: 'o', + pathname, + operations: ['get'], + validUntil, + iat: fixedNow, + }, + blobSigningSecret, + ); + const client = deriveClientSigningToken(blobSigningSecret, delegation); + const spy = jest.spyOn(Date, 'now').mockReturnValue(fixedNow); + try { + const payload = await presign( + { delegationToken: delegation, clientSigningToken: client }, + { operation: 'get', pathname, validUntil }, + ); + expect(payload.params[BLOB_PRESIGN_QUERY_VALID_UNTIL]).toBeUndefined(); + await expectSignatureMatches( + delegation, + client, + { operation: 'get', pathname, validUntil }, + fixedNow, + ); + } finally { + spy.mockRestore(); + } + }); + }); +} diff --git a/packages/blob/src/signed-token.presignurl.test-helpers.ts b/packages/blob/src/signed-token.presignurl.test-helpers.ts new file mode 100644 index 000000000..db3d2f97b --- /dev/null +++ b/packages/blob/src/signed-token.presignurl.test-helpers.ts @@ -0,0 +1,41 @@ +import { createHmac, randomBytes } from 'node:crypto'; + +function toBase64urlFromBase64(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * Matches `api-blob` `createDelegationToken` (test-only) so we can issue tokens + * locally and assert `presignUrl` matches the same HMAC the edge will check. + */ +export function createDelegationToken( + payload: { + storeId: string; + ownerId: string; + pathname: string; + operations: string[]; + validUntil: number; + iat: number; + }, + blobSigningSecret: string, +): string { + const payloadJson = JSON.stringify(payload); + const payloadSegment = toBase64urlFromBase64( + Buffer.from(payloadJson, 'utf8').toString('base64'), + ); + const sig = createHmac('sha256', blobSigningSecret) + .update(payloadSegment, 'utf8') + .digest('base64url'); + return `${payloadSegment}.${sig}`; +} + +export function deriveClientSigningToken( + blobSigningSecret: string, + delegationToken: string, +): string { + return createHmac('sha256', blobSigningSecret) + .update(delegationToken, 'utf8') + .digest('base64url'); +} + +export { randomBytes }; diff --git a/packages/blob/src/signed-token.ts b/packages/blob/src/signed-token.ts new file mode 100644 index 000000000..2840e7f0c --- /dev/null +++ b/packages/blob/src/signed-token.ts @@ -0,0 +1,558 @@ +import { requestApi } from './api'; +import { + addPresignedParams, + type BlobCommandOptions, + BlobError, + constructBlobUrl, + getApiUrl, + isUrl, + type PresignedUrlPayload, + parseStoreIdFromDelegationToken, +} from './helpers'; +import { + buildPresignCanonicalQueryEntries, + PRESIGN_CANONICAL_QUERY_KEYS, +} from './presign-query-params'; + +/** + * Operations that may be encoded in a delegation token (e.g. read: `get` / + * `head` for blob object reads, write: `put` for presigned control-plane writes + * — both single-object `PUT`, destructive: `delete` for presigned control-plane + * `DELETE /?pathname=…`). `head` shares the GET URL shape (blob object host) + * and is distinguished only by the HTTP method and `operation=head` in the + * canonical signing string. + */ +export type DelegationOperation = 'get' | 'head' | 'put' | 'delete'; + +/** + * Result of `issueSignedToken` — the same values returned from `POST /signed-token` on + * the Blob API. Use with {@link presignUrl} to obtain `{ presignedUrl }` for GET/HEAD, + * presigned `PUT`, presigned multipart `POST` + * without a bearer token when verified by the CDN. + */ +export interface IssuedSignedToken { + /** + * Encodes delegation scope (pathname, allowed operations, expiry) and a store-level + * HMAC, as issued by the API. + */ + delegationToken: string; + /** + * Per-issuance HMAC key: `HMAC-SHA256(blobSigningSecret, delegationToken)` in base64url + * form. The SDK uses this as the HMAC key when signing a concrete blob URL + * (the CDN re-derives the same value from the delegation token and store secret). + */ + clientSigningToken: string; + /** Time after which the delegation (and any presigned URLs) must be rejected, in ms since epoch. */ + validUntil: number; +} + +/** + * Options for {@link issueSignedToken}. + */ +export type IssueSignedTokenOptions = BlobCommandOptions & { + /** + * Blob object pathname to scope the token to, e.g. `media/photo.png`. + * Use `"*"` to allow any pathname in the store. When omitted, the API defaults + * to a whole-store `"*"` wildcard. + */ + pathname?: string; + /** + * Allowed operations (e.g. `get` / `head` for reads to `*.blob.vercel-storage.com`, + * `put` for presigned control-plane `PUT` and multipart `POST /mpu`, + * When omitted, the API defaults to read (`get`) only. + */ + operations?: DelegationOperation[]; + /** + * Absolute delegation expiry (ms since epoch). Must be after `now`. When omitted, the API uses `now + 1 hour`. + */ + validUntil?: number; + + allowedContentTypes?: string[]; + + maximumSizeInBytes?: number; +}; + +interface IssuedSignedTokenResponse { + delegationToken: string; + clientSigningToken: string; + validUntil: number; +} + +function assertIssueSignedTokenValidUntilOption(validUntil: number): void { + const now = Date.now(); + if ( + typeof validUntil !== 'number' || + !Number.isInteger(validUntil) || + !Number.isFinite(validUntil) + ) { + throw new BlobError( + '`issueSignedToken`: validUntil must be an integer milliseconds timestamp.', + ); + } + if (validUntil <= now) { + throw new BlobError( + '`issueSignedToken`: validUntil must be in the future.', + ); + } +} + +/** + * Requests short-lived signed-token material from the Blob control API + * (`POST /signed-token`). Use OIDC (`VERCEL_OIDC_TOKEN` + `storeId` / `BLOB_STORE_ID`) + * or a read–write token like other SDK control-plane calls. Client (browser) tokens + * are not allowed by the server for this operation. + */ +export async function issueSignedToken( + options: IssueSignedTokenOptions, +): Promise { + if (!options) { + throw new BlobError('`issueSignedToken` requires an options object'); + } + + const body: Record = {}; + if (options.pathname !== undefined) { + body.pathname = options.pathname; + } + if (options.operations !== undefined) { + if (options.operations.length === 0) { + throw new BlobError('`operations` must be a non-empty array if provided'); + } + body.operations = dedupeOps(options.operations); + } + if (options.validUntil !== undefined) { + assertIssueSignedTokenValidUntilOption(options.validUntil); + body.validUntil = options.validUntil; + } + if (options.maximumSizeInBytes !== undefined) { + body.maximumSizeInBytes = options.maximumSizeInBytes; + } + if (options.allowedContentTypes !== undefined) { + body.allowedContentTypes = options.allowedContentTypes; + } + + return requestApi( + '/signed-token', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: options.abortSignal, + }, + options, + ); +} + +function dedupeOps( + operations: readonly DelegationOperation[], +): DelegationOperation[] { + return Array.from(new Set(operations)); +} + +interface DecodedDelegationPayload { + storeId: string; + ownerId: string; + pathname: string; + operations: string[]; + validUntil: number; + iat: number; + maximumSizeInBytes?: number; + allowedContentTypes?: string[]; +} + +function base64UrlDecodeToString(segment: string): string { + let base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padding = 4 - (base64.length % 4); + if (padding !== 4) { + base64 += '='.repeat(padding); + } + if (typeof atob === 'function') { + return atob(base64); + } + if (typeof Buffer !== 'undefined') { + // eslint-disable-next-line no-restricted-globals + return Buffer.from(base64, 'base64').toString('utf8'); + } + throw new BlobError('Cannot decode base64: no atob or Buffer available.'); +} + +function tryDecodePayload( + delegationToken: string, +): DecodedDelegationPayload | null { + const dot = delegationToken.indexOf('.'); + if (dot < 0) { + return null; + } + const payloadSeg = delegationToken.slice(0, dot); + try { + return JSON.parse( + base64UrlDecodeToString(payloadSeg), + ) as DecodedDelegationPayload; + } catch { + return null; + } +} + +/** + * Builds a blob object URL for `pathname` and store from `delegationToken` for use with + * {@link presignUrl} for `GET` / `HEAD` only (to `*.public|*.private.blob.vercel-storage.com`). + */ +export function publicBlobObjectUrl( + access: 'public' | 'private', + objectPathname: string, + delegationToken: string, +): string { + const scope = tryDecodePayload(delegationToken); + if (!scope) { + throw new BlobError('Invalid or unreadable `delegationToken` payload.'); + } + const storeId = normalizeStoreId(scope.storeId); + const encodedPath = objectPathname + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + return `https://${storeId}.${access}.blob.vercel-storage.com/${encodedPath}`; +} + +/** + * @internal + */ +function uint8ToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') { + // eslint-disable-next-line no-restricted-globals + return Buffer.from( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).toString('base64'); + } + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += String.fromCharCode(bytes[i]!); + } + return btoa(s); +} + +/** + * HMAC-SHA256 with the `clientSigningToken` string as the UTF-8 key; signature + * digest is base64url (no padding), i.e. `vercel-blob-signature`. + * Uses the Web Crypto API (Node 20+, browsers, Edge). + */ +async function hmacSha256Base64Url(key: string, data: string): Promise { + if (!globalThis.crypto?.subtle) { + throw new BlobError( + 'HMAC is not available: expected globalThis.crypto.subtle (Node 20+ or a modern browser).', + ); + } + const enc = new TextEncoder(); + const cryptoKey = await globalThis.crypto.subtle.importKey( + 'raw', + enc.encode(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const buf = await globalThis.crypto.subtle.sign( + 'HMAC', + cryptoKey, + enc.encode(data), + ); + return toBase64Url(uint8ToBase64(new Uint8Array(buf))); +} + +/** + * @internal + */ +function toBase64Url(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function normalizeStoreId(storeId: string): string { + const lowercase = storeId.toLowerCase(); + return lowercase.startsWith('store_') + ? lowercase.slice('store_'.length) + : lowercase; +} + +/** + * Presign URL options for {@link presignUrl} when `operation` is `get`. + * Only `validUntil` is honored for these operations; upload-only fields are rejected at the type level. + */ +export type PresignGetUrlOptions = { + operation: 'get'; + + pathname: string; + /** + * Absolute URL expiry (ms since epoch), capped to the delegation `validUntil`. + * Omitted on the wire when equal to the delegation ceiling (server defaults to delegation). + */ + validUntil?: number; +}; + +/** + * Presign URL options for {@link presignUrl} when `operation` is `head`. + * Mirrors {@link PresignGetUrlOptions}; the CDN URL is identical, the HTTP + * method differentiates, and the canonical signing string carries + * `operation=head` so a GET-signed URL cannot be replayed for HEAD. + */ +export type PresignHeadUrlOptions = { + operation: 'head'; + + pathname: string; + + validUntil?: number; +}; + +/** + * Presign URL options for {@link presignUrl} when `operation` is `put` (single `PUT` or multipart `POST`). + * Serialized as individual `vercel-blob-*` query params (see {@link PRESIGN_CANONICAL_QUERY_KEYS}). + */ +export type PresignPutUrlOptions = { + operation: 'put'; + + pathname: string; + + validUntil?: number; + + allowedContentTypes?: string[]; + + maximumSizeInBytes?: number; + + onUploadCompleted?: { + callbackUrl: string; + tokenPayload?: string; + }; + + allowOverwrite?: boolean; + + addRandomSuffix?: boolean; + + cacheControlMaxAge?: number; + + ifMatch?: string; +}; + +/** + * Presign URL options for {@link presignUrl} when `operation` is `delete`. + * Targets the control-plane `DELETE /?pathname=…` route — mirrors the PUT + * presign URL shape, signed with `operation=delete` so the HTTP method + * differentiates without changing the URL path. Only `validUntil` and + * `ifMatch` are honored; upload-only fields are rejected at the type level. + */ +export type PresignDeleteUrlOptions = { + operation: 'delete'; + + pathname: string; + + validUntil?: number; + + ifMatch?: string; +}; + +export type PresignUrlOptions = + | PresignGetUrlOptions + | PresignHeadUrlOptions + | PresignPutUrlOptions + | PresignDeleteUrlOptions; + +/** + * Builds the payload for a presigned URL + */ +export async function presign( + signedToken: Pick< + IssuedSignedToken, + 'clientSigningToken' | 'delegationToken' + >, + options: PresignUrlOptions, +): Promise { + if (!signedToken?.clientSigningToken || !signedToken?.delegationToken) { + throw new BlobError( + '`clientSigningToken` and `delegationToken` from `issueSignedToken` are required.', + ); + } + + const scope = tryDecodePayload(signedToken.delegationToken); + if (!scope) { + throw new BlobError('Invalid or unreadable `delegationToken` payload.'); + } + + const p = scope.pathname; + if (p && p !== '*') { + if (options.pathname !== p) { + throw new BlobError( + `Blob path does not match the signed token scope; expected \`${p}\`, got \`${options.pathname}\`.`, + ); + } + } + if (Number.isFinite(scope.validUntil) && Date.now() > scope.validUntil) { + throw new BlobError( + 'The signed delegation has expired; issue a new token first.', + ); + } + + if (options.operation === 'get' && !scope.operations?.includes('get')) { + throw new BlobError( + 'The delegation token is not valid for `GET` requests. Include `"get"` in `operations` when calling `issueSignedToken`.', + ); + } + if (options.operation === 'head' && !scope.operations?.includes('head')) { + throw new BlobError( + 'The delegation token is not valid for `HEAD` requests. Include `"head"` in `operations` when calling `issueSignedToken`.', + ); + } + if (options.operation === 'put' && !scope.operations?.includes('put')) { + throw new BlobError( + 'The delegation token is not valid for presigned write requests. Include `"put"` in `operations` when calling `issueSignedToken`.', + ); + } + if (options.operation === 'delete' && !scope.operations?.includes('delete')) { + throw new BlobError( + 'The delegation token is not valid for presigned delete requests. Include `"delete"` in `operations` when calling `issueSignedToken`.', + ); + } + + const delegationForOptions = { + validUntil: scope.validUntil, + maximumSizeInBytes: scope.maximumSizeInBytes, + allowedContentTypes: scope.allowedContentTypes, + }; + + let presignEntries: [string, string][]; + try { + presignEntries = buildPresignCanonicalQueryEntries({ + operation: options.operation, + delegation: delegationForOptions, + urlOptions: options, + nowMs: Date.now(), + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new BlobError(msg); + } + + const canonical = canonicalString( + options.pathname, + presignEntries, + options.operation, + ); + const signature = await hmacSha256Base64Url( + signedToken.clientSigningToken, + canonical, + ); + + return { + delegationToken: signedToken.delegationToken, + signature, + params: Object.fromEntries(presignEntries), + }; +} + +function buildPresignedGetUrl( + pathnameOrUrl: string, + presignedUrlPayload: PresignedUrlPayload, + options: { + access: 'public' | 'private'; + }, +): string { + const storeId = parseStoreIdFromDelegationToken( + presignedUrlPayload.delegationToken, + ); + const blobUrl = isUrl(pathnameOrUrl) + ? pathnameOrUrl + : constructBlobUrl(storeId, pathnameOrUrl, options.access); + return addPresignedParams(blobUrl, presignedUrlPayload); +} + +function buildPresignedPutUrl( + pathname: string, + presignedUrlPayload: PresignedUrlPayload, +): string { + const params = new URLSearchParams({ pathname }); + const apiUrl = getApiUrl(`/?${params.toString()}`); + return addPresignedParams(apiUrl, presignedUrlPayload); +} + +function buildPresignedDeleteUrl( + pathname: string, + presignedUrlPayload: PresignedUrlPayload, +): string { + const params = new URLSearchParams({ pathname }); + const apiUrl = getApiUrl(`/?${params.toString()}`); + return addPresignedParams(apiUrl, presignedUrlPayload); +} + +/** Result of {@link presignUrl}: a ready-to-fetch blob object URL or control-plane upload URL. */ +export type PresignUrlResult = { + presignedUrl: string; +}; + +/** + * Builds a presigned URL for `GET` / `HEAD` (blob object host), `PUT` / multipart (control API), + * or `DELETE` (`DELETE /?pathname=…` on the control API — mirrors the PUT URL shape; the + * HTTP method discriminates, and the canonical signing string includes `operation=delete`). + * `HEAD` reuses the GET URL shape against the blob object host; `operation=head` in the + * canonical string prevents a GET-signed URL from being replayed as a HEAD. + */ +export async function presignUrl( + signedToken: Pick< + IssuedSignedToken, + 'clientSigningToken' | 'delegationToken' + >, + options: PresignUrlOptions & { access: 'public' | 'private' }, +): Promise { + const payload = await presign(signedToken, options); + + if (options.operation === 'get' || options.operation === 'head') { + return { + presignedUrl: buildPresignedGetUrl(options.pathname, payload, options), + }; + } + if (options.operation === 'put') { + return { + presignedUrl: buildPresignedPutUrl(options.pathname, payload), + }; + } + if (options.operation === 'delete') { + return { + presignedUrl: buildPresignedDeleteUrl(options.pathname, payload), + }; + } + + throw new BlobError(`Unknown operation`); +} + +/** @internal Exported for presign URL contract tests (must match proxy / api-blob). */ +export function canonicalString( + pathname: string, + presignEntries: [string, string][], + operation: DelegationOperation, +): string { + const lines: string[] = [`operation=${operation}`, `pathname=${pathname}`]; + for (const k of PRESIGN_CANONICAL_QUERY_KEYS) { + const v = presignEntries.find(([key]) => key === k)?.[1]; + if (v) { + lines.push(`${k}=${v}`); + } + } + lines.sort((a, b) => compareUtf8(a, b)); + return lines.join('\n'); +} + +const utf8Encoder = new TextEncoder(); + +/** + * Lexicographic order of UTF-8 bytewise (matches Go `string` comparison and + * `url.Values` key / pair ordering in practice). + * @internal + */ +function compareUtf8(a: string, b: string): number { + const ab = utf8Encoder.encode(a); + const bb = utf8Encoder.encode(b); + const n = Math.min(ab.length, bb.length); + for (let i = 0; i < n; i++) { + const d = (ab[i]! - bb[i]!) as number; + if (d !== 0) { + return d; + } + } + return ab.length - bb.length; +} diff --git a/test/next/src/app/vercel/blob/api/app/handle-blob-upload-presigned/serverless/route.ts b/test/next/src/app/vercel/blob/api/app/handle-blob-upload-presigned/serverless/route.ts new file mode 100644 index 000000000..79ff83491 --- /dev/null +++ b/test/next/src/app/vercel/blob/api/app/handle-blob-upload-presigned/serverless/route.ts @@ -0,0 +1,3 @@ +import { handleUploadPresignedHandler } from '@/app/vercel/blob/handle-blob-upload-presigned'; + +export const POST = handleUploadPresignedHandler; diff --git a/test/next/src/app/vercel/blob/api/app/presigned-delete/route.ts b/test/next/src/app/vercel/blob/api/app/presigned-delete/route.ts new file mode 100644 index 000000000..959cbb139 --- /dev/null +++ b/test/next/src/app/vercel/blob/api/app/presigned-delete/route.ts @@ -0,0 +1,67 @@ +import { issueSignedToken, presignUrl } from '@vercel/blob'; +import { NextResponse } from 'next/server'; + +function parseBlobObjectUrl( + blobUrl: string, +): { access: 'public' | 'private'; pathname: string } | null { + let u: URL; + try { + u = new URL(blobUrl); + } catch { + return null; + } + const { hostname } = u; + if (hostname.endsWith('.public.blob.vercel-storage.com')) { + return { access: 'public', pathname: pathnameFromObjectUrl(u.pathname) }; + } + if (hostname.endsWith('.private.blob.vercel-storage.com')) { + return { access: 'private', pathname: pathnameFromObjectUrl(u.pathname) }; + } + return null; +} + +function pathnameFromObjectUrl(urlPathname: string): string { + const trimmed = urlPathname.startsWith('/') + ? urlPathname.slice(1) + : urlPathname; + return trimmed + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'); +} + +export async function POST(request: Request): Promise { + const body = (await request.json()) as { url?: string }; + if (!body.url || typeof body.url !== 'string') { + return NextResponse.json({ error: 'url is required' }, { status: 400 }); + } + + const parsed = parseBlobObjectUrl(body.url); + if (!parsed) { + return NextResponse.json( + { + error: + 'url must be a Vercel Blob object URL (*.public|*.private.blob.vercel-storage.com)', + }, + { status: 400 }, + ); + } + + try { + const token = await issueSignedToken({ + pathname: parsed.pathname, + operations: ['delete'], + validUntil: Date.now() + 60 * 60 * 1000, // 1 hour + }); + const { presignedUrl } = await presignUrl(token, { + operation: 'delete', + pathname: parsed.pathname, + access: parsed.access, + validUntil: Date.now() + 30 * 60 * 1000, // 30 minutes + }); + return NextResponse.json({ presignedUrl }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/test/next/src/app/vercel/blob/api/app/presigned-head/route.ts b/test/next/src/app/vercel/blob/api/app/presigned-head/route.ts new file mode 100644 index 000000000..5b0af5225 --- /dev/null +++ b/test/next/src/app/vercel/blob/api/app/presigned-head/route.ts @@ -0,0 +1,67 @@ +import { issueSignedToken, presignUrl } from '@vercel/blob'; +import { NextResponse } from 'next/server'; + +function parseBlobObjectUrl( + blobUrl: string, +): { access: 'public' | 'private'; pathname: string } | null { + let u: URL; + try { + u = new URL(blobUrl); + } catch { + return null; + } + const { hostname } = u; + if (hostname.endsWith('.public.blob.vercel-storage.com')) { + return { access: 'public', pathname: pathnameFromObjectUrl(u.pathname) }; + } + if (hostname.endsWith('.private.blob.vercel-storage.com')) { + return { access: 'private', pathname: pathnameFromObjectUrl(u.pathname) }; + } + return null; +} + +function pathnameFromObjectUrl(urlPathname: string): string { + const trimmed = urlPathname.startsWith('/') + ? urlPathname.slice(1) + : urlPathname; + return trimmed + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'); +} + +export async function POST(request: Request): Promise { + const body = (await request.json()) as { url?: string }; + if (!body.url || typeof body.url !== 'string') { + return NextResponse.json({ error: 'url is required' }, { status: 400 }); + } + + const parsed = parseBlobObjectUrl(body.url); + if (!parsed) { + return NextResponse.json( + { + error: + 'url must be a Vercel Blob object URL (*.public|*.private.blob.vercel-storage.com)', + }, + { status: 400 }, + ); + } + + try { + const token = await issueSignedToken({ + pathname: parsed.pathname, + operations: ['head'], + validUntil: Date.now() + 60 * 60 * 1000, // 1 hour + }); + const { presignedUrl } = await presignUrl(token, { + operation: 'head', + pathname: parsed.pathname, + access: parsed.access, + validUntil: Date.now() + 30 * 60 * 1000, // 30 minutes + }); + return NextResponse.json({ presignedUrl }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/test/next/src/app/vercel/blob/api/app/presigned-read/route.ts b/test/next/src/app/vercel/blob/api/app/presigned-read/route.ts new file mode 100644 index 000000000..46aa3dc85 --- /dev/null +++ b/test/next/src/app/vercel/blob/api/app/presigned-read/route.ts @@ -0,0 +1,69 @@ +import { issueSignedToken, presignUrl } from '@vercel/blob'; +import { NextResponse } from 'next/server'; + +function parseBlobObjectUrl( + blobUrl: string, +): { access: 'public' | 'private'; pathname: string } | null { + let u: URL; + try { + u = new URL(blobUrl); + } catch { + return null; + } + const { hostname } = u; + if (hostname.endsWith('.public.blob.vercel-storage.com')) { + return { access: 'public', pathname: pathnameFromObjectUrl(u.pathname) }; + } + if (hostname.endsWith('.private.blob.vercel-storage.com')) { + return { access: 'private', pathname: pathnameFromObjectUrl(u.pathname) }; + } + return null; +} + +function pathnameFromObjectUrl(urlPathname: string): string { + const trimmed = urlPathname.startsWith('/') + ? urlPathname.slice(1) + : urlPathname; + return trimmed + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'); +} + +export async function POST(request: Request): Promise { + const body = (await request.json()) as { url?: string }; + if (!body.url || typeof body.url !== 'string') { + return NextResponse.json({ error: 'url is required' }, { status: 400 }); + } + + const parsed = parseBlobObjectUrl(body.url); + if (!parsed) { + return NextResponse.json( + { + error: + 'url must be a Vercel Blob object URL (*.public|*.private.blob.vercel-storage.com)', + }, + { status: 400 }, + ); + } + + try { + const token = await issueSignedToken({ + pathname: parsed.pathname, + operations: ['get'], + validUntil: Date.now() + 60 * 60 * 1000, // 1 hour + }); + const { presignedUrl } = await presignUrl(token, { + operation: 'get', + pathname: parsed.pathname, + access: parsed.access, + validUntil: Date.now() + 30 * 60 * 1000, // 30 minutes + }); + return NextResponse.json({ + presignedUrl, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/test/next/src/app/vercel/blob/app/list/page.tsx b/test/next/src/app/vercel/blob/app/list/page.tsx index be7a5821e..5508478ec 100644 --- a/test/next/src/app/vercel/blob/app/list/page.tsx +++ b/test/next/src/app/vercel/blob/app/list/page.tsx @@ -4,11 +4,27 @@ import type * as vercelBlob from '@vercel/blob'; import { useCallback, useEffect, useState } from 'react'; import { API_ROOT } from '../../api/app/constants'; +type PresignedReadOk = { + presignedUrl: string; +}; + +type PresignedPreview = + | { + pathname: string; + /** Full blob object URL including presigned query params from `presignUrl`. */ + resolvedBlobUrl: string; + loading: false; + fetchError?: string; + imageError: boolean; + } + | { pathname: string; loading: true }; + export default function AppList(): React.JSX.Element { const [result, setResult] = useState(); const [searchPrefix, setSearchPrefix] = useState(''); const [urlsToRemove, setUrlsToRemove] = useState([]); const [head, setHead] = useState(); + const [presignedPreview, setPresignedPreview] = useState(); const getList = useCallback( async ( @@ -76,6 +92,57 @@ export default function AppList(): React.JSX.Element { setHead(data); }; + const handlePresignedView = async ( + blob: vercelBlob.ListBlobResultBlob, + ): Promise => { + setPresignedPreview({ pathname: blob.pathname, loading: true }); + try { + const res = await fetch(`${API_ROOT}/presigned-read`, { + method: 'POST', + body: JSON.stringify({ url: blob.url }), + headers: { 'Content-Type': 'application/json' }, + }); + const data = (await res.json()) as Partial & { + error?: string; + }; + if (!res.ok) { + setPresignedPreview({ + pathname: blob.pathname, + resolvedBlobUrl: '', + loading: false, + fetchError: data.error ?? res.statusText, + imageError: true, + }); + return; + } + if (!data.presignedUrl) { + setPresignedPreview({ + pathname: blob.pathname, + resolvedBlobUrl: '', + loading: false, + fetchError: 'Missing presignedUrl', + imageError: true, + }); + return; + } + + setPresignedPreview({ + pathname: blob.pathname, + resolvedBlobUrl: data.presignedUrl, + loading: false, + imageError: false, + }); + } catch (e) { + setPresignedPreview({ + pathname: blob.pathname, + resolvedBlobUrl: '', + loading: false, + fetchError: e instanceof Error ? e.message : String(e), + imageError: true, + }); + } + }; + if (!result) { return
Loading...
; } @@ -108,16 +175,84 @@ export default function AppList(): React.JSX.Element { Multi-delete + {presignedPreview ? ( +
+
+

+ Presigned GET preview: {presignedPreview.pathname} +

+ +
+ {presignedPreview.loading ? ( +

Issuing presigned read URL…

+ ) : ( + <> + {presignedPreview.fetchError ? ( +

{presignedPreview.fetchError}

+ ) : null} + {presignedPreview.resolvedBlobUrl ? ( + <> +

+ Presigned read URL: + + {presignedPreview.resolvedBlobUrl} + +

+ {!presignedPreview.fetchError ? ( +
+ {presignedPreview.imageError ? ( +

+ Could not render as an image (non-image blob or load + error). Open the URL above to download or view in a + new tab. +

+ ) : ( + // eslint-disable-next-line @next/next/no-img-element -- presigned blob object URL + {presignedPreview.pathname} { + setPresignedPreview((prev) => + prev && !prev.loading + ? { ...prev, imageError: true } + : prev, + ); + }} + src={presignedPreview.resolvedBlobUrl} + /> + )} +
+ ) : null} + + ) : null} + + )} +
+ ) : null}
    -
  • +
  • Select

    path

    size

    download

    date

    +

    Presigned GET

    +

    Head

    +

    Delete

  • {result.blobs.map((blob) => ( -
  • +
  • { @@ -140,6 +275,13 @@ export default function AppList(): React.JSX.Element { download

    {new Date(blob.uploadedAt).toISOString()}

    + + + + {progressEvents.length > 0 && ( +
    +

    + Upload Progress Events: +

    +
      + {progressEvents.map((event, index) => ( +
    • + {event} +
    • + ))} +
    +
    + )} + + {blob ? ( +
    +

    + Blob url:{' '} + + {blob.url} + +

    + {presignedGetError ? ( +

    + Presigned GET URL could not be issued: {presignedGetError} +

    + ) : null} + {presignedGetUrl ? ( +

    + Presigned GET url: + + {presignedGetUrl} + +

    + ) : null} + {blob.url.endsWith('.mp4') ? ( + + ) : null} + + +
    + ) : null} + + {headStatus ? ( +

    + {headStatus} +

    + ) : null} + {headError ? ( +

    + Presigned HEAD failed: {headError} +

    + ) : null} + + {deleteStatus ? ( +

    + {deleteStatus} +

    + ) : null} + {deleteError ? ( +

    + Presigned delete failed: {deleteError} +

    + ) : null} + + ); +} diff --git a/test/next/src/app/vercel/blob/handle-blob-upload-presigned.ts b/test/next/src/app/vercel/blob/handle-blob-upload-presigned.ts new file mode 100644 index 000000000..f72754e7f --- /dev/null +++ b/test/next/src/app/vercel/blob/handle-blob-upload-presigned.ts @@ -0,0 +1,100 @@ +import { issueSignedToken } from '@vercel/blob'; +import { + type HandleUploadPresignedBody, + handleUploadPresigned, +} from '@vercel/blob/client'; +import { NextResponse } from 'next/server'; +import { validateUploadToken } from './validate-upload-token'; + +async function auth( + request: Request, + _pathname: string, +): Promise<{ user: { id: string } | null; userRole: 'hobby' | 'pro' }> { + if (!validateUploadToken(request)) { + return { + userRole: 'hobby', + user: null, + }; + } + + // faking an async auth call + await Promise.resolve(); + + return { + user: { id: '12345' }, + userRole: 'pro', + }; +} + +// Upload button + +// { delegationToken } + +// Presigned URLs include signed `vercel-blob-*` constraint params + delegation + signature. + +const getCachedToken = async () => { + // fake: get from cache if it's there + return await issueSignedToken({ + pathname: '*', + allowedContentTypes: ['image/png', 'image/jpeg', 'video/mp4'], + maximumSizeInBytes: 1024 * 1024 * 10, // 10MB, + operations: ['put'], + validUntil: Date.now() + 60 * 60 * 1000, // 1 hour + }); +}; + +export async function handleUploadPresignedHandler( + request: Request, +): Promise { + const body = (await request.json()) as HandleUploadPresignedBody; + + try { + const jsonResponse = await handleUploadPresigned({ + body, + request, + webhookPublicKey: process.env.BLOB_WEBHOOK_PUBLIC_KEY, + getSignedToken: async (pathname) => { + const { user, userRole } = await auth(request, pathname); + + if (!user) { + throw new Error('Not authorized'); + } + + const token = await getCachedToken(); + + // Allow pro to upload image and video, hobby only image + const allowedContentTypes = + userRole === 'pro' + ? ['image/png', 'image/jpeg', 'video/mp4'] + : ['image/png', 'image/jpeg']; + + // Allow pro to upload up to 10MB, hobby up to 5MB + const maximumSizeInBytes = + userRole === 'pro' ? 1024 * 1024 * 10 : 1024 * 1024 * 5; + + return { + token, + urlOptions: { + allowedContentTypes, + maximumSizeInBytes, + validUntil: Date.now() + 60 * 10 * 1000, // 10 minutes (≤ delegation; may set vercel-blob-valid-until) + addRandomSuffix: true, + allowOverwrite: false, + cacheControlMaxAge: 30 * 24 * 60 * 60, // 30 days + }, + }; + }, + onUploadCompleted: async (body) => { + console.log('onUploadCompleted', body); + }, + }); + + return NextResponse.json(jsonResponse); + } catch (error) { + const message = (error as Error).message; + return NextResponse.json( + { error: message }, + { status: message === 'Not authorized' ? 401 : 400 }, + ); + } +} diff --git a/test/next/src/app/vercel/blob/handle-blob-upload.ts b/test/next/src/app/vercel/blob/handle-blob-upload.ts index 3d5b9f0f0..8ded488a5 100644 --- a/test/next/src/app/vercel/blob/handle-blob-upload.ts +++ b/test/next/src/app/vercel/blob/handle-blob-upload.ts @@ -1,3 +1,4 @@ +import { del, get, head } from '@vercel/blob'; import { type HandleUploadBody, handleUpload } from '@vercel/blob/client'; import { NextResponse } from 'next/server'; import { validateUploadToken } from './validate-upload-token'; diff --git a/test/next/src/app/vercel/blob/page.tsx b/test/next/src/app/vercel/blob/page.tsx index 8f01cefd6..50ce17286 100644 --- a/test/next/src/app/vercel/blob/page.tsx +++ b/test/next/src/app/vercel/blob/page.tsx @@ -50,6 +50,12 @@ export default function Home(): React.JSX.Element { Client Upload (multipart) →{' '} /app/client-multipart
  • +
  • + Client Upload (Presigned Upload) →{' '} + + /app/presigned-upload/client + +
  • List blob items → /app/list
  • From 50745a0ed78efc6b47d86c8ec1d680e2ed9ecb74 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 18 May 2026 11:12:53 -0700 Subject: [PATCH 11/15] up --- .../blob/src/vercel-oidc-token.node.test.ts | 34 +++++++++++++ packages/blob/src/vercel-oidc-token.ts | 51 +++++++------------ 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/blob/src/vercel-oidc-token.node.test.ts b/packages/blob/src/vercel-oidc-token.node.test.ts index 1ba142449..79a53157b 100644 --- a/packages/blob/src/vercel-oidc-token.node.test.ts +++ b/packages/blob/src/vercel-oidc-token.node.test.ts @@ -2,10 +2,14 @@ import { getVercelOidcToken } from './vercel-oidc-token'; describe('vercel-oidc-token', () => { const OLD_ENV = process.env; + const REQUEST_CONTEXT_SYMBOL = Symbol.for('@vercel/request-context'); beforeEach(() => { process.env = { ...OLD_ENV }; delete process.env.VERCEL_OIDC_TOKEN; + delete (globalThis as typeof globalThis & Record)[ + REQUEST_CONTEXT_SYMBOL + ]; }); afterAll(() => { @@ -21,4 +25,34 @@ describe('vercel-oidc-token', () => { delete process.env.VERCEL_OIDC_TOKEN; expect(getVercelOidcToken()).toBeUndefined(); }); + + it('getVercelOidcToken prioritizes request context headers over env', () => { + process.env.VERCEL_OIDC_TOKEN = 'jwt-from-env'; + (globalThis as typeof globalThis & Record unknown }>)[ + REQUEST_CONTEXT_SYMBOL + ] = { + get: () => ({ + headers: { + 'x-vercel-oidc-token': 'jwt-from-request-context', + }, + }), + }; + + expect(getVercelOidcToken()).toBe('jwt-from-request-context'); + }); + + it('getVercelOidcToken ignores empty request context header values', () => { + process.env.VERCEL_OIDC_TOKEN = 'jwt-from-env'; + (globalThis as typeof globalThis & Record unknown }>)[ + REQUEST_CONTEXT_SYMBOL + ] = { + get: () => ({ + headers: { + 'x-vercel-oidc-token': ' ', + }, + }), + }; + + expect(getVercelOidcToken()).toBe('jwt-from-env'); + }); }); diff --git a/packages/blob/src/vercel-oidc-token.ts b/packages/blob/src/vercel-oidc-token.ts index f31e85ec7..e30567fc6 100644 --- a/packages/blob/src/vercel-oidc-token.ts +++ b/packages/blob/src/vercel-oidc-token.ts @@ -1,16 +1,5 @@ -import { createRequire } from 'node:module'; -import { isNodeProcess } from 'is-node-process'; - -type MutateResponseHeadersBeforeFlushHandler = (headers: Headers) => void; - type Context = { - waitUntil?: (promise: Promise) => void; - // TODO change this to __unstable_ - mutateResponseHeadersBeforeFlush?: ( - callback: MutateResponseHeadersBeforeFlushHandler, - ) => void; - headers?: Record; - url?: string; + headers?: Record; }; const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); @@ -23,27 +12,25 @@ const getContext = (): Context => { return fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.() ?? {}; }; +function readEnv(name: string): string | undefined { + try { + const value = process.env[name]; + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : undefined; + } catch { + return undefined; + } +} + /** - * Gets the current OIDC token from the request context or the environment variable. - * - * Do not cache this value, as it is subject to change in production! - * - * This function is used to retrieve the OIDC token from the request context or the environment variable. - * It checks for the `x-vercel-oidc-token` header in the request context and falls back to the `VERCEL_OIDC_TOKEN` environment variable if the header is not present. - * - * @returns {string} The OIDC token, or undefined if not found - * - * @example - * - * ```js - * // Using the OIDC token - * const token = getVercelOidcToken(); - * console.log('OIDC Token:', token); - * ``` + * Gets the current OIDC token from request context headers or environment. */ export function getVercelOidcToken(): string | undefined { - return ( - getContext().headers?.['x-vercel-oidc-token'] ?? - process.env.VERCEL_OIDC_TOKEN - ); + const tokenFromContext = getContext().headers?.['x-vercel-oidc-token']; + if (typeof tokenFromContext === 'string' && tokenFromContext.trim() !== '') { + return tokenFromContext.trim(); + } + + return readEnv('VERCEL_OIDC_TOKEN'); } From 982fa1b4edb0220db979266a0d2f9fe444a847a9 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 18 May 2026 12:01:55 -0700 Subject: [PATCH 12/15] rm dead code --- packages/blob/src/signed-token.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/blob/src/signed-token.ts b/packages/blob/src/signed-token.ts index 2840e7f0c..9d5bfd6ec 100644 --- a/packages/blob/src/signed-token.ts +++ b/packages/blob/src/signed-token.ts @@ -192,27 +192,6 @@ function tryDecodePayload( } } -/** - * Builds a blob object URL for `pathname` and store from `delegationToken` for use with - * {@link presignUrl} for `GET` / `HEAD` only (to `*.public|*.private.blob.vercel-storage.com`). - */ -export function publicBlobObjectUrl( - access: 'public' | 'private', - objectPathname: string, - delegationToken: string, -): string { - const scope = tryDecodePayload(delegationToken); - if (!scope) { - throw new BlobError('Invalid or unreadable `delegationToken` payload.'); - } - const storeId = normalizeStoreId(scope.storeId); - const encodedPath = objectPathname - .split('/') - .map((segment) => encodeURIComponent(segment)) - .join('/'); - return `https://${storeId}.${access}.blob.vercel-storage.com/${encodedPath}`; -} - /** * @internal */ From cbd969ad1808bc75d9bba70c71ef9f1dfab3656f Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 18 May 2026 12:06:18 -0700 Subject: [PATCH 13/15] throw on manual token --- packages/blob/src/helpers.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index e8641495d..1506b94db 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -179,12 +179,6 @@ export function parseStoreIdFromReadWriteToken(token: string): string { return storeId; } -function normalizeDelegationStoreId(storeId: string): string { - return storeId.startsWith('store_') - ? storeId.slice('store_'.length) - : storeId; -} - function base64UrlDecodeDelegationSegment(segment: string): string { let base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); const padding = 4 - (base64.length % 4); @@ -231,7 +225,7 @@ export function parseStoreIdFromDelegationToken( throw new BlobError('Delegation token payload is missing `storeId`.'); } - return normalizeDelegationStoreId(parsed.storeId); + return normalizeStoreId(parsed.storeId); } export function parseStoreIdFromPresignedUrl( @@ -275,7 +269,8 @@ export function resolveBlobAuth( return { kind: 'readWrite', token: options.token, storeId }; } - const oidcToken = options?.oidcToken?.trim() || getVercelOidcToken(); + const manualOidcToken = options?.oidcToken?.trim(); + const oidcToken = manualOidcToken || getVercelOidcToken(); if (oidcToken) { // Try to get storeId from the supplied options const manualStoreId = options?.storeId?.trim(); @@ -296,6 +291,14 @@ export function resolveBlobAuth( storeId: normalizeStoreId(blobStoreId), }; } + + // User passed an OIDC token explicitly, but we couldn't resolve the storeId + // So throw an error. If the oidcToken is from the environment, fall back to rw token + if (manualOidcToken) { + throw new BlobError( + 'oidcToken was passed, but no storeId was found. Pass a `storeId` option or set `BLOB_STORE_ID` to use OIDC auth', + ); + } } const readWrite = readEnv('BLOB_READ_WRITE_TOKEN'); From 4f5fbbe4a293df4106a35591128ce1a8ef2b15cf Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 18 May 2026 12:08:13 -0700 Subject: [PATCH 14/15] up --- packages/blob/src/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 2b6a2dea2..7cff447e7 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -297,7 +297,7 @@ export const upload = createPutMethod({ // @ts-expect-error -- Runtime check for DX. options.addRandomSuffix !== undefined || // @ts-expect-error -- Runtime check for DX. - options.createPutExtraChecks !== undefined || + options.allowOverwrite !== undefined || // @ts-expect-error -- Runtime check for DX. options.cacheControlMaxAge !== undefined || // @ts-expect-error -- Runtime check for DX. @@ -358,7 +358,7 @@ export const uploadPresigned = createPutMethod({ options.ifMatch !== undefined ) { throw new BlobError( - "client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating client tokens.", + "client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating presigned URLs.", ); } }, From bdea9c393197d812a2dabe445c442c2e8c667aa4 Mon Sep 17 00:00:00 2001 From: Elliot Dauber Date: Mon, 18 May 2026 12:33:42 -0700 Subject: [PATCH 15/15] up --- packages/blob/src/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index 7cff447e7..b84d8d096 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -351,14 +351,14 @@ export const uploadPresigned = createPutMethod({ // @ts-expect-error -- Runtime check for DX. options.addRandomSuffix !== undefined || // @ts-expect-error -- Runtime check for DX. - options.createPutExtraChecks !== undefined || + options.allowOverwrite !== undefined || // @ts-expect-error -- Runtime check for DX. options.cacheControlMaxAge !== undefined || // @ts-expect-error -- Runtime check for DX. options.ifMatch !== undefined ) { throw new BlobError( - "client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating presigned URLs.", + "client/`uploadPresigned` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating presigned URLs.", ); } },