Skip to content

Central Auth Service Design

Overview

A standalone NestJS service that centralizes API client management, token issuance (RS256 JWTs), and scope registration for the SA Platform. Other services validate tokens via JWKS and enforce scopes locally using @sa-platform/auth-client.

Goals

  • Single authority for API client credentials and token issuance
  • RS256 asymmetric JWTs — auth service signs with private key, all services verify via JWKS public keys
  • Scope registry — services self-register their scopes at startup and deploy time
  • Manual key rotation with multi-key JWKS support, designed so automatic rotation can be added later
  • Multiple active secrets per API client for zero-downtime rotation
  • Backward-compatible migration from current HS256 tokens

Boundaries

Owned by auth service

  • API client CRUD (credentials, scopes, status)
  • API client secrets (Argon2id hashing, multiple active secrets, rotation)
  • Token issuance (POST /v1/oauth/token, client_credentials flow)
  • JWKS endpoint (GET /.well-known/jwks.json)
  • RSA signing key management and rotation
  • Scope registry (registration + listing)
  • Auth domain events (client.revoked, key.rotated)

NOT owned by auth service

  • Organisations — remain in clinical-api, auth references org_id without owning the entity
  • Products — remain in clinical-api, auth references product_id
  • End-user authentication (login, MFA, password reset) — future user-management service
  • Authorization policies (RBAC/ABAC) — each service enforces its own scopes
  • Session management — stateless JWT, no server-side sessions

Data Model

The auth service has its own Prisma schema and MySQL database (auth). No tenant scoping — the auth service operates across all orgs as a platform service.

SigningKey

Field Type Notes
id UUID v7 Primary key
kid string Key ID, used in JWT header and JWKS
algorithm string RS256
publicKey text PEM format
privateKey text PEM, encrypted at application level
status enum active, rotated, revoked
activatedAt datetime When this key became the active signing key
rotatedAt datetime Nullable, when this key was superseded
createdAt datetime
updatedAt datetime

Only one key has status: active at a time. Rotated keys remain in JWKS until all tokens signed by them have expired.

ApiClient

Migrated from clinical-api. References org_id and product_id without foreign keys (those entities live in a different database).

Field Type Notes
id UUID v7 Primary key
organisationId string Reference to org in clinical-api's DB
productId string Reference to product in clinical-api's DB
displayName string
authMethod enum oauth2_client_credentials, external_jwt
authConfigJson text Nullable, JWKS URL/issuer/audience for external JWT
status enum active, suspended, revoked
scopes JSON Array of granted scope strings
createdAt datetime
updatedAt datetime
deletedAt datetime Nullable, soft delete

ApiClientSecret

Replaces the single secret field on ApiClient. Supports multiple active secrets for zero-downtime rotation.

Field Type Notes
id UUID v7 Primary key
apiClientId UUID v7 FK to ApiClient
secretHash string Argon2id hash
label string Nullable, e.g. "production-2026-04"
status enum active, expired, revoked
expiresAt datetime Nullable, for grace-period rotation
createdAt datetime

Validation tries each active (non-expired) secret for a client until one matches.

RegisteredScope

Field Type Notes
id UUID v7 Primary key
scope string Unique, e.g. clinical:patients:read
serviceId string Which service registered this scope
description string Human-readable description
createdAt datetime
updatedAt datetime

API Endpoints

Public (no auth required)

POST /v1/oauth/token — Token issuance (client_credentials flow)

  • Request: { client_id, client_secret, grant_type: "client_credentials" }
  • Validates client_id exists and is active
  • Tries each active secret for the client (Argon2id verify)
  • Signs JWT with the active RSA private key (RS256)
  • Response: { access_token, token_type: "bearer", expires_in: 900 }

GET /.well-known/jwks.json — JSON Web Key Set

  • Returns all keys with status active or rotated
  • Standard JWKS format: { keys: [{ kty, kid, alg, n, e, use: "sig" }] }
  • Cached in memory, rebuilt when keys change

Service-to-service (require valid platform token)

POST /v1/scopes/register — Scope registration

  • Request: { service_id, scopes: [{ scope, description }] }
  • Upserts: creates new scopes, updates descriptions on existing
  • Idempotent — safe to call on every service startup
  • Response: { registered: <count>, updated: <count> }

GET /v1/scopes — List all registered scopes

  • Optional filter: ?service_id=clinical-api
  • Response: { scopes: [{ scope, service_id, description }] }

Admin (require admin bearer token)

POST /v1/admin/api-clients — Create API client

  • Request: { organisation_id, product_id, display_name, auth_method, scopes[], auth_config_json? }
  • Generates initial secret, returns it once (never stored in plaintext)
  • Response: { id, client_id, client_secret, display_name, scopes, ... }

