OAuth Client Registry
What this is
The admin-only API that registers and manages the OAuth/OIDC clients the IdP issues tokens to — the mobile app, the dashboard, the Alexa skill, and any future first- or third-party relying party. It is CRUD over the espuser-oauth-clients table: create a client, list them (optionally with their secrets), patch a client, and delete it. This spec is the authoritative record of the client registry as built.
Why it is needed
Onboarding a new app or changing its redirect URIs must not require a code deploy. The client registry is the runtime configuration surface every OAuth flow reads: /oauth2/authorize checks redirect_uris/require_pkce, /oauth2/token authenticates confidential clients against the stored secret, and the OTP direct-token flow checks the client exists. These facts are data in the registry, not code.
Access control
Authorizer: the admin Cognito pool authorizer (
CognitoAuthorizer), not our RS256 token — clients are configured by ESP admins, who authenticate against Cognito (D2/D4), never by end users.Claim: the handler verifies the bearer token against the admin user pool’s JWKS; a token that pool did not issue is rejected
403. Admin-pool membership is the whole privilege, so every admin may register clients (D47).Path prefix
/v1/admin/clients(D47 API-surface table).
Key rules (enforced on write)
These OAuth 2.1 client invariants are enforced at create/patch so the admin UI fails fast:
Client secrets are stored in plaintext and are retrievable. A confidential client’s secret is stored as-is in the row and returned both at Create and by List when the caller passes
get_secret=true. This is a deliberate, weaker-than-hashing posture (a table read exposes usable secrets) chosen so an admin can look a lost secret back up instead of rotating. It is acceptable here only because the table is admin-only and there is no dynamic/third-party client registration; if that changes, move to hashing (secret shown once) or encryption-at-rest. There is no rotate endpoint — to replace a secret, delete and recreate the client.redirect_urisare exact-match strings — no wildcards, no path-prefix matching (RFC 9700 / OAuth 2.1).grant_typesmay not containimplicitorpassword(ROPC) — rejected.response_typesis["code"]only.publicclients may not carry a secret and haverequire_pkceforcedtrue.confidentialclients get a generated secret. Thetoken_endpoint_auth_methodis derived, not stored —nonefor public, implied by the secret for confidential. (M2M is not a distinct type: it is a confidential client with theclient_credentialsgrant, which arrives with the token-endpoint M2M slice —client_credentialsis not an accepted grant yet.)
APIs
All requests carry the admin Cognito token in Authorization. Errors use the API’s generic { "message": ... } shape (these are admin config endpoints, not an OAuth protocol surface).
Create Client
API: POST /v1/admin/clients
Request:
field |
example |
notes |
|---|---|---|
|
|
|
|
|
|
|
|
exact-match set; custom schemes allowed for native apps |
|
|
|
|
|
|
|
|
|
|
|
Scope of this slice. The body carries only fields with a live consumer today. Reserved for later slices (not accepted yet):
jwks_uri,audiences,post_logout_redirect_uris,branding_id, andtoken_ttls— (M2M/private_key_jwt, RFC 8707 resource indicators, RP-initiated logout, hosted-UI branding, and per-client TTLs are later slices). They will be added to the schema when their features land; adding fields is Native.
Process:
Authorize (token verified against the admin Cognito pool).
Validate the Key Rules above; reject on the first violation with
400and a specific message.Generate an opaque
client_id(a caller-suppliedclient_idis honored for the seed path sorm_mobileetc. are stable — collision-checked with a conditional write).For
confidential: generate a high-entropy secret and store it (plaintext) on the row.PutItemconditional onattribute_not_exists(client_id); stampcreated_at/updated_at.
Response (201; client_secret present only for confidential):
field |
example |
notes |
|---|---|---|
|
|
|
|
|
|
|
|
List Clients
API: GET /v1/admin/clients
Returns every client. By default the secret is omitted; pass ?get_secret=true to include each confidential client’s plaintext client_secret in the rows (this is how a lost secret is recovered — there is no per-client Get and no rotate). Supports page_size / pagination like other list endpoints. Whether a client has a secret is implied by client_type (confidential ⇒ yes, public ⇒ no).
Update Client
API: PUT /v1/admin/clients/{client_id}
Full replace of the mutable fields (client name, redirect URIs, grant types, scopes, require_pkce): the body is the complete desired state, so an omitted field resets to empty/default. client_id, client_type, and the secret are immutable and rejected in the body. client_name is required. Re-runs the Key-Rule validation on the result. 404 if unknown.
Delete Client
API: DELETE /v1/admin/clients/{client_id}
Hard delete: permanently removes the client row. Any tokens already issued keep validating until they expire (they are self-contained JWTs), but the client can no longer authorize, exchange, or use OTP. Returns 200.
Seeding the current clients (deploy-time)
The clients that exist today are created by a deploy-time custom resource in the base stack (not the clients-API/core stack). Each create is a conditional PutItem on attribute_not_exists(client_id), so a client that already exists is left untouched — re-deploys never clobber a client an admin has since edited.
Seed set = ESP-User OIDC clients for the current first-party apps, with client_id fixed so the apps and tests keep a stable id. Each is an OAuth 2.1 authorization_code/refresh_token client:
client_id |
client_type |
notable config |
|---|---|---|
|
public |
|
|
confidential |
|
The registry accepts only
authorization_code/refresh_tokengrants.
Consumers of the registry
OTP direct-token (auth-flows.md):
POST /v1/auth/otp/initiatelooks the client up in the registry and rejects an unknown client withinvalid_client. Any registered client may use direct-token OTP — there is no per-client gate. This is safe because client registration is admin-only (there is no dynamic/third-party registration — RFC 7591 is deferred), so every registered client is first-party by construction. If third-party or dynamic registration is ever added, a per-client gate must be reintroduced before then.Token / authorize (later slices): confidential-client auth against the stored
secret,redirect_uris/require_pkceenforcement.
Storage
TableName:
espuser-oauth-clientsKeys:
client_id(PK), no SK.Attributes written by this slice:
client_name,client_type,secret(plaintext),redirect_uris,grant_types,scopes,require_pkce,created_at,updated_at.token_endpoint_auth_methodandresponse_typesare derived at read time (response_typesis always["code"]), not stored; there is nostatus(delete is a hard delete) and nohas_secret(implied byclient_type). The full design also reservesjwks_uri,audiences,post_logout_redirect_uris,branding_id,token_ttls,allowed_providers(federation),skip_consent(consent screen), and a per-client direct-token flag for later slices.No GSI: keyed by
client_id; List scans the table.
Standards reference
OAuth 2.1 / RFC 9700 (BCP 240) — exact redirect match, code+PKCE, no implicit/ROPC. Client invariants enforced on write.
RFC 6749 §2 — client types (
public/confidential) andtoken_endpoint_auth_method.RFC 7591/7592 (Dynamic Client Registration) — explicitly deferred; this API is admin-managed, not self-service DCR.