# 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: 1. **Authentication Flows** — JWT, OAuth 2.1, and session-based authentication 2. **Rate Limiting** — Contract-level rate limit validation and burst testing 3. **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: ```typescript // 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 // 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: ```typescript // 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 against `AuthContext.scopes`. - `x-scopes-match`: `"any"` means at least one scope required; `"all"` means all required. - `x-auth-optional`: If `true`, 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): ```typescript export interface RouteContract { path: string method: string category: OperationCategory requires: string[] ensures: string[] invariants: string[] regexPatterns: Record validateRuntime: boolean schema?: Record // NEW: authFlow: AuthFlow requiredScopes: string[] scopesMatch: 'any' | 'all' authOptional: boolean } ``` Add to `EvalContext` (line 71-86): ```typescript export interface EvalContext { readonly request: { /* ... */ } readonly response: { /* ... */ } readonly previous?: EvalContext // NEW: readonly auth: AuthContext } ``` Add to `ApophisOptions` (line 257-262): ```typescript export interface ApophisOptions { readonly swagger?: Record readonly runtime?: 'off' | 'warn' | 'error' readonly cleanup?: boolean readonly scopes?: Record // NEW: readonly auth?: AuthConfig } ``` Add to `TestConfig` (line 144-148): ```typescript 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: ```typescript // 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).`: Access a claim from the decoded JWT payload. Returns `undefined` if no JWT or claim missing. - `auth_scope(this).`: Returns `true` if the scope is present in `AuthContext.scopes`, `false` otherwise. **Parser changes** (`src/formula/parser.ts`, line 222-225): ```typescript 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): ```typescript function resolveOperation(node: Extract, 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` ```typescript /** * 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, 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 | 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` ```typescript /** * 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 private readonly config: AuthConfig private codeChallengeStore: Map = 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 { // 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 { // 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` ```typescript /** * 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 readonly createdAt: number } export class SessionSimulator { private readonly sessions: Map = new Map() private readonly secret: string constructor(config: AuthConfig) { this.secret = config.sessionSecret ?? 'test-session-secret-change-in-production' } createSession(data: Record = {}): 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): ```typescript getHeaders(scopeName: string | null, overrides?: Record, authContext?: AuthContext): Record { 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 = { ...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): ```typescript const buildHeaders = ( route: RouteContract, scopeHeaders: Record, data: Record, _state: ModelState, authContext?: AuthContext // NEW parameter ): Record => { const headers: Record = { ...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): ```typescript export const buildRequest = ( route: RouteContract, generatedData: Record, scopeHeaders: Record, state: ModelState, rng?: SeededRng, authContext?: AuthContext // NEW parameter ): RequestStructure => { const url = substitutePathParams(route.path, generatedData, state, rng) const bodySchema = route.schema?.body as Record | undefined const body = bodySchema ? extractBodyParams(generatedData, bodySchema) : undefined const querySchema = route.schema?.querystring as Record | 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): ```typescript export const runPetitTests = async ( fastify: FastifyInjectInstance, config: TestConfig, scopeRegistry?: ScopeRegistry ): Promise => { // ... 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): ```typescript async function initializeAuth(config: AuthConfig): Promise { 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): ```typescript 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 ```typescript // 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 ```typescript // 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`: ```typescript 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`: ```typescript 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: ```typescript 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: ```typescript 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: ```typescript // 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` ```typescript /** * 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 = 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 { return this.state } } ``` ### 3.7 Contract Extraction for Rate Limits Update `src/domain/contract.ts`: ```typescript const rateLimit = s['x-rate-limit'] as Record | 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 ```typescript 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).` operation: ``` auth_scope(this).read:users auth_scope(this).admin auth_scope(this).write:posts && auth_scope(this).read:users ``` **Semantics**: - `auth_scope(this).` evaluates to `true` if the scope is present in `AuthContext.scopes`. - Scope matching is exact (no wildcards). `read:users` does not match `read:users:profile`. - If no auth context is present, all `auth_scope` operations return `false`. ### 4.3 Scope Validation in Contract Validation Update `src/domain/contract-validation.ts` to validate scope requirements: ```typescript 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: ```typescript export interface ScopeConfig { headers: Record metadata?: Record // NEW: auth?: AuthConfig } ``` When a scope has auth config, the test runner automatically initializes auth for that scope: ```typescript // 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: ```typescript 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: ```typescript const scopeHeaders = scopeRegistry.getHeaders(config.scope ?? null, undefined, authContext) // Returns: { 'x-tenant-id': 'tenant-a', 'authorization': 'Bearer ' } ``` ### 5.3 Auth in Cleanup The cleanup manager needs auth context to delete resources in scoped environments: ```typescript // In cleanup manager async cleanup(authContext?: AuthContext): Promise> { 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 ```typescript 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 1. **JWT Flow**: Verify `jwt_claim(this).sub` works with generated test tokens. 2. **OAuth 2.1 Client Credentials**: Verify token acquisition and scope assignment. 3. **OAuth 2.1 Authorization Code + PKCE**: Verify full flow simulation. 4. **Session Cookie**: Verify session creation, cookie generation, and validation. 5. **Scope Enforcement**: Verify routes reject requests without required scopes. 6. **Auth Optional**: Verify `x-auth-optional: true` allows unauthenticated access. ### 8.2 Rate Limit Tests 1. **Header Validation**: Verify `response_headers(this).x-ratelimit-remaining >= 0` passes. 2. **Burst Mode**: Verify rapid sequential requests trigger rate limit responses. 3. **State Tracking**: Verify rate limit state persists across test runs. 4. **Contract Violation**: Verify 429 responses are handled correctly when rate limit exceeded. ### 8.3 Integration Tests 1. **Auth + Scope**: Verify JWT route with `read:users` scope works when scope is granted. 2. **Auth + Rate Limit**: Verify authenticated requests are rate-limited per-user. 3. **Scope + Tenant**: Verify tenant isolation with per-tenant auth contexts. --- ## 9. Backward Compatibility All new features are **opt-in**: - Routes without `x-auth` default to `authFlow: 'none'`. - Routes without `x-scopes` default to `requiredScopes: []`. - Routes without `x-rate-limit` default to no rate limit validation. - Test configurations without `auth` default to no auth context. No breaking changes to existing APOPHIS v1.0 APIs. --- ## 10. Security Considerations 1. **Test Keys**: The `generateTestKeyPair()` function generates 2048-bit RSA keys. These are for testing only and should never be used in production. 2. **Session Secrets**: The `SessionSimulator` uses a default secret if none is provided. Production code must always provide a strong secret. 3. **Token Expiry**: Test JWTs expire after 1 hour by default. Short-lived tokens prevent accidental reuse. 4. **No External Calls**: The OAuth simulator does not make HTTP requests to external IdPs. All tokens are generated locally. 5. **Scope Validation**: Scope checks are exact-match only. No wildcard or regex matching to prevent scope escalation attacks in tests. --- *End of Specification*