Skip to content

Commit 0c20096

Browse files
authored
feat(api)!: add support for preflight requests to use presigned S3 urls (#18)
* wip * update deps * add hash options endpoint * create presigned requests * wip * refactor(api): update authorizer and user info function handling - Renamed `tokenAuthorizerFunction` to `authorizerFunction` for clarity. - Introduced `userInfoFunction` prop to allow custom user info function. - Enabled the addition of a `GET /v2/user` endpoint with proper documentation. - Updated `LambdaFunctions` class to handle custom authorizer and user info functions, improving flexibility in function management. * fix(api): update artifact integration paths to use teamId instead of slug - Changed API integration paths in getArtifact, headArtifact, and putArtifact functions to replace 'slug' with 'teamId'. - Updated request parameters accordingly to ensure proper handling of teamId in API requests. - This change improves the clarity and functionality of artifact management by aligning with the new team-based structure. * refactor(preflight): replace JWT team extraction with authorizer teamId and improve error handling - Updated the handler to use `teamId` from the request context instead of extracting it from the JWT token. - Enhanced error responses to return JSON formatted messages for missing headers and invalid request methods. - Adjusted S3 object keys and presigned URL generation to utilize `teamId`, ensuring consistency with the new authorization structure. * feat(infra): add AWS Lambda authorizer and user info functions - Introduced two new Lambda functions: `aryaAuthorizer` for token-based authorization and `getUserInfo` for fetching user data. - Updated `package.json` and `pnpm-lock.yaml` to include `@types/aws-lambda` as a dev dependency. - Enhanced the TurboRemoteCacheStack to integrate the new Lambda functions, improving API security and user management. * remove debug logs * add default team for token auth
1 parent ecc10bf commit 0c20096

File tree

14 files changed

+526
-123
lines changed

14 files changed

+526
-123
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { APIGatewayTokenAuthorizerHandler } from "aws-lambda";
2+
3+
export const handler: APIGatewayTokenAuthorizerHandler = async (event) => {
4+
let token = event.authorizationToken;
5+
if (token.startsWith('Bearer ')) {
6+
token = token.substring(7);
7+
}
8+
9+
const methodArnParts = event.methodArn.split(':');
10+
const region = methodArnParts[3];
11+
const accountId = methodArnParts[4];
12+
const apiParts = methodArnParts[5].split('/');
13+
const apiId = apiParts[0];
14+
const stage = apiParts[1];
15+
16+
const response = await fetch('https://auth.arya.sh/turborepo/user', {
17+
headers: {
18+
Authorization: `Bearer ${token}`,
19+
},
20+
});
21+
22+
if (!response.ok) {
23+
throw new Error('Unauthorized');
24+
}
25+
26+
const userData = await response.json();
27+
28+
return {
29+
principalId: 'user',
30+
policyDocument: {
31+
Version: '2012-10-17',
32+
Statement: [{
33+
Action: 'execute-api:Invoke',
34+
Effect: 'Allow',
35+
Resource: `arn:aws:execute-api:${region}:${accountId}:${apiId}/${stage}/*/*`
36+
}]
37+
},
38+
context: {
39+
userId: userData.user.id,
40+
teamId: userData.team.id,
41+
teamSlug: userData.team.slug,
42+
},
43+
};
44+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { APIGatewayProxyHandler } from 'aws-lambda';
2+
3+
export const handler: APIGatewayProxyHandler = async (event) => {
4+
const authHeader = event.headers.Authorization;
5+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
6+
return {
7+
statusCode: 401,
8+
body: JSON.stringify({ error: 'Unauthorized' }),
9+
};
10+
}
11+
12+
const token = authHeader.split(' ')[1];
13+
14+
const response = await fetch('https://auth.arya.sh/turborepo/user', {
15+
headers: {
16+
Authorization: `Bearer ${token}`,
17+
},
18+
});
19+
20+
if (!response.ok) {
21+
return {
22+
statusCode: 401,
23+
body: JSON.stringify({ error: 'Unauthorized' }),
24+
};
25+
}
26+
27+
const user = await response.json();
28+
29+
return {
30+
statusCode: 200,
31+
body: JSON.stringify(user),
32+
};
33+
};

apps/infrastructure/lib/turbo-remote-cache-stack.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as acm from 'aws-cdk-lib/aws-certificatemanager';
55
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
66
import * as dotenv from 'dotenv';
77
import * as path from 'path';
8+
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
89

910
export class TurboRemoteCacheStack extends cdk.Stack {
1011
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
@@ -16,6 +17,18 @@ export class TurboRemoteCacheStack extends cdk.Stack {
1617
throw new Error('TURBO_TOKEN is not set');
1718
}
1819

20+
const aryaAuthorizer = new NodejsFunction(this, 'AryaAuthorizer', {
21+
runtime: cdk.aws_lambda.Runtime.NODEJS_20_X,
22+
handler: 'handler',
23+
entry: path.join(__dirname, '..', 'lambda', 'arya-authorizer', 'index.ts'),
24+
});
25+
26+
const userInfo = new NodejsFunction(this, 'UserInfo', {
27+
runtime: cdk.aws_lambda.Runtime.NODEJS_20_X,
28+
handler: 'handler',
29+
entry: path.join(__dirname, '..', 'lambda', 'get-user-info', 'index.ts'),
30+
});
31+
1932
new TurboRemoteCache(this, 'TurboRemoteCache', {
2033
turboToken: process.env.TURBO_TOKEN,
2134
apiProps: {
@@ -26,6 +39,11 @@ export class TurboRemoteCacheStack extends cdk.Stack {
2639
securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
2740
},
2841
},
42+
authorizerFunction: aryaAuthorizer,
43+
userInfoFunction: userInfo,
44+
lambdaProps: {
45+
memorySize: 1024,
46+
},
2947
artifactsBucketProps: {
3048
bucketName: 'turbo-remote-cache-artifacts',
3149
removalPolicy: cdk.RemovalPolicy.RETAIN,
@@ -36,4 +54,4 @@ export class TurboRemoteCacheStack extends cdk.Stack {
3654
},
3755
});
3856
}
39-
}
57+
}

