Files
apophis-fastify/docs/attic/extensions/AUTH-RATE-LIMIT-REVISED.md

26 KiB

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:

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

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:

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

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
  rateLimit?: RateLimitConfig
}

5.2 EvalContext Extension

export interface EvalContext {
  readonly request: { /* ... */ }
  readonly response: { /* ... */ }
  readonly previous?: EvalContext
  // NEW:
  readonly auth: AuthContext
}

5.3 TestConfig Extension

export interface TestConfig {
  readonly depth?: TestDepth
  readonly scope?: string
  readonly seed?: number
  // NEW:
  readonly auth?: AuthConfig
  readonly routeAuth?: Record<string, { requiredScopes?: string[]; scopesMatch?: 'any' | 'all'; authOptional?: boolean }>
  readonly burst?: boolean  // Enable burst testing for rate limits
}

5.4 ApophisOptions Extension

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
}

6. APOSTL Extensions for Auth

New operation headers for auth introspection:

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).<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.
  • 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

/**
 * 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<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
}

8. 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'
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<string, string> = 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<OAuthSimulationResult> {
    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<OAuthSimulationResult> {
    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(' '),
    }
  }
}

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 { generateTestSessionCookie, parseTestSessionCookie } from './auth-test-helpers.js'
import type { 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)
  }
}

10. Rate Limiting

10.1 Contract Annotations (Annotation Mode)

{
  "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

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

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

11. Scope Registry Integration

The scope registry integrates auth context into scope resolution:

// src/infrastructure/scope-registry.ts

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

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

// src/domain/request-builder.ts

const buildHeaders = (
  route: RouteContract,
  scopeHeaders: Record<string, string>,
  data: Record<string, unknown>,
  _state: ModelState,
  authContext?: AuthContext
): Record<string, string> => {
  const headers: Record<string, string> = { ...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:

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

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

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

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