Skip to content

Commit 839814f

Browse files
committed
Add the GitHub OpenID Connect proxy for Cognito
1 parent 7994069 commit 839814f

File tree

13 files changed

+1716
-104
lines changed

13 files changed

+1716
-104
lines changed

.gitignore

Lines changed: 10 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,10 @@
1-
# Logs
2-
logs
3-
*.log
4-
npm-debug.log*
5-
yarn-debug.log*
6-
yarn-error.log*
7-
lerna-debug.log*
8-
9-
# Diagnostic reports (https://nodejs.org/api/report.html)
10-
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11-
12-
# Runtime data
13-
pids
14-
*.pid
15-
*.seed
16-
*.pid.lock
17-
18-
# Directory for instrumented libs generated by jscoverage/JSCover
19-
lib-cov
20-
21-
# Coverage directory used by tools like istanbul
22-
coverage
23-
*.lcov
24-
25-
# nyc test coverage
26-
.nyc_output
27-
28-
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29-
.grunt
30-
31-
# Bower dependency directory (https://bower.io/)
32-
bower_components
33-
34-
# node-waf configuration
35-
.lock-wscript
36-
37-
# Compiled binary addons (https://nodejs.org/api/addons.html)
38-
build/Release
39-
40-
# Dependency directories
41-
node_modules/
42-
jspm_packages/
43-
44-
# TypeScript v1 declaration files
45-
typings/
46-
47-
# TypeScript cache
48-
*.tsbuildinfo
49-
50-
# Optional npm cache directory
51-
.npm
52-
53-
# Optional eslint cache
54-
.eslintcache
55-
56-
# Microbundle cache
57-
.rpt2_cache/
58-
.rts2_cache_cjs/
59-
.rts2_cache_es/
60-
.rts2_cache_umd/
61-
62-
# Optional REPL history
63-
.node_repl_history
64-
65-
# Output of 'npm pack'
66-
*.tgz
67-
68-
# Yarn Integrity file
69-
.yarn-integrity
70-
71-
# dotenv environment variables file
72-
.env
73-
.env.test
74-
75-
# parcel-bundler cache (https://parceljs.org/)
76-
.cache
77-
78-
# Next.js build output
79-
.next
80-
81-
# Nuxt.js build / generate output
82-
.nuxt
83-
dist
84-
85-
# Gatsby files
86-
.cache/
87-
# Comment in the public line in if your project uses Gatsby and *not* Next.js
88-
# https://nextjs.org/blog/next-9-1#public-directory-support
89-
# public
90-
91-
# vuepress build output
92-
.vuepress/dist
93-
94-
# Serverless directories
95-
.serverless/
96-
97-
# FuseBox cache
98-
.fusebox/
99-
100-
# DynamoDB Local files
101-
.dynamodb/
102-
103-
# TernJS port file
104-
.tern-port
1+
*.js
2+
!jest.config.js
3+
*.d.ts
4+
node_modules
5+
yarn-error.log
6+
7+
# CDK asset staging directory
8+
.cdk.staging
9+
cdk.out
10+
*.pem

.npmignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
*.ts
2+
!*.d.ts
3+
4+
# CDK asset staging directory
5+
.cdk.staging
6+
cdk.out

.vscode/settings.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"files.exclude": {
3+
"amplify/.config": true,
4+
"amplify/**/*-parameters.json": true,
5+
"amplify/**/amplify.state": true,
6+
"amplify/**/transform.conf.json": true,
7+
"amplify/#current-cloud-backend": true,
8+
"amplify/backend/amplify-meta.json": true,
9+
"amplify/backend/awscloudformation": true
10+
}
11+
}

bin/github-cognito-oidc-proxy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env node
2+
import 'source-map-support/register';
3+
import * as cdk from 'aws-cdk-lib';
4+
import { GithubCognitoOidcProxyStack } from '../lib/github-cognito-oidc-proxy-stack';
5+
import { EnvironmentFile } from 'aws-cdk-lib/aws-ecs';
6+
7+
const app = new cdk.App();
8+
const env = app.node.tryGetContext('env') as string;
9+
10+
new GithubCognitoOidcProxyStack(app, `${env}-github-cognito-oidc-proxy-stack`, {
11+
environment: env
12+
});