apps/infrastructure/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"devDependencies": {
1616
"@types/jest": "^29.5.12",
1717
"@types/node": "20.14.9",
18+
"@types/aws-lambda": "^8.10.145",
1819
"aws-cdk": "2.176.0",
1920
"dotenv": "^16.4.5",
2021
"esbuild": "^0.23.1",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { APIGatewayProxyHandler } from 'aws-lambda';
2+
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
3+
import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner';
4+
import { Hash } from '@aws-sdk/hash-node';
5+
import { parseUrl } from '@aws-sdk/url-parser';
6+
import { HttpRequest } from '@aws-sdk/protocol-http';
7+
import { formatUrl } from '@aws-sdk/util-format-url';
8+
9+
/**
10+
* Preflight request for the artifact resource
11+
*
12+
* @param event lambda event
13+
*/
14+
export const handler: APIGatewayProxyHandler = async (event) => {
15+
const requestMethod = event.headers['access-control-request-method'];
16+
const signableHeaders = event.headers['access-control-request-headers']?.split(',').map(header => header.trim()).filter(header => header !== 'Authorization');
17+
const requestHeaders = signableHeaders?.reduce((acc: Record<string, string>, header) => {
18+
if (event.headers[header]) {
19+
acc[header] = event.headers[header];
20+
}
21+
return acc;
22+
}, {});
23+
24+
if (!requestMethod) {
25+
return {
26+
statusCode: 400,
27+
body: JSON.stringify({ error: 'Missing required headers' }),
28+
};
29+
}
30+
31+
const teamId = event.requestContext.authorizer?.teamId;
32+
33+
if (!teamId) {
34+
return {
35+
statusCode: 400,
36+
body: JSON.stringify({ error: 'Missing teamId' }),
37+
};
38+
}
39+
40+
let command: GetObjectCommand | PutObjectCommand | undefined;
41+
if (requestMethod === 'GET') {
42+
command = new GetObjectCommand({
43+
Bucket: process.env.ARTIFACTS_BUCKET,
44+
Key: `${teamId}/${event.pathParameters?.hash}`,
45+
});
46+
} else if (requestMethod === 'PUT') {
47+
command = new PutObjectCommand({
48+
Bucket: process.env.ARTIFACTS_BUCKET,
49+
Key: `${teamId}/${event.pathParameters?.hash}`,
50+
ContentType: event.headers['content-type'],
51+
});
52+
}
53+
54+
if (!command) {
55+
return {
56+
statusCode: 400,
57+
body: JSON.stringify({ error: 'Invalid request method' }),
58+
};
59+
}
60+
61+
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) {
62+
throw new Error('AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION must be set');
63+
}
64+
65+
const presigner = new S3RequestPresigner({
66+
credentials: {
67+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
68+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
69+
sessionToken: process.env.AWS_SESSION_TOKEN,
70+
},
71+
region: process.env.AWS_REGION,
72+
sha256: Hash.bind(null, 'sha256'),
73+
});
74+
75+
const url = parseUrl(`https://${process.env.ARTIFACTS_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${teamId}/${event.pathParameters?.hash}`);
76+
const request = new HttpRequest({
77+
...url,
78+
method: requestMethod,
79+
query: {
80+
teamId: teamId,
81+
},
82+
headers: requestHeaders,
83+
});
84+
const presignedUrl = await presigner.presign(request, {
85+
expiresIn: 60 * 60 * 24,
86+
signableHeaders: signableHeaders ? new Set(signableHeaders) : undefined,
87+
});
88+
const formattedUrl = formatUrl(presignedUrl);
89+
90+
// turborepo cli appends teamId to the url, so we need to remove it to ensure valid signature
91+
const responseUrl = new URL(formattedUrl);
92+
responseUrl.searchParams.delete('teamId');
93+
94+
return {
95+
statusCode: 200,
96+
headers: {
97+
location: responseUrl.href,
98+
allow_authorization_header: false,
99+
},
100+
body: '',
101+
};
102+
};

