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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blob-s3-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vercel/blob': minor
---

Add `@vercel/blob/s3` with `blobS3Credentials()`, a credentials provider for the AWS S3 SDK. It exchanges the Vercel OIDC token (`VERCEL_OIDC_TOKEN` + `BLOB_STORE_ID`, or the `oidcToken`/`storeId` options) for temporary S3-compatible credentials via `POST /blob/s3-credentials`, usable against the Blob S3-compatible endpoint (`https://public.blob.vercel-storage.com` or `https://private.blob.vercel-storage.com`, bucket = store id). Also exports the one-shot `issueBlobS3Credentials()`.
21 changes: 21 additions & 0 deletions packages/blob/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ We have examples on the vercel.com documentation, there are two ways to upload f
1. [Server uploads](https://vercel.com/docs/vercel-blob/server-upload): This is the most common way to upload files. The file is first sent to your server and then to Vercel Blob. It's straightforward to implement, but you are limited to the request body your server can handle. Which in case of a Vercel-hosted website is 4.5 MB. **This means you can't upload files larger than 4.5 MB on Vercel when using this method.**
2. [Client uploads](https://vercel.com/docs/vercel-blob/client-upload): This is a more advanced solution for when you need to upload larger files. The file is securely sent directly from the client (a browser for example) to Vercel Blob. This requires a bit more work to implement, but it allows you to upload files up to 5 TB.

## Using the AWS S3 SDK

Vercel Blob exposes an S3-compatible API. Point any S3 client at `https://public.blob.vercel-storage.com` (or `https://private.blob.vercel-storage.com` for private stores) and use your store id as the bucket. Credentials come from Vercel OIDC: `@vercel/blob/s3` exchanges the OIDC token of your deployment (`VERCEL_OIDC_TOKEN` + `BLOB_STORE_ID`) for temporary S3 credentials the SDK refreshes on its own.

```ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { blobS3Credentials } from '@vercel/blob/s3';

const s3 = new S3Client({
endpoint: 'https://public.blob.vercel-storage.com',
region: 'auto',
credentials: blobS3Credentials(),
});

await s3.send(
new PutObjectCommand({ Bucket: '<store id>', Key: 'hello.txt', Body: 'hi' }),
);
```

Use `issueBlobS3Credentials()` for a single set of credentials (for example to hand them to a non-JavaScript S3 client). Read-write tokens cannot be used as S3 credentials.

## Releasing

Make sure to include a changeset in your PR. You can do this by running:
Expand Down
7 changes: 7 additions & 0 deletions packages/blob/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
"./client": {
"import": "./dist/client.js",
"require": "./dist/client.cjs"
},
"./s3": {
"import": "./dist/s3.js",
"require": "./dist/s3.cjs"
}
},
"main": "./dist/index.cjs",
Expand All @@ -35,6 +39,9 @@
"*": {
"client": [
"dist/client.d.ts"
],
"s3": [
"dist/s3.d.ts"
]
}
},
Expand Down
109 changes: 109 additions & 0 deletions packages/blob/src/s3.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import undici from 'undici';
import { blobS3Credentials, issueBlobS3Credentials } from './s3';

let oidcTokenFromEnv: string | undefined;
jest.mock('@vercel/oidc', () => {
const actual = jest.requireActual('@vercel/oidc');
return {
...actual,
getVercelOidcToken: () =>
oidcTokenFromEnv
? Promise.resolve(oidcTokenFromEnv)
: Promise.reject(new Error('no token')),
};
});

const OIDC_TOKEN = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.sig';

describe('s3 credentials', () => {
const OLD_ENV = process.env;

beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
process.env = { ...OLD_ENV, BLOB_STORE_ID: 'store_12345fakeStoreId' };
delete process.env.BLOB_READ_WRITE_TOKEN;
oidcTokenFromEnv = OIDC_TOKEN;
});

afterAll(() => {
process.env = OLD_ENV;
});

const apiResponse = {
accessKeyId: 'eyJwYXlsb2FkIjoxfQ.sig',
secretAccessKey: 's3-secret',
expiration: '2030-01-01T00:00:00.000Z',
endpoint: 'https://public.blob.vercel-storage.com',
bucket: '12345fakestoreid',
region: 'auto',
};

function mockApi() {
return jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve(apiResponse),
}),
);
}

