- 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
26 KiB
APOPHIS v1.0 — Authentication, Authorization & Rate Limiting Extension (REVISED)
Status: NOT IMPLEMENTED This document describes a proposed extension that is not yet available in APOPHIS. The predicates, types, and infrastructure described here do not exist in the current codebase. Use
createAuthExtensionfromapophis-fastify/extension/factoriesfor auth testing today.
1. Overview
This document specifies the extension of APOPHIS v1.0 to support production-critical concerns:
- Authentication Flows — JWT, OAuth 2.1, session-based, and mTLS authentication
- Rate Limiting — Contract-level rate limit validation and burst testing
- 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-limitin JSON Schema (for standard REST APIs) - Programmatic mode: Pass auth/rate-limit config directly to
contract()/stateful()(for gate-based architectures like Arbiter)
- Annotation mode:
- Test isolation: Each test run receives its own auth context. No shared tokens across tests.
- Deterministic when seeded: Auth flows are simulated, not delegated to external IdPs. Token/session generation must receive the test seed and clock.
- No breaking changes: All new features are opt-in. Existing v1.0 contracts work unchanged.
3. Auth State Model
Auth state is tracked per-test-run in an AuthContext object:
// src/types.ts (additions)
export type AuthFlow = 'jwt' | 'oauth2' | 'session' | 'mtls' | 'none'
export interface AuthContext {
readonly flow: AuthFlow
readonly token: string | null // Current access token (JWT or OAuth)
readonly refreshToken: string | null // OAuth refresh token
readonly tokenExpiry: number | null // Unix timestamp (ms)
readonly sessionCookie: string | null // Session ID for cookie flows
readonly clientCert: string | null // mTLS client certificate
readonly scopes: string[] // Granted scopes
readonly claims: Record<string, unknown> // Decoded claims (JWT payload or OAuth token introspection)
}
export interface AuthConfig {
readonly flow: AuthFlow
readonly issuer?: string
readonly audience?: string
readonly clientId?: string
readonly clientSecret?: string
readonly tokenEndpoint?: string
readonly authorizationEndpoint?: string
readonly scopes?: string[]
readonly testKeyPair?: { publicKey: string; privateKey: string }
readonly sessionSecret?: string
readonly clientCert?: string // PEM-encoded client certificate for mTLS
readonly clientKey?: string // PEM-encoded client private key for mTLS
}
4. Contract Definition Modes
4.1 Annotation Mode (JSON Schema)
For APIs that use schema annotations, auth requirements are declared in the schema:
fastify.get('/users/:id', {
schema: {
params: { type: 'object', properties: { id: { type: 'string' } } },
response: {
200: {
type: 'object',
properties: { id: { type: 'string' }, email: { type: 'string' } },
'x-auth': 'jwt',
'x-scopes': ['read:users'],
'x-ensures': ['jwt_claims(this).sub != null']
}
}
}
}, handler)
Annotation semantics:
x-auth: Required auth flow. Values:"jwt","oauth2","session","mtls","none"(default).x-scopes: Array of scope strings. Checked againstAuthContext.scopes.x-scopes-match:"any"(at least one) or"all"(all required). Default:"any".x-auth-optional: Iftrue, route works with or without auth.
4.2 Programmatic Mode (No Schema Annotations)
For architectures like Arbiter that don't use schema annotations for auth, pass auth requirements directly to the test runner:
// Arbiter-style: auth is handled in preHandler gates, not schema annotations
const suite = await fastify.apophis.contract({
scope: 'tenant-a',
auth: {
flow: 'jwt',
issuer: 'https://auth.example.com',
scopes: ['read:users', 'read:posts']
},
// Optional: per-route auth overrides
routeAuth: {
'GET /users/:id': { requiredScopes: ['read:users'] },
'POST /admin/users': { requiredScopes: ['admin'], scopesMatch: 'all' }
}
})
Programmatic mode semantics:
authinTestConfiginitializes the auth context for the entire test runrouteAuthprovides 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).subetc. to validate claims in responses
5. Type Changes in src/types.ts
5.1 RouteContract Extension
export interface RouteContract {
path: string
method: string
category: OperationCategory
requires: string[]
ensures: string[]
invariants: string[]
regexPatterns: Record<string, string>
validateRuntime: boolean
schema?: Record<string, unknown>
// NEW:
authFlow: AuthFlow
requiredScopes: string[]
scopesMatch: 'any' | 'all'
authOptional: boolean
rateLimit?: RateLimitConfig
}
5.2 EvalContext Extension
export interface EvalContext {
readonly request: { /* ... */ }
readonly response: { /* ... */ }
readonly previous?: EvalContext
// NEW:
readonly auth: AuthContext
}
5.3 TestConfig Extension
export interface TestConfig {
readonly depth?: TestDepth
readonly scope?: string
readonly seed?: number
// NEW:
readonly auth?: AuthConfig
readonly routeAuth?: Record<string, { requiredScopes?: string[]; scopesMatch?: 'any' | 'all'; authOptional?: boolean }>
readonly burst?: boolean // Enable burst testing for rate limits
}
5.4 ApophisOptions Extension
export interface ApophisOptions {
readonly swagger?: Record<string, unknown>
readonly runtime?: 'off' | 'warn' | 'error'
readonly cleanup?: boolean
readonly scopes?: Record<string, ScopeConfig>
// NEW:
readonly auth?: AuthConfig
}
6. APOSTL Extensions for Auth
New operation headers for auth introspection:
export type OperationHeader =
| 'request_body' | 'response_body' | 'response_code'
| 'request_headers' | 'response_headers' | 'query_params'
| 'cookies' | 'response_time'
// NEW:
| 'jwt_claim' | 'auth_scope' | 'rate_limit_remaining' | 'rate_limit_limit' | 'rate_limit_reset'
New formula syntax:
jwt_claims(this).sub == "user-123"
jwt_claims(this).role == "admin"
auth_has_scope(this, "read:users") == true
auth_has_scope(this, "admin") == true
rate_limit_remaining(this) >= 0
rate_limit_limit(this) == 100
Semantics:
jwt_claim(this).<claim>: Access a claim from the decoded JWT payload. Returnsundefinedif no JWT or claim missing.auth_scope(this).<scope>: Returnstrueif the scope is present inAuthContext.scopes,falseotherwise.rate_limit_remaining(this): Returns the number of requests remaining in the current window (from response headers).rate_limit_limit(this): Returns the total request limit for the window.rate_limit_reset(this): Returns the Unix timestamp when the rate limit window resets.
7. Token Generation Helpers for Testing
New module: src/infrastructure/auth-test-helpers.ts
/**
* Auth Test Helpers
* Deterministic token generation for testing. No external IdP calls.
*/
import { createSign, createVerify, randomBytes, createHash, createHmac } from 'node:crypto'
export interface TestKeyPair {
readonly publicKey: string
readonly privateKey: string
}
export const generateTestKeyPair = (): TestKeyPair => {
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
return { publicKey, privateKey }
}
export const signTestJwt = (
payload: Record<string, unknown>,
privateKey: string,
options: { expiresIn?: number; issuer?: string; audience?: string } = {}
): string => {
const header = { alg: 'RS256', typ: 'JWT' }
const now = Math.floor(Date.now() / 1000)
const claims = {
...payload,
iat: now,
exp: options.expiresIn ? now + options.expiresIn : now + 3600,
...(options.issuer ? { iss: options.issuer } : {}),
...(options.audience ? { aud: options.audience } : {}),
}
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url')
const claimsB64 = Buffer.from(JSON.stringify(claims)).toString('base64url')
const signingInput = `${headerB64}.${claimsB64}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(privateKey, 'base64url')
return `${signingInput}.${signature}`
}
export const verifyTestJwt = (token: string, publicKey: string): Record<string, unknown> | null => {
const [headerB64, claimsB64, signature] = token.split('.')
if (!headerB64 || !claimsB64 || !signature) return null
const verifier = createVerify('RSA-SHA256')
verifier.update(`${headerB64}.${claimsB64}`)
const valid = verifier.verify(publicKey, signature, 'base64url')
if (!valid) return null
return JSON.parse(Buffer.from(claimsB64, 'base64url').toString())
}
export const generateTestSessionCookie = (sessionId: string, secret: string): string => {
const signature = createHmac('sha256', secret).update(sessionId).digest('base64url')
return `session=${sessionId}.${signature}`
}
export const parseTestSessionCookie = (cookie: string, secret: string): string | null => {
const match = cookie.match(/session=([^;]+)/)
if (!match) return null
const [sessionId, signature] = match[1].split('.')
if (!sessionId || !signature) return null
const expected = createHmac('sha256', secret).update(sessionId).digest('base64url')
return signature === expected ? sessionId : null
}
8. OAuth 2.1 Grant Flow Simulation
New module: src/infrastructure/oauth-simulator.ts
/**
* OAuth 2.1 Grant Flow Simulator
* Simulates authorization code, client credentials, and PKCE flows
* without external IdP dependency. Returns tokens deterministically.
*/
import { signTestJwt, generateTestKeyPair } from './auth-test-helpers.js'
import type { AuthContext, AuthConfig } from '../types.js'
import { randomBytes, createHash } from 'node:crypto'
export interface OAuthSimulationResult {
readonly accessToken: string
readonly refreshToken: string
readonly tokenType: 'Bearer'
readonly expiresIn: number
readonly scope: string
}
export class OAuthSimulator {
private readonly keyPair: TestKeyPair
private readonly config: AuthConfig
private codeChallengeStore: Map<string, string> = new Map()
constructor(config: AuthConfig) {
this.config = config
this.keyPair = config.testKeyPair ?? generateTestKeyPair()
}
async authorizationCode(params: {
code: string
codeVerifier?: string
redirectUri: string
clientId: string
}): Promise<OAuthSimulationResult> {
if (params.codeVerifier) {
const challenge = this.codeChallengeStore.get(params.code)
const verifierHash = createHash('sha256').update(params.codeVerifier).digest('base64url')
if (verifierHash !== challenge) {
throw new Error('invalid_grant: PKCE verification failed')
}
}
return this.issueToken(params.clientId, this.config.scopes ?? ['openid'])
}
async clientCredentials(params: {
clientId: string
clientSecret: string
scope?: string
}): Promise<OAuthSimulationResult> {
if (params.clientSecret !== `secret-${params.clientId}`) {
throw new Error('invalid_client: Client authentication failed')
}
const scopes = params.scope ? params.scope.split(' ') : (this.config.scopes ?? [])
return this.issueToken(params.clientId, scopes)
}
async authorize(params: {
responseType: string
clientId: string
redirectUri: string
scope?: string
state?: string
codeChallenge?: string
codeChallengeMethod?: 'S256' | 'plain'
}): Promise<{ code: string; state?: string }> {
if (params.responseType !== 'code') {
throw new Error('unsupported_response_type')
}
const code = randomBytes(16).toString('hex')
if (params.codeChallenge) {
this.codeChallengeStore.set(code, params.codeChallenge)
}
return { code, state: params.state }
}
private issueToken(clientId: string, scopes: string[]): OAuthSimulationResult {
const accessToken = signTestJwt(
{ sub: clientId, scope: scopes.join(' '), client_id: clientId },
this.keyPair.privateKey,
{ issuer: this.config.issuer, audience: this.config.audience, expiresIn: 3600 }
)
const refreshToken = randomBytes(32).toString('base64url')
return {
accessToken,
refreshToken,
tokenType: 'Bearer',
expiresIn: 3600,
scope: scopes.join(' '),
}
}
}
9. Session Cookie Flow Simulation
New module: src/infrastructure/session-simulator.ts
/**
* Session Cookie Flow Simulator
* Manages session state for cookie-based auth testing.
*/
import { randomBytes } from 'node:crypto'
import { generateTestSessionCookie, parseTestSessionCookie } from './auth-test-helpers.js'
import type { AuthConfig } from '../types.js'
interface Session {
readonly id: string
readonly data: Record<string, unknown>
readonly createdAt: number
}
export class SessionSimulator {
private readonly sessions: Map<string, Session> = new Map()
private readonly secret: string
constructor(config: AuthConfig) {
this.secret = config.sessionSecret ?? 'test-session-secret-change-in-production'
}
createSession(data: Record<string, unknown> = {}): Session {
const id = randomBytes(16).toString('hex')
const session: Session = { id, data, createdAt: Date.now() }
this.sessions.set(id, session)
return session
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId)
}
destroySession(sessionId: string): boolean {
return this.sessions.delete(sessionId)
}
generateCookie(sessionId: string): string {
return generateTestSessionCookie(sessionId, this.secret)
}
parseCookie(cookieHeader: string): string | null {
return parseTestSessionCookie(cookieHeader, this.secret)
}
}
10. Rate Limiting
10.1 Contract Annotations (Annotation Mode)
{
"x-rate-limit": {
"requests": 100,
"window": "1m",
"burst": 10,
"key": "ip"
}
}
Annotation semantics:
x-rate-limit.requests: Maximum requests allowed in the window.x-rate-limit.window: Time window as a duration string (e.g.,"1m","1h","1d").x-rate-limit.burst: Maximum burst size.x-rate-limit.key: Rate limit bucket key:"ip","user","tenant","global".
10.2 Programmatic Rate Limit Config
const suite = await fastify.apophis.contract({
auth: { flow: 'jwt', scopes: ['read:users'] },
routeRateLimits: {
'GET /api/data': { requests: 100, window: '1m', burst: 10, key: 'ip' },
'POST /api/action': { requests: 10, window: '1h', burst: 2, key: 'user' }
}
})
10.3 Rate Limit State Tracking
New module: src/infrastructure/rate-limit-tracker.ts
export interface RateLimitState {
readonly bucket: string
readonly remaining: number
readonly limit: number
readonly resetAt: number
readonly window: string
}
export class RateLimitTracker {
private readonly state: Map<string, RateLimitState> = new Map()
update(bucket: string, remaining: number, limit: number, resetAt: number, window: string): void {
this.state.set(bucket, { bucket, remaining, limit, resetAt, window })
}
get(bucket: string): RateLimitState | undefined {
return this.state.get(bucket)
}
isExhausted(bucket: string): boolean {
const state = this.state.get(bucket)
if (!state) return false
return state.remaining <= 0 && Date.now() < state.resetAt
}
reset(bucket: string): void {
this.state.delete(bucket)
}
getAll(): ReadonlyMap<string, RateLimitState> {
return this.state
}
}
11. Scope Registry Integration
The scope registry integrates auth context into scope resolution:
// src/infrastructure/scope-registry.ts
getHeaders(
scopeName: string | null,
overrides?: Record<string, string>,
authContext?: AuthContext
): Record<string, string> {
const scope = scopeName !== null ? this.scopes.get(scopeName) : undefined
const base = scope ?? this.defaultScope
const tenantId = base.metadata?.tenantId as string | undefined
const applicationId = base.metadata?.applicationId as string | undefined
const headers: Record<string, string> = {
...base.headers,
...(tenantId !== undefined && tenantId !== 'default' ? { 'x-tenant-id': tenantId } : {}),
...(applicationId !== undefined && applicationId !== 'default' ? { 'x-application-id': applicationId } : {}),
...(overrides ?? {}),
}
// Inject auth headers if auth context is provided
if (authContext?.token) {
if (authContext.flow === 'jwt' || authContext.flow === 'oauth2') {
headers['authorization'] = `Bearer ${authContext.token}`
} else if (authContext.flow === 'session' && authContext.sessionCookie) {
headers['cookie'] = authContext.sessionCookie
}
}
// Inject mTLS certificate info if present
if (authContext?.clientCert && authContext.flow === 'mtls') {
headers['x-client-cert'] = authContext.clientCert
}
return headers
}
12. Request Builder Integration
The request builder injects auth headers based on route requirements and current auth context:
// src/domain/request-builder.ts
const buildHeaders = (
route: RouteContract,
scopeHeaders: Record<string, string>,
data: Record<string, unknown>,
_state: ModelState,
authContext?: AuthContext
): Record<string, string> => {
const headers: Record<string, string> = { ...scopeHeaders }
if (route.schema?.body) {
headers['content-type'] = 'application/json'
}
// Inject auth headers based on route's auth flow requirement
if (route.authFlow !== 'none' && authContext) {
if (route.authFlow === 'jwt' || route.authFlow === 'oauth2') {
if (authContext.token) {
headers['authorization'] = `Bearer ${authContext.token}`
}
} else if (route.authFlow === 'session' && authContext.sessionCookie) {
headers['cookie'] = authContext.sessionCookie
} else if (route.authFlow === 'mtls' && authContext.clientCert) {
headers['x-client-cert'] = authContext.clientCert
}
}
return headers
}
13. Auth Context Initialization in Test Runners
Both petit-runner.ts and stateful-runner.ts initialize auth context before test execution:
// In runPetitTests()
let authContext: AuthContext = {
flow: config.auth?.flow ?? 'none',
token: null,
refreshToken: null,
tokenExpiry: null,
sessionCookie: null,
clientCert: null,
scopes: [],
claims: {},
}
if (config.auth && config.auth.flow !== 'none') {
authContext = await initializeAuth(config.auth)
}
// Pass authContext to buildRequest in the execution loop
for (const command of allCommands) {
const request = buildRequest(command.route, command.params, scopeHeaders, state, rng, authContext)
// ...
}
Auth initialization helper:
async function initializeAuth(config: AuthConfig): Promise<AuthContext> {
switch (config.flow) {
case 'jwt': {
const keyPair = config.testKeyPair ?? generateTestKeyPair()
const token = signTestJwt(
{ sub: 'test-user', scope: (config.scopes ?? []).join(' ') },
keyPair.privateKey,
{ issuer: config.issuer, audience: config.audience }
)
const claims = verifyTestJwt(token, keyPair.publicKey) ?? {}
return {
flow: 'jwt',
token,
refreshToken: null,
tokenExpiry: Date.now() + 3600000,
sessionCookie: null,
clientCert: null,
scopes: config.scopes ?? [],
claims,
}
}
case 'oauth2': {
const simulator = new OAuthSimulator(config)
const result = await simulator.clientCredentials({
clientId: config.clientId ?? 'test-client',
clientSecret: config.clientSecret ?? `secret-${config.clientId ?? 'test-client'}`,
scope: (config.scopes ?? []).join(' '),
})
const claims = verifyTestJwt(result.accessToken, simulator['keyPair'].publicKey) ?? {}
return {
flow: 'oauth2',
token: result.accessToken,
refreshToken: result.refreshToken,
tokenExpiry: Date.now() + result.expiresIn * 1000,
sessionCookie: null,
clientCert: null,
scopes: result.scope.split(' '),
claims,
}
}
case 'session': {
const simulator = new SessionSimulator(config)
const session = simulator.createSession({ userId: 'test-user', roles: config.scopes ?? [] })
const cookie = simulator.generateCookie(session.id)
return {
flow: 'session',
token: null,
refreshToken: null,
tokenExpiry: null,
sessionCookie: cookie,
clientCert: null,
scopes: config.scopes ?? [],
claims: session.data,
}
}
case 'mtls': {
return {
flow: 'mtls',
token: null,
refreshToken: null,
tokenExpiry: null,
sessionCookie: null,
clientCert: config.clientCert ?? null,
scopes: config.scopes ?? [],
claims: {},
}
}
case 'none':
default:
return { flow: 'none', token: null, refreshToken: null, tokenExpiry: null, sessionCookie: null, clientCert: null, scopes: [], claims: {} }
}
}
14. Contract Extraction
Update src/domain/contract.ts to extract auth annotations from schema (annotation mode):
const contract: RouteContract = {
path,
method: method.toUpperCase(),
category,
requires,
ensures,
invariants: EMPTY_INVARIANTS,
regexPatterns: {},
validateRuntime,
schema: s,
// NEW:
authFlow: (s['x-auth'] as AuthFlow) ?? 'none',
requiredScopes: Array.isArray(s['x-scopes']) ? (s['x-scopes'] as string[]) : [],
scopesMatch: (s['x-scopes-match'] as 'any' | 'all') ?? 'any',
authOptional: s['x-auth-optional'] === true,
rateLimit: s['x-rate-limit'] ? {
requests: Number(s['x-rate-limit'].requests) || 100,
window: String(s['x-rate-limit'].window) || '1m',
burst: Number(s['x-rate-limit'].burst) || 10,
key: (s['x-rate-limit'].key as 'ip' | 'user' | 'tenant' | 'global') || 'global',
} : undefined,
}
15. Example: Arbiter-Style Programmatic Auth
import fastify from 'fastify'
import { apophisPlugin } from 'apophis-fastify'
const app = fastify()
// Register APOPHIS with auth support
await app.register(apophisPlugin, {
scopes: {
'tenant-a': {
headers: { 'x-tenant-id': 'tenant-a' },
metadata: { tenantId: 'tenant-a' }
}
}
})
// Arbiter-style route: NO schema annotations for auth
// Auth is handled in preHandler gates (not shown)
app.get('/users/:id', {
schema: {
params: { type: 'object', properties: { id: { type: 'string' } } },
response: {
200: {
type: 'object',
properties: { id: { type: 'string' }, email: { type: 'string' } }
}
}
}
}, async (req, reply) => {
// Gate-based auth happens in preHandler
return { id: req.params.id, email: 'user@example.com' }
})
// Test with programmatic auth config
const suite = await app.apophis.contract({
scope: 'tenant-a',
auth: {
flow: 'jwt',
issuer: 'https://auth.example.com',
scopes: ['read:users']
},
routeAuth: {
'GET /users/:id': { requiredScopes: ['read:users'] }
}
})
console.log(`Tests: ${suite.summary.passed} passed, ${suite.summary.failed} failed`)
16. Test Plan
16.1 Auth Tests
- JWT Flow: Verify
jwt_claim(this).subworks with generated test tokens. - OAuth 2.1 Client Credentials: Verify token acquisition and scope assignment.
- OAuth 2.1 Authorization Code + PKCE: Verify full flow simulation.
- Session Cookie: Verify session creation, cookie generation, and validation.
- mTLS: Verify client certificate injection.
- Scope Enforcement: Verify routes reject requests without required scopes.
- Auth Optional: Verify
x-auth-optional: trueallows unauthenticated access. - Programmatic Mode: Verify
routeAuthconfig works without schema annotations.
16.2 Rate Limit Tests
- Header Validation: Verify
response_headers(this).x-ratelimit-remaining >= 0passes. - Burst Mode: Verify rapid sequential requests trigger rate limit responses.
- State Tracking: Verify rate limit state persists across requests within one test run and resets between runs.
- Contract Violation: Verify 429 responses are handled correctly when rate limit exceeded.
16.3 Integration Tests
- Auth + Scope: Verify JWT route with
read:usersscope works when scope is granted. - Auth + Rate Limit: Verify authenticated requests are rate-limited per-user.
- Scope + Tenant: Verify tenant isolation with per-tenant auth contexts.
- Programmatic + Annotation: Verify both modes work in the same test run.
17. Backward Compatibility
All new features are opt-in:
- Routes without
x-authdefault toauthFlow: 'none'. - Routes without
x-scopesdefault torequiredScopes: []. - Routes without
x-rate-limitdefault to no rate limit validation. - Test configurations without
authdefault to no auth context. - Test configurations without
routeAuthdefault to annotation-only mode.
No breaking changes to existing APOPHIS v1.0 APIs.
18. Security Considerations
- Test Keys:
generateTestKeyPair()generates 2048-bit RSA keys for testing only. Never use in production. - Session Secrets:
SessionSimulatoruses a default secret if none provided. Production code must always provide a strong secret. - Token Expiry: Test JWTs expire after 1 hour by default. Short-lived tokens prevent accidental reuse.
- No External Calls: The OAuth simulator does not make HTTP requests to external IdPs. All tokens are generated locally.
- Scope Validation: Scope checks are exact-match only. No wildcard or regex matching to prevent scope escalation attacks in tests.
- mTLS Certificates: Test client certificates should be generated for each test run. Never reuse production certificates.
End of Revised Specification