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_idwithout 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
activeorrotated - 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
expiresAton 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 becomesrotated - 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:
- Extract Bearer token from Authorization header
- Decode JWT header to get
kid - Fetch JWKS from auth service (
GET /.well-known/jwks.json) — cached in memory with 5-minute TTL - Find matching key by
kid, verify RS256 signature - Check
exp,iss,audclaims - Populate
AuthenticatedClientfrom 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:
AuthGuardtries RS256 JWKS verification first- Falls back to HS256 shared-secret verification if RS256 fails
- 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-configargon2— secret hashingjsonwebtoken— 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_SECRETenv 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¶
AuthGuardgains JWKS-based RS256 verification (fetches from auth service's/.well-known/jwks.json)AuthGuardgets HS256 fallback for backward compatibility during migrationTokenServiceandTokenControllerremoved (move to auth service)OAuth2CredentialsStrategyremoved (token validation is now JWKS-based, not strategy-based)ExternalJwtStrategystays (for products that bring their own JWT issuer)- New:
ScopeRegistrationService— callsPOST /v1/scopes/registeron module init
clinical-api¶
ApiClientmodel 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