packages/construct/lambda/src/token-authorizer/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export const handler: APIGatewayTokenAuthorizerHandler = async (event) => {
2424
Effect: 'Allow',
2525
Resource: `arn:aws:execute-api:${region}:${accountId}:${apiId}/${stage}/*/*`
2626
}]
27+
},
28+
context: {
29+
teamId: 'team_turbo_default'
2730
}
2831
};
2932
} else {

packages/construct/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,12 @@
4444
"dependencies": {
4545
"@aws-sdk/client-dynamodb": "^3.645.0",
4646
"@aws-sdk/client-s3": "^3.645.0",
47+
"@aws-sdk/hash-node": "^3.374.0",
48+
"@aws-sdk/protocol-http": "^3.374.0",
4749
"@aws-sdk/s3-request-presigner": "^3.645.0",
48-
"@aws-sdk/util-dynamodb": "^3.645.0"
50+
"@aws-sdk/url-parser": "^3.374.0",
51+
"@aws-sdk/util-dynamodb": "^3.645.0",
52+
"@aws-sdk/util-format-url": "^3.696.0"
4953
},
5054
"devDependencies": {
5155
"@types/aws-lambda": "^8.10.145",

packages/construct/src/api.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ export class APIGateway extends Construct {
2222
const logGroup = logs.LogGroup.fromLogGroupName(this, 'LogGroup', '/aws/apigateway/turbo-remote-cache-api');
2323

2424
let tokenAuthorizer: apigateway.TokenAuthorizer | undefined;
25-
if (props.lambdaFunctions.tokenAuthorizerFunction) {
25+
if (props.lambdaFunctions.authorizerFunction) {
2626
tokenAuthorizer = new apigateway.TokenAuthorizer(this, 'TokenAuthorizer', {
27-
handler: props.lambdaFunctions.tokenAuthorizerFunction,
27+
handler: props.lambdaFunctions.authorizerFunction,
2828
resultsCacheTtl: cdk.Duration.minutes(60),
2929
identitySource: 'method.request.header.Authorization',
3030
});
@@ -144,6 +144,11 @@ export class APIGateway extends Construct {
144144

145145
const hashResource = artifactsResource.addResource('{hash}');
146146

147+
hashResource.addMethod('OPTIONS', new apigateway.LambdaIntegration(props.lambdaFunctions.preflightArtifactFunction), {
148+
operationName: 'preflightArtifact',
149+
methodResponses: [{ statusCode: '200' }],
150+
});
151+
147152
// GET /v8/artifacts/{hash}
148153
getArtifactIntegration(this, {
149154
artifactsBucket: props.artifactsBucket,
@@ -257,30 +262,30 @@ export class APIGateway extends Construct {
257262
// restApiId: api.restApiId,
258263
// });
259264

260-
// const v2Resource = api.root.addResource('v2');
265+
const v2Resource = api.root.addResource('v2');
261266

262-
// const userResource = v2Resource.addResource('user');
267+
const userResource = v2Resource.addResource('user');
263268

264-
// // GET /v2/user
265-
// userResource.addMethod('GET', new apigateway.LambdaIntegration(props.lambdaFunctions.getUserInfoFunction), {
266-
// operationName: 'getUserInfo',
267-
// });
269+
// GET /v2/user
270+
userResource.addMethod('GET', new apigateway.LambdaIntegration(props.lambdaFunctions.getUserInfoFunction), {
271+
operationName: 'getUserInfo',
272+
});
268273

269-
// const getUserInfoDocumentationPart = {
270-
// description: 'Retrieves information about the authenticated user.',
271-
// summary: 'Get user info',
272-
// tags: ['login'],
273-
// }
274+
const getUserInfoDocumentationPart = {
275+
description: 'Retrieves information about the authenticated user.',
276+
summary: 'Get user info',
277+
tags: ['login'],
278+
}
274279

275-
// new apigateway.CfnDocumentationPart(this, 'TurborepoUserInfoDocumentationPart', {
276-
// location: {
277-
// type: 'METHOD',
278-
// method: 'GET',
279-
// path: '/v2/user',
280-
// },
281-
// properties: JSON.stringify(getUserInfoDocumentationPart),
282-
// restApiId: api.restApiId,
283-
// });
280+
new apigateway.CfnDocumentationPart(this, 'TurborepoUserInfoDocumentationPart', {
281+
location: {
282+
type: 'METHOD',
283+
method: 'GET',
284+
path: '/v2/user',
285+
},
286+
properties: JSON.stringify(getUserInfoDocumentationPart),
287+
restApiId: api.restApiId,
288+
});
284289

285290
// cloudfront domain name for CNAME
286291
new cdk.CfnOutput(this, 'CloudfrontAliasDomainName', {

packages/construct/src/get-artifact.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function getArtifactIntegration(scope: Construct, props: GetArtifactInteg
1414
const getIntegration = new apigateway.AwsIntegration({
1515
service: 's3',
1616
integrationHttpMethod: 'GET',
17-
path: `${props.artifactsBucket.bucketName}/{slug}/{hash}`,
17+
path: `${props.artifactsBucket.bucketName}/{teamId}/{hash}`,
1818
options: {
1919
credentialsRole: props.s3Credentials,
2020
integrationResponses: [
@@ -53,7 +53,7 @@ export function getArtifactIntegration(scope: Construct, props: GetArtifactInteg
5353
],
5454
requestParameters: {
5555
'integration.request.path.hash': 'method.request.path.hash',
56-
'integration.request.path.slug': 'method.request.querystring.slug',
56+
'integration.request.path.teamId': 'method.request.querystring.teamId',
5757
},
5858
},
5959
});
@@ -62,7 +62,7 @@ export function getArtifactIntegration(scope: Construct, props: GetArtifactInteg
6262
operationName: 'downloadArtifact',
6363
requestParameters: {
6464
'method.request.path.hash': true,
65-
'method.request.querystring.slug': true,
65+
'method.request.querystring.teamId': true,
6666
},
6767
methodResponses: [
6868
{

packages/construct/src/head-artifact.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function headArtifactIntegration(scope: Construct, props: HeadArtifactInt
1414
const headIntegration = new apigateway.AwsIntegration({
1515
service: 's3',
1616
integrationHttpMethod: 'HEAD',
17-
path: `${props.artifactsBucket.bucketName}/{slug}/{hash}`,
17+
path: `${props.artifactsBucket.bucketName}/{teamId}/{hash}`,
1818
options: {
1919
credentialsRole: props.s3Credentials,
2020
integrationResponses: [
@@ -36,7 +36,7 @@ export function headArtifactIntegration(scope: Construct, props: HeadArtifactInt
3636
],
3737
requestParameters: {
3838
'integration.request.path.hash': 'method.request.path.hash',
39-
'integration.request.path.slug': 'method.request.querystring.slug',
39+
'integration.request.path.teamId': 'method.request.querystring.teamId',
4040
},
4141
},
4242
});
@@ -45,7 +45,7 @@ export function headArtifactIntegration(scope: Construct, props: HeadArtifactInt
4545
operationName: 'artifactExists',
4646
requestParameters: {
4747
'method.request.path.hash': true,
48-
'method.request.querystring.slug': true,
48+
'method.request.querystring.teamId': true,
4949
},
5050
methodResponses: [
5151
{

0 commit comments

Comments
 (0)