Files
apophis-fastify/docs/auth-patterns.md
T

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: '',
})

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/protocol-extensions-spec.md for full JWT extension configuration.


Refresh Logic

getToken runs per request. Handle refresh inline:

let cachedToken: string | null = 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.