41 KiB
APOPHIS v1.0 — Authentication, Authorization & Rate Limiting Extension
1. Overview
This document specifies the extension of APOPHIS v1.0 to support three production-critical concerns:
- Authentication Flows — JWT, OAuth 2.1, and session-based authentication
- Rate Limiting — Contract-level rate limit validation and burst testing
- Authorization/Scope Claims — Fine-grained permission modeling in contracts
These features integrate with the existing APOSTL formula language, scope registry, and test runners without breaking the v1.0 hard-break API contract.
2. Authentication Flows
2.1 Design Principles
- Auth is a cross-cutting concern, not a route category. Auth requirements are declared in schema annotations.
- Test isolation: Each test run receives its own auth context. No shared tokens across tests.
- Deterministic: Auth flows are simulated, not delegated to external IdPs. Test keys are generated locally.
- Three auth modes: JWT (stateless), OAuth 2.1 (grant flows), Session (cookie-based).
2.2 Auth State Model
Auth state is tracked per-test-run in an AuthContext object:
// src/types.ts (additions)
export type AuthFlow = 'jwt' | 'oauth2' | 'session' | 'none'
export interface AuthContext {
readonly flow: AuthFlow
readonly token: string | null // Current access token (JWT or OAuth)
readonly refreshToken: string | null // OAuth refresh token
readonly tokenExpiry: number | null // Unix timestamp (ms)
readonly sessionCookie: string | null // Session ID for cookie flows
readonly scopes: string[] // Granted scopes
readonly claims: Record<string, unknown> // Decoded claims (JWT payload or OAuth token introspection)
}
export interface AuthConfig {
readonly flow: AuthFlow
readonly issuer?: string
readonly audience?: string
readonly clientId?: string
readonly clientSecret?: string
readonly tokenEndpoint?: string
readonly authorizationEndpoint?: string
readonly scopes?: string[]
readonly testKeyPair?: { publicKey: string; privateKey: string }
readonly sessionSecret?: string
}
2.3 Schema Annotations
Two new schema extensions declare auth requirements:
// In route schema (e.g., schema.response[200] or top-level schema)
{
"x-auth": "jwt", // Required auth flow for this route
"x-scopes": ["read:users", "admin"], // Required scopes (any match)
"x-scopes-match": "any", // "any" | "all" — default "any"
"x-auth-optional": false // If true, route works with or without auth
}
Annotation semantics:
x-auth: Declares which auth flow the route requires. Values:"jwt","oauth2","session","none"(default).x-scopes: Array of scope strings. Checked againstAuthContext.scopes.x-scopes-match:"any"means at least one scope required;"all"means all required.x-auth-optional: Iftrue, the route does not fail when auth is missing (useful for public endpoints with optional auth).
2.4 Type Changes in src/types.ts
Add to RouteContract interface (line 12-22):
export interface RouteContract {
path: string
method: string
category: OperationCategory
requires: string[]
ensures: string[]
invariants: string[]
regexPatterns: Record<string, string>
validateRuntime: boolean
schema?: Record<string, unknown>
// NEW:
authFlow: AuthFlow
requiredScopes: string[]
scopesMatch: 'any' | 'all'
authOptional: boolean
}
Add to EvalContext (line 71-86):
export interface EvalContext {
readonly request: { /* ... */ }
readonly response: { /* ... */ }
readonly previous?: EvalContext
// NEW:
readonly auth: AuthContext
}
Add to ApophisOptions (line 257-262):
export interface ApophisOptions {
readonly swagger?: Record<string, unknown>
readonly runtime?: 'off' | 'warn' | 'error'
readonly cleanup?: boolean
readonly scopes?: Record<string, ScopeConfig>
// NEW:
readonly auth?: AuthConfig
}
Add to TestConfig (line 144-148):
export interface TestConfig {
readonly depth?: TestDepth
readonly scope?: string
readonly seed?: number
// NEW:
readonly auth?: AuthConfig
}
2.5 APOSTL Extensions for Auth
New operation headers for auth introspection:
// Add to OperationHeader type (line 58)
export type OperationHeader =
| 'request_body' | 'response_body' | 'response_code'
| 'request_headers' | 'response_headers' | 'query_params'
| 'cookies' | 'response_time'
// NEW:
| 'jwt_claim' | 'auth_scope'
New formula syntax:
jwt_claim(this).sub == "user-123"
jwt_claim(this).role == "admin"
auth_scope(this).read:users
auth_scope(this).admin
Semantics:
jwt_claim(this).<claim>: Access a claim from the decoded JWT payload. Returnsundefinedif no JWT or claim missing.auth_scope(this).<scope>: Returnstrueif the scope is present inAuthContext.scopes,falseotherwise.
Parser changes (src/formula/parser.ts, line 222-225):
const VALID_HEADERS: OperationHeader[] = [
'request_body', 'response_body', 'response_code',
'request_headers', 'response_headers', 'query_params', 'cookies', 'response_time',
// NEW:
'jwt_claim', 'auth_scope'
]
Add parser branches for jwt_claim (9 chars) and auth_scope (10 chars) in parseOperation() (around line 323).
Evaluator changes (src/formula/evaluator.ts, line 9-65):
function resolveOperation(node: Extract<FormulaNode, { type: 'operation' }>, ctx: EvalContext): unknown {
const { header, parameter, accessor } = node
switch (header) {
// ... existing cases ...
// NEW:
case 'jwt_claim':
if (!ctx.auth.token || ctx.auth.flow !== 'jwt') return undefined
return accessor && accessor.length > 0
? getNestedValue(ctx.auth.claims, accessor)
: ctx.auth.claims
case 'auth_scope':
if (!accessor || accessor.length === 0) return false
const scope = accessor.join(':') // Handle scopes like "read:users"
return ctx.auth.scopes.includes(scope)
default:
throw new Error(`Unknown operation header: ${header}`)
}
}
2.6 Token Generation Helpers for Testing
New module: src/infrastructure/auth-test-helpers.ts
/**
* Auth Test Helpers
* Deterministic token generation for testing. No external IdP calls.
*/
import { createSign, createVerify, randomBytes } from 'node:crypto'
export interface TestKeyPair {
readonly publicKey: string
readonly privateKey: string
}
export const generateTestKeyPair = (): TestKeyPair => {
// Generate a 2048-bit RSA key pair for JWT signing
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
return { publicKey, privateKey }
}
export const signTestJwt = (
payload: Record<string, unknown>,
privateKey: string,
options: { expiresIn?: number; issuer?: string; audience?: string } = {}
): string => {
const header = { alg: 'RS256', typ: 'JWT' }
const now = Math.floor(Date.now() / 1000)
const claims = {
...payload,
iat: now,
exp: options.expiresIn ? now + options.expiresIn : now + 3600,
...(options.issuer ? { iss: options.issuer } : {}),
...(options.audience ? { aud: options.audience } : {}),
}
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url')
const claimsB64 = Buffer.from(JSON.stringify(claims)).toString('base64url')
const signingInput = `${headerB64}.${claimsB64}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(privateKey, 'base64url')
return `${signingInput}.${signature}`
}
export const verifyTestJwt = (token: string, publicKey: string): Record<string, unknown> | null => {
const [headerB64, claimsB64, signature] = token.split('.')
if (!headerB64 || !claimsB64 || !signature) return null
const verifier = createVerify('RSA-SHA256')
verifier.update(`${headerB64}.${claimsB64}`)
const valid = verifier.verify(publicKey, signature, 'base64url')
if (!valid) return null
return JSON.parse(Buffer.from(claimsB64, 'base64url').toString())
}
export const generateTestSessionCookie = (sessionId: string, secret: string): string => {
const signature = createHmac('sha256', secret).update(sessionId).digest('base64url')
return `session=${sessionId}.${signature}`
}
export const parseTestSessionCookie = (cookie: string, secret: string): string | null => {
const match = cookie.match(/session=([^;]+)/)
if (!match) return null
const [sessionId, signature] = match[1].split('.')
if (!sessionId || !signature) return null
const expected = createHmac('sha256', secret).update(sessionId).digest('base64url')
return signature === expected ? sessionId : null
}
2.7 OAuth 2.1 Grant Flow Simulation
New module: src/infrastructure/oauth-simulator.ts
/**
* OAuth 2.1 Grant Flow Simulator
* Simulates authorization code, client credentials, and PKCE flows
* without external IdP dependency. Returns tokens deterministically.
*/
import { signTestJwt, generateTestKeyPair } from './auth-test-helpers.js'
import type { AuthContext, AuthConfig } from '../types.js'
export interface OAuthSimulationResult {
readonly accessToken: string
readonly refreshToken: string
readonly tokenType: 'Bearer'
readonly expiresIn: number
readonly scope: string
}
export class OAuthSimulator {
private readonly keyPair: ReturnType<typeof generateTestKeyPair>
private readonly config: AuthConfig
private codeChallengeStore: Map<string, string> = new Map()
constructor(config: AuthConfig) {
this.config = config
this.keyPair = config.testKeyPair ?? generateTestKeyPair()
}
/**
* Simulate Authorization Code flow (with optional PKCE)
*/
async authorizationCode(params: {
code: string
codeVerifier?: string
redirectUri: string
clientId: string
}): Promise<OAuthSimulationResult> {
// Validate code challenge if PKCE was used
if (params.codeVerifier) {
const challenge = this.codeChallengeStore.get(params.code)
const verifierHash = createHash('sha256').update(params.codeVerifier).digest('base64url')
if (verifierHash !== challenge) {
throw new Error('invalid_grant: PKCE verification failed')
}
}
return this.issueToken(params.clientId, this.config.scopes ?? ['openid'])
}
/**
* Simulate Client Credentials flow
*/
async clientCredentials(params: {
clientId: string
clientSecret: string
scope?: string
}): Promise<OAuthSimulationResult> {
// Validate client credentials (deterministic check)
if (params.clientSecret !== `secret-${params.clientId}`) {
throw new Error('invalid_client: Client authentication failed')
}
const scopes = params.scope ? params.scope.split(' ') : (this.config.scopes ?? [])
return this.issueToken(params.clientId, scopes)
}
/**
* Simulate PKCE authorization endpoint (returns code + stores challenge)
*/
async authorize(params: {
responseType: string
clientId: string
redirectUri: string
scope?: string
state?: string
codeChallenge?: string
codeChallengeMethod?: 'S256' | 'plain'
}): Promise<{ code: string; state?: string }> {
if (params.responseType !== 'code') {
throw new Error('unsupported_response_type')
}
const code = randomBytes(16).toString('hex')
if (params.codeChallenge) {
this.codeChallengeStore.set(code, params.codeChallenge)
}
return { code, state: params.state }
}
private issueToken(clientId: string, scopes: string[]): OAuthSimulationResult {
const accessToken = signTestJwt(
{ sub: clientId, scope: scopes.join(' '), client_id: clientId },
this.keyPair.privateKey,
{ issuer: this.config.issuer, audience: this.config.audience, expiresIn: 3600 }
)
const refreshToken = randomBytes(32).toString('base64url')
return {
accessToken,
refreshToken,
tokenType: 'Bearer',
expiresIn: 3600,
scope: scopes.join(' '),
}
}
}
2.8 Session Cookie Flow Simulation
New module: src/infrastructure/session-simulator.ts
/**
* Session Cookie Flow Simulator
* Manages session state for cookie-based auth testing.
*/
import { randomBytes } from 'node:crypto'
import type { AuthContext, AuthConfig } from '../types.js'
interface Session {
readonly id: string
readonly data: Record<string, unknown>
readonly createdAt: number
}
export class SessionSimulator {
private readonly sessions: Map<string, Session> = new Map()
private readonly secret: string
constructor(config: AuthConfig) {
this.secret = config.sessionSecret ?? 'test-session-secret-change-in-production'
}
createSession(data: Record<string, unknown> = {}): Session {
const id = randomBytes(16).toString('hex')
const session: Session = { id, data, createdAt: Date.now() }
this.sessions.set(id, session)
return session
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId)
}
destroySession(sessionId: string): boolean {
return this.sessions.delete(sessionId)
}
generateCookie(sessionId: string): string {
return generateTestSessionCookie(sessionId, this.secret)
}
parseCookie(cookieHeader: string): string | null {
return parseTestSessionCookie(cookieHeader, this.secret)
}
}
2.9 Changes to src/infrastructure/scope-registry.ts
The scope registry needs to integrate auth context into scope resolution. When a scope is configured with auth metadata, the registry should include auth headers.
Changes (around line 88-101):
getHeaders(scopeName: string | null, overrides?: Record<string, string>, authContext?: AuthContext): Record<string, string> {
const scope = scopeName !== null ? this.scopes.get(scopeName) : undefined
const base = scope ?? this.defaultScope
const tenantId = base.metadata?.tenantId as string | undefined
const applicationId = base.metadata?.applicationId as string | undefined
const headers: Record<string, string> = {
...base.headers,
...(tenantId !== undefined && tenantId !== 'default' ? { 'x-tenant-id': tenantId } : {}),
...(applicationId !== undefined && applicationId !== 'default' ? { 'x-application-id': applicationId } : {}),
...(overrides ?? {}),
}
// Inject auth headers if auth context is provided
if (authContext?.token) {
if (authContext.flow === 'jwt' || authContext.flow === 'oauth2') {
headers['authorization'] = `Bearer ${authContext.token}`
} else if (authContext.flow === 'session' && authContext.sessionCookie) {
headers['cookie'] = authContext.sessionCookie
}
}
return headers
}
2.10 Changes to src/domain/request-builder.ts
The request builder needs to inject auth headers based on route requirements and current auth context.
Changes to buildHeaders() (line 119-133):
const buildHeaders = (
route: RouteContract,
scopeHeaders: Record<string, string>,
data: Record<string, unknown>,
_state: ModelState,
authContext?: AuthContext // NEW parameter
): Record<string, string> => {
const headers: Record<string, string> = { ...scopeHeaders }
// Content-Type for body requests
if (route.schema?.body) {
headers['content-type'] = 'application/json'
}
// Inject auth headers based on route's auth flow requirement
if (route.authFlow !== 'none' && authContext) {
if (route.authFlow === 'jwt' || route.authFlow === 'oauth2') {
if (authContext.token) {
headers['authorization'] = `Bearer ${authContext.token}`
}
} else if (route.authFlow === 'session' && authContext.sessionCookie) {
headers['cookie'] = authContext.sessionCookie
}
}
return headers
}
Changes to buildRequest() signature (line 135-163):
export const buildRequest = (
route: RouteContract,
generatedData: Record<string, unknown>,
scopeHeaders: Record<string, string>,
state: ModelState,
rng?: SeededRng,
authContext?: AuthContext // NEW parameter
): RequestStructure => {
const url = substitutePathParams(route.path, generatedData, state, rng)
const bodySchema = route.schema?.body as Record<string, unknown> | undefined
const body = bodySchema ? extractBodyParams(generatedData, bodySchema) : undefined
const querySchema = route.schema?.querystring as Record<string, unknown> | undefined
const query = querySchema
? extractQueryParams(generatedData, querySchema)
: extractRemainingParams(generatedData, parseRouteParams(route.path), body)
// Pass authContext to buildHeaders
const headers = buildHeaders(route, scopeHeaders, generatedData, state, authContext)
const contentType = body ? 'application/json' : undefined
return { method: route.method, url, headers, query, body, contentType }
}
2.11 Auth Context Initialization in Test Runners
Both petit-runner.ts and stateful-runner.ts need to initialize auth context before test execution.
In runPetitTests() (src/test/petit-runner.ts, line 188-220):
export const runPetitTests = async (
fastify: FastifyInjectInstance,
config: TestConfig,
scopeRegistry?: ScopeRegistry
): Promise<TestSuite> => {
// ... existing setup ...
// Initialize auth context if configured
let authContext: AuthContext = {
flow: config.auth?.flow ?? 'none',
token: null,
refreshToken: null,
tokenExpiry: null,
sessionCookie: null,
scopes: [],
claims: {},
}
if (config.auth && config.auth.flow !== 'none') {
authContext = await initializeAuth(config.auth)
}
// Pass authContext to buildRequest in the execution loop
for (const command of allCommands) {
// ...
const request = buildRequest(command.route, command.params, scopeHeaders, state, rng, authContext)
// ...
}
}
Auth initialization helper (new function):
async function initializeAuth(config: AuthConfig): Promise<AuthContext> {
switch (config.flow) {
case 'jwt': {
const keyPair = config.testKeyPair ?? generateTestKeyPair()
const token = signTestJwt(
{ sub: 'test-user', scope: (config.scopes ?? []).join(' ') },
keyPair.privateKey,
{ issuer: config.issuer, audience: config.audience }
)
const claims = verifyTestJwt(token, keyPair.publicKey) ?? {}
return {
flow: 'jwt',
token,
refreshToken: null,
tokenExpiry: Date.now() + 3600000,
sessionCookie: null,
scopes: config.scopes ?? [],
claims,
}
}
case 'oauth2': {
const simulator = new OAuthSimulator(config)
const result = await simulator.clientCredentials({
clientId: config.clientId ?? 'test-client',
clientSecret: config.clientSecret ?? `secret-${config.clientId ?? 'test-client'}`,
scope: (config.scopes ?? []).join(' '),
})
const claims = verifyTestJwt(result.accessToken, simulator['keyPair'].publicKey) ?? {}
return {
flow: 'oauth2',
token: result.accessToken,
refreshToken: result.refreshToken,
tokenExpiry: Date.now() + result.expiresIn * 1000,
sessionCookie: null,
scopes: result.scope.split(' '),
claims,
}
}
case 'session': {
const simulator = new SessionSimulator(config)
const session = simulator.createSession({ userId: 'test-user', roles: config.scopes ?? [] })
const cookie = simulator.generateCookie(session.id)
return {
flow: 'session',
token: null,
refreshToken: null,
tokenExpiry: null,
sessionCookie: cookie,
scopes: config.scopes ?? [],
claims: session.data,
}
}
case 'none':
default:
return { flow: 'none', token: null, refreshToken: null, tokenExpiry: null, sessionCookie: null, scopes: [], claims: {} }
}
}
2.12 Contract Extraction for Auth Annotations
Update src/domain/contract.ts to extract auth annotations:
Changes to extractContract() (around line 63):
const contract: RouteContract = {
path,
method: method.toUpperCase(),
category,
requires,
ensures,
invariants: EMPTY_INVARIANTS,
regexPatterns: {},
validateRuntime,
schema: s,
// NEW:
authFlow: (s['x-auth'] as AuthFlow) ?? 'none',
requiredScopes: Array.isArray(s['x-scopes']) ? (s['x-scopes'] as string[]) : [],
scopesMatch: (s['x-scopes-match'] as 'any' | 'all') ?? 'any',
authOptional: s['x-auth-optional'] === true,
}
2.13 Example Fastify Routes with Auth Contracts
// JWT-protected route
fastify.get('/users/:id', {
schema: {
params: { type: 'object', properties: { id: { type: 'string' } } },
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
role: { type: 'string' }
},
'x-auth': 'jwt',
'x-scopes': ['read:users'],
'x-ensures': [
'jwt_claim(this).sub != null',
'response_body(this).id != null',
'response_body(this).email != null'
]
}
}
}
}, async (req, reply) => {
// Handler implementation
})
// OAuth 2.1 protected route with admin scope
fastify.post('/admin/users', {
schema: {
body: {
type: 'object',
properties: {
email: { type: 'string' },
role: { type: 'string', enum: ['user', 'admin'] }
}
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' }
},
'x-auth': 'oauth2',
'x-scopes': ['admin', 'write:users'],
'x-scopes-match': 'any',
'x-ensures': [
'auth_scope(this).write:users',
'response_code(this) == 201',
'response_body(this).id != null'
]
}
}
}
}, async (req, reply) => {
// Handler implementation
})
// Session-based auth route
fastify.get('/profile', {
schema: {
response: {
200: {
type: 'object',
properties: {
name: { type: 'string' },
preferences: { type: 'object' }
},
'x-auth': 'session',
'x-ensures': [
'response_body(this).name != null',
'jwt_claim(this).sub == null' // JWT should NOT be present in session auth
]
}
}
}
}, async (req, reply) => {
// Handler implementation
})
// Public route with optional auth
fastify.get('/public/health', {
schema: {
response: {
200: {
type: 'object',
properties: { status: { type: 'string' } },
'x-auth': 'none',
'x-auth-optional': true,
'x-ensures': ['response_body(this).status == "ok"']
}
}
}
}, async (req, reply) => {
return { status: 'ok' }
})
3. Rate Limiting
3.1 Design Principles
- Rate limits are contracts, not just infrastructure config. They are validated like any other postcondition.
- Burst testing mode: The fuzzer can send rapid sequential requests to trigger rate limits.
- State tracking: Rate limit state (remaining quota, reset time) is tracked across test runs for accurate validation.
3.2 Contract Annotations
// In route schema
{
"x-rate-limit": {
"requests": 100, // Max requests per window
"window": "1m", // Time window (1m, 1h, 1d)
"burst": 10, // Max burst allowed
"key": "ip" // Rate limit key: "ip" | "user" | "tenant" | "global"
}
}
Annotation semantics:
x-rate-limit.requests: Maximum number of requests allowed in the window.x-rate-limit.window: Time window as a duration string (e.g.,"1m","1h","1d").x-rate-limit.burst: Maximum burst size (requests that can exceed the steady rate temporarily).x-rate-limit.key: How to identify the rate limit bucket."ip"uses client IP,"user"uses authenticated user,"tenant"uses tenant ID,"global"is a single bucket.
3.3 Type Changes in src/types.ts
Add to RouteContract:
export interface RateLimitConfig {
readonly requests: number
readonly window: string
readonly burst: number
readonly key: 'ip' | 'user' | 'tenant' | 'global'
}
export interface RouteContract {
// ... existing fields ...
rateLimit?: RateLimitConfig
}
Add to EvalContext:
export interface EvalContext {
// ... existing fields ...
readonly rateLimit?: {
readonly remaining: number
readonly limit: number
readonly reset: number
readonly window: string
}
}
3.4 APOSTL Formulas for Rate Limit Headers
New operation headers:
export type OperationHeader =
// ... existing headers ...
| 'rate_limit_remaining'
| 'rate_limit_limit'
| 'rate_limit_reset'
Formula syntax:
response_headers(this).x-ratelimit-remaining >= 0
rate_limit_remaining(this) >= 0
rate_limit_limit(this) == 100
rate_limit_reset(this) > 0
Semantics:
rate_limit_remaining(this): Returns the number of requests remaining in the current window (from response headers).rate_limit_limit(this): Returns the total request limit for the window.rate_limit_reset(this): Returns the Unix timestamp when the rate limit window resets.
3.5 Burst Testing Mode in the Fuzzer
New test configuration option:
export interface TestConfig {
// ... existing fields ...
readonly burst?: boolean // Enable burst testing mode
}
Burst mode behavior (src/test/petit-runner.ts):
When burst: true, the PETIT runner sends requests rapidly without delay between them:
// In the execution loop (around line 221)
for (const command of allCommands) {
testId++
// ... preconditions check ...
const request = buildRequest(command.route, command.params, scopeHeaders, state, rng, authContext)
// Burst mode: no delay between requests
const ctx = await executeHttp(fastify, command.route, request, previousCtx)
// Track rate limit headers in context
if (ctx.response.headers['x-ratelimit-remaining']) {
ctx.rateLimit = {
remaining: parseInt(ctx.response.headers['x-ratelimit-remaining'], 10),
limit: parseInt(ctx.response.headers['x-ratelimit-limit'] ?? '0', 10),
reset: parseInt(ctx.response.headers['x-ratelimit-reset'] ?? '0', 10),
window: command.route.rateLimit?.window ?? '1m',
}
}
// ... postcondition validation ...
}
3.6 Rate Limit State Tracking
New module: src/infrastructure/rate-limit-tracker.ts
/**
* Rate Limit State Tracker
* Tracks rate limit consumption across test runs for accurate validation.
*/
export interface RateLimitState {
readonly bucket: string
readonly remaining: number
readonly limit: number
readonly resetAt: number
readonly window: string
}
export class RateLimitTracker {
private readonly state: Map<string, RateLimitState> = new Map()
update(bucket: string, remaining: number, limit: number, resetAt: number, window: string): void {
this.state.set(bucket, { bucket, remaining, limit, resetAt, window })
}
get(bucket: string): RateLimitState | undefined {
return this.state.get(bucket)
}
isExhausted(bucket: string): boolean {
const state = this.state.get(bucket)
if (!state) return false
return state.remaining <= 0 && Date.now() < state.resetAt
}
reset(bucket: string): void {
this.state.delete(bucket)
}
getAll(): ReadonlyMap<string, RateLimitState> {
return this.state
}
}
3.7 Contract Extraction for Rate Limits
Update src/domain/contract.ts:
const rateLimit = s['x-rate-limit'] as Record<string, unknown> | undefined
const contract: RouteContract = {
// ... existing fields ...
rateLimit: rateLimit ? {
requests: Number(rateLimit.requests) || 100,
window: String(rateLimit.window) || '1m',
burst: Number(rateLimit.burst) || 10,
key: (rateLimit.key as 'ip' | 'user' | 'tenant' | 'global') || 'global',
} : undefined,
}
3.8 Example Fastify Routes with Rate Limit Contracts
fastify.get('/api/data', {
schema: {
response: {
200: {
type: 'object',
properties: { data: { type: 'array' } },
'x-rate-limit': {
requests: 100,
window: '1m',
burst: 10,
key: 'ip'
},
'x-ensures': [
'response_headers(this).x-ratelimit-remaining >= 0',
'response_headers(this).x-ratelimit-limit == 100'
]
}
}
}
}, async (req, reply) => {
// Set rate limit headers
reply.header('x-ratelimit-limit', 100)
reply.header('x-ratelimit-remaining', 99)
reply.header('x-ratelimit-reset', Math.floor(Date.now() / 1000) + 60)
return { data: [] }
})
fastify.post('/api/action', {
schema: {
'x-rate-limit': {
requests: 10,
window: '1h',
burst: 2,
key: 'user'
},
response: {
201: {
'x-ensures': [
'rate_limit_remaining(this) >= 0',
'response_code(this) == 201 || response_code(this) == 429'
]
}
}
}
}, async (req, reply) => {
// Handler with rate limiting
})
4. Authorization/Scope Claims in Contracts
4.1 Scope Claim Model
Scopes are strings representing permissions, following the OAuth 2.0 format: action:resource (e.g., read:users, write:posts).
4.2 APOSTL Integration
Scopes are accessible via the auth_scope(this).<scope> operation:
auth_scope(this).read:users
auth_scope(this).admin
auth_scope(this).write:posts && auth_scope(this).read:users
Semantics:
auth_scope(this).<scope>evaluates totrueif the scope is present inAuthContext.scopes.- Scope matching is exact (no wildcards).
read:usersdoes not matchread:users:profile. - If no auth context is present, all
auth_scopeoperations returnfalse.
4.3 Scope Validation in Contract Validation
Update src/domain/contract-validation.ts to validate scope requirements:
export const validatePostconditions = (
ensures: string[],
ctx: EvalContext,
route?: { method: string; path: string }
): EvalResult => {
// Check auth requirements first
if (route && ctx.auth.flow === 'none') {
// Route requires auth but none provided
const routeContract = /* get contract for route */ null
if (routeContract && routeContract.authFlow !== 'none' && !routeContract.authOptional) {
return {
success: false,
error: `Authentication required: ${routeContract.authFlow}`,
violation: makeViolation({
route,
formula: `x-auth: ${routeContract.authFlow}`,
kind: 'precondition',
request: ctx.request,
response: ctx.response,
context: { expected: routeContract.authFlow, actual: 'none', diff: null },
suggestion: `This route requires ${routeContract.authFlow} authentication. Ensure auth is configured in TestConfig.`,
}),
}
}
}
// Check scope requirements
if (route && ctx.auth.scopes.length > 0) {
const routeContract = /* get contract for route */ null
if (routeContract && routeContract.requiredScopes.length > 0) {
const hasRequired = routeContract.scopesMatch === 'all'
? routeContract.requiredScopes.every(s => ctx.auth.scopes.includes(s))
: routeContract.requiredScopes.some(s => ctx.auth.scopes.includes(s))
if (!hasRequired) {
return {
success: false,
error: `Insufficient scopes. Required: ${routeContract.requiredScopes.join(', ')}`,
violation: makeViolation({
route,
formula: `x-scopes: [${routeContract.requiredScopes.join(', ')}]`,
kind: 'precondition',
request: ctx.request,
response: ctx.response,
context: {
expected: routeContract.requiredScopes.join(', '),
actual: ctx.auth.scopes.join(', '),
diff: null
},
suggestion: `Missing required scopes. Grant one of: ${routeContract.requiredScopes.join(', ')}`,
}),
}
}
}
}
// Continue with existing postcondition validation
for (const ensure of ensures) {
// ... existing validation logic ...
}
return { success: true, value: ctx.response.statusCode }
}
4.4 Scope Registry Integration
The scope registry can now include auth metadata:
export interface ScopeConfig {
headers: Record<string, string>
metadata?: Record<string, unknown>
// NEW:
auth?: AuthConfig
}
When a scope has auth config, the test runner automatically initializes auth for that scope:
// In test runner initialization
const scopeConfig = config.scope ? scopeRegistry?.scopes.get(config.scope) : undefined
const authConfig = config.auth ?? scopeConfig?.auth
if (authConfig) {
authContext = await initializeAuth(authConfig)
}
5. Integration with Existing Scope System
5.1 Scope + Auth Interaction
The existing scope system (tenant/application isolation) and the new auth system are orthogonal but complementary:
- Scope determines which tenant/application the request targets (via headers like
x-tenant-id). - Auth determines who is making the request and what they can do.
A test configuration can specify both:
const suite = await fastify.apophis.contract({
scope: 'tenant-a',
auth: {
flow: 'jwt',
issuer: 'https://auth.example.com',
scopes: ['read:users', 'read:posts']
}
})
5.2 Scope-Aware Auth
The scope registry's getHeaders() method now accepts an authContext parameter (see section 2.9). This allows auth headers to be injected alongside scope headers:
const scopeHeaders = scopeRegistry.getHeaders(config.scope ?? null, undefined, authContext)
// Returns: { 'x-tenant-id': 'tenant-a', 'authorization': 'Bearer <token>' }
5.3 Auth in Cleanup
The cleanup manager needs auth context to delete resources in scoped environments:
// In cleanup manager
async cleanup(authContext?: AuthContext): Promise<Array<{ resource: TrackedResource; error?: string }>> {
const results = []
for (const resource of this.resources) {
const scopeHeaders = this.scopeRegistry.getHeaders(resource.scope, undefined, authContext)
try {
await this.fastify.inject({
method: 'DELETE',
url: resource.url,
headers: scopeHeaders,
})
results.push({ resource })
} catch (err) {
results.push({ resource, error: err instanceof Error ? err.message : String(err) })
}
}
return results
}
6. File Paths and Line Number References
6.1 New Files
| File | Purpose |
|---|---|
src/infrastructure/auth-test-helpers.ts |
JWT signing/verification, session cookie helpers |
src/infrastructure/oauth-simulator.ts |
OAuth 2.1 grant flow simulation |
src/infrastructure/session-simulator.ts |
Session cookie flow simulation |
src/infrastructure/rate-limit-tracker.ts |
Rate limit state tracking across test runs |
6.2 Modified Files
| File | Lines | Changes |
|---|---|---|
src/types.ts |
12-22, 71-86, 144-148, 257-262 | Add AuthContext, AuthConfig, AuthFlow, RateLimitConfig; extend RouteContract, EvalContext, TestConfig, ApophisOptions |
src/formula/parser.ts |
222-225, ~323 | Add jwt_claim, auth_scope, rate_limit_* to VALID_HEADERS; add parser branches |
src/formula/evaluator.ts |
9-65 | Add evaluation cases for new operation headers |
src/domain/contract.ts |
~63 | Extract auth and rate limit annotations from schema |
src/domain/request-builder.ts |
119-133, 135-163 | Inject auth headers; add authContext parameter |
src/domain/contract-validation.ts |
57-166 | Add auth and scope precondition checks |
src/infrastructure/scope-registry.ts |
88-101 | Accept authContext in getHeaders() |
src/test/petit-runner.ts |
188-220, ~221 | Initialize auth context; pass to buildRequest; track rate limits |
src/test/stateful-runner.ts |
Similar to petit-runner | Same auth initialization and injection |
src/domain/error-suggestions.ts |
~127-130 | Add suggestions for auth/scope failures |
7. Example Complete Fastify Application
import fastify from 'fastify'
import { apophisPlugin } from 'apophis-fastify'
const app = fastify()
// Register APOPHIS with auth and rate limit support
await app.register(apophisPlugin, {
auth: {
flow: 'jwt',
issuer: 'https://auth.example.com',
audience: 'api.example.com',
testKeyPair: {
publicKey: process.env.JWT_PUBLIC_KEY!,
privateKey: process.env.JWT_PRIVATE_KEY!,
}
},
scopes: {
'tenant-a': {
headers: { 'x-tenant-id': 'tenant-a' },
metadata: { tenantId: 'tenant-a' }
}
}
})
// Public health check (no auth)
app.get('/health', {
schema: {
response: {
200: {
type: 'object',
properties: { status: { type: 'string' } },
'x-auth': 'none',
'x-ensures': ['response_body(this).status == "ok"']
}
}
}
}, async () => ({ status: 'ok' }))
// JWT-protected user list (read scope)
app.get('/users', {
schema: {
response: {
200: {
type: 'object',
properties: {
users: { type: 'array', items: { type: 'object' } }
},
'x-auth': 'jwt',
'x-scopes': ['read:users'],
'x-rate-limit': { requests: 100, window: '1m', burst: 10, key: 'ip' },
'x-ensures': [
'jwt_claim(this).sub != null',
'auth_scope(this).read:users',
'response_headers(this).x-ratelimit-remaining >= 0',
'response_body(this).users != null'
]
}
}
}
}, async (req, reply) => {
reply.header('x-ratelimit-limit', 100)
reply.header('x-ratelimit-remaining', 99)
reply.header('x-ratelimit-reset', Math.floor(Date.now() / 1000) + 60)
return { users: [] }
})
// OAuth 2.1 protected admin endpoint (admin scope)
app.post('/admin/users', {
schema: {
body: {
type: 'object',
properties: {
email: { type: 'string' },
role: { type: 'string', enum: ['user', 'admin'] }
}
},
response: {
201: {
type: 'object',
properties: { id: { type: 'string' } },
'x-auth': 'oauth2',
'x-scopes': ['admin'],
'x-scopes-match': 'all',
'x-ensures': [
'auth_scope(this).admin',
'response_code(this) == 201',
'response_body(this).id != null'
]
}
}
}
}, async (req, reply) => {
return { id: 'user-123' }
})
// Session-based profile endpoint
app.get('/profile', {
schema: {
response: {
200: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' }
},
'x-auth': 'session',
'x-ensures': [
'response_body(this).name != null',
'response_body(this).email != null'
]
}
}
}
}, async (req, reply) => {
return { name: 'Test User', email: 'test@example.com' }
})
// Run contract tests
const suite = await app.apophis.contract({
scope: 'tenant-a',
depth: 'standard',
burst: true // Enable burst testing for rate limit validation
})
console.log(`Tests: ${suite.summary.passed} passed, ${suite.summary.failed} failed`)
8. Test Plan
8.1 Auth Tests
- JWT Flow: Verify
jwt_claim(this).subworks with generated test tokens. - OAuth 2.1 Client Credentials: Verify token acquisition and scope assignment.
- OAuth 2.1 Authorization Code + PKCE: Verify full flow simulation.
- Session Cookie: Verify session creation, cookie generation, and validation.
- Scope Enforcement: Verify routes reject requests without required scopes.
- Auth Optional: Verify
x-auth-optional: trueallows unauthenticated access.
8.2 Rate Limit Tests
- Header Validation: Verify
response_headers(this).x-ratelimit-remaining >= 0passes. - Burst Mode: Verify rapid sequential requests trigger rate limit responses.
- State Tracking: Verify rate limit state persists across test runs.
- Contract Violation: Verify 429 responses are handled correctly when rate limit exceeded.
8.3 Integration Tests
- Auth + Scope: Verify JWT route with
read:usersscope works when scope is granted. - Auth + Rate Limit: Verify authenticated requests are rate-limited per-user.
- Scope + Tenant: Verify tenant isolation with per-tenant auth contexts.
9. Backward Compatibility
All new features are opt-in:
- Routes without
x-authdefault toauthFlow: 'none'. - Routes without
x-scopesdefault torequiredScopes: []. - Routes without
x-rate-limitdefault to no rate limit validation. - Test configurations without
authdefault to no auth context.
No breaking changes to existing APOPHIS v1.0 APIs.
10. Security Considerations
- Test Keys: The
generateTestKeyPair()function generates 2048-bit RSA keys. These are for testing only and should never be used in production. - Session Secrets: The
SessionSimulatoruses a default secret if none is provided. Production code must always provide a strong secret. - Token Expiry: Test JWTs expire after 1 hour by default. Short-lived tokens prevent accidental reuse.
- No External Calls: The OAuth simulator does not make HTTP requests to external IdPs. All tokens are generated locally.
- Scope Validation: Scope checks are exact-match only. No wildcard or regex matching to prevent scope escalation attacks in tests.
End of Specification