8d7382417d
- Cite arxiv 2602.23922 (Invariant-Driven Automated Testing) in all major docs - Add Progressive Complexity section to SKILL.md for LLM guidance - Fix SKILL.md Fast Start example to use deterministic ID generation - Fix getting-started.md failure output inconsistency - Fix auth-patterns.md TypeScript syntax in JS doc - Fix fastify-structure.md Date.now() in test helper - Fix observe.md misleading workspace heading - Build: clean | Tests: 849 pass, 0 fail
189 lines
4.2 KiB
Markdown
189 lines
4.2 KiB
Markdown
# 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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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):
|
|
|
|
```javascript
|
|
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/protocol-extensions-spec.md` for full JWT extension configuration.
|
|
|
|
---
|
|
|
|
## Refresh Logic
|
|
|
|
`getToken` runs per request. Handle refresh inline:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
// 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.
|