Skip to content

Commit b091999

Browse files
committed
init
0 parents  commit b091999

10 files changed

+1806
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist/
2+
node_modules/

.prettierrc

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"singleQuote": true,
3+
"semi": false,
4+
"trailingComma": "all",
5+
"tabWidth": 2,
6+
"printWidth": 80
7+
}

config.yml

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
cf_account_id: fbbebdb1eed350f2a05f517e1d80915f
2+
cf_access_team: eidam
3+
cf_access_aud: d27389ecd9bdc9c651bdadea01b6d9f835269f94fa3be1a9f9a4a5c755a1a0f9
4+
5+
jwt_ttl: 600 # TTL of the generated JWT tokens, in seconds
6+
7+
clients:
8+
- name: "My test app"
9+
client_id: clientId1 # should not be guessable, you can get for example uuidv4 from https://uuid.rocks/plain
10+
client_secret_key: SECRET_SOMETHING_SOMETHING # should be set with'wragler secret put SECRET_SOMETHING_SOMETHING' (could be also uuid)
11+
redirect_uris:
12+
- https://oidcdebugger.com/debug
13+
- https://openidconnect.net/callback
14+
- https://psteniusubi.github.io/oidc-tester/authorization-code-flow.html
15+
cors_origins:
16+
- https://psteniusubi.github.io
17+
18+
- name: "My test app 2"
19+
client_id: clientId2 # should not be guessable, you can get for example uuidv4 from https://uuid.rocks/plain
20+
client_secret_key: SECRET_CLIENT_ID_2 # should be set with'wragler secret put SECRET_SOMETHING_SOMETHING' (could be also uuid)
21+
redirect_uris:
22+
- https://vault.eidam.dev/ui/vault/auth/oidc/oidc/callback
23+
- http://10.142.0.16:8200/ui/vault/auth/oidc/oidc/callback
24+
- http://localhost:8250/oidc/callback
25+
- http://127.0.0.1:8250/oidc/callback
26+
cors_origins:
27+
- https://psteniusubi.github.io

package.json

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "access-workers-oidc",
3+
"author": "Adam Janis <[email protected]>",
4+
"version": "1.0.0",
5+
"type": "module",
6+
"private": true,
7+
"module": "./dist/main.mjs",
8+
"scripts": {
9+
"build": "rollup -c",
10+
"dev": "miniflare --modules --watch",
11+
"deploy": "wrangler publish",
12+
"test": "echo \"Error: no test specified\" && exit 1",
13+
"format": "prettier --write '**/*.{js,mjs,css,json,md}'"
14+
},
15+
"license": "MIT",
16+
"devDependencies": {
17+
"@rollup/plugin-alias": "^3.1.5",
18+
"@rollup/plugin-commonjs": "^17.0.0",
19+
"@rollup/plugin-node-resolve": "^11.1.0",
20+
"@rollup/plugin-typescript": "^8.2.5",
21+
"prettier": "^1.19.1",
22+
"rollup": "^2.36.1",
23+
"rollup-plugin-yaml": "^2.0.0"
24+
},
25+
"dependencies": {
26+
"itty-router": "^2.1.9",
27+
"jwt-decode": "^3.1.2",
28+
"miniflare": "^1.3.3",
29+
"rfc4648": "^1.5.0",
30+
"uuid": "^8.3.2"
31+
}
32+
}

rollup.config.js

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// plugin-node-resolve and plugin-commonjs are required for a rollup bundled project
2+
// to resolve dependencies from node_modules. See the documentation for these plugins
3+
// for more details.
4+
import { nodeResolve } from '@rollup/plugin-node-resolve'
5+
import commonjs from '@rollup/plugin-commonjs'
6+
import yaml from 'rollup-plugin-yaml'
7+
8+
export default {
9+
input: 'src/main.mjs',
10+
output: {
11+
exports: 'named',
12+
format: 'es',
13+
file: 'dist/main.mjs',
14+
sourcemap: false,
15+
},
16+
plugins: [yaml(), commonjs(), nodeResolve({ browser: true })],
17+
}

src/main.mjs

