2024-12-13 16:16:11 +08:00
|
|
|
import { Hono } from 'hono'
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
|
2024-12-25 17:08:43 +08:00
|
|
|
import crypto from 'crypto'
|
|
|
|
import { promisify } from 'util'
|
|
|
|
import { cloudflareAccess } from '../src'
|
2024-12-13 16:16:11 +08:00
|
|
|
|
2024-12-25 17:08:43 +08:00
|
|
|
const generateKeyPair = promisify(crypto.generateKeyPair)
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
interface KeyPairResult {
|
2024-12-25 17:08:43 +08:00
|
|
|
publicKey: string
|
|
|
|
privateKey: string
|
2024-12-13 16:16:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
interface JWK {
|
2024-12-25 17:08:43 +08:00
|
|
|
kid: string
|
|
|
|
kty: string
|
|
|
|
alg: string
|
|
|
|
use: string
|
|
|
|
e: string
|
|
|
|
n: string
|
2024-12-13 16:16:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
async function generateJWTKeyPair(): Promise<KeyPairResult> {
|
|
|
|
try {
|
|
|
|
const { publicKey, privateKey } = await generateKeyPair('rsa', {
|
|
|
|
modulusLength: 2048,
|
|
|
|
publicKeyEncoding: {
|
|
|
|
type: 'spki',
|
2024-12-25 17:08:43 +08:00
|
|
|
format: 'pem',
|
2024-12-13 16:16:11 +08:00
|
|
|
},
|
|
|
|
privateKeyEncoding: {
|
|
|
|
type: 'pkcs8',
|
2024-12-25 17:08:43 +08:00
|
|
|
format: 'pem',
|
|
|
|
},
|
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
return {
|
|
|
|
publicKey,
|
2024-12-25 17:08:43 +08:00
|
|
|
privateKey,
|
|
|
|
}
|
2024-12-13 16:16:11 +08:00
|
|
|
} catch (error) {
|
2024-12-25 17:08:43 +08:00
|
|
|
throw new Error(`Failed to generate key pair: ${(error as Error).message}`)
|
2024-12-13 16:16:11 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function generateKeyThumbprint(modulusBase64: string): string {
|
2024-12-25 17:08:43 +08:00
|
|
|
const hash = crypto.createHash('sha256')
|
|
|
|
hash.update(Buffer.from(modulusBase64, 'base64'))
|
|
|
|
return hash.digest('hex')
|
2024-12-13 16:16:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
function publicKeyToJWK(publicKey: string): JWK {
|
|
|
|
// Convert PEM to key object
|
2024-12-25 17:08:43 +08:00
|
|
|
const keyObject = crypto.createPublicKey(publicKey)
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
// Export the key in JWK format
|
2024-12-25 17:08:43 +08:00
|
|
|
const jwk = keyObject.export({ format: 'jwk' })
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
// Generate key ID using the modulus
|
2024-12-25 17:08:43 +08:00
|
|
|
const kid = generateKeyThumbprint(jwk.n as string)
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
return {
|
|
|
|
kid,
|
|
|
|
kty: 'RSA',
|
|
|
|
alg: 'RS256',
|
|
|
|
use: 'sig',
|
|
|
|
e: jwk.e as string,
|
|
|
|
n: jwk.n as string,
|
2024-12-25 17:08:43 +08:00
|
|
|
}
|
2024-12-13 16:16:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
function base64URLEncode(str: string): string {
|
|
|
|
return Buffer.from(str)
|
|
|
|
.toString('base64')
|
|
|
|
.replace(/\+/g, '-')
|
|
|
|
.replace(/\//g, '_')
|
2024-12-25 17:08:43 +08:00
|
|
|
.replace(/=/g, '')
|
2024-12-13 16:16:11 +08:00
|
|
|
}
|
|
|
|
|
2024-12-25 17:08:43 +08:00
|
|
|
function generateJWT(
|
|
|
|
privateKey: string,
|
|
|
|
payload: Record<string, any>,
|
|
|
|
expiresIn: number = 3600
|
|
|
|
): string {
|
2024-12-13 16:16:11 +08:00
|
|
|
// Create header
|
|
|
|
const header = {
|
|
|
|
alg: 'RS256',
|
2024-12-25 17:08:43 +08:00
|
|
|
typ: 'JWT',
|
|
|
|
}
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
// Add expiration to payload
|
2024-12-25 17:08:43 +08:00
|
|
|
const now = Math.floor(Date.now() / 1000)
|
2024-12-13 16:16:11 +08:00
|
|
|
const fullPayload = {
|
|
|
|
...payload,
|
|
|
|
iat: now,
|
2024-12-25 17:08:43 +08:00
|
|
|
exp: now + expiresIn,
|
|
|
|
}
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
// Encode header and payload
|
2024-12-25 17:08:43 +08:00
|
|
|
const encodedHeader = base64URLEncode(JSON.stringify(header))
|
|
|
|
const encodedPayload = base64URLEncode(JSON.stringify(fullPayload))
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
// Create signature
|
2024-12-25 17:08:43 +08:00
|
|
|
const signatureInput = `${encodedHeader}.${encodedPayload}`
|
|
|
|
const signer = crypto.createSign('RSA-SHA256')
|
|
|
|
signer.update(signatureInput)
|
|
|
|
const signature = signer.sign(privateKey)
|
|
|
|
// @ts-expect-error signature is not typed correctly
|
|
|
|
const encodedSignature = base64URLEncode(signature)
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
// Combine all parts
|
2024-12-25 17:08:43 +08:00
|
|
|
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`
|
2024-12-13 16:16:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
describe('Cloudflare Access middleware', async () => {
|
2024-12-25 17:08:43 +08:00
|
|
|
const keyPair1 = await generateJWTKeyPair()
|
|
|
|
const keyPair2 = await generateJWTKeyPair()
|
|
|
|
const keyPair3 = await generateJWTKeyPair()
|
2024-12-13 16:16:11 +08:00
|
|
|
|
2024-12-23 10:19:56 +08:00
|
|
|
beforeEach(() => {
|
2024-12-25 17:08:43 +08:00
|
|
|
vi.clearAllMocks()
|
2024-12-23 10:19:56 +08:00
|
|
|
vi.stubGlobal('fetch', async () => {
|
|
|
|
return Response.json({
|
2024-12-25 17:08:43 +08:00
|
|
|
keys: [publicKeyToJWK(keyPair1.publicKey), publicKeyToJWK(keyPair2.publicKey)],
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
2024-12-25 17:08:43 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
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')))
|
|
|
|
|
2024-12-23 10:19:56 +08:00
|
|
|
app.onError((err, c) => {
|
2024-12-25 17:08:43 +08:00
|
|
|
return c.json(
|
|
|
|
{
|
|
|
|
err: err.toString(),
|
|
|
|
},
|
|
|
|
500
|
|
|
|
)
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
|
|
|
|
2024-12-13 16:16:11 +08:00
|
|
|
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: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': 'asdasdasda',
|
|
|
|
},
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
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 () => {
|
2024-12-25 17:08:43 +08:00
|
|
|
const token = generateJWT(
|
|
|
|
keyPair1.privateKey,
|
|
|
|
{
|
|
|
|
sub: '1234567890',
|
|
|
|
},
|
|
|
|
-3600
|
|
|
|
)
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
const res = await app.request('http://localhost/hello-behind-access', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': token,
|
|
|
|
},
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
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',
|
2024-12-25 17:08:43 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
const res = await app.request('http://localhost/hello-behind-access', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': token,
|
|
|
|
},
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
expect(res).not.toBeNull()
|
|
|
|
expect(res.status).toBe(401)
|
2024-12-25 17:08:43 +08:00
|
|
|
expect(await res.text()).toBe(
|
|
|
|
'Authentication error: Expected team name https://my-cool-team-name.cloudflareaccess.com, but received https://different-team.cloudflareaccess.com'
|
|
|
|
)
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
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',
|
2024-12-25 17:08:43 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
const res = await app.request('http://localhost/hello-behind-access', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': token,
|
|
|
|
},
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
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',
|
2024-12-25 17:08:43 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
const res = await app.request('http://localhost/hello-behind-access', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': token,
|
|
|
|
},
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
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',
|
2024-12-25 17:08:43 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
const res = await app.request('http://localhost/hello-behind-access', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': token,
|
|
|
|
},
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
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',
|
2024-12-25 17:08:43 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
|
|
|
|
const res = await app.request('http://localhost/access-payload', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': token,
|
|
|
|
},
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
expect(res).not.toBeNull()
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
expect(await res.json()).toEqual({
|
2024-12-25 17:08:43 +08:00
|
|
|
sub: '1234567890',
|
|
|
|
iss: 'https://my-cool-team-name.cloudflareaccess.com',
|
|
|
|
iat: expect.any(Number),
|
|
|
|
exp: expect.any(Number),
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|
|
|
|
})
|
2024-12-23 10:19:56 +08:00
|
|
|
|
|
|
|
it('Should throw an error, if the access organization does not exist', async () => {
|
|
|
|
vi.stubGlobal('fetch', async () => {
|
2024-12-25 17:08:43 +08:00
|
|
|
return Response.json({ success: false }, { status: 404 })
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
const res = await app.request('http://localhost/hello-behind-access', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': 'asdads',
|
|
|
|
},
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
|
|
|
expect(res).not.toBeNull()
|
|
|
|
expect(res.status).toBe(500)
|
2024-12-25 17:08:43 +08:00
|
|
|
expect(await res.json()).toEqual({
|
|
|
|
err: "Error: Authentication error: The Access Organization 'my-cool-team-name' does not exist",
|
|
|
|
})
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
it('Should throw an error, if the access certs url is unavailable', async () => {
|
|
|
|
vi.stubGlobal('fetch', async () => {
|
2024-12-25 17:08:43 +08:00
|
|
|
return Response.json({ success: false }, { status: 500 })
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
const res = await app.request('http://localhost/hello-behind-access', {
|
|
|
|
headers: {
|
2024-12-25 17:08:43 +08:00
|
|
|
'cf-access-jwt-assertion': 'asdads',
|
|
|
|
},
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
|
|
|
expect(res).not.toBeNull()
|
|
|
|
expect(res.status).toBe(500)
|
2024-12-25 17:08:43 +08:00
|
|
|
expect(await res.json()).toEqual({
|
|
|
|
err: 'Error: Authentication error: Received unexpected HTTP code 500 from Cloudflare Access',
|
|
|
|
})
|
2024-12-23 10:19:56 +08:00
|
|
|
})
|
2024-12-13 16:16:11 +08:00
|
|
|
})
|