328 lines
9.7 KiB
TypeScript
328 lines
9.7 KiB
TypeScript
|
|
/**
|
||
|
|
* JWT Extension for APOPHIS
|
||
|
|
*
|
||
|
|
* Provides APOSTL predicates for JWT claim inspection, header access,
|
||
|
|
* and signature validation. Supports extracting JWT from Authorization
|
||
|
|
* header, response body, or custom headers.
|
||
|
|
*
|
||
|
|
* Example:
|
||
|
|
* ```typescript
|
||
|
|
* import { jwtExtension } from 'apophis-fastify/extensions/jwt'
|
||
|
|
*
|
||
|
|
* await fastify.register(apophis, {
|
||
|
|
* extensions: [
|
||
|
|
* jwtExtension({
|
||
|
|
* jwks: 'https://auth.example.com/.well-known/jwks.json',
|
||
|
|
* verify: true,
|
||
|
|
* })
|
||
|
|
* ]
|
||
|
|
* })
|
||
|
|
* ```
|
||
|
|
*
|
||
|
|
* APOSTL formulas:
|
||
|
|
* ```apostl
|
||
|
|
* jwt_claims(this).sub != null
|
||
|
|
* jwt_claims(this).exp > jwt_claims(this).iat
|
||
|
|
* jwt_header(this).alg == "RS256"
|
||
|
|
* jwt_valid(this) == true
|
||
|
|
* jwt_format(this) == "compact"
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { ApophisExtension, PredicateContext } from '../extension/types.js'
|
||
|
|
import { createHmac, createPublicKey, createVerify } from 'node:crypto'
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Types
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
export interface JwtExtensionConfig {
|
||
|
|
/** JWKS endpoint URL for signature verification */
|
||
|
|
jwks?: string
|
||
|
|
/** Inline key material: kid -> PEM or JWK */
|
||
|
|
keys?: Record<string, string>
|
||
|
|
/** Where to extract JWT from. Default: 'authorization' */
|
||
|
|
extractFrom?: 'authorization' | 'body' | 'header' | 'query'
|
||
|
|
/** Custom header name when extractFrom is 'header' */
|
||
|
|
headerName?: string
|
||
|
|
/** Whether to verify signatures. Default: true */
|
||
|
|
verify?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
interface DecodedJwt {
|
||
|
|
header: Record<string, unknown>
|
||
|
|
payload: Record<string, unknown>
|
||
|
|
signature: string
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Base64URL Decoding (no verification)
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function base64UrlDecode(str: string): string {
|
||
|
|
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
||
|
|
const padLen = (4 - (base64.length % 4)) % 4
|
||
|
|
const padded = base64 + '='.repeat(padLen)
|
||
|
|
return Buffer.from(padded, 'base64').toString('utf8')
|
||
|
|
}
|
||
|
|
|
||
|
|
function base64UrlToBuffer(str: string): Buffer {
|
||
|
|
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
||
|
|
const padLen = (4 - (base64.length % 4)) % 4
|
||
|
|
const padded = base64 + '='.repeat(padLen)
|
||
|
|
return Buffer.from(padded, 'base64')
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseCompactJwt(token: string): DecodedJwt | null {
|
||
|
|
const parts = token.split('.')
|
||
|
|
if (parts.length !== 3) return null
|
||
|
|
|
||
|
|
try {
|
||
|
|
const header = JSON.parse(base64UrlDecode(parts[0]!))
|
||
|
|
const payload = JSON.parse(base64UrlDecode(parts[1]!))
|
||
|
|
return { header, payload, signature: parts[2]! }
|
||
|
|
} catch {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// JWT Extraction
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function extractJwt(ctx: PredicateContext, config: JwtExtensionConfig): string | null {
|
||
|
|
const extractFrom = config.extractFrom ?? 'authorization'
|
||
|
|
|
||
|
|
switch (extractFrom) {
|
||
|
|
case 'authorization': {
|
||
|
|
const auth = ctx.evalContext.request.headers['authorization']
|
||
|
|
if (typeof auth === 'string') {
|
||
|
|
const match = auth.match(/^Bearer\s+(.+)$/i)
|
||
|
|
if (match && match[1]) {
|
||
|
|
return match[1].trim()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
case 'body': {
|
||
|
|
const body = ctx.evalContext.response.body
|
||
|
|
if (body && typeof body === 'object') {
|
||
|
|
const token = (body as Record<string, unknown>)['access_token']
|
||
|
|
?? (body as Record<string, unknown>)['token']
|
||
|
|
?? (body as Record<string, unknown>)['jwt']
|
||
|
|
if (typeof token === 'string') return token
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
case 'header': {
|
||
|
|
const headerName = config.headerName ?? 'x-jwt'
|
||
|
|
const value = ctx.evalContext.request.headers[headerName.toLowerCase()]
|
||
|
|
if (typeof value === 'string') return value
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
case 'query': {
|
||
|
|
const query = ctx.evalContext.request.query
|
||
|
|
const token = query['token'] ?? query['jwt'] ?? query['access_token']
|
||
|
|
if (typeof token === 'string') return token
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Extension State
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
interface JwtState {
|
||
|
|
seenJtis: Set<string>
|
||
|
|
decodedCache: Map<string, DecodedJwt>
|
||
|
|
jwksCache?: Record<string, unknown>
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Predicate Resolvers
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function resolveClaims(ctx: PredicateContext, config: JwtExtensionConfig, state: JwtState): unknown {
|
||
|
|
const token = extractJwt(ctx, config)
|
||
|
|
if (!token) return null
|
||
|
|
|
||
|
|
const cached = state.decodedCache.get(token)
|
||
|
|
if (cached) return cached.payload
|
||
|
|
|
||
|
|
const decoded = parseCompactJwt(token)
|
||
|
|
if (!decoded) return null
|
||
|
|
|
||
|
|
state.decodedCache.set(token, decoded)
|
||
|
|
|
||
|
|
// Track JTI for replay detection
|
||
|
|
const jti = decoded.payload['jti']
|
||
|
|
if (typeof jti === 'string') {
|
||
|
|
state.seenJtis.add(jti)
|
||
|
|
}
|
||
|
|
|
||
|
|
return decoded.payload
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveHeader(ctx: PredicateContext, config: JwtExtensionConfig, state: JwtState): unknown {
|
||
|
|
const token = extractJwt(ctx, config)
|
||
|
|
if (!token) return null
|
||
|
|
|
||
|
|
const cached = state.decodedCache.get(token)
|
||
|
|
if (cached) return cached.header
|
||
|
|
|
||
|
|
const decoded = parseCompactJwt(token)
|
||
|
|
if (!decoded) return null
|
||
|
|
|
||
|
|
state.decodedCache.set(token, decoded)
|
||
|
|
return decoded.header
|
||
|
|
}
|
||
|
|
|
||
|
|
function getVerificationKey(config: JwtExtensionConfig, kid: string | undefined): string | undefined {
|
||
|
|
if (!config.keys) return undefined
|
||
|
|
if (kid && config.keys[kid]) return config.keys[kid]
|
||
|
|
return config.keys.default
|
||
|
|
}
|
||
|
|
|
||
|
|
function verifyJwt(token: string, decoded: DecodedJwt, config: JwtExtensionConfig): boolean {
|
||
|
|
const alg = typeof decoded.header.alg === 'string' ? decoded.header.alg : undefined
|
||
|
|
if (!alg) return false
|
||
|
|
|
||
|
|
// Reject unsupported algorithms early to prevent alg confusion
|
||
|
|
const supportedAlgs = ['HS256', 'RS256', 'ES256']
|
||
|
|
if (!supportedAlgs.includes(alg)) return false
|
||
|
|
|
||
|
|
const [encodedHeader, encodedPayload, encodedSignature] = token.split('.')
|
||
|
|
if (!encodedHeader || !encodedPayload || !encodedSignature) return false
|
||
|
|
|
||
|
|
const signingInput = `${encodedHeader}.${encodedPayload}`
|
||
|
|
const signature = base64UrlToBuffer(encodedSignature)
|
||
|
|
const kid = typeof decoded.header.kid === 'string' ? decoded.header.kid : undefined
|
||
|
|
const key = getVerificationKey(config, kid)
|
||
|
|
if (!key) return false
|
||
|
|
|
||
|
|
if (alg === 'HS256') {
|
||
|
|
const expected = createHmac('sha256', key).update(signingInput).digest()
|
||
|
|
return expected.length === signature.length && expected.equals(signature)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (alg === 'RS256') {
|
||
|
|
const verifier = createVerify('RSA-SHA256')
|
||
|
|
verifier.update(signingInput)
|
||
|
|
verifier.end()
|
||
|
|
return verifier.verify(createPublicKey(key), signature)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (alg === 'ES256') {
|
||
|
|
const verifier = createVerify('SHA256')
|
||
|
|
verifier.update(signingInput)
|
||
|
|
verifier.end()
|
||
|
|
return verifier.verify(createPublicKey(key), signature)
|
||
|
|
}
|
||
|
|
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveValid(ctx: PredicateContext, config: JwtExtensionConfig, state: JwtState): unknown {
|
||
|
|
const token = extractJwt(ctx, config)
|
||
|
|
if (!token) return false
|
||
|
|
|
||
|
|
const cached = state.decodedCache.get(token)
|
||
|
|
const decoded = cached ?? parseCompactJwt(token)
|
||
|
|
if (!decoded) return false
|
||
|
|
if (!cached) {
|
||
|
|
state.decodedCache.set(token, decoded)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.verify === false) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
return verifyJwt(token, decoded, config)
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveFormat(ctx: PredicateContext, config: JwtExtensionConfig): unknown {
|
||
|
|
const token = extractJwt(ctx, config)
|
||
|
|
if (!token) return null
|
||
|
|
|
||
|
|
const parts = token.split('.')
|
||
|
|
if (parts.length === 3) return 'compact'
|
||
|
|
if (parts.length === 1) {
|
||
|
|
try {
|
||
|
|
JSON.parse(base64UrlDecode(parts[0]!))
|
||
|
|
return 'json'
|
||
|
|
} catch {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Extension Factory
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a JWT extension for APOPHIS.
|
||
|
|
*
|
||
|
|
* Provides predicates for inspecting JWT claims and headers without
|
||
|
|
* requiring the application under test to expose them.
|
||
|
|
*
|
||
|
|
* @param config - JWT extension configuration
|
||
|
|
* @returns ApophisExtension ready for registration
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* ```typescript
|
||
|
|
* const jwt = jwtExtension({
|
||
|
|
* jwks: 'https://auth.example.com/.well-known/jwks.json',
|
||
|
|
* verify: true,
|
||
|
|
* })
|
||
|
|
*
|
||
|
|
* await fastify.register(apophis, { extensions: [jwt] })
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
export function jwtExtension(config: JwtExtensionConfig = {}): ApophisExtension {
|
||
|
|
return {
|
||
|
|
name: 'jwt',
|
||
|
|
|
||
|
|
headers: ['jwt_claims', 'jwt_header', 'jwt_valid', 'jwt_format'],
|
||
|
|
|
||
|
|
onSuiteStart: () => {
|
||
|
|
const state: Record<string, unknown> = {
|
||
|
|
seenJtis: new Set<string>(),
|
||
|
|
decodedCache: new Map<string, DecodedJwt>(),
|
||
|
|
}
|
||
|
|
return state
|
||
|
|
},
|
||
|
|
|
||
|
|
predicates: {
|
||
|
|
jwt_claims: (ctx) => {
|
||
|
|
const state = ctx.extensionState as unknown as JwtState
|
||
|
|
const value = resolveClaims(ctx, config, state)
|
||
|
|
return { value, success: true }
|
||
|
|
},
|
||
|
|
|
||
|
|
jwt_header: (ctx) => {
|
||
|
|
const state = ctx.extensionState as unknown as JwtState
|
||
|
|
const value = resolveHeader(ctx, config, state)
|
||
|
|
return { value, success: true }
|
||
|
|
},
|
||
|
|
|
||
|
|
jwt_valid: (ctx) => {
|
||
|
|
const state = ctx.extensionState as unknown as JwtState
|
||
|
|
const value = resolveValid(ctx, config, state)
|
||
|
|
return { value, success: true }
|
||
|
|
},
|
||
|
|
|
||
|
|
jwt_format: (ctx) => {
|
||
|
|
const value = resolveFormat(ctx, config)
|
||
|
|
return { value, success: true }
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Type is already exported at declaration site
|