AUTH4D-207 Server clients

Token introspection (AUTH4D-155 (repository file docs/api-spec/auth4d-155.md)) needs a confidential server client. Project owners and admins now create one through the management API or the console, rotate its secret with an overlap period, and delete it. auth4.dev generates every secret, returns it once and stores only its SHA-256 bearer hash (client_secret_hash). A secret can never be read back.

All routes below are management routes: they need a control-plane management token from the console BFF with the named scope, plus a live project role that grants it. Owners and admins hold client:write; viewers hold client:read only (403 insufficient_role on writes). Player access tokens, ID tokens and a server client’s own Basic credentials are not management tokens and get 401. Another developer’s project gets 403 forbidden, as for every project route.

Every write needs Content-Type: application/json and a JSON body ({} is fine), and every response has Cache-Control: no-store.

Create a server client

POST /management/tenants/{tenantId}/clients (scope client:write)

{ "displayName": "Match server", "clientType": "server", "idempotencyKey": "optional-16-128" }

201:

{
  "client": {
    "tenantId": "ten_…",
    "clientId": "cli_…",
    "displayName": "Match server",
    "clientType": "server",
    "enabled": true,
    "redirectUris": ["https://server-client.auth4.invalid/no-redirect"],
    "allowedOrigins": [],
    "allowedScopes": ["openid"],
    "grantTypes": ["authorization_code"],
    "tokenEndpointAuthMethod": "client_secret_basic"
  },
  "clientSecret": "a4cs_<43 base64url characters>"
}

The body matches the existing CreateClientResponseSchema contract, which requires clientSecret for server clients. Notes:

  • Only displayName is chosen. redirectUris or allowedOrigins get 400 invalid_redirect_uri, allowedScopes gets 400 invalid_scope and allowBrowserRefresh gets 400 browser_refresh_unsupported. tokenEndpointAuthMethod, clientSecret, grantTypes, audience, audiences, tenantId and clientId still get 400 reserved_field. 400 privileged_client_type is no longer returned.
  • The shared client contract requires the authorization_code grant and a redirect URI on every non-native client, so server clients get a fixed placeholder on the reserved .invalid TLD. The authorization endpoint refuses every client that is not a browser application, so a server client can never start a sign-in and nothing is redirected there. allowedScopes is ["openid"] only to satisfy the contract’s minimum; introspection is not limited by scopes.
  • A server client uses an application slot like any other enabled client: the plan limit (402 plan_limit_exceeded) and the 50-client project limit (409 client_limit_reached) apply.
  • Repeating a create with the same idempotencyKey does not return the secret again. It gets 409 client_secret_not_retrievable, with the existing client ID in error.details[0].issue; rotate the secret to get a new one.
  • Audit: management.client.created with client_type: "server", in the same D1 batch.

GET /management/tenants/{tenantId}/clients lists server clients with the other applications. Neither the list nor any other response includes a secret or a hash.

Read a server client

GET /management/tenants/{tenantId}/clients/{clientId} (scope client:read) returns the usual application document plus, for server clients only:

{
  "secret_status": {
    "rotated_at": "2026-10-10T12:00:00.000Z",
    "previous_secret_expires_at": "2026-10-11T12:00:00.000Z"
  }
}

rotated_at is null until the first rotation. previous_secret_expires_at is null when no previous secret is still accepted (never rotated, rotated with no overlap, or the overlap has run out). The shape is ClientSecretStatusSchema in packages/contracts.

A server client’s logo routes get 403 reserved_client: a server client has no sign-in settings. Since AUTH4D-305 (repository file docs/api-spec/auth4d-305.md), PUT/PATCH on a server client may change only displayName and enabled; any other field gets 400 validation_failed.

Rotate the secret

POST /management/tenants/{tenantId}/clients/{clientId}/rotate-secret (scope client:write)

{ "previous_secret_ttl_seconds": 86400 }
Field Required Notes
previous_secret_ttl_seconds No Integer 0–604800 (seven days). Default 86400 (one day). 0 stops it at once.

200 (RotateClientSecretResponseSchema):

{
  "client": { "clientId": "cli_…", "clientType": "server", "…": "…" },
  "clientSecret": "a4cs_…",
  "secret_status": {
    "rotated_at": "2026-10-10T12:00:00.000Z",
    "previous_secret_expires_at": "2026-10-11T12:00:00.000Z"
  }
}
  • The new secret is shown once. The secret that was current keeps authenticating until previous_secret_expires_at, so a server fleet can switch without downtime. Only one previous secret is kept: a further rotation stops any older secret at once.
  • Each call issues a new secret, so the route is not idempotent and takes no idempotencyKey.
  • Errors: 400 validation_failed for an out-of-range or non-integer TTL or an unknown field, 400 not_a_server_client for a browser or native application, 404 not_found for an unknown client. Other methods are refused.
  • Audit: management.client.secret_rotated with client_type: "server" and overlap_seconds (the TTL; 0 when the old secret stopped at once). Secrets and hashes never enter the audit log.

Client authentication (AUTH4D-155 (repository file docs/api-spec/auth4d-155.md) introspection, and the token and revocation endpoints) accepts the current secret, or the previous one while it has not expired. The previous hash is read only when the current one does not match.

Delete a server client

DELETE /management/tenants/{tenantId}/clients/{clientId} (scope client:write)

For a server client this removes the row and its secret hashes for good, so its secrets stop working at once. 200 (DeleteServerClientResponseSchema):

{ "deleted": true, "client_id": "cli_…" }

A second delete, or any later call for that client, gets 404 not_found. Audit: management.client.deleted with client_type: "server". For browser and native applications DELETE still only disables the application (AUTH4D-18 (repository file docs/api-spec/auth4d-18.md)).

Storage

Migration 0032_client_secret_rotation.sql adds three nullable clients columns: previous_client_secret_hash, previous_client_secret_expires_at (a CHECK keeps it paired with the hash) and client_secret_rotated_at. It is additive; code reading a database without it treats every server client as never rotated. Rotation needs the migration.

Console

Applications → Register application → Server (confidential) creates a server client and shows the secret once, with a copy button and a warning. The server client screen shows the client ID, when the secret was last rotated and until when the previous one works, a Rotate secret action with a choice of overlap (one hour, one day, seven days or Stop it now) and a confirmation, and Delete server client with a confirmation. Viewers see the screen without the actions. Secrets stay in page memory: never in a URL, browser storage or history state. The BFF forwards /api/management/tenants/{tenantId}/clients/{clientId}/rotate-secret.

Tests

  • tests/integration/auth4d-207.server-clients.test.ts: create, introspection with the issued secret, refused fields, idempotent replay, roles, tenant isolation and customer tokens, rotation overlap and expiry, rotation validation, deletion, audit events and a leak scan of every response.
  • tests/e2e/auth4d-207.server-clients.spec.ts (auth4d-207.playwright.config.ts, port 5373): create with the one-time secret, rotate with an overlap and with none, delete, viewer access and 320px layout.

Source: docs/api-spec/auth4d-207.md