Skip to content

Commit 55d3215

Browse files
committed
assume AWS role
1 parent 0f0eab7 commit 55d3215

File tree

5 files changed

+147
-58
lines changed

5 files changed

+147
-58
lines changed

action.yml

+7-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ branding:
44
icon: 'arrow-up-circle'
55
color: 'blue'
66
inputs:
7-
token:
8-
description: A GitHub Token
7+
region:
8+
description: 'The AWS Region to deploy to'
99
required: false
10-
default: ${{ github.token }}
10+
default: 'us-east-1'
11+
role:
12+
description: 'The AWS Role to use for deployment'
13+
required: false
14+
default: ${{ vars.DEPLOYMENT_ROLE }}
1115
runs:
1216
using: 'node20'
1317
main: 'dist/main.js'

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@actions/core": "^1.10.1",
3434
"@actions/github": "^6.0.0",
3535
"@aws-sdk/client-cloudformation": "^3.533.0",
36+
"@aws-sdk/client-sts": "^3.533.0",
3637
"axios": "^1.6.7",
3738
"jose": "^5.2.3"
3839
},
@@ -63,4 +64,4 @@
6364
"webpack-cli": "^4.9.1",
6465
"webpack-node-externals": "^3.0.0"
6566
}
66-
}
67+
}

src/action.ts

+93-53
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
import { debug, notice, getIDToken } from '@actions/core';
1+
import { debug, notice, getIDToken, exportVariable, info } from '@actions/core';
22
import { getInput } from '@actions/core';
3+
import { context } from '@actions/github';
34
import { warn } from 'console';
45
import fs from 'fs';
56
import path from 'path';
6-
import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
7-
// import axios from 'axios';
8-
import * as jose from 'jose';
7+
import { CloudFormationClient, DescribeStacksCommand, Stack } from '@aws-sdk/client-cloudformation';
8+
import {
9+
STSClient,
10+
AssumeRoleWithWebIdentityCommand,
11+
GetCallerIdentityCommand,
12+
} from '@aws-sdk/client-sts';
13+
import packageJson from '../package.json';
14+
import { roleSetupInstructions } from './messages';
915

10-
const { GITHUB_TOKEN } = process.env;
16+
const { GITHUB_TOKEN, GITHUB_REPOSITORY } = process.env;
1117