it('exchanges the OIDC token for S3-shaped credentials', async () => {
const fetchMock = mockApi();

const credentials = await issueBlobS3Credentials();

expect(credentials).toEqual({
...apiResponse,
expiration: new Date('2030-01-01T00:00:00.000Z'),
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://vercel.com/api/blob/s3-credentials');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({});
const headers = init.headers as Record<string, string>;
expect(headers.authorization).toBe(`Bearer ${OIDC_TOKEN}`);
expect(headers['x-vercel-blob-store-id']).toBe('12345fakeStoreId');
});

it('forwards scope options and returns a provider function', async () => {
const fetchMock = mockApi();
const provider = blobS3Credentials({
operations: ['get', 'get'],
pathname: 'a/b.txt',
durationMs: 60_000,
storeId: 'otherStore',
});
expect(fetchMock).not.toHaveBeenCalled();

await provider();
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body).toMatchObject({ operations: ['get'], pathname: 'a/b.txt' });
expect(body.validUntil).toBeGreaterThan(Date.now());
expect(
(init.headers as Record<string, string>)['x-vercel-blob-store-id'],
).toBe('otherStore');
});

it('requires an OIDC token even when a read-write token is present', async () => {
oidcTokenFromEnv = undefined;
process.env.BLOB_READ_WRITE_TOKEN =
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';
const fetchMock = mockApi();
await expect(issueBlobS3Credentials()).rejects.toThrow(
'requires a Vercel OIDC token',
);
expect(fetchMock).not.toHaveBeenCalled();
});

it('rejects empty operations and bad durations', async () => {
await expect(issueBlobS3Credentials({ operations: [] })).rejects.toThrow(
'`operations` must be a non-empty array',
);
await expect(issueBlobS3Credentials({ durationMs: 0 })).rejects.toThrow(
'`durationMs` must be a positive integer',
);
});
});
114 changes: 114 additions & 0 deletions packages/blob/src/s3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { requestApi } from './api';
import { type BlobCommandOptions, BlobError } from './helpers';
import type { DelegationOperation } from './signed-token';
import { getVercelOidcToken } from './vercel-oidc-token';

/**
* Credentials in the shape the AWS SDK expects (`AwsCredentialIdentity`), so they
* can be passed straight to `new S3Client({ credentials })`.
*/
export interface BlobS3Credentials {
accessKeyId: string;
secretAccessKey: string;
/** When these credentials stop working. The AWS SDK refreshes shortly before. */
expiration: Date;
/** S3 endpoint to configure on the client, e.g. `https://public.blob.vercel-storage.com`. */
endpoint: string;
/** Bucket name to use with this store (its lowercase store id). */
bucket: string;
region: 'auto';
}

export type BlobS3CredentialsOptions = Pick<
BlobCommandOptions,
'oidcToken' | 'storeId' | 'abortSignal'
> & {
/**
* S3 operations the credentials may perform. `get`/`head` cover reads, `put` covers
* uploads (including multipart), `delete` covers deletes. Listing needs `get` on the
* whole store. Defaults to all four.
*/
operations?: DelegationOperation[];
/** Restrict the credentials to a single object pathname. Defaults to `"*"` (the whole store). */
pathname?: string;
/** Lifetime of each issued credential in milliseconds. Defaults to 1 hour, max 7 days. */
durationMs?: number;
};

interface BlobS3CredentialsResponse {
accessKeyId: string;
secretAccessKey: string;
expiration: string;
endpoint: string;
bucket: string;
region: 'auto';
}

/**
* Exchanges the Vercel OIDC token for temporary S3-compatible credentials. Returns a
* provider function the AWS SDK calls on first use and again before expiry.
*
* ```ts
* const s3 = new S3Client({
* endpoint: 'https://public.blob.vercel-storage.com',
* region: 'auto',
* credentials: blobS3Credentials(),
* });
* await s3.send(new PutObjectCommand({ Bucket: '<store id>', Key: 'hello.txt', Body: 'hi' }));
* ```
*
* Requires OIDC: `VERCEL_OIDC_TOKEN` (or the `oidcToken` option) plus `BLOB_STORE_ID`
* (or the `storeId` option). Read-write tokens cannot mint S3 credentials.
*/
export function blobS3Credentials(
options: BlobS3CredentialsOptions = {},
): () => Promise<BlobS3Credentials> {
return () => issueBlobS3Credentials(options);
}

/** One-shot variant of {@link blobS3Credentials}: issues a single credential set. */
export async function issueBlobS3Credentials(
options: BlobS3CredentialsOptions = {},
): Promise<BlobS3Credentials> {
const { operations, pathname, durationMs, oidcToken, ...commandOptions } =
options;
if (operations !== undefined && operations.length === 0) {
throw new BlobError('`operations` must be a non-empty array if provided');
}
if (
durationMs !== undefined &&
(!Number.isInteger(durationMs) || durationMs <= 0)
) {
throw new BlobError('`durationMs` must be a positive integer.');
}

const token = oidcToken?.trim() || (await getVercelOidcToken());
if (!token) {
throw new BlobError(
'`blobS3Credentials` requires a Vercel OIDC token: set the `oidcToken` option or run where `VERCEL_OIDC_TOKEN` is available (see https://vercel.com/docs/oidc).',
);
}

const body: Record<string, unknown> = {};
if (operations !== undefined) {
body.operations = Array.from(new Set(operations));
}
if (pathname !== undefined) {
body.pathname = pathname;
}
if (durationMs !== undefined) {
body.validUntil = Date.now() + durationMs;
}

const response = await requestApi<BlobS3CredentialsResponse>(
'/s3-credentials',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: commandOptions.abortSignal,
},
{ ...commandOptions, oidcToken: token },
);
return { ...response, expiration: new Date(response.expiration) };
}
2 changes: 1 addition & 1 deletion packages/blob/tsup.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts', 'src/client.ts'],
entry: ['src/index.ts', 'src/client.ts', 'src/s3.ts'],
format: ['esm', 'cjs'],
splitting: true,
target: 'es2019',
Expand Down
Loading