Skip to content

Commit 669e187

Browse files
committed
Initial commit
0 parents  commit 669e187

18 files changed

+1850
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.env
2+
.vercel/
3+
node_modules/
4+
dist/

.prettierrc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"arrowParens": "avoid",
3+
"bracketSpacing": true,
4+
"insertPragma": false,
5+
"jsxBracketSameLine": false,
6+
"jsxSingleQuote": false,
7+
"printWidth": 120,
8+
"proseWrap": "preserve",
9+
"requirePragma": false,
10+
"semi": false,
11+
"singleQuote": false,
12+
"tabWidth": 2,
13+
"trailingComma": "es5",
14+
"useTabs": false
15+
}

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# ▲🔐 vercel-github-oauth-proxy
2+
3+
Protect a static website hosted on Vercel behind GitHub authentication.
4+
5+
## Setup
6+
7+
### Step 1 — Add the library
8+
9+
```
10+
yarn add vercel-github-oauth-proxy
11+
```
12+
13+
### Step 2 — Create an API endpoint at `/api/index.ts`
14+
15+
```ts
16+
import { createLambdaHandler } from "vercel-github-oauth-proxy"
17+
18+
export default createLambdaHandler(config)
19+
```
20+
21+
`config.cryptoSecret`
22+
23+
This is used to sign cookies.
24+
25+
`config.staticDir`
26+
27+
The output directory of the static website.
28+
29+
`config.githubOrgName`
30+
31+
The GitHub org users need to be part of.
32+
33+
`config.githubClientId`
34+
`config.githubClientSecret`
35+
36+
The id/secret pair of your GitHub OAuth app.
37+
You can create a new app at `https://github.com/organizations/{config.githubOrgName}/settings/applications/new`
38+
39+
`config.githubOrgAdminToken`
40+
41+
Private org memberships can only be determined by making an authenticated API request.
42+
43+
We could request `read:org` scope during the OAuth flow and then use each user's access token to determine org membership, but using this method means the user additionally needs to request org access during or after the login flow and requires an org admin to confirm. This makes this approach inconvenient for both the users and the admin.
44+
45+
Therefore we're using a separate org admin token to verify membership during login (org admins can see all users).
46+
47+
### Step 3 — Create a `vercel.json`
48+
49+
```json
50+
{
51+
"version": 2,
52+
"routes": [{ "src": "/(.*)", "dest": "/api/index.ts" }],
53+
"functions": {
54+
"api/index.ts": {
55+
"includeFiles": "**"
56+
}
57+
}
58+
}
59+
```
60+
61+
This routes all traffic through the lambda endpoint.
62+
63+
Note that we also include all repo files in the lambda build. This is required because the static website needs to be deployed as part of the lambda function, not the default build. See also the [function docs](https://vercel.com/docs/configuration?query=includeFiles#project/functions) and [limits](https://vercel.com/docs/platform/limits?query=includeFiles#serverless-function-size).
64+
65+
### Step 4 — Build
66+
67+
To build your website during lambda deploylent, add a `vercel-build` step that builds your website.
68+
69+
```json
70+
{
71+
"scripts": {
72+
"vercel-build": "your website build command"
73+
}
74+
}
75+
```
76+
77+
## Local development
78+
79+
To develop locally, run
80+
81+
```
82+
yarn vercel dev
83+
```
84+
85+
When developing locally, you'll need to update your GitHub OAuth app's redirect URL to `http://localhost:3000`.

api/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import path from "path"
2+
3+
import { createLambdaProxyAuthHandler } from "../lib"
4+
5+
export default createLambdaProxyAuthHandler({
6+
cryptoSecret: process.env.CRYPTO_SECRET!,
7+
githubClientId: process.env.GITHUB_CLIENT_ID!,
8+
githubClientSecret: process.env.GITHUB_CLIENT_SECRET!,
9+
githubOrgAdminToken: process.env.GITHUB_ORG_ADMIN_TOKEN!,
10+
githubOrgName: process.env.GITHUB_ORG_NAME!,
11+
staticDir: path.resolve(__dirname, "../static"),
12+
sessionDurationSeconds: 604800, // 1 week
13+
})

lib/fastify-cookie.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { FastifyInstance } from "fastify"
2+
import fastifyCookie from "fastify-cookie"
3+
4+
import { Config } from "./types"
5+
6+
//
7+
// https://github.com/fastify/fastify-cookie#fastify-cookie
8+
//
9+
export function registerCookieMiddleware(server: FastifyInstance, config: Config) {
10+
server.register(fastifyCookie, { secret: config.cryptoSecret })
11+
}

lib/fastify-lambda.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { NowApiHandler, NowRequest, NowResponse } from "@vercel/node"
2+
import { FastifyInstance } from "fastify"
3+
4+
//
5+
// https://www.fastify.io/docs/latest/Serverless/#vercel
6+
// https://vercel.com/docs/serverless-functions/supported-languages#using-typescript
7+
//
8+
export const createLambdaHandler: (server: FastifyInstance) => NowApiHandler = server => {
9+
return async (req: NowRequest, res: NowResponse) => {
10+
await server.ready()
11+
server.server.emit("request", req, res)
12+
}
13+
}

lib/fastify-static.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { FastifyInstance } from "fastify"
2+
import fastifyStatic from "fastify-static"
3+
import path from "path"
4+
import { Config } from "./types"
5+
6+
//
7+
// https://github.com/fastify/fastify-static#fastify-static
8+
//
9+
export function registerServeStatic(server: FastifyInstance, config: Config) {
10+
server.register(fastifyStatic, {
11+
root: path.resolve(config.staticDir),
12+
})
13+
}

lib/github-oauth.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import axios from "axios"
2+
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
3+
import { nanoid } from "nanoid"
4+
import { URLSearchParams } from "url"
5+
6+
import { GitHubAccessToken, GitHubOrgMembership, GitHubUser, RoutePrams, Config, OAuthState } from "./types"
7+
8+
export function registerGitHubOAuth(server: FastifyInstance, config: Config) {
9+
const secureCookies = !!process.env.VERCEL_URL
10+
11+
const urls = {
12+
localAuthorize: "/login/oauth/authorize",
13+
githubAuthorize: "https://github.com/login/oauth/authorize",
14+
githubToken: "https://github.com/login/oauth/access_token",
15+
githubOrgMembers: `https://api.github.com/orgs/${config.githubOrgName}/members`,
16+
githubUserDetails: "https://api.github.com/user",
17+
}
18+
19+
const cookieNames = {
20+
state: "state",
21+
user: "user",
22+
} as const
23+
24+
const formatQueryParams = (params: NodeJS.Dict<string>) => {
25+
return "?" + new URLSearchParams(params).toString()
26+
}
27+
28+
const unsignCookie = (res: FastifyReply, value: string) => {
29+
const unsigned = res.unsignCookie(value)
30+
31+
if (unsigned.valid) {
32+
return JSON.parse(unsigned.value || "null")
33+
}
34+
}
35+
36+
/**
37+
* Make sure the authentication request was initiated by this application.
38+
*/
39+
const initiateOAuth = async (req: FastifyRequest, res: FastifyReply) => {
40+
const state: OAuthState = {
41+
randomToken: nanoid(),
42+
path: req.url,
43+
}
44+
45+
res.clearCookie(cookieNames.user)
46+
res.setCookie(cookieNames.state, JSON.stringify(state), {
47+
httpOnly: true,
48+
maxAge: config.sessionDurationSeconds,
49+
path: "/",
50+
sameSite: "lax",
51+
secure: secureCookies,
52+
signed: true,
53+
})
54+
res.redirect(302, urls.localAuthorize)
55+
}
56+
57+
//
58+
// https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps#web-application-flow
59+
//
60+
const redirectToGitHub = async (req: FastifyRequest<RoutePrams>, res: FastifyReply) => {
61+
const query = formatQueryParams({
62+
client_id: config.githubClientId,
63+
scope: "read:user",
64+
state: req.cookies[cookieNames.state],
65+
})
66+
res.redirect(302, urls.githubAuthorize + query)
67+
}
68+
69+
const denyAccess = async (res: FastifyReply, message?: string) => {
70+
res.clearCookie(cookieNames.user)
71+
res.clearCookie(cookieNames.state)
72+
res.status(401).send({
73+
statusCode: 401,
74+
error: "Unauthorized",
75+
message,
76+
})
77+
}
78+
79+
const getGitHubAccessToken = async (code: string): Promise<GitHubAccessToken> => {
80+
const url = urls.githubToken
81+
const headers = {
82+
Accept: "application/json",
83+
}
84+
const body = {
85+
client_id: config.githubClientId,
86+
client_secret: config.githubClientSecret,
87+
code,
88+
}
89+
90+
const { data } = await axios.post<GitHubAccessToken>(url, body, { headers })
91+
92+
return data
93+
}
94+
95+
const getGitHubUser = async (tokenData: GitHubAccessToken): Promise<GitHubUser> => {
96+
const url = urls.githubUserDetails
97+
const headers = {
98+
Accept: "application/json",
99+
Authorization: `${tokenData.token_type} ${tokenData.access_token}`,
100+
}
101+
102+
const { data } = await axios.get<GitHubUser>(url, { headers })
103+
104+
return data
105+
}
106+
107+
const getGitHubOrgMemberships = async (): Promise<GitHubOrgMembership[]> => {
108+
const url = urls.githubOrgMembers
109+
const headers = {
110+
Accept: "application/json",
111+
Authorization: `Bearer ${config.githubOrgAdminToken}`,
112+
}
113+
114+
const { data } = await axios.get<GitHubOrgMembership[]>(url, { headers })
115+
116+
return data
117+
}
118+
119+
const retrieveState = (req: FastifyRequest<RoutePrams>, res: FastifyReply) => {
120+
const state: OAuthState = unsignCookie(res, req.query.state || "")
121+
const expectedState: OAuthState = unsignCookie(res, req.cookies[cookieNames.state] || "")
122+
123+
if (!state?.randomToken || state.randomToken !== expectedState?.randomToken) {
124+
throw new Error("State mismatch")
125+
}
126+
127+
return state
128+
}
129+
130+
const succeed = (res: FastifyReply, user: GitHubUser, path: string) => {
131+
res.setCookie(cookieNames.user, JSON.stringify(user), {
132+
httpOnly: false,
133+
maxAge: config.sessionDurationSeconds,
134+
path: "/",
135+
sameSite: "lax",
136+
secure: secureCookies,
137+
signed: false,
138+
})
139+
res.redirect(302, path)
140+
}
141+
142+
//
143+
// https://www.fastify.io/docs/latest/Hooks/
144+
//
145+
server.addHook<RoutePrams>("preValidation", async (req, res) => {
146+
if (req.cookies[cookieNames.state] && req.cookies[cookieNames.user]) {
147+
return
148+
}
149+
150+
if (req.url === urls.localAuthorize) {
151+
return redirectToGitHub(req, res)
152+
}
153+
154+
const code = req.query.code
155+
156+
if (!code) {
157+
return initiateOAuth(req, res)
158+
}
159+
160+
try {
161+
const state = retrieveState(req, res)
162+
const tokenData = await getGitHubAccessToken(code)
163+
const user = await getGitHubUser(tokenData)
164+
const members = await getGitHubOrgMemberships()
165+
166+
if (!members.find(member => member.id === user.id)) {
167+
return denyAccess(res, "It appears you are not a member of the required GitHub organization.")
168+
}
169+
170+
return succeed(res, user, state.path)
171+
} catch (error) {
172+
console.error(error)
173+
return denyAccess(res, "It appears that the authentication request was initiated or processed incorrectly.")
174+
}
175+
})
176+
}

lib/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { NowApiHandler } from "@vercel/node"
2+
import fastify from "fastify"
3+
import assert from "ow"
4+
import { registerCookieMiddleware } from "./fastify-cookie"
5+
import { createLambdaHandler } from "./fastify-lambda"
6+
import { registerServeStatic } from "./fastify-static"
7+
import { registerGitHubOAuth } from "./github-oauth"
8+
import { Config } from "./types"
9+
10+
export const createLambdaProxyAuthHandler: (config: Config) => NowApiHandler = config => {
11+
assert(config.cryptoSecret, "config.cryptoSecret", assert.string.nonEmpty)
12+
assert(config.githubClientId, "config.githubClientId", assert.string.nonEmpty)
13+
assert(config.githubClientSecret, "config.githubClientSecret", assert.string.nonEmpty)
14+
assert(config.githubOrgAdminToken, "config.githubOrgAdminToken", assert.string.nonEmpty)
15+
assert(config.githubOrgName, "config.githubOrgName", assert.string.nonEmpty)
16+
17+
const server = fastify({ logger: true })
18+
19+
registerCookieMiddleware(server, config)
20+
registerGitHubOAuth(server, config)
21+
registerServeStatic(server, config)
22+
23+
return createLambdaHandler(server)
24+
}
25+
26+
export type { Config } from "./types"

0 commit comments

Comments
 (0)