chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user