1410 lines
41 KiB
Markdown
1410 lines
41 KiB
Markdown
# 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'
|
|
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* |