Issuer API
The public surface your app and its users talk to. Base URL — the pool issuer:
https://appid.dodil.io/{org}/{pool}No platform auth anywhere here: endpoints are anonymous, credential-bearing, or pool-token-bearing as noted. JSON in/out unless stated; the token endpoint also accepts application/x-www-form-urlencoded for OAuth-library compatibility.
Discovery & keys
| Path | Returns |
|---|---|
GET /.well-known/openid-configuration | OIDC discovery — endpoints plus grant_types_supported: ["password","refresh_token","authorization_code"], response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], id_token_signing_alg_values_supported: ["EdDSA"], token_endpoint_auth_methods_supported: ["none"] |
GET /.well-known/jwks.json | The pool’s public keys (OKP / Ed25519), Cache-Control: max-age=300 |
GET /branding | Pre-auth branding document for custom login UIs (includes the pool’s real password_min_length) |
Clients are public (token_endpoint_auth_methods_supported: ["none"]) — there are no client secrets; PKCE is the code-flow proof.
Authorization-code flow (PKCE)
GET /authorize
{issuer}/authorize?response_type=code
&client_id=my-web
&redirect_uri=https://app.example.com/callback
&state=<random>
&code_challenge=<S256(verifier)>
&code_challenge_method=S256
[&scope=openid] [&nonce=<random>] [&tenant=<slug>]- PKCE S256 is mandatory; the challenge must be 43–128 chars.
redirect_urimust exactly match one of the pool’ssettings.redirect_uris— an unlisted URI gets an error page and never a redirect. Other errors redirect back with RFC 6749 error codes plus the RFC 9207issparameter.- The user lands on the hosted login page; the login transaction is valid for 30 minutes. An expired transaction sends the user back to your app with
error=access_denied&error_description=login_session_expired— just start a fresh authorize. scope=openidadditionally mints an ID token at exchange (withaud=client_id,nonceechoed).tenant=<slug>pins the resulting tokens to one tenant.
POST /token — code exchange
{ "grant_type": "authorization_code", "code": "…",
"redirect_uri": "https://app.example.com/callback",
"code_verifier": "…", "client_id": "my-web" }Codes are single-use with a 60-second TTL and bound to the client_id that started the flow. Replaying a code revokes the session family it minted.
Direct grants
POST /token — password
{ "grant_type": "password", "email": "[email protected]",
"password": "…", "tenant": "acme-uk" }POST /token — refresh
{ "grant_type": "refresh_token", "refresh_token": "…" }Successful grants return:
{ "access_token": "<EdDSA JWT>", "token_type": "bearer", "expires_in": 900,
"refresh_token": "<opaque — new every time>", "id_token": "<code flow + openid only>" }Refresh tokens rotate on every use; reusing an old one burns the whole family (with a small grace window for double-submits). Store only the newest.
Accounts
| Method + path | Body | Notes |
|---|---|---|
POST /signup | { "email", "password" } | 403 when signup is disabled; sends a verification link; returns a session unless verification is required first |
POST /recover | { "email" } | Always 200 — no user enumeration. Emails a reset link |
POST /reset | { "token", "password" } | Single-use token (30 min TTL); marks the email verified and revokes all sessions |
POST /verify | { "token" } | Email confirmation (link TTL 3 days) |
POST /logout | { "refresh_token" } | Revokes the session family |
GET /user | — (Bearer: pool access token) | { "claims": {…}, "user": {…} } |
The hosted pages live on the same base: GET /login, /reset, /verify render the branded UI that drives these endpoints.
Errors
Non-token endpoints: { "error": "<message>" }. The token endpoint speaks RFC 6749 §5.2:
{ "error": "invalid_grant", "error_description": "…" }| Status | Meaning |
|---|---|
| 400 | Malformed request / invalid_request |
| 401 | Bad credentials, bad or expired token |
| 403 | Signup disabled, connection disabled, email not verified |
| 404 | Unknown pool or user |
| 409 | Conflict (e.g. email already registered) |
| 429 | Rate limit exceeded — see budgets |
| 503 | Pool user-store migration in its locked phase — retry shortly |
CORS
Browser calls are allowed only from origins in the pool’s policies.cors_allowed_origins (exact scheme+host+port match). Allowed requests get Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS, Access-Control-Allow-Headers: authorization,content-type, and a 600-second preflight cache. There is no Allow-Credentials — auth is bearer-token, never cookies, so your frontend sends Authorization explicitly.