OAuth Discovery & Dynamic Client Registration
A JMAP client needs an OAuth client ID before it can start an authorization flow. Traditionally an operator creates that client ID by hand and the user copies it into the client’s settings. Serverless Inbox supports the standards-based alternative: the client discovers where the OAuth endpoints are, registers itself, and receives its own client ID — with no operator involvement.
Three RFCs do the work:
| RFC | What it provides |
|---|---|
| RFC 8414 | Authorization Server Metadata — the openid-configuration document |
| RFC 9728 | Protected Resource Metadata — the oauth-protected-resource document |
| RFC 7591 | Dynamic Client Registration — POST /oauth/register |
The two discovery documents
Section titled “The two discovery documents”Both are unauthenticated GET requests returning application/json, served by explicit API Gateway routes on the JMAP host.
/.well-known/openid-configuration
Section titled “/.well-known/openid-configuration”The authorization server metadata. It tells a client where to send the user for authorization, where to exchange the code for tokens, which scopes exist, and — when Dynamic Client Registration is enabled — where to register. The registration_endpoint field is the client’s signal that self-registration is available: when DCR is disabled, that field is simply absent, and a well-behaved client falls back to asking the user for a manually issued client ID.
/.well-known/oauth-protected-resource
Section titled “/.well-known/oauth-protected-resource”The protected resource metadata. This is the JMAP API describing itself — which authorization server guards it, and which scopes it expects. It exists so a client that starts from the mail server (rather than from the identity provider) can find its way to the right authorization server instead of being configured with one.
/.well-known/jmap
Section titled “/.well-known/jmap”Not an OAuth document, but the third URL in the same namespace and the one the client uses after authenticating. It returns the JMAP session resource described in RFC 8620 §2, and unlike the two above it requires a valid token.
The flow a client follows
Section titled “The flow a client follows”sequenceDiagram participant Client participant JMAP as JMAP host participant DCR as /oauth/register participant Cognito
Client->>JMAP: GET /.well-known/openid-configuration JMAP-->>Client: endpoints + registration_endpoint
Client->>DCR: POST /oauth/register (client_name, redirect_uris) DCR->>Cognito: Create app client DCR-->>Client: 201 { client_id, scope, ... }
Client->>Cognito: authorization_code + PKCE (user signs in) Cognito-->>Client: authorization code Client->>Cognito: Exchange code + verifier Cognito-->>Client: access token
Client->>JMAP: GET /.well-known/jmap (Bearer token) JMAP-->>Client: session resource (apiUrl, accounts, ...) Client->>JMAP: JMAP requests to apiUrlFour steps, and the last one matters more than it looks:
- Discover — fetch
openid-configurationto learn the endpoints. - Register —
POST /oauth/registerto obtain aclient_id. This happens once per installation of the client, not once per sign-in. - Authorize — run a standard
authorization_codeflow with PKCE against Cognito. Serverless Inbox does not issue tokens itself; Cognito is the authorization server. - Open the session — call
/.well-known/jmapwith the token, then follow theapiUrlfrom the returned session object for every subsequent API call.
That last point is a requirement of RFC 8620 §3.1, not a convention. The session resource is the only authority on where the API lives, and a client that assumes a URL layout instead of reading apiUrl will break on deployments whose layout differs — regardless of whether the server is behaving correctly.
What registration actually creates
Section titled “What registration actually creates”A successful POST /oauth/register produces two things:
- A Cognito app client — a real app client in the user pool, configured for the authorization code grant with the registered redirect URIs. This is what makes the resulting
client_idusable in a real authorization flow. - A
DynamicClientrecord — the server’s own record of the registration, holding the client name, redirect URIs, and the issued client ID. It is what makes deduplication and the per-tenant cap possible.
Registration is unauthenticated by design. RFC 7591 calls this open registration, and it is what allows a mail client to complete setup before the user has signed in — there is no credential to authenticate with yet. The abuse controls described below exist precisely because the endpoint is open.
Granted scope
Section titled “Granted scope”The granted scope is openid email profile. A client that requests an empty scope has that default substituted rather than being rejected — RFC 7591 §3.2.1 explicitly permits the server to substitute. The scope field in the registration response always reports what was actually granted, so a client should read that field rather than assume its request was honoured verbatim.
Redirect URIs
Section titled “Redirect URIs”Following RFC 8252, which covers OAuth for native applications:
| Redirect URI form | Result |
|---|---|
Private-use scheme, e.g. myapp://callback | Accepted |
Loopback: 127.0.0.1, [::1], localhost, any port | Accepted |
Non-loopback http/https, e.g. https://example.com/cb | Rejected |
Pseudo-schemes: javascript:, data:, file: | Rejected |
Loopback matching is done on the parsed hostname, not by substring — http://127.0.0.1.attacker.com/ and http://127.0.0.1@attacker.com/ both contain the literal text 127.0.0.1 while resolving elsewhere, and both are rejected.
Deduplication
Section titled “Deduplication”Registration is idempotent on redirect URIs plus client name. A repeat registration with the same pair returns the same client_id and creates no second Cognito app client.
This is not a cache with an expiry — the dedup is permanent. It matters because the realistic failure mode of open registration is not a determined attacker but an ordinary client that re-registers on every launch, or a user who reinstalls an app. Without dedup, each of those would consume a Cognito app client and a slot against the tenant cap. With it, they are free.
The practical consequence for client authors: re-registering is safe, but storing the returned client_id is still the right behaviour.
Abuse controls
Section titled “Abuse controls”Because the endpoint is unauthenticated, four controls apply in order. Each one is cheaper than the one after it, so the common cases are rejected before doing real work:
- API Gateway per-route throttle — burst 5, rate 2. Applied at the edge, before any Lambda runs.
- Body and redirect-URI validation — malformed JSON, missing fields, and disallowed redirect URIs are rejected without touching Cognito.
- Permanent dedup — a repeat registration returns the existing client ID instead of creating anything.
- Per-tenant cap — 20 new registrations per rolling ~24 hours. Only genuinely new registrations count; deduplicated ones do not.
Endpoint and error reference
Section titled “Endpoint and error reference”Endpoints
Section titled “Endpoints”| Method | Path | Auth | Success |
|---|---|---|---|
GET | /.well-known/openid-configuration | None | 200 application/json |
GET | /.well-known/oauth-protected-resource | None | 200 application/json |
GET | /.well-known/jmap | Bearer token | 200 JMAP session resource |
POST | /oauth/register | None (RFC 7591 open registration) | 201 |
An unmatched path under /.well-known/ returns a genuine 404.
Registration errors
Section titled “Registration errors”Errors follow RFC 7591 §3.2.2: a JSON body of the form {"error": "...", "error_description": "..."}.
| Status | error | Meaning |
|---|---|---|
400 | invalid_redirect_uri | One or more redirect URIs are not permitted |
400 | invalid_client_metadata | Body is not valid JSON, or a field failed validation |
409 | invalid_client_metadata | Registration conflict |
429 | invalid_client_metadata | Per-tenant registration cap exceeded |
500 | server_error | Server-side failure creating or persisting the client |
A 400 is the client’s fault and will keep failing until the request changes. A 429 is temporal — the cap is a rolling window and the client can retry later. A 500 indicates a server-side problem and is worth reporting.
A note on client support
Section titled “A note on client support”The standards above are settled, but JMAP client support for OAuth discovery and dynamic registration is still uneven. Some clients implement neither and expect a manually issued client ID; some implement discovery but not registration.
This documentation deliberately does not claim that any specific third-party mail client works. Two properties determine whether one will:
- Does it perform RFC 8414 discovery, and RFC 7591 registration when
registration_endpointis present? - Does it follow
apiUrlfrom the JMAP session object, or does it hardcode API paths?
A client that hardcodes endpoint paths will not work here no matter how correct the server is.
Related
Section titled “Related”- Configure Authentication — enabling or disabling registration, and the
.well-knowndeployment constraint - Setup Email Client — connecting a JMAP client as an end user
- Security Model — the wider trust and authentication model