# APOPHIS v1.0 — Authentication, Authorization & Rate Limiting Extension (REVISED) > **Status: NOT IMPLEMENTED** > This document describes a proposed extension that is not yet available in APOPHIS. The predicates, types, and infrastructure described here do not exist in the current codebase. Use `createAuthExtension` from `apophis-fastify/extension/factories` for auth testing today. ## 1. Overview This document specifies the extension of APOPHIS v1.0 to support production-critical concerns: 1. **Authentication Flows** — JWT, OAuth 2.1, session-based, and mTLS authentication 2. **Rate Limiting** — Contract-level rate limit validation and burst testing 3. **Authorization/Scope Claims** — Fine-grained permission modeling in contracts **Critical Design Constraint**: Arbiter (the primary production user) uses **programmatic gate-based auth**, not JSON Schema annotations. Routes validate auth in `preHandler` hooks, not via `schema:` properties. This spec supports **both** annotation-based and programmatic contract definition. --- ## 2. Design Principles - **Auth is a cross-cutting concern**, not a route category - **Two contract definition modes**: - **Annotation mode**: `x-auth`, `x-scopes`, `x-rate-limit` in JSON Schema (for standard REST APIs) - **Programmatic mode**: Pass auth/rate-limit config directly to `contract()`/`stateful()` (for gate-based architectures like Arbiter) - **Test isolation**: Each test run receives its own auth context. No shared tokens across tests. - **Deterministic when seeded**: Auth flows are simulated, not delegated to external IdPs. Token/session generation must receive the test seed and clock. - **No breaking changes**: All new features are opt-in. Existing v1.0 contracts work unchanged. --- ## 3. 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' | 'mtls' | '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 clientCert: string | null // mTLS client certificate 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 readonly clientCert?: string // PEM-encoded client certificate for mTLS readonly clientKey?: string // PEM-encoded client private key for mTLS } ``` --- ## 4. Contract Definition Modes ### 4.1 Annotation Mode (JSON Schema) For APIs that use schema annotations, auth requirements are declared in the schema: ```typescript fastify.get('/users/:id', { schema: { params: { type: 'object', properties: { id: { type: 'string' } } }, response: { 200: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } }, 'x-auth': 'jwt', 'x-scopes': ['read:users'], 'x-ensures': ['jwt_claims(this).sub != null'] } } } }, handler) ``` **Annotation semantics**: - `x-auth`: Required auth flow. Values: `"jwt"`, `"oauth2"`, `"session"`, `"mtls"`, `"none"` (default). - `x-scopes`: Array of scope strings. Checked against `AuthContext.scopes`. - `x-scopes-match`: `"any"` (at least one) or `"all"` (all required). Default: `"any"`. - `x-auth-optional`: If `true`, route works with or without auth. ### 4.2 Programmatic Mode (No Schema Annotations) For architectures like Arbiter that don't use schema annotations for auth, pass auth requirements directly to the test runner: ```typescript // Arbiter-style: auth is handled in preHandler gates, not schema annotations const suite = await fastify.apophis.contract({ scope: 'tenant-a', auth: { flow: 'jwt', issuer: 'https://auth.example.com', scopes: ['read:users', 'read:posts'] }, // Optional: per-route auth overrides routeAuth: { 'GET /users/:id': { requiredScopes: ['read:users'] }, 'POST /admin/users': { requiredScopes: ['admin'], scopesMatch: 'all' } } }) ``` **Programmatic mode semantics**: - `auth` in `TestConfig` initializes the auth context for the entire test run - `routeAuth` provides per-route auth requirements when schemas don't have annotations - Auth headers are injected into all requests automatically - Postconditions can still use `jwt_claim(this).sub` etc. to validate claims in responses --- ## 5. Type Changes in `src/types.ts` ### 5.1 RouteContract Extension ```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 rateLimit?: RateLimitConfig } ``` ### 5.2 EvalContext Extension ```typescript export interface EvalContext { readonly request: { /* ... */ } readonly response: { /* ... */ } readonly previous?: EvalContext // NEW: readonly auth: AuthContext } ``` ### 5.3 TestConfig Extension ```typescript export interface TestConfig { readonly depth?: TestDepth readonly scope?: string readonly seed?: number // NEW: readonly auth?: AuthConfig readonly routeAuth?: Record readonly burst?: boolean // Enable burst testing for rate limits } ``` ### 5.4 ApophisOptions Extension ```typescript export interface ApophisOptions { readonly swagger?: Record readonly runtime?: 'off' | 'warn' | 'error' readonly cleanup?: boolean readonly scopes?: Record // NEW: readonly auth?: AuthConfig } ``` --- ## 6. APOSTL Extensions for Auth New operation headers for auth introspection: ```typescript export type OperationHeader = | 'request_body' | 'response_body' | 'response_code' | 'request_headers' | 'response_headers' | 'query_params' | 'cookies' | 'response_time' // NEW: | 'jwt_claim' | 'auth_scope' | 'rate_limit_remaining' | 'rate_limit_limit' | 'rate_limit_reset' ``` **New formula syntax**: ``` jwt_claims(this).sub == "user-123" jwt_claims(this).role == "admin" auth_has_scope(this, "read:users") == true auth_has_scope(this, "admin") == true rate_limit_remaining(this) >= 0 rate_limit_limit(this) == 100 ``` **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. - `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. --- ## 7. 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, createHash, createHmac } from 'node:crypto' export interface TestKeyPair { readonly publicKey: string readonly privateKey: string } export const generateTestKeyPair = (): TestKeyPair => { 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 } ``` --- ## 8. 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' import { randomBytes, createHash } from 'node:crypto' export interface OAuthSimulationResult { readonly accessToken: string readonly refreshToken: string readonly tokenType: 'Bearer' readonly expiresIn: number readonly scope: string } export class OAuthSimulator { private readonly keyPair: TestKeyPair private readonly config: AuthConfig private codeChallengeStore: Map = new Map() constructor(config: AuthConfig) { this.config = config this.keyPair = config.testKeyPair ?? generateTestKeyPair() } async authorizationCode(params: { code: string codeVerifier?: string redirectUri: string clientId: string }): Promise { 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']) } async clientCredentials(params: { clientId: string clientSecret: string scope?: string }): Promise { 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) } 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(' '), } } } ``` --- ## 9. 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 { generateTestSessionCookie, parseTestSessionCookie } from './auth-test-helpers.js' import type { 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) } } ``` --- ## 10. Rate Limiting ### 10.1 Contract Annotations (Annotation Mode) ```typescript { "x-rate-limit": { "requests": 100, "window": "1m", "burst": 10, "key": "ip" } } ``` **Annotation semantics**: - `x-rate-limit.requests`: Maximum 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. - `x-rate-limit.key`: Rate limit bucket key: `"ip"`, `"user"`, `"tenant"`, `"global"`. ### 10.2 Programmatic Rate Limit Config ```typescript const suite = await fastify.apophis.contract({ auth: { flow: 'jwt', scopes: ['read:users'] }, routeRateLimits: { 'GET /api/data': { requests: 100, window: '1m', burst: 10, key: 'ip' }, 'POST /api/action': { requests: 10, window: '1h', burst: 2, key: 'user' } } }) ``` ### 10.3 Rate Limit State Tracking New module: `src/infrastructure/rate-limit-tracker.ts` ```typescript 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 } } ``` --- ## 11. Scope Registry Integration The scope registry integrates auth context into scope resolution: ```typescript // src/infrastructure/scope-registry.ts 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 } } // Inject mTLS certificate info if present if (authContext?.clientCert && authContext.flow === 'mtls') { headers['x-client-cert'] = authContext.clientCert } return headers } ``` --- ## 12. Request Builder Integration The request builder injects auth headers based on route requirements and current auth context: ```typescript // src/domain/request-builder.ts const buildHeaders = ( route: RouteContract, scopeHeaders: Record, data: Record, _state: ModelState, authContext?: AuthContext ): Record => { const headers: Record = { ...scopeHeaders } 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 } else if (route.authFlow === 'mtls' && authContext.clientCert) { headers['x-client-cert'] = authContext.clientCert } } return headers } ``` --- ## 13. Auth Context Initialization in Test Runners Both `petit-runner.ts` and `stateful-runner.ts` initialize auth context before test execution: ```typescript // In runPetitTests() let authContext: AuthContext = { flow: config.auth?.flow ?? 'none', token: null, refreshToken: null, tokenExpiry: null, sessionCookie: null, clientCert: 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**: ```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, clientCert: 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, clientCert: 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, clientCert: null, scopes: config.scopes ?? [], claims: session.data, } } case 'mtls': { return { flow: 'mtls', token: null, refreshToken: null, tokenExpiry: null, sessionCookie: null, clientCert: config.clientCert ?? null, scopes: config.scopes ?? [], claims: {}, } } case 'none': default: return { flow: 'none', token: null, refreshToken: null, tokenExpiry: null, sessionCookie: null, clientCert: null, scopes: [], claims: {} } } } ``` --- ## 14. Contract Extraction Update `src/domain/contract.ts` to extract auth annotations from schema (annotation mode): ```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, rateLimit: s['x-rate-limit'] ? { requests: Number(s['x-rate-limit'].requests) || 100, window: String(s['x-rate-limit'].window) || '1m', burst: Number(s['x-rate-limit'].burst) || 10, key: (s['x-rate-limit'].key as 'ip' | 'user' | 'tenant' | 'global') || 'global', } : undefined, } ``` --- ## 15. Example: Arbiter-Style Programmatic Auth ```typescript import fastify from 'fastify' import { apophisPlugin } from 'apophis-fastify' const app = fastify() // Register APOPHIS with auth support await app.register(apophisPlugin, { scopes: { 'tenant-a': { headers: { 'x-tenant-id': 'tenant-a' }, metadata: { tenantId: 'tenant-a' } } } }) // Arbiter-style route: NO schema annotations for auth // Auth is handled in preHandler gates (not shown) app.get('/users/:id', { schema: { params: { type: 'object', properties: { id: { type: 'string' } } }, response: { 200: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } } } } }, async (req, reply) => { // Gate-based auth happens in preHandler return { id: req.params.id, email: 'user@example.com' } }) // Test with programmatic auth config const suite = await app.apophis.contract({ scope: 'tenant-a', auth: { flow: 'jwt', issuer: 'https://auth.example.com', scopes: ['read:users'] }, routeAuth: { 'GET /users/:id': { requiredScopes: ['read:users'] } } }) console.log(`Tests: ${suite.summary.passed} passed, ${suite.summary.failed} failed`) ``` --- ## 16. Test Plan ### 16.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. **mTLS**: Verify client certificate injection. 6. **Scope Enforcement**: Verify routes reject requests without required scopes. 7. **Auth Optional**: Verify `x-auth-optional: true` allows unauthenticated access. 8. **Programmatic Mode**: Verify `routeAuth` config works without schema annotations. ### 16.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 requests within one test run and resets between runs. 4. **Contract Violation**: Verify 429 responses are handled correctly when rate limit exceeded. ### 16.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. 4. **Programmatic + Annotation**: Verify both modes work in the same test run. --- ## 17. 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. - Test configurations without `routeAuth` default to annotation-only mode. No breaking changes to existing APOPHIS v1.0 APIs. --- ## 18. Security Considerations 1. **Test Keys**: `generateTestKeyPair()` generates 2048-bit RSA keys for testing only. Never use in production. 2. **Session Secrets**: `SessionSimulator` uses a default secret if none 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. 6. **mTLS Certificates**: Test client certificates should be generated for each test run. Never reuse production certificates. --- *End of Revised Specification*