1218
type ServerlessState = {
1319
service: {
@@ -21,20 +27,18 @@ type ServerlessState = {
2127

2228
export class Action {
2329
async run(): Promise<void> {
24-
debug('Running!');
25-
26-
const token = getInput('token') || GITHUB_TOKEN;
27-
28-
if (!token) {
29-
throw new Error('Missing GitHub Token');
30-
}
30+
const region = getInput('region') || 'us-east-1';
31+
const role = getInput('role');
32+
const [owner, repo] = GITHUB_REPOSITORY?.split('/') || [];
3133

3234
let idToken: string | undefined = undefined;
3335

3436
try {
3537
idToken = await getIDToken();
3638
} catch (e) {
37-
warn('Unable to get ID Token.');
39+
warn(
40+
'Unable to get ID Token. Please ensure the `id-token: write` is enabled in the GitHub Action permissions.',
41+
);
3842
debug(`Error: ${e}`);
3943
}
4044

@@ -43,77 +47,113 @@ export class Action {
4347
return;
4448
}
4549

46-
// console.log(
47-
// '!!! idToken',
48-
// Buffer.from(Buffer.from(idToken, 'utf8').toString('base64'), 'utf8').toString('base64'),
49-
// );
50+
try {
51+
let client = new STSClient({ region });
52+
const assumeResponse = await client.send(
53+
new AssumeRoleWithWebIdentityCommand({
54+
WebIdentityToken: idToken,
55+
RoleArn: role,
56+
RoleSessionName: `${packageJson.name}@${packageJson.version}:${context.runId}`,
57+
}),
58+
);
59+
60+
exportVariable('AWS_DEFAULT_REGION', region);
61+
exportVariable('AWS_ACCESS_KEY_ID', assumeResponse.Credentials?.AccessKeyId);
62+
exportVariable('AWS_SECRET_ACCESS_KEY', assumeResponse.Credentials?.SecretAccessKey);
63+
exportVariable('AWS_SESSION_TOKEN', assumeResponse.Credentials?.SessionToken);
5064

51-
const JWKS = jose.createRemoteJWKSet(
52-
new URL('https://token.actions.githubusercontent.com/.well-known/jwks'),
53-
);
65+
client = new STSClient({
66+
region,
67+
credentials: {
68+
accessKeyId: assumeResponse.Credentials!.AccessKeyId!,
69+
secretAccessKey: assumeResponse.Credentials!.SecretAccessKey!,
70+
sessionToken: assumeResponse.Credentials!.SessionToken!,
71+
},
72+
});
5473

55-
const { payload, protectedHeader } = await jose.jwtVerify(idToken, JWKS, {
56-
issuer: 'https://token.actions.githubusercontent.com',
57-
// audience: 'urn:example:audience',
58-
});
74+
const callerIdentity = await client.send(new GetCallerIdentityCommand({}));
5975

60-
console.log('!!! payload', payload);
61-
console.log('!!! protectedHeader', protectedHeader);
76+
info(
77+
`Assumed ${role}: ${callerIdentity.Arn} (Credential expiration at ${assumeResponse.Credentials?.Expiration})`,
78+
);
79+
} catch (e) {
80+
if (!(e instanceof Error)) {
81+
throw e;
82+
}
83+
debug(`Error: ${e}`);
84+
throw new Error(`Unable to assume role: ${e.message}\n${roleSetupInstructions(owner, repo)}`);
85+
}
6286
}
6387

6488
async post(): Promise<void> {
65-
debug('Post Running!');
66-
6789
const token = getInput('token') || GITHUB_TOKEN;
6890

6991
if (!token) {
7092
throw new Error('Missing GitHub Token');
7193
}
7294

73-
let serverlessState: ServerlessState | undefined = undefined;
95+
const httpApiUrl = await this.httpApiUrl;
7496

75-
try {
76-
serverlessState = JSON.parse(
77-
fs.readFileSync(path.join('.serverless', 'serverless-state.json'), 'utf8'),
78-
);
79-
} catch (e) {
80-
warn('No serverless state found.');
81-
debug(`Error: ${e}`);
82-
return;
83-
}
97+
notice(`HTTP API URL: ${httpApiUrl}`);
98+
}
99+
100+
get serverlessState(): Promise<ServerlessState | undefined> {
101+
return new Promise(async (resolve) => {
102+
try {
103+
const serverlessState = JSON.parse(
104+
fs.readFileSync(path.join('.serverless', 'serverless-state.json'), 'utf8'),
105+
);
106+
107+
return resolve(serverlessState);
108+
} catch (e) {
109+
warn('No serverless state found.');
110+
debug(`Error: ${e}`);
111+
return;
112+
}
113+
});
114+
}
84115

85-
let httpApiUrl: string | undefined = undefined;
116+
get stack(): Promise<Stack | undefined> {
117+
return new Promise(async (resolve) => {
118+
const serverlessState = await this.serverlessState;
86119

87-
try {
88-
const stackName = `${serverlessState!.service.service}-${
89-
serverlessState!.service.provider.stage
90-
}`;
120+
if (!serverlessState) {
121+
return resolve(undefined);
122+
}
91123

92-
const client = new CloudFormationClient({ region: serverlessState!.service.provider.region });
124+
const stackName = `${serverlessState.service.service}-${serverlessState.service.provider.stage}`;
125+
126+
const client = new CloudFormationClient({ region: serverlessState.service.provider.region });
93127

94128
const describeStacks = await client.send(new DescribeStacksCommand({ StackName: stackName }));
95129

96130
const stack = describeStacks.Stacks?.find(
97131
(s) =>
98132
s.StackName === stackName &&
99133
s.Tags?.find(
100-
(t) => t.Key === 'STAGE' && t.Value === serverlessState!.service.provider.stage,
134+
(t) => t.Key === 'STAGE' && t.Value === serverlessState.service.provider.stage,
101135
),
102136
);
103137

104138
if (!stack) {
105139
warn('Unable to find stack.');
106140
debug(JSON.stringify(describeStacks));
107-
return;
141+
return resolve(undefined);
108142
}
109143

110-
httpApiUrl = stack.Outputs?.find((o) => o.OutputKey === 'HttpApiUrl')?.OutputValue;
111-
} catch (e) {
112-
warn('Unable to determine HTTP API URL.');
113-
debug(`Error: ${e}`);
114-
return;
115-
}
144+
return resolve(stack);
145+
});
146+
}
116147

117-
notice(`HTTP API URL: ${httpApiUrl}`);
148+
get httpApiUrl(): Promise<string | undefined> {
149+
return new Promise(async (resolve) => {
150+
const stack = await this.stack;
151+
152+
if (!stack) {
153+
return resolve(undefined);
154+
}
155+
156+
return resolve(stack.Outputs?.find((o) => o.OutputKey === 'HttpApiUrl')?.OutputValue);
157+
});
118158
}
119159
}

src/messages.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export const roleSetupInstructions = (owner: string, repo: string): string => {
2+
return `
3+
1. If you haven't already, create an Identity Provider in AWS IAM for GitHub Actions:
4+
- https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html
5+
- For the provider URL: Use https://token.actions.githubusercontent.com
6+
- For the "Audience": Use sts.amazonaws.com
7+
8+
2. Create or update a role in AWS IAM with the following trust relationship:
9+
{
10+
"Version": "2012-10-17",
11+
"Statement": [
12+
{
13+
"Effect": "Allow",
14+
"Principal": {
15+
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
16+
},
17+
"Action": "sts:AssumeRoleWithWebIdentity",
18+
"Condition": {
19+
"StringEquals": {
20+
"token.actions.githubusercontent.com:sub": "repo:${owner}/${repo}:*"
21+
}
22+
}
23+
}
24+
]
25+
}
26+
27+
3. Ensure the IAM role has the following policies attached:
28+
- AWSLambdaFullAccess
29+
- AWSLambdaRole
30+
- AWSLambdaVPCAccessExecutionRole
31+
- AWSLambdaENIManagementAccess
32+
- AWSLambdaBasicExecutionRole
33+
- AWSLambdaKinesisExecutionRole
34+
- AWSLambdaSQSQueueExecutionRole
35+
- AWSLambdaDynamoDB
36+
37+
4. Add the AWS IAM Role ARN to your GitHub Repository Variables:
38+
- https://github.com/${owner}/${repo}/settings/variables/actions
39+
- Name: DEPLOYMENT_ROLE
40+
- Value: arn:aws:iam::YOUR_ACCOUNT_ID:role/YOUR_ROLE_NAME
41+
42+
5. Re-run this action. Enable debug logging if you need more information.
43+
`;
44+
};

yarn.lock

+1-1
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@
233233
"@smithy/util-utf8" "^2.2.0"
234234
tslib "^2.5.0"
235235

236-
"@aws-sdk/client-sts@3.533.0":
236+
"@aws-sdk/client-sts@3.533.0", "@aws-sdk/client-sts@^3.533.0":
237237
version "3.533.0"
238238
resolved "https://registry.yarnpkg.com/@aws-sdk/client-sts/-/client-sts-3.533.0.tgz#a792fc321509dee0b104a3470653663315068bce"
239239
integrity sha512-Z/z76T/pEq0DsBpoyWSMQdS7R6IRpq2ZV6dfZwr+HZ2vho2Icd70nIxwiNzZxaV16aVIhu5/l/5v5Ns9ZCfyOA==

0 commit comments

Comments
 (0)