- P0: CLI verify now honors test budget with seeded multi-sample - P0: Observe sampling enforced via Math.random() gate in hook-validator - P1: Remove misleading undici-mock-agent isolation option - P1: Qualify reuses shared discoverRouteDetails() with warnings - P1: Chaos/scenario config exposed via preset schema - P1: README/docs limitations updated to current state - P2: Nested response annotations prefer 2xx deterministically - P2: --changed documented as heuristic in verify.md - Add observe sink tests (sampling 0/1, sink failure non-interference) - Add verify runs regression tests (scale, determinism, variants) - Add configured-scenario qualify test (independent of OAuth fixture) - Add coverageBreakdown to qualify artifacts (per-gate route coverage) - Add production-style observe example with real sink in docs/observe.md - Add nightly/staging vs PR gating guidance to docs/qualify.md - Enrich VerifyFailure with formula-aware diagnostics: status:201 => 'HTTP 200', body field checks => actual values - Remove stale observe CLI activation message - Document outbound mocks as process-global in getting-started.md - Refresh APOPHIS_ADOPTION_AUDIT.md with current state 903 tests pass, build clean, typecheck clean.
4.2 KiB
Authentication Patterns for APOPHIS
APOPHIS generates requests automatically. For authenticated routes, you need to inject auth tokens, session cookies, or API keys into those requests. The cleanest way is via an auth extension.
The Pattern: createAuthExtension
Use createAuthExtension from @apophis/fastify to inject credentials into every request:
import { createAuthExtension } from '@apophis/fastify'
const jwtAuth = createAuthExtension({
name: 'jwt',
getToken: async () => {
const res = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ client_id: 'test', client_secret: 'secret' }),
})
const { access_token } = await res.json()
return access_token
},
})
await fastify.register(apophis, {
extensions: [jwtAuth]
})
getToken is called for every request. Return a token string; APOPHIS writes ${prefix}${token} to headerName, defaulting to authorization: Bearer <token>.
JWT Bearer Token
Standard OAuth 2.1 / OIDC pattern:
const jwtAuth = createAuthExtension({
name: 'jwt',
getToken: async () => {
// Fetch fresh token per request
const { access_token } = await fetchToken()
return access_token
},
// Default: headerName='authorization', prefix='Bearer '
})
API Key
No prefix, custom header:
const apiKeyAuth = createAuthExtension({
name: 'apikey',
getToken: () => {
if (!process.env.API_KEY) throw new Error('API_KEY is required')
return process.env.API_KEY
},
headerName: 'x-api-key',
prefix: '',
})
Session Cookie
const sessionAuth = createAuthExtension({
name: 'session',
getToken: async () => {
const cookie = await loginAndGetCookie()
return cookie
},
headerName: 'cookie',
prefix: 'session=',
})
Conditional Auth (Skip Public Routes)
Skip auth for health checks or public endpoints:
const auth = createAuthExtension({
name: 'conditional',
getToken: () => 'token',
matcher: (route) => !route.path.startsWith('/public/'),
})
Routes matching the matcher get the header. Others proceed unmodified.
Multiple Auth Schemes
Register multiple extensions. They run in order:
await fastify.register(apophis, {
extensions: [
createAuthExtension({ name: 'jwt', getToken: fetchJwt }), // Authorization: Bearer ...
createAuthExtension({ name: 'apikey', getToken: getApiKey, headerName: 'x-api-key', prefix: '' }),
]
})
Per-Route Auth Config
Some routes need different validation (e.g., verify vs parse-only):
fastify.get('/wimse/wit', {
schema: {
'x-category': 'observer',
'x-extension-config': {
jwt: { verify: false, extractFrom: 'body' }
},
'x-ensures': [
'jwt_claims(this).sub != null',
'jwt_claims(this).cnf.jwk != null'
]
}
})
See docs/attic/protocol-extensions-spec.md for full JWT extension configuration.
Refresh Logic
getToken runs per request. Handle refresh inline:
let cachedToken = null
const auth = createAuthExtension({
name: 'jwt-with-refresh',
getToken: async () => {
if (cachedToken && !isExpired(cachedToken)) {
return cachedToken
}
const { access_token } = await refreshToken()
cachedToken = access_token
return access_token
},
})
Testing Without Auth
For routes that don't need auth, omit the extension or use a matcher:
// Only auth for /api/* routes
const auth = createAuthExtension({
name: 'api-only',
getToken: () => 'token',
matcher: (route) => route.path.startsWith('/api/'),
})
Summary
| Pattern | headerName |
prefix |
matcher |
|---|---|---|---|
| JWT Bearer | authorization (default) |
Bearer (default) |
optional |
| API Key | x-api-key |
'' |
optional |
| Session Cookie | cookie |
session= |
optional |
| Conditional | any | any | required |
The auth extension is the standard way to test authenticated routes in APOPHIS. It keeps auth logic out of your route handlers and tests, and centralizes it where it belongs.