GET /v1/admin/api-clients — List API clients

  • Filter by organisation_id, product_id, status

GET /v1/admin/api-clients/{id} — Get API client details

PATCH /v1/admin/api-clients/{id} — Update client (display name, scopes, status)

POST /v1/admin/api-clients/{id}/secrets — Generate new secret

  • Creates a new active secret
  • Optionally sets expiresAt on previous active secrets (grace period)
  • Response: { secret_id, client_secret, expires_at? }

DELETE /v1/admin/api-clients/{id}/secrets/{secretId} — Revoke a secret

POST /v1/admin/keys/rotate — Rotate signing key

  • Generates new RSA key pair
  • New key becomes active, old key becomes rotated
  • Response: { kid, activated_at }

GET /v1/admin/keys — List signing keys with status

Health

GET /health/live{ status: "ok" } GET /health/ready{ status: "ok", checks: { database: "ok" } }


Token Format

JWT claims (RS256-signed)

{
  "sub": "client_abc123",
  "iss": "https://auth.sa-platform.com",
  "aud": "sa-platform",
  "org_id": "org_xyz",
  "product_id": "prod_ov2",
  "scopes": ["clinical:patients:read", "clinical:cases:write"],
  "exp": 1713500400,
  "iat": 1713499500
}

Token lifetime: 15 minutes. The kid is in the JWT header (not the payload).

Validation flow in consuming services

@sa-platform/auth-client's AuthGuard:

  1. Extract Bearer token from Authorization header
  2. Decode JWT header to get kid
  3. Fetch JWKS from auth service (GET /.well-known/jwks.json) — cached in memory with 5-minute TTL
  4. Find matching key by kid, verify RS256 signature
  5. Check exp, iss, aud claims
  6. Populate AuthenticatedClient from token claims — no database lookup needed

Caching Strategy

Services that need org/product details beyond what's in the token:

  • Baseline: In-memory LRU cache with 5-minute TTL, fetched from clinical-api's API
  • Invalidation: Auth service publishes events on Redis pub/sub when API client status changes (auth.client.revoked, auth.client.updated). Services subscribed to these events evict relevant cache entries.
  • Cache miss triggers a synchronous fetch — first request after eviction is slightly slower

Backward Compatibility

During migration from HS256 to RS256:

  1. AuthGuard tries RS256 JWKS verification first
  2. Falls back to HS256 shared-secret verification if RS256 fails
  3. HS256 fallback removed after all clients are migrated to the auth service

Project Structure

services/auth/
├── prisma/
│   └── schema.prisma
├── src/
│   ├── main.ts
│   ├── app.module.ts
│   ├── config/
│   │   └── app-config.ts
│   ├── health/
│   ├── token/                  # POST /v1/oauth/token
│   ├── jwks/                   # GET /.well-known/jwks.json
│   ├── clients/                # API client CRUD + secret management
│   ├── scopes/                 # Scope registration + listing
│   ├── keys/                   # Signing key management + rotation
│   └── events/                 # Publishes auth domain events
├── test/
│   └── integration/
├── package.json
├── tsconfig.json
└── .env.example

Dependencies

  • @sa-platform/common — PrismaModule, IdModule, HashModule, RedisModule, CorrelationIdMiddleware, ProblemJsonFilter, RequestContext
  • @sa-platform/tsconfig, @sa-platform/eslint-config
  • argon2 — secret hashing
  • jsonwebtoken — JWT signing (RS256)
  • No dependency on @sa-platform/auth-client — the auth service is the authority, it does not validate its own tokens

Auth for the auth service itself

  • Admin endpoints: Static bearer token (ADMIN_API_SECRET env var), OIDC/SSO in production later
  • Service-to-service endpoints (scope registration): Services present a valid RS256 JWT (obtained via POST /v1/oauth/token). The auth service validates these tokens against its own JWKS — same RS256 verification path as any other service, no special case needed since the auth service has access to its own public keys

Changes to Existing Code

@sa-platform/auth-client

  • AuthGuard gains JWKS-based RS256 verification (fetches from auth service's /.well-known/jwks.json)
  • AuthGuard gets HS256 fallback for backward compatibility during migration
  • TokenService and TokenController removed (move to auth service)
  • OAuth2CredentialsStrategy removed (token validation is now JWKS-based, not strategy-based)
  • ExternalJwtStrategy stays (for products that bring their own JWT issuer)
  • New: ScopeRegistrationService — calls POST /v1/scopes/register on module init

clinical-api

  • ApiClient model removed from Prisma schema (migrated to auth DB)
  • Admin endpoints for API clients removed (move to auth service)
  • Keeps Organisation, Product, and all other admin endpoints (consent types, code mappings, webhooks)
  • Integration tests updated to obtain tokens from the auth service instead of local TokenService