+249
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { Router } from 'itty-router'
2+
import {
3+
getAllowedOrigin,
4+
getClientConfig,
5+
getClientSecret,
6+
getCloudflareAccessGroups,
7+
getCorsHeaders,
8+
getDoStub,
9+
getIssuer,
10+
getResponse,
11+
verifyCloudflareAccessJwt,
12+
} from './utils'
13+
export { OpenIDConnectDurableObject } from './oidc-do.mjs'
14+
15+
const router = Router()
16+
router.get('/.well-known/openid-configuration', (req, env) =>
17+
handleOIDConfig(req, env),
18+
)
19+
router.get('/.well-known/jwks.json', (req, env) => handleGetJwks(req, env))
20+
router.get('/authorize', (req, env) => handleAuthorize(req, env))
21+
router.post('/token', (req, env) => handleToken(req, env))
22+
router.get('/userinfo', (req, env) => handleUserInfo(req, env))
23+
router.post('/userinfo', (req, env) => handleUserInfo(req, env))
24+
router.options('*', (req, env) => handleOptions(req, env))
25+
router.all('*', (req, env) => getResponse({ err: 'Bad request' }, 400))
26+
27+
export default {
28+
async fetch(request, env) {
29+
try {
30+
return await handleRequest(request, env)
31+
} catch (e) {
32+
return new Response(e.message)
33+
}
34+
},
35+
36+
async scheduled(controller, env, ctx) {
37+
const stub = getDoStub(env)
38+
39+
ctx.waitUntil(
40+
stub.fetch('/jwks', {
41+
method: 'PATCH',
42+
}),
43+
)
44+
},
45+
}
46+
47+
async function handleRequest(req, env) {
48+
return router.handle(req, env)
49+
}
50+
51+
async function handleOIDConfig(req, env) {
52+
const corsHeaders = getCorsHeaders(getAllowedOrigin(req, '*'))
53+
const issuer = getIssuer(req)
54+
55+
return getResponse(
56+
{
57+
issuer: `${issuer}`,
58+
authorization_endpoint: `${issuer}/authorize`,
59+
token_endpoint: `${issuer}/token`,
60+
userinfo_endpoint: `${issuer}/userinfo`,
61+
jwks_uri: `${issuer}/.well-known/jwks.json`,
62+
response_types_supported: ['id_token', 'code', 'code id_token'],
63+
id_token_signing_alg_values_supported: ['RS256'],
64+
token_endpoint_auth_methods_supported: [
65+
'client_secret_post',
66+
//"client_secret_basic"
67+
],
68+
claims_supported: [
69+
'aud',
70+
'email',
71+
'exp',
72+
'iat',
73+
'nbf',
74+
'iss',
75+
'name',
76+
'sub',
77+
'country',
78+
],
79+
grant_types_supported: ['authorization_code'],
80+
},
81+
200,
82+
corsHeaders,
83+
)
84+
}
85+
86+
async function handleUserInfo(req, env) {
87+
const authorization = req.headers.get('Authorization')
88+
const corsHeaders = getCorsHeaders(getAllowedOrigin(req, '*'), [
89+
'authorization',
90+
])
91+
92+
if (!authorization || !authorization.startsWith('Bearer '))
93+
return getResponse({ err: 'Missing Bearer token' }, 400, corsHeaders)
94+
const access_token = authorization.substring(7, authorization.length)
95+
96+
const identityRes = await fetch(
97+
`https://${config.cf_account_team}.cloudflareaccess.com/cdn-cgi/access/get-identity`,
98+
{
99+
headers: {
100+
cookie: `CF_Authorization=${access_token}`,
101+
},
102+
},
103+
)
104+
const identity = await identityRes.json()
105+
106+
if (identity.err) {
107+
return getResponse(identity.err, 401, corsHeaders)
108+
}
109+
110+
const userinfo = {
111+
sub: identity.user_uuid,
112+
name: identity.name,
113+
email: identity.email,
114+
}
115+
116+
return getResponse(userinfo, 200, corsHeaders)
117+
}
118+
119+
async function handleToken(req, env) {
120+
const body = await req.text()
121+
let formData = new URLSearchParams(body)
122+
const {
123+
grant_type,
124+
client_id,
125+
client_secret,
126+
redirect_uri,
127+
code,
128+
} = Object.fromEntries(formData)
129+
const corsHeaders = getCorsHeaders(getAllowedOrigin(req, client_id))
130+
const clientConfig = getClientConfig(client_id)
131+
132+
// Authorization request validation
133+
if (!client_id || !code || !grant_type)
134+
return getResponse({ err: 'Missing client_id or code or grant_type' }, 400)
135+
if (!clientConfig)
136+
return getResponse(
137+
{ err: 'Client configuration not found' },
138+
404,
139+
corsHeaders,
140+
)
141+
if (!clientConfig?.redirect_uris.includes(redirect_uri))
142+
return getResponse(
143+
{ err: 'Redirect URIs does not match' },
144+
400,
145+
corsHeaders,
146+
)
147+
if (
148+
!client_secret ||
149+
client_secret !== getClientSecret(clientConfig.client_secret_key, env)
150+
)
151+
return getResponse({ err: 'Wrong client_secret' }, 400, corsHeaders)
152+
153+
// Exchange code for id_token and access code
154+
const stub = getDoStub(env)
155+
const res = await stub.fetch(`/exchange/${code}`)
156+
return getResponse(await res.text(), res.status, corsHeaders)
157+
}
158+
159+
async function handleAuthorize(req, env) {
160+
const access_jwt = req.headers.get('cf-access-jwt-assertion')
161+
const {
162+
client_id,
163+
redirect_uri,
164+
response_type,
165+
state,
166+
nonce,
167+
justGiveJwt,
168+
} = req.query
169+
const clientConfig = getClientConfig(client_id)
170+
171+
// Authorization request validation
172+
if (!client_id || !response_type)
173+
return getResponse(
174+
{ err: 'Missing client_id or response_type or scope' },
175+
400,
176+
)
177+
if (!clientConfig)
178+
return getResponse({ err: 'Client configuration not found' }, 404)
179+
if (!clientConfig?.redirect_uris.includes(redirect_uri))
180+
return getResponse({ err: 'Redirect URIs does not match' }, 400)
181+
182+
// Validate Cloudflare Access JWT token and return decoded data
183+
const result = await verifyCloudflareAccessJwt(access_jwt, env)
184+
if (!result.success) {
185+
return getResponse({ error: result.error }, 400)
186+
}
187+
188+
// Get issuer
189+
const issuer = getIssuer(req)
190+
// Fetch Cloudflare API and get Cloudflare Access Groups for user email
191+
const groups = await getCloudflareAccessGroups(result.payload.email, env)
192+
// Construct new JWT payload
193+
const payload = {
194+
iss: issuer,
195+
aud: [client_id],
196+
azp: client_id,
197+
email: result.payload.email,
198+
sub: result.payload.sub,
199+
country: result.payload.country,
200+
groups,
201+
nonce,
202+
}
203+
204+
// Pass the payload to Durable Object and get signed JWT token back
205+
// Also generate exchange code in case of 'code' OIDC authorization flow
206+
const stub = getDoStub(env)
207+
const idTokenRes = await stub.fetch('/sign', {
208+
method: 'POST',
209+
body: JSON.stringify({
210+
payload,
211+
access_jwt,
212+
generate_exchange_code: response_type.includes('code'),
213+
}),
214+
})
215+
const { id_token, code } = await idTokenRes.json()
216+
217+
// Prepare the redirect query
218+
const redirectSearchParams = new URLSearchParams()
219+
if (response_type.includes('code')) redirectSearchParams.set('code', code)
220+
if (response_type.includes('id_token'))
221+
redirectSearchParams.set('id_token', id_token)
222+
if (state) redirectSearchParams.set('state', state)
223+
if (nonce) redirectSearchParams.set('nonce', nonce)
224+
225+
if (justGiveJwt) {
226+
return getResponse({ id_token })
227+
}
228+
229+
// Redirect user back to the OIDC client
230+
return Response.redirect(`${redirect_uri}?${redirectSearchParams.toString()}`)
231+
}
232+
233+
// Get current public keys from Durable Object for JWT validation
234+
async function handleGetJwks(req, env) {
235+
const corsHeaders = getCorsHeaders(getAllowedOrigin(req, '*'))
236+
const stub = getDoStub(env)
237+
const jwksRes = await stub.fetch('/jwks')
238+
239+
return getResponse(await jwksRes.text(), 200, corsHeaders)
240+
}
241+
242+
async function handleOptions(req, env) {
243+
const { pathname } = new URL(req.url)
244+
const corsHeaders = getCorsHeaders(
245+
getAllowedOrigin(req, '*'),
246+
pathname === '/userinfo' ? ['authorization'] : [],
247+
)
248+
return getResponse(null, 200, corsHeaders)
249+
}

0 commit comments

Comments
 (0)