Files

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:

  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:

// 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 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):

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. Returns undefined if no JWT or claim missing.
  • auth_scope(this).<scope>: Returns true if the scope is present in AuthContext.scopes, false otherwise.

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(' '),
    }
  }
}

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 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:

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

  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