6c39bd0a6c
- Fix const inference bug: wrap inferred contracts with status-code guards - Add integration test for status-guarded contract inference - Tighten and deduplicate docs across verify, qualify, getting-started, cli - Fix broken cross-references and TypeScript→JavaScript conversions - Fix factual errors: license, Date.now(), sampling defaults, cache env - Add missing features: --workspace, --generation-profile, json-summary formats - Move stale extension docs (AUTH-RATE-LIMIT-REVISED, HTTP-EXTENSIONS) to attic - Update PLUGIN_CONTRACTS_SPEC status to Implemented - Build: clean | Tests: 849 pass, 0 fail
874 lines
26 KiB
Markdown
874 lines
26 KiB
Markdown
# APOPHIS v1.0 — Authentication, Authorization & Rate Limiting Extension (REVISED)
|
|
|
|
> **Status: NOT IMPLEMENTED**
|
|
> This document describes a proposed extension that is not yet available in APOPHIS. The predicates, types, and infrastructure described here do not exist in the current codebase. Use `createAuthExtension` from `apophis-fastify/extension/factories` for auth testing today.
|
|
|
|
## 1. Overview
|
|
|
|
This document specifies the extension of APOPHIS v1.0 to support production-critical concerns:
|
|
|
|
1. **Authentication Flows** — JWT, OAuth 2.1, session-based, and mTLS authentication
|
|
2. **Rate Limiting** — Contract-level rate limit validation and burst testing
|
|
3. **Authorization/Scope Claims** — Fine-grained permission modeling in contracts
|
|
|
|
**Critical Design Constraint**: Arbiter (the primary production user) uses **programmatic gate-based auth**, not JSON Schema annotations. Routes validate auth in `preHandler` hooks, not via `schema:` properties. This spec supports **both** annotation-based and programmatic contract definition.
|
|
|
|
---
|
|
|
|
## 2. Design Principles
|
|
|
|
- **Auth is a cross-cutting concern**, not a route category
|
|
- **Two contract definition modes**:
|
|
- **Annotation mode**: `x-auth`, `x-scopes`, `x-rate-limit` in JSON Schema (for standard REST APIs)
|
|
- **Programmatic mode**: Pass auth/rate-limit config directly to `contract()`/`stateful()` (for gate-based architectures like Arbiter)
|
|
- **Test isolation**: Each test run receives its own auth context. No shared tokens across tests.
|
|
- **Deterministic when seeded**: Auth flows are simulated, not delegated to external IdPs. Token/session generation must receive the test seed and clock.
|
|
- **No breaking changes**: All new features are opt-in. Existing v1.0 contracts work unchanged.
|
|
|
|
---
|
|
|
|
## 3. Auth State Model
|
|
|
|
Auth state is tracked per-test-run in an `AuthContext` object:
|
|
|
|
```typescript
|
|
// src/types.ts (additions)
|
|
|
|
export type AuthFlow = 'jwt' | 'oauth2' | 'session' | 'mtls' | 'none'
|
|
|
|
export interface AuthContext {
|
|
readonly flow: AuthFlow
|
|
readonly token: string | null // Current access token (JWT or OAuth)
|
|
readonly refreshToken: string | null // OAuth refresh token
|
|
readonly tokenExpiry: number | null // Unix timestamp (ms)
|
|
readonly sessionCookie: string | null // Session ID for cookie flows
|
|
readonly clientCert: string | null // mTLS client certificate
|
|
readonly scopes: string[] // Granted scopes
|
|
readonly claims: Record<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:
|
|
|
|
```typescript
|
|
fastify.get('/users/:id', {
|
|
schema: {
|
|
params: { type: 'object', properties: { id: { type: 'string' } } },
|
|
response: {
|
|
200: {
|
|
type: 'object',
|
|
properties: { id: { type: 'string' }, email: { type: 'string' } },
|
|
'x-auth': 'jwt',
|
|
'x-scopes': ['read:users'],
|
|
'x-ensures': ['jwt_claims(this).sub != null']
|
|
}
|
|
}
|
|
}
|
|
}, handler)
|
|
```
|
|
|
|
**Annotation semantics**:
|
|
|
|
- `x-auth`: Required auth flow. Values: `"jwt"`, `"oauth2"`, `"session"`, `"mtls"`, `"none"` (default).
|
|
- `x-scopes`: Array of scope strings. Checked against `AuthContext.scopes`.
|
|
- `x-scopes-match`: `"any"` (at least one) or `"all"` (all required). Default: `"any"`.
|
|
- `x-auth-optional`: If `true`, route works with or without auth.
|
|
|
|
### 4.2 Programmatic Mode (No Schema Annotations)
|
|
|
|
For architectures like Arbiter that don't use schema annotations for auth, pass auth requirements directly to the test runner:
|
|
|
|
```typescript
|
|
// Arbiter-style: auth is handled in preHandler gates, not schema annotations
|
|
const suite = await fastify.apophis.contract({
|
|
scope: 'tenant-a',
|
|
auth: {
|
|
flow: 'jwt',
|
|
issuer: 'https://auth.example.com',
|
|
scopes: ['read:users', 'read:posts']
|
|
},
|
|
// Optional: per-route auth overrides
|
|
routeAuth: {
|
|
'GET /users/:id': { requiredScopes: ['read:users'] },
|
|
'POST /admin/users': { requiredScopes: ['admin'], scopesMatch: 'all' }
|
|
}
|
|
})
|
|
```
|
|
|
|
**Programmatic mode semantics**:
|
|
|
|
- `auth` in `TestConfig` initializes the auth context for the entire test run
|
|
- `routeAuth` provides per-route auth requirements when schemas don't have annotations
|
|
- Auth headers are injected into all requests automatically
|
|
- Postconditions can still use `jwt_claim(this).sub` etc. to validate claims in responses
|
|
|
|
---
|
|
|
|
## 5. Type Changes in `src/types.ts`
|
|
|
|
### 5.1 RouteContract Extension
|
|
|
|
```typescript
|
|
export interface RouteContract {
|
|
path: string
|
|
method: string
|
|
category: OperationCategory
|
|
requires: string[]
|
|
ensures: string[]
|
|
invariants: string[]
|
|
regexPatterns: Record<string, string>
|
|
validateRuntime: boolean
|
|
schema?: Record<string, unknown>
|
|
// NEW:
|
|
authFlow: AuthFlow
|
|
requiredScopes: string[]
|
|
scopesMatch: 'any' | 'all'
|
|
authOptional: boolean
|
|
rateLimit?: RateLimitConfig
|
|
}
|
|
```
|
|
|
|
### 5.2 EvalContext Extension
|
|
|
|
```typescript
|
|
export interface EvalContext {
|
|
readonly request: { /* ... */ }
|
|
readonly response: { /* ... */ }
|
|
readonly previous?: EvalContext
|
|
// NEW:
|
|
readonly auth: AuthContext
|
|
}
|
|
```
|
|
|
|
### 5.3 TestConfig Extension
|
|
|
|
```typescript
|
|
export interface TestConfig {
|
|
readonly depth?: TestDepth
|
|
readonly scope?: string
|
|
readonly seed?: number
|
|
// NEW:
|
|
readonly auth?: AuthConfig
|
|
readonly routeAuth?: Record<string, { requiredScopes?: string[]; scopesMatch?: 'any' | 'all'; authOptional?: boolean }>
|
|
readonly burst?: boolean // Enable burst testing for rate limits
|
|
}
|
|
```
|
|
|
|
### 5.4 ApophisOptions Extension
|
|
|
|
```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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. APOSTL Extensions for Auth
|
|
|
|
New operation headers for auth introspection:
|
|
|
|
```typescript
|
|
export type OperationHeader =
|
|
| 'request_body' | 'response_body' | 'response_code'
|
|
| 'request_headers' | 'response_headers' | 'query_params'
|
|
| 'cookies' | 'response_time'
|
|
// NEW:
|
|
| 'jwt_claim' | 'auth_scope' | 'rate_limit_remaining' | 'rate_limit_limit' | 'rate_limit_reset'
|
|
```
|
|
|
|
**New formula syntax**:
|
|
|
|
```
|
|
jwt_claims(this).sub == "user-123"
|
|
jwt_claims(this).role == "admin"
|
|
auth_has_scope(this, "read:users") == true
|
|
auth_has_scope(this, "admin") == true
|
|
rate_limit_remaining(this) >= 0
|
|
rate_limit_limit(this) == 100
|
|
```
|
|
|
|
**Semantics**:
|
|
|
|
- `jwt_claim(this).<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`
|
|
|
|
```typescript
|
|
/**
|
|
* Auth Test Helpers
|
|
* Deterministic token generation for testing. No external IdP calls.
|
|
*/
|
|
|
|
import { createSign, createVerify, randomBytes, createHash, createHmac } from 'node:crypto'
|
|
|
|
export interface TestKeyPair {
|
|
readonly publicKey: string
|
|
readonly privateKey: string
|
|
}
|
|
|
|
export const generateTestKeyPair = (): TestKeyPair => {
|
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
})
|
|
return { publicKey, privateKey }
|
|
}
|
|
|
|
export const signTestJwt = (
|
|
payload: Record<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`
|
|
|
|
```typescript
|
|
/**
|
|
* OAuth 2.1 Grant Flow Simulator
|
|
* Simulates authorization code, client credentials, and PKCE flows
|
|
* without external IdP dependency. Returns tokens deterministically.
|
|
*/
|
|
|
|
import { signTestJwt, generateTestKeyPair } from './auth-test-helpers.js'
|
|
import type { AuthContext, AuthConfig } from '../types.js'
|
|
import { randomBytes, createHash } from 'node:crypto'
|
|
|
|
export interface OAuthSimulationResult {
|
|
readonly accessToken: string
|
|
readonly refreshToken: string
|
|
readonly tokenType: 'Bearer'
|
|
readonly expiresIn: number
|
|
readonly scope: string
|
|
}
|
|
|
|
export class OAuthSimulator {
|
|
private readonly keyPair: TestKeyPair
|
|
private readonly config: AuthConfig
|
|
private codeChallengeStore: Map<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(' '),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Session Cookie Flow Simulation
|
|
|
|
New module: `src/infrastructure/session-simulator.ts`
|
|
|
|
```typescript
|
|
/**
|
|
* Session Cookie Flow Simulator
|
|
* Manages session state for cookie-based auth testing.
|
|
*/
|
|
|
|
import { randomBytes } from 'node:crypto'
|
|
import { generateTestSessionCookie, parseTestSessionCookie } from './auth-test-helpers.js'
|
|
import type { AuthConfig } from '../types.js'
|
|
|
|
interface Session {
|
|
readonly id: string
|
|
readonly data: Record<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)
|
|
|
|
```typescript
|
|
{
|
|
"x-rate-limit": {
|
|
"requests": 100,
|
|
"window": "1m",
|
|
"burst": 10,
|
|
"key": "ip"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Annotation semantics**:
|
|
|
|
- `x-rate-limit.requests`: Maximum requests allowed in the window.
|
|
- `x-rate-limit.window`: Time window as a duration string (e.g., `"1m"`, `"1h"`, `"1d"`).
|
|
- `x-rate-limit.burst`: Maximum burst size.
|
|
- `x-rate-limit.key`: Rate limit bucket key: `"ip"`, `"user"`, `"tenant"`, `"global"`.
|
|
|
|
### 10.2 Programmatic Rate Limit Config
|
|
|
|
```typescript
|
|
const suite = await fastify.apophis.contract({
|
|
auth: { flow: 'jwt', scopes: ['read:users'] },
|
|
routeRateLimits: {
|
|
'GET /api/data': { requests: 100, window: '1m', burst: 10, key: 'ip' },
|
|
'POST /api/action': { requests: 10, window: '1h', burst: 2, key: 'user' }
|
|
}
|
|
})
|
|
```
|
|
|
|
### 10.3 Rate Limit State Tracking
|
|
|
|
New module: `src/infrastructure/rate-limit-tracker.ts`
|
|
|
|
```typescript
|
|
export interface RateLimitState {
|
|
readonly bucket: string
|
|
readonly remaining: number
|
|
readonly limit: number
|
|
readonly resetAt: number
|
|
readonly window: string
|
|
}
|
|
|
|
export class RateLimitTracker {
|
|
private readonly state: Map<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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// In runPetitTests()
|
|
|
|
let authContext: AuthContext = {
|
|
flow: config.auth?.flow ?? 'none',
|
|
token: null,
|
|
refreshToken: null,
|
|
tokenExpiry: null,
|
|
sessionCookie: null,
|
|
clientCert: null,
|
|
scopes: [],
|
|
claims: {},
|
|
}
|
|
|
|
if (config.auth && config.auth.flow !== 'none') {
|
|
authContext = await initializeAuth(config.auth)
|
|
}
|
|
|
|
// Pass authContext to buildRequest in the execution loop
|
|
for (const command of allCommands) {
|
|
const request = buildRequest(command.route, command.params, scopeHeaders, state, rng, authContext)
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Auth initialization helper**:
|
|
|
|
```typescript
|
|
async function initializeAuth(config: AuthConfig): Promise<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):
|
|
|
|
```typescript
|
|
const contract: RouteContract = {
|
|
path,
|
|
method: method.toUpperCase(),
|
|
category,
|
|
requires,
|
|
ensures,
|
|
invariants: EMPTY_INVARIANTS,
|
|
regexPatterns: {},
|
|
validateRuntime,
|
|
schema: s,
|
|
// NEW:
|
|
authFlow: (s['x-auth'] as AuthFlow) ?? 'none',
|
|
requiredScopes: Array.isArray(s['x-scopes']) ? (s['x-scopes'] as string[]) : [],
|
|
scopesMatch: (s['x-scopes-match'] as 'any' | 'all') ?? 'any',
|
|
authOptional: s['x-auth-optional'] === true,
|
|
rateLimit: s['x-rate-limit'] ? {
|
|
requests: Number(s['x-rate-limit'].requests) || 100,
|
|
window: String(s['x-rate-limit'].window) || '1m',
|
|
burst: Number(s['x-rate-limit'].burst) || 10,
|
|
key: (s['x-rate-limit'].key as 'ip' | 'user' | 'tenant' | 'global') || 'global',
|
|
} : undefined,
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 15. Example: Arbiter-Style Programmatic Auth
|
|
|
|
```typescript
|
|
import fastify from 'fastify'
|
|
import { apophisPlugin } from 'apophis-fastify'
|
|
|
|
const app = fastify()
|
|
|
|
// Register APOPHIS with auth support
|
|
await app.register(apophisPlugin, {
|
|
scopes: {
|
|
'tenant-a': {
|
|
headers: { 'x-tenant-id': 'tenant-a' },
|
|
metadata: { tenantId: 'tenant-a' }
|
|
}
|
|
}
|
|
})
|
|
|
|
// Arbiter-style route: NO schema annotations for auth
|
|
// Auth is handled in preHandler gates (not shown)
|
|
app.get('/users/:id', {
|
|
schema: {
|
|
params: { type: 'object', properties: { id: { type: 'string' } } },
|
|
response: {
|
|
200: {
|
|
type: 'object',
|
|
properties: { id: { type: 'string' }, email: { type: 'string' } }
|
|
}
|
|
}
|
|
}
|
|
}, async (req, reply) => {
|
|
// Gate-based auth happens in preHandler
|
|
return { id: req.params.id, email: 'user@example.com' }
|
|
})
|
|
|
|
// Test with programmatic auth config
|
|
const suite = await app.apophis.contract({
|
|
scope: 'tenant-a',
|
|
auth: {
|
|
flow: 'jwt',
|
|
issuer: 'https://auth.example.com',
|
|
scopes: ['read:users']
|
|
},
|
|
routeAuth: {
|
|
'GET /users/:id': { requiredScopes: ['read:users'] }
|
|
}
|
|
})
|
|
|
|
console.log(`Tests: ${suite.summary.passed} passed, ${suite.summary.failed} failed`)
|
|
```
|
|
|
|
---
|
|
|
|
## 16. Test Plan
|
|
|
|
### 16.1 Auth Tests
|
|
|
|
1. **JWT Flow**: Verify `jwt_claim(this).sub` works with generated test tokens.
|
|
2. **OAuth 2.1 Client Credentials**: Verify token acquisition and scope assignment.
|
|
3. **OAuth 2.1 Authorization Code + PKCE**: Verify full flow simulation.
|
|
4. **Session Cookie**: Verify session creation, cookie generation, and validation.
|
|
5. **mTLS**: Verify client certificate injection.
|
|
6. **Scope Enforcement**: Verify routes reject requests without required scopes.
|
|
7. **Auth Optional**: Verify `x-auth-optional: true` allows unauthenticated access.
|
|
8. **Programmatic Mode**: Verify `routeAuth` config works without schema annotations.
|
|
|
|
### 16.2 Rate Limit Tests
|
|
|
|
1. **Header Validation**: Verify `response_headers(this).x-ratelimit-remaining >= 0` passes.
|
|
2. **Burst Mode**: Verify rapid sequential requests trigger rate limit responses.
|
|
3. **State Tracking**: Verify rate limit state persists across requests within one test run and resets between runs.
|
|
4. **Contract Violation**: Verify 429 responses are handled correctly when rate limit exceeded.
|
|
|
|
### 16.3 Integration Tests
|
|
|
|
1. **Auth + Scope**: Verify JWT route with `read:users` scope works when scope is granted.
|
|
2. **Auth + Rate Limit**: Verify authenticated requests are rate-limited per-user.
|
|
3. **Scope + Tenant**: Verify tenant isolation with per-tenant auth contexts.
|
|
4. **Programmatic + Annotation**: Verify both modes work in the same test run.
|
|
|
|
---
|
|
|
|
## 17. Backward Compatibility
|
|
|
|
All new features are **opt-in**:
|
|
|
|
- Routes without `x-auth` default to `authFlow: 'none'`.
|
|
- Routes without `x-scopes` default to `requiredScopes: []`.
|
|
- Routes without `x-rate-limit` default to no rate limit validation.
|
|
- Test configurations without `auth` default to no auth context.
|
|
- Test configurations without `routeAuth` default to annotation-only mode.
|
|
|
|
No breaking changes to existing APOPHIS v1.0 APIs.
|
|
|
|
---
|
|
|
|
## 18. Security Considerations
|
|
|
|
1. **Test Keys**: `generateTestKeyPair()` generates 2048-bit RSA keys for testing only. Never use in production.
|
|
2. **Session Secrets**: `SessionSimulator` uses a default secret if none provided. Production code must always provide a strong secret.
|
|
3. **Token Expiry**: Test JWTs expire after 1 hour by default. Short-lived tokens prevent accidental reuse.
|
|
4. **No External Calls**: The OAuth simulator does not make HTTP requests to external IdPs. All tokens are generated locally.
|
|
5. **Scope Validation**: Scope checks are exact-match only. No wildcard or regex matching to prevent scope escalation attacks in tests.
|
|
6. **mTLS Certificates**: Test client certificates should be generated for each test run. Never reuse production certificates.
|
|
|
|
---
|
|
|
|
*End of Revised Specification*
|