feat: Add Cloudflare Access middleware (#880)
parent
9150550bb8
commit
2720ac7172
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/cloudflare-access': minor
|
||||
---
|
||||
|
||||
Initial release
|
|
@ -0,0 +1,25 @@
|
|||
name: ci-cloudflare-access
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/cloudflare-access/**'
|
||||
pull_request:
|
||||
branches: ['*']
|
||||
paths:
|
||||
- 'packages/cloudflare-access/**'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./packages/cloudflare-access
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn build
|
||||
- run: yarn test
|
|
@ -40,6 +40,7 @@
|
|||
"build:casbin": "yarn workspace @hono/casbin build",
|
||||
"build:ajv-validator": "yarn workspace @hono/ajv-validator build",
|
||||
"build:tsyringe": "yarn workspace @hono/tsyringe build",
|
||||
"build:cloudflare-access": "yarn workspace @hono/cloudflare-access build",
|
||||
"build": "run-p 'build:*'",
|
||||
"lint": "eslint 'packages/**/*.{ts,tsx}'",
|
||||
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
# Cloudflare Access middleware for Hono
|
||||
|
||||
This is a [Cloudflare Access](https://www.cloudflare.com/zero-trust/products/access/) third-party middleware
|
||||
for [Hono](https://github.com/honojs/hono).
|
||||
|
||||
This middleware can be used to validate that your application is being served behind Cloudflare Access by verifying the
|
||||
JWT received, User details from the JWT are also available inside the request context.
|
||||
|
||||
This middleware will also ensure the Access policy serving the application is from a
|
||||
specific [Access Team](https://developers.cloudflare.com/cloudflare-one/faq/getting-started-faq/#whats-a-team-domainteam-name).
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { cloudflareAccess } from '@hono/cloudflare-access'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use('*', cloudflareAccess('my-access-team-name'))
|
||||
app.get('/', (c) => c.text('foo'))
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
## Access JWT payload
|
||||
|
||||
```ts
|
||||
import { cloudflareAccess, CloudflareAccessVariables } from '@hono/cloudflare-access'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
type myVariables = {
|
||||
user: number
|
||||
}
|
||||
|
||||
const app = new Hono<{ Variables: myVariables & CloudflareAccessVariables }>()
|
||||
|
||||
app.use('*', cloudflareAccess('my-access-team-name'))
|
||||
app.get('/', (c) => {
|
||||
const payload = c.get('accessPayload')
|
||||
|
||||
return c.text(`You just authenticated with the email ${payload.email}`)
|
||||
})
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
|
||||
## Errors throw by the middleware
|
||||
|
||||
| Error | HTTP Code |
|
||||
|--------------------------------------------------------------------------------------------------------|-----------|
|
||||
| Authentication error: Missing bearer token | 401 |
|
||||
| Authentication error: Unable to decode Bearer token | 401 |
|
||||
| Authentication error: Token is expired | 401 |
|
||||
| Authentication error: Expected team name {your-team-name}, but received ${different-team-signed-token} | 401 |
|
||||
| Authentication error: Invalid Token | 401 |
|
||||
|
||||
## Author
|
||||
|
||||
Gabriel Massadas <https://github.com/g4brym>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@hono/cloudflare-access",
|
||||
"version": "0.0.0",
|
||||
"description": "A third-party Cloudflare Access auth middleware for Hono",
|
||||
"type": "module",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest --run",
|
||||
"build": "tsup ./src/index.ts --format esm,cjs --dts",
|
||||
"publint": "publint",
|
||||
"release": "yarn build && yarn test && yarn publint && yarn publish"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/honojs/middleware.git"
|
||||
},
|
||||
"homepage": "https://github.com/honojs/middleware",
|
||||
"peerDependencies": {
|
||||
"hono": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hono": "^4.4.12",
|
||||
"tsup": "^8.1.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,251 @@
|
|||
import { Hono } from 'hono'
|
||||
import { cloudflareAccess } from '../src'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const generateKeyPair = promisify(crypto.generateKeyPair);
|
||||
|
||||
interface KeyPairResult {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
interface JWK {
|
||||
kid: string;
|
||||
kty: string;
|
||||
alg: string;
|
||||
use: string;
|
||||
e: string;
|
||||
n: string;
|
||||
}
|
||||
|
||||
async function generateJWTKeyPair(): Promise<KeyPairResult> {
|
||||
try {
|
||||
const { publicKey, privateKey } = await generateKeyPair('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
publicKey,
|
||||
privateKey
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate key pair: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateKeyThumbprint(modulusBase64: string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(Buffer.from(modulusBase64, 'base64'));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function publicKeyToJWK(publicKey: string): JWK {
|
||||
// Convert PEM to key object
|
||||
const keyObject = crypto.createPublicKey(publicKey);
|
||||
|
||||
// Export the key in JWK format
|
||||
const jwk = keyObject.export({ format: 'jwk' });
|
||||
|
||||
// Generate key ID using the modulus
|
||||
const kid = generateKeyThumbprint(jwk.n as string);
|
||||
|
||||
return {
|
||||
kid,
|
||||
kty: 'RSA',
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
e: jwk.e as string,
|
||||
n: jwk.n as string,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function base64URLEncode(str: string): string {
|
||||
return Buffer.from(str)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
function generateJWT(privateKey: string, payload: Record<string, any>, expiresIn: number = 3600): string {
|
||||
// Create header
|
||||
const header = {
|
||||
alg: 'RS256',
|
||||
typ: 'JWT'
|
||||
};
|
||||
|
||||
// Add expiration to payload
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const fullPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + expiresIn
|
||||
};
|
||||
|
||||
// Encode header and payload
|
||||
const encodedHeader = base64URLEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64URLEncode(JSON.stringify(fullPayload));
|
||||
|
||||
// Create signature
|
||||
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signer = crypto.createSign('RSA-SHA256');
|
||||
signer.update(signatureInput);
|
||||
const signature = signer.sign(privateKey);
|
||||
// @ts-ignore
|
||||
const encodedSignature = base64URLEncode(signature);
|
||||
|
||||
// Combine all parts
|
||||
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
|
||||
describe('Cloudflare Access middleware', async () => {
|
||||
const keyPair1 = await generateJWTKeyPair();
|
||||
const keyPair2 = await generateJWTKeyPair();
|
||||
const keyPair3 = await generateJWTKeyPair();
|
||||
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
return Response.json({
|
||||
keys: [
|
||||
publicKeyToJWK(keyPair1.publicKey),
|
||||
publicKeyToJWK(keyPair2.publicKey),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use('/*', cloudflareAccess('my-cool-team-name'))
|
||||
app.get('/hello-behind-access', (c) => c.text('foo'))
|
||||
app.get('/access-payload', (c) => c.json(c.get('accessPayload')))
|
||||
|
||||
it('Should be throw Missing bearer token when nothing is sent', async () => {
|
||||
const res = await app.request('http://localhost/hello-behind-access')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.text()).toBe('Authentication error: Missing bearer token')
|
||||
})
|
||||
|
||||
it('Should be throw Unable to decode Bearer token when sending garbage', async () => {
|
||||
const res = await app.request('http://localhost/hello-behind-access', {
|
||||
headers: {
|
||||
'cf-access-jwt-assertion': 'asdasdasda'
|
||||
}
|
||||
})
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.text()).toBe('Authentication error: Unable to decode Bearer token')
|
||||
})
|
||||
|
||||
it('Should be throw Token is expired when sending expired token', async () => {
|
||||
const token = generateJWT(keyPair1.privateKey, {
|
||||
sub: '1234567890',
|
||||
}, -3600);
|
||||
|
||||
const res = await app.request('http://localhost/hello-behind-access', {
|
||||
headers: {
|
||||
'cf-access-jwt-assertion': token
|
||||
}
|
||||
})
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.text()).toBe('Authentication error: Token is expired')
|
||||
})
|
||||
|
||||
it('Should be throw Expected team name x, but received y when sending invalid iss', async () => {
|
||||
const token = generateJWT(keyPair1.privateKey, {
|
||||
sub: '1234567890',
|
||||
iss: 'https://different-team.cloudflareaccess.com',
|
||||
});
|
||||
|
||||
const res = await app.request('http://localhost/hello-behind-access', {
|
||||
headers: {
|
||||
'cf-access-jwt-assertion': token
|
||||
}
|
||||
})
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.text()).toBe('Authentication error: Expected team name https://my-cool-team-name.cloudflareaccess.com, but received https://different-team.cloudflareaccess.com')
|
||||
})
|
||||
|
||||
it('Should be throw Invalid token when sending token signed with private key not in the allowed list', async () => {
|
||||
const token = generateJWT(keyPair3.privateKey, {
|
||||
sub: '1234567890',
|
||||
iss: 'https://my-cool-team-name.cloudflareaccess.com',
|
||||
});
|
||||
|
||||
const res = await app.request('http://localhost/hello-behind-access', {
|
||||
headers: {
|
||||
'cf-access-jwt-assertion': token
|
||||
}
|
||||
})
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.text()).toBe('Authentication error: Invalid Token')
|
||||
})
|
||||
|
||||
it('Should work when sending everything correctly', async () => {
|
||||
const token = generateJWT(keyPair1.privateKey, {
|
||||
sub: '1234567890',
|
||||
iss: 'https://my-cool-team-name.cloudflareaccess.com',
|
||||
});
|
||||
|
||||
const res = await app.request('http://localhost/hello-behind-access', {
|
||||
headers: {
|
||||
'cf-access-jwt-assertion': token
|
||||
}
|
||||
})
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('foo')
|
||||
})
|
||||
|
||||
it('Should work with tokens signed by the 2º key in the public keys list', async () => {
|
||||
const token = generateJWT(keyPair2.privateKey, {
|
||||
sub: '1234567890',
|
||||
iss: 'https://my-cool-team-name.cloudflareaccess.com',
|
||||
});
|
||||
|
||||
const res = await app.request('http://localhost/hello-behind-access', {
|
||||
headers: {
|
||||
'cf-access-jwt-assertion': token
|
||||
}
|
||||
})
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('foo')
|
||||
})
|
||||
|
||||
it('Should be able to retrieve the JWT payload from Hono context', async () => {
|
||||
const token = generateJWT(keyPair1.privateKey, {
|
||||
sub: '1234567890',
|
||||
iss: 'https://my-cool-team-name.cloudflareaccess.com',
|
||||
});
|
||||
|
||||
const res = await app.request('http://localhost/access-payload', {
|
||||
headers: {
|
||||
'cf-access-jwt-assertion': token
|
||||
}
|
||||
})
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
"sub":"1234567890",
|
||||
"iss":"https://my-cool-team-name.cloudflareaccess.com",
|
||||
"iat":expect.any(Number),
|
||||
"exp":expect.any(Number)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,164 @@
|
|||
import { createMiddleware } from 'hono/factory'
|
||||
import { Context } from 'hono'
|
||||
|
||||
export type CloudflareAccessPayload = {
|
||||
aud: string[],
|
||||
email: string,
|
||||
exp: number,
|
||||
iat: number,
|
||||
nbf: number,
|
||||
iss: string,
|
||||
type: string,
|
||||
identity_nonce: string,
|
||||
sub: string,
|
||||
country: string,
|
||||
}
|
||||
|
||||
export type CloudflareAccessVariables = {
|
||||
accessPayload: CloudflareAccessPayload
|
||||
}
|
||||
|
||||
type DecodedToken = {
|
||||
header: object
|
||||
payload: CloudflareAccessPayload
|
||||
signature: string
|
||||
raw: { header?: string; payload?: string; signature?: string }
|
||||
}
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
accessPayload: CloudflareAccessPayload
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudflareAccess = (accessTeamName: string) => {
|
||||
// This var will hold already imported jwt keys, this reduces the load of importing the key on every request
|
||||
let cacheKeys: Record<string, CryptoKey> = {}
|
||||
let cacheExpiration = 0
|
||||
|
||||
return createMiddleware(async (c, next) => {
|
||||
const encodedToken = getJwt(c)
|
||||
if (encodedToken === null) return c.text('Authentication error: Missing bearer token', 401)
|
||||
|
||||
// Load jwt keys if they are not in memory or already expired
|
||||
if (Object.keys(cacheKeys).length === 0 || Math.floor(Date.now() / 1000) < cacheExpiration) {
|
||||
const publicKeys = await getPublicKeys(accessTeamName)
|
||||
cacheKeys = publicKeys.keys
|
||||
cacheExpiration = publicKeys.cacheExpiration
|
||||
}
|
||||
|
||||
// Decode Token
|
||||
let token
|
||||
try {
|
||||
token = decodeJwt(encodedToken)
|
||||
} catch (err) {
|
||||
return c.text('Authentication error: Unable to decode Bearer token', 401)
|
||||
}
|
||||
|
||||
// Is the token expired?
|
||||
const expiryDate = new Date(token.payload.exp * 1000)
|
||||
const currentDate = new Date(Date.now())
|
||||
if (expiryDate <= currentDate) return c.text('Authentication error: Token is expired', 401)
|
||||
|
||||
// Check is token is valid against at least one public key?
|
||||
if (!(await isValidJwtSignature(token, cacheKeys)))
|
||||
return c.text('Authentication error: Invalid Token', 401)
|
||||
|
||||
// Is signed from the correct team?
|
||||
const expectedIss = `https://${accessTeamName}.cloudflareaccess.com`
|
||||
if (token.payload?.iss !== expectedIss)
|
||||
return c.text(
|
||||
`Authentication error: Expected team name ${expectedIss}, but received ${token.payload?.iss}`,
|
||||
401
|
||||
)
|
||||
|
||||
c.set('accessPayload', token.payload)
|
||||
await next()
|
||||
})
|
||||
}
|
||||
|
||||
async function getPublicKeys(accessTeamName: string) {
|
||||
const jwtUrl = `https://${accessTeamName}.cloudflareaccess.com/cdn-cgi/access/certs`
|
||||
|
||||
const result = await fetch(jwtUrl, {
|
||||
method: 'GET',
|
||||
// @ts-ignore
|
||||
cf: {
|
||||
// Dont cache error responses
|
||||
cacheTtlByStatus: { '200-299': 30, '300-599': 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const data: any = await result.json()
|
||||
|
||||
// Because we keep CryptoKey's in memory between requests, we need to make sure they are refreshed once in a while
|
||||
let cacheExpiration = Math.floor(Date.now() / 1000) + 3600 // 1h
|
||||
|
||||
const importedKeys: Record<string, CryptoKey> = {}
|
||||
for (const key of data.keys) {
|
||||
importedKeys[key.kid] = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
key,
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
false,
|
||||
['verify']
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
keys: importedKeys,
|
||||
cacheExpiration: cacheExpiration,
|
||||
}
|
||||
}
|
||||
|
||||
function getJwt(c: Context) {
|
||||
const authHeader = c.req.header('cf-access-jwt-assertion')
|
||||
if (!authHeader) {
|
||||
return null
|
||||
}
|
||||
return authHeader.trim()
|
||||
}
|
||||
|
||||
function decodeJwt(token: string): DecodedToken {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid token')
|
||||
}
|
||||
|
||||
const header = JSON.parse(atob(parts[0] as string))
|
||||
const payload = JSON.parse(atob(parts[1] as string))
|
||||
const signature = atob((parts[2] as string).replace(/_/g, '/').replace(/-/g, '+'))
|
||||
|
||||
return {
|
||||
header: header,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
raw: { header: parts[0], payload: parts[1], signature: parts[2] },
|
||||
}
|
||||
}
|
||||
|
||||
async function isValidJwtSignature(token: DecodedToken, keys: Record<string, CryptoKey>) {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode([token.raw.header, token.raw.payload].join('.'))
|
||||
|
||||
const signature = new Uint8Array(Array.from(token.signature).map((c) => c.charCodeAt(0)))
|
||||
|
||||
for (const key of Object.values(keys)) {
|
||||
const isValid = await validateSingleKey(key, signature, data)
|
||||
|
||||
if (isValid) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function validateSingleKey(
|
||||
key: CryptoKey,
|
||||
signature: Uint8Array,
|
||||
data: Uint8Array
|
||||
): Promise<boolean> {
|
||||
return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, data)
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
})
|
12
yarn.lock
12
yarn.lock
|
@ -2505,6 +2505,18 @@ __metadata:
|
|||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@hono/cloudflare-access@workspace:packages/cloudflare-access":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@hono/cloudflare-access@workspace:packages/cloudflare-access"
|
||||
dependencies:
|
||||
hono: "npm:^4.4.12"
|
||||
tsup: "npm:^8.1.0"
|
||||
vitest: "npm:^1.6.0"
|
||||
peerDependencies:
|
||||
hono: "*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@hono/conform-validator@workspace:packages/conform-validator":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@hono/conform-validator@workspace:packages/conform-validator"
|
||||
|
|
Loading…
Reference in New Issue