2026-03-10 00:00:00 -07:00
# 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 < 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:
``` 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 < string , string >
validateRuntime : boolean
schema? : Record < string , unknown >
// 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 < 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):
``` 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).<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):
``` 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 < 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`
``` 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 < 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`
``` 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 < typeof generateTestKeyPair >
private readonly config : AuthConfig
private codeChallengeStore : Map < string , string > = new Map ( )
constructor ( config : AuthConfig ) {
this . config = config
this . keyPair = config . testKeyPair ? ? generateTestKeyPair ( )
}
/**
* Simulate Authorization Code flow (with optional PKCE)
*/
async authorizationCode ( params : {
code : string
codeVerifier? : string
redirectUri : string
clientId : string
} ) : Promise < OAuthSimulationResult > {
// Validate code challenge if PKCE was used
if ( params . codeVerifier ) {
const challenge = this . codeChallengeStore . get ( params . code )
const verifierHash = createHash ( 'sha256' ) . update ( params . codeVerifier ) . digest ( 'base64url' )
if ( verifierHash !== challenge ) {
throw new Error ( 'invalid_grant: PKCE verification failed' )
}
}
return this . issueToken ( params . clientId , this . config . scopes ? ? [ 'openid' ] )
}
/**
* Simulate Client Credentials flow
*/
async clientCredentials ( params : {
clientId : string
clientSecret : string
scope? : string
} ) : Promise < OAuthSimulationResult > {
// Validate client credentials (deterministic check)
if ( params . clientSecret !== ` secret- ${ params . clientId } ` ) {
throw new Error ( 'invalid_client: Client authentication failed' )
}
const scopes = params . scope ? params . scope . split ( ' ' ) : ( this . config . scopes ? ? [ ] )
return this . issueToken ( params . clientId , scopes )
}
/**
* Simulate PKCE authorization endpoint (returns code + stores challenge)
*/
async authorize ( params : {
responseType : string
clientId : string
redirectUri : string
scope? : string
state? : string
codeChallenge? : string
codeChallengeMethod ? : 'S256' | 'plain'
} ) : Promise < { code : string ; state? : string } > {
if ( params . responseType !== 'code' ) {
throw new Error ( 'unsupported_response_type' )
}
const code = randomBytes ( 16 ) . toString ( 'hex' )
if ( params . codeChallenge ) {
this . codeChallengeStore . set ( code , params . codeChallenge )
}
return { code , state : params.state }
}
private issueToken ( clientId : string , scopes : string [ ] ) : OAuthSimulationResult {
const accessToken = signTestJwt (
{ sub : clientId , scope : scopes.join ( ' ' ) , client_id : clientId } ,
this . keyPair . privateKey ,
{ issuer : this.config.issuer , audience : this.config.audience , expiresIn : 3600 }
)
const refreshToken = randomBytes ( 32 ) . toString ( 'base64url' )
return {
accessToken ,
refreshToken ,
tokenType : 'Bearer' ,
expiresIn : 3600 ,
scope : scopes.join ( ' ' ) ,
}
}
}
```
### 2.8 Session Cookie Flow Simulation
New module: `src/infrastructure/session-simulator.ts`
``` 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 < 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):
``` typescript
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):
``` typescript
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):
``` typescript
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):
``` typescript
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):
``` typescript
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):
``` 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 < 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` :
``` typescript
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
``` 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).<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:
``` 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 < string , string >
metadata? : Record < string , unknown >
// 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 <token>' }
```
### 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 < 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
``` typescript
import fastify from 'fastify'
2026-05-21 20:39:36 -07:00
import { apophisPlugin } from '@apophis/fastify'
2026-03-10 00:00:00 -07:00
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 *