cdk.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"app": "rm -rf *.pem && yarn run -p create-keypair --bits 4096 jwtRS256 && npx ts-node --prefer-ts-exts bin/github-cognito-oidc-proxy.ts",
3+
"watch": {
4+
"include": [
5+
"**"
6+
],
7+
"exclude": [
8+
"README.md",
9+
"cdk*.json",
10+
"**/*.d.ts",
11+
"**/*.js",
12+
"tsconfig.json",
13+
"package*.json",
14+
"yarn.lock",
15+
"node_modules",
16+
"test"
17+
]
18+
},
19+
"requireApproval": "never",
20+
"context": {
21+
"@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
22+
"@aws-cdk/core:stackRelativeExports": true,
23+
"@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
24+
"@aws-cdk/aws-lambda:recognizeVersionProps": true,
25+
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
26+
"@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
27+
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
28+
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
29+
"@aws-cdk/core:checkSecretUsage": true,
30+
"@aws-cdk/aws-iam:minimizePolicies": true,
31+
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
32+
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
33+
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
34+
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
35+
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
36+
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
37+
"@aws-cdk/core:enablePartitionLiterals": true,
38+
"@aws-cdk/core:target-partitions": [
39+
"aws",
40+
"aws-cn"
41+
]
42+
}
43+
}

lambda/authorize/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Handler, APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
2+
3+
export const handler: Handler<APIGatewayProxyEventV2, APIGatewayProxyResultV2> = async (event, _context, _callback) => {
4+
const { client_id, scope, state, response_type } = event.queryStringParameters || {};
5+
const redirectUri = `https://github.com?client_id=${client_id}&scope=${encodeURIComponent(scope!)}&state=${state}&response_type=${response_type}`;
6+
7+
return {
8+
cookies: [],
9+
statusCode: 200,
10+
headers: { Location: redirectUri },
11+
};
12+
}

lambda/jwks/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Handler, APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
2+
import { pem2jwk } from 'pem-jwk';
3+
import * as fs from 'fs';
4+
5+
export const handler: Handler<APIGatewayProxyEventV2, APIGatewayProxyResultV2> = async (event, _context, _callback) => {
6+
const pem = fs.readFileSync('/var/task/jwtRS256.private.pem', 'ascii');
7+
const jwk = pem2jwk(pem, {
8+
alg: 'RS256',
9+
kid: 'jwtRS256',
10+
});
11+
return {
12+
cookies: [],
13+
statusCode: 200,
14+
body: JSON.stringify(jwk),
15+
};
16+
}

lambda/token/index.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Handler, APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
2+
import fetch from 'cross-fetch';
3+
import { left, right, isRight } from 'fp-ts/Either'
4+
5+
6+
const eventToRequest = (source: string) => {
7+
const bodyString = Buffer.from(source, 'base64').toString('ascii');
8+
const body = new URLSearchParams(bodyString);
9+
const paramNames =[ 'grant_type', 'redirect_uri', 'client_id', 'client_secret', 'code' ];
10+
11+
const invalidParams = paramNames.filter(name => !body.has(name));
12+
if (invalidParams.length > 0) {
13+
return right(() => `token request body ${invalidParams}`);
14+
}
15+
16+
return left(() => ({
17+
grant_type: body.get('grant_type')!,
18+
redirect_uri: body.get('redirect_uri')!,
19+
client_id: body.get('client_id')!,
20+
client_secret: body.get('client_secret')!,
21+
code: body.get('code')!,
22+
state: body.get('state') ?? undefined,
23+
}));
24+
}
25+
26+
export const handler: Handler<APIGatewayProxyEventV2, APIGatewayProxyResultV2> = async (event, _context, _callback) => {
27+
if (!event.body) {
28+
return {
29+
cookies: [],
30+
statusCode: 400,
31+
};
32+
}
33+
const result = eventToRequest(event.body);
34+
if (isRight(result)) {
35+
return {
36+
cookies: [],
37+
statusCode: 400,
38+
body: result.right(),
39+
};
40+
}
41+
const body = JSON.stringify(result.left());
42+
const response = await fetch('https://github.com/login/oauth/access_token', {
43+
method: 'POST',
44+
headers: {
45+
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
46+
'Accept': 'application/json'
47+
},
48+
body
49+
});
50+
return {
51+
cookies: [],
52+
statusCode: 200,
53+
body: JSON.stringify(await response.json()),
54+
};
55+
}

lambda/userinfo/index.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Handler, APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
2+
import fetch from 'cross-fetch';
3+
import { left, right, isRight, Either } from 'fp-ts/Either';
4+
5+
interface Email {
6+
email: string,
7+
primary: boolean,
8+
verified: boolean,
9+
visibility?: 'private' | 'public'
10+
}
11+
12+
const getUserId = async (token: string): Promise<Either<number, string>> => {
13+
const response = await fetch('https://api.github.com/user', {
14+
method: 'GET',
15+
headers: {
16+
'Accept': 'application/vnd.github+json',
17+
'Authorization': `Bearer ${token}`
18+
}
19+
});
20+
if (response.status !== 200) {
21+
return right(`Cannot get user ID. status: ${response.statusText}. ${response.text()}`);
22+
}
23+
const id = ((await response.json()) as {
24+
id: number,
25+
}).id;
26+
27+
return left(id);
28+
}
29+
30+
const getValidEmail = async (token: string): Promise<Either<Email, string>> => {
31+
const response = await fetch('https://api.github.com/user/emails', {
32+
method: 'GET',
33+
headers: {
34+
'Accept': 'application/vnd.github+json',
35+
'Authorization': `Bearer ${token}`
36+
}
37+
});
38+
if (response.status !== 200) {
39+
return right(`Cannot get mails. status: ${response.statusText}. ${response.text()}`);
40+
}
41+
const emails = (await response.json()) as {
42+
email: string,
43+
primary: boolean,
44+
verified: boolean,
45+
visibility?: 'private' | 'public'
46+
}[];
47+
48+
const email = emails.find(it => it.primary && it.verified && it.email.trim().endsWith('noreply.github.com'));
49+
return email ? left(email) : right('/user/emails returned no valid emails');
50+
}
51+
52+
export const handler: Handler<APIGatewayProxyEventV2, APIGatewayProxyResultV2> = async (event, _context, _callback) => {
53+
const headers = event.headers;
54+
const authHeader = headers['authorization'] || headers['Authorization'];
55+
if (!authHeader) {
56+
return {
57+
cookies: [],
58+
statusCode: 400,
59+
body: '/userinfo request contained no accessToken',
60+
};
61+
}
62+
const authHeaderPrefix = authHeader.slice(0, 'bearer '.length);
63+
if (authHeaderPrefix.toLowerCase() !== 'bearer ') {
64+
return {
65+
cookies: [],
66+
statusCode: 400,
67+
body: 'authorization header does not contain bearer token',
68+
};
69+
}
70+
const token = authHeader.slice('bearer '.length).trim();
71+
if (!token) {
72+
return {
73+
cookies: [],
74+
statusCode: 400,
75+
body: 'authorization header does not contain bearer token',
76+
};
77+
}
78+
79+
const [idResult, emailResult] = await Promise.all([
80+
getUserId(token),
81+
getValidEmail(token)
82+
]);
83+
if (isRight(idResult)) {
84+
return {
85+
cookies: [],
86+
statusCode: 400,
87+
body: `/userinfo ${idResult.right}`,
88+
};
89+
}
90+
if (isRight(emailResult)) {
91+
return {
92+
cookies: [],
93+
statusCode: 400,
94+
body: `/userinfo ${emailResult.right}`,
95+
};
96+
}
97+
98+
const id = idResult.left;
99+
const email = emailResult.left;
100+
return {
101+
cookies: [],
102+
statusCode: 200,
103+
body: JSON.stringify({
104+
sub: id.toString(),
105+
email: email.email,
106+
email_verified: email.verified,
107+
}),
108+
}
109+
}

0 commit comments

Comments
 (0)