Compare commits

...

7 Commits

Author SHA1 Message Date
Marc Greenstock
62574bbebf Merge branch 'functions/add_oidc_token_functions' of https://github.com/vercel/vercel into functions/add_oidc_token_functions 2024-06-06 16:38:24 +02:00
Marc Greenstock
1b9c4e2618 switch back to vitest 2024-06-06 16:38:20 +02:00
Marc Greenstock
1327973b55 Update packages/functions/package.json
Co-authored-by: Sean Massa <EndangeredMassa@gmail.com>
2024-06-06 16:19:13 +02:00
Marc Greenstock
5c3727ec65 Fix ts issues 2024-06-06 10:30:17 +02:00
Marc Greenstock
590180bdae Update package.json 2024-06-06 10:30:00 +02:00
Marc Greenstock
e5f3f45d00 Create flat-peaches-begin.md 2024-06-06 10:29:52 +02:00
Marc Greenstock
851230ef4f Add OIDC utils to @vercel/functions 2024-06-06 10:09:25 +02:00
16 changed files with 1989 additions and 73 deletions

View File

@@ -0,0 +1,5 @@
---
'@vercel/functions': minor
---
Added OIDC Token utility functions

View File

@@ -0,0 +1,62 @@
<p align="center">
<a href="https://vercel.com">
<img src="https://assets.vercel.com/image/upload/v1588805858/repositories/vercel/logo.png" height="96">
<h3 align="center">Vercel</h3>
</a>
<p align="center">Develop. Preview. Ship.</p>
</p>
[![Join the community on GitHub Discussions](https://badgen.net/badge/join%20the%20discussion/on%20github/black?icon=github)](https://github.com/vercel/vercel/discussions)
# @vercel/functions
## Usage
### AWS S3 Example
```ts
import * as s3 from '@aws-sdk/client-s3';
import { awsCredentialsProvider } from '@vercel/functions';
const s3Client = new s3.S3Client({
credentials: awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN,
}),
});
export const GET = () => {
const result = await s3Client.send(
new s3.ListObjectsV2Command({
Bucket: process.env.BUCKET_NAME,
})
);
return Response.json({ objects: result.Contents });
};
```
### Azure CosmosDB Example
```ts
import { ClientAssertionCredential } from '@azure/identity';
import { CosmosClient } from '@azure/cosmos';
import { getVercelOidcToken } from '@vercel/functions';
const credentialsProvider = new ClientAssertionCredential(
process.env.AZURE_TENANT_ID,
process.env.AZURE_CLIENT_ID,
async () => getVercelOidcToken()
);
const cosmosClient = new CosmosClient({
endpoint: process.env.COSMOS_DB_ENDPOINT,
aadCredentials: credentialsProvider,
});
export const GET = () => {
const container = cosmosClient
.database(process.env.COSMOS_DB_NAME)
.container(process.env.COSMOS_DB_CONTAINER);
const items = await container.items.query('SELECT * FROM f').fetchAll();
return Response.json({ items: items.resources });
};
```

View File

@@ -1,17 +0,0 @@
/**
* Extends the lifetime of the request handler for the lifetime of the given {@link Promise}
* @see https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/waitUntil
*
* @param promise The promise to wait for.
* @example
*
* ```
* import { waitUntil } from '@vercel/functions';
*
* export function GET(request) {
* waitUntil(fetch('https://vercel.com'));
* return new Response('OK');
* }
* ```
*/
export function waitUntil(promise: Promise<unknown>): void;

View File

@@ -1,16 +0,0 @@
/* global globalThis */
exports.waitUntil = promise => {
if (
promise === null ||
typeof promise !== 'object' ||
typeof promise.then !== 'function'
) {
throw new TypeError(
`waitUntil can only be called with a Promise, got ${typeof promise}`
);
}
const ctx = globalThis[Symbol.for('@vercel/request-context')]?.get?.() ?? {};
ctx.waitUntil?.(promise);
};

View File

@@ -0,0 +1,5 @@
/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@@ -3,8 +3,10 @@
"description": "Runtime functions to be used with your Vercel Functions",
"homepage": "https://vercel.com",
"version": "1.0.2",
"types": "index.d.ts",
"main": "index.js",
"main": "./dist/index.js",
"files": [
"dist"
],
"repository": {
"directory": "packages/functions",
"type": "git",
@@ -14,17 +16,27 @@
"url": "https://github.com/vercel/vercel/issues"
},
"devDependencies": {
"@aws-sdk/client-s3": "3.590.0",
"@aws-sdk/credential-provider-web-identity": "3.587.0",
"@smithy/types": "3.0.0",
"typescript": "5.4.5",
"vitest": "1.3.1"
},
"peerDependencies": {
"@aws-sdk/credential-provider-web-identity": "3.x"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-provider-web-identity": {
"optional": true
}
},
"engines": {
"node": ">= 16"
},
"files": [
"index.d.ts",
"index.js"
],
"scripts": {
"test": "vitest"
"build": "node ../../utils/build.mjs",
"test": "vitest --run",
"type-check": "tsc --noEmit"
},
"license": "Apache-2.0",
"publishConfig": {

View File

@@ -0,0 +1,59 @@
import type { AwsCredentialIdentityProvider } from '@smithy/types';
import type { FromWebTokenInit } from '@aws-sdk/credential-provider-web-identity';
import { getVercelOidcToken } from './getVercelOidcToken';
/**
* The init object for the `awsCredentialsProvider` function.
*/
export type AwsCredentialsProviderInit = Omit<
FromWebTokenInit,
'webIdentityToken'
>;
/**
* Obtains the Vercel OIDC token and creates an AWS credential provider function
* that gets AWS credentials calling STS AssumeRoleWithWebIdentity API.
*
* ```javascript
* import * as s3 from '@aws-sdk/client-s3';
* import { awsCredentialsProvider } from '@vercel/functions';
*
* const s3Client = new s3.S3Client({
* credentials: awsCredentialsProvider({
* // Required. ARN of the role that the caller is assuming.
* roleArn: "arn:aws:iam::1234567890:role/RoleA",
* // Optional. Custom STS client configurations overriding the default ones.
* clientConfig: { region }
* // Optional. Custom STS client middleware plugin to modify the client default behavior.
* // e.g. adding custom headers.
* clientPlugins: [addFooHeadersPlugin],
* // Optional. A function that assumes a role with web identity and returns a promise fulfilled with credentials for
* // the assumed role.
* roleAssumerWithWebIdentity,
* // Optional. An identifier for the assumed role session.
* roleSessionName: "session_123",
* // Optional. The fully qualified host component of the domain name of the identity provider.
* providerId: "graph.facebook.com",
* // Optional. ARNs of the IAM managed policies that you want to use as managed session.
* policyArns: [{arn: "arn:aws:iam::1234567890:policy/SomePolicy"}],
* // Optional. An IAM policy in JSON format that you want to use as an inline session policy.
* policy: "JSON_STRING",
* // Optional. The duration, in seconds, of the role session. Default to 3600.
* durationSeconds: 7200
* }),
* });
* ```
*/
export function awsCredentialsProvider(
init: AwsCredentialsProviderInit
): AwsCredentialIdentityProvider {
return async () => {
const { fromWebToken } = await import(
'@aws-sdk/credential-provider-web-identity'
);
return fromWebToken({
...init,
webIdentityToken: getVercelOidcToken(),
})();
};
}

View File

@@ -0,0 +1,24 @@
/**
* Returns the OIDC token from the request context or the environment variable.
*/
export function getVercelOidcToken(): string {
if (process.env.VERCEL_OIDC_TOKEN) {
return process.env.VERCEL_OIDC_TOKEN;
}
const requestContext =
globalThis[
Symbol.for(
'@vercel/request-context'
) as unknown as keyof typeof globalThis
];
const token = requestContext?.get?.()?.headers?.['x-vercel-oidc-token'];
if (!token) {
throw new Error(
`The 'x-vercel-oidc-token' header is missing from the request. Do you have the OIDC option enabled in the Vercel project settings?`
);
}
return token;
}

View File

@@ -0,0 +1,3 @@
export * from './awsCredentialsProvider';
export * from './getVercelOidcToken';
export * from './waitUntil';

View File

@@ -0,0 +1,35 @@
/**
* Extends the lifetime of the request handler for the lifetime of the given {@link Promise}
* @see https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/waitUntil
*
* @param promise The promise to wait for.
* @example
*
* ```
* import { waitUntil } from '@vercel/functions';
*
* export function GET(request) {
* waitUntil(fetch('https://vercel.com'));
* return new Response('OK');
* }
* ```
*/
export const waitUntil = (promise: Promise<any>) => {
if (
promise === null ||
typeof promise !== 'object' ||
typeof promise.then !== 'function'
) {
throw new TypeError(
`waitUntil can only be called with a Promise, got ${typeof promise}`
);
}
const ctx =
globalThis[
Symbol.for(
'@vercel/request-context'
) as unknown as keyof typeof globalThis
]?.get?.() ?? {};
ctx.waitUntil?.(promise);
};

View File

@@ -0,0 +1,48 @@
import { expect, it, vi } from 'vitest';
import { awsCredentialsProvider } from '../src';
const getVercelOidcTokenMock = vi.fn().mockReturnValue('token');
vi.mock('../src/getVercelOidcToken', () => {
return {
getVercelOidcToken: () => getVercelOidcTokenMock(),
};
});
const fromWebTokenExectionMock = vi.fn();
const fromWebTokenMock = vi.fn().mockReturnValue(fromWebTokenExectionMock);
vi.mock('@aws-sdk/credential-provider-web-identity', () => {
return {
fromWebToken: (...args: any[]) => fromWebTokenMock(...args),
};
});
it('returns a function', () => {
expect(awsCredentialsProvider({ roleArn: 'roleArn' })).toBeInstanceOf(
Function
);
});
it('calls fromWebToken with the correct arguments', async () => {
const init = {
roleArn: 'roleArn',
roleSessionName: 'roleSessionName',
};
const fn = awsCredentialsProvider(init);
await fn();
expect(fromWebTokenMock).toHaveBeenCalledWith({
...init,
webIdentityToken: getVercelOidcTokenMock(),
});
});
it('calls the function returned by fromWebToken', async () => {
const init = {
roleArn: 'roleArn',
roleSessionName: 'roleSessionName',
};
const fn = awsCredentialsProvider(init);
await fn();
expect(fromWebTokenExectionMock).toHaveBeenCalled();
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { getVercelOidcToken } from '../src';
import { randomUUID } from 'crypto';
describe('when VERCEL_OIDC_TOKEN is present in the environment variables', () => {
const token = randomUUID();
beforeEach(() => {
process.env.VERCEL_OIDC_TOKEN = token;
});
afterEach(() => {
delete process.env.VERCEL_OIDC_TOKEN;
});
it('returns the OIDC token', () => {
expect(getVercelOidcToken()).toEqual(token);
});
});
describe('when loading from the request context', () => {
const token = randomUUID();
beforeEach(() => {
globalThis[
// @ts-ignore
Symbol.for(
'@vercel/request-context'
) as unknown as keyof typeof globalThis
] = {
get: () => ({ headers: { 'x-vercel-oidc-token': token } }),
};
});
afterEach(() => {
delete globalThis[
Symbol.for(
'@vercel/request-context'
) as unknown as keyof typeof globalThis
];
});
it('returns the OIDC token', () => {
expect(getVercelOidcToken()).toEqual(token);
});
});
describe('when neither the environment variables or the request context is present', () => {
it('throws an error', () => {
expect(() => getVercelOidcToken()).toThrow(
/The 'x-vercel-oidc-token' header is missing from the request/
);
});
});

8
packages/functions/test/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"sourceMap": true,
"types": ["node"]
},
"extends": "../tsconfig.json",
"include": ["*.test.ts"]
}

View File

@@ -1,8 +1,6 @@
/* global globalThis */
import { expect, test, vi } from 'vitest';
import { vi, expect, test } from 'vitest';
import { waitUntil } from '.';
import { waitUntil } from '../src';
test.each([
{},
@@ -16,8 +14,8 @@ test.each([
[],
'▲',
])('waitUntil throws when called with %s', input => {
expect(() => waitUntil(input)).toThrow(TypeError);
expect(() => waitUntil(input)).toThrow(
expect(() => waitUntil(input as Promise<any>)).toThrow(TypeError);
expect(() => waitUntil(input as Promise<any>)).toThrow(
`waitUntil can only be called with a Promise, got ${typeof input}`
);
});
@@ -26,7 +24,12 @@ test.each([null, undefined, {}])(
'waitUntil does not throw an error when context is %s',
input => {
const promise = Promise.resolve();
globalThis[Symbol.for('@vercel/request-context')] = input;
globalThis[
// @ts-ignore
Symbol.for(
'@vercel/request-context'
) as unknown as keyof typeof globalThis
] = input;
expect(() => waitUntil(promise)).not.toThrow();
}
);
@@ -34,7 +37,10 @@ test.each([null, undefined, {}])(
test('waitUntil calls ctx.waitUntil when available', async () => {
const promise = Promise.resolve();
const waitUntilMock = vi.fn().mockReturnValue(promise);
globalThis[Symbol.for('@vercel/request-context')] = {
globalThis[
// @ts-ignore
Symbol.for('@vercel/request-context') as unknown as keyof typeof globalThis
] = {
get: () => ({ waitUntil: waitUntilMock }),
};
waitUntil(promise);

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"outDir": "./dist"
},
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

1671
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff