API Reference
oidc-exchange exposes public endpoints for token operations and internal endpoints for user management. All responses use JSON. The API follows OAuth 2.0 conventions (RFC 6749) for token endpoints and RFC 7009 for revocation.
Public endpoints
Section titled “Public endpoints”| Method | Path | Description |
|---|---|---|
| POST | /token |
Token exchange and refresh |
| POST | /revoke |
Token revocation |
| GET | /keys |
JWKS (JSON Web Key Set) endpoint |
| GET | /.well-known/openid-configuration |
OpenID Connect discovery document |
| GET | /health |
Health check |
| POST | /nonce |
Mint a single-use nonce for the direct ID-token grant (only when [grants] id_token = true) |
POST /token (authorization code exchange)
Section titled “POST /token (authorization code exchange)”Exchange an authorization code from an identity provider for access and refresh tokens.
Request:
POST /tokenContent-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=AUTH_CODE_FROM_PROVIDER&provider=google&redirect_uri=https://app.example.com/callback| Parameter | Required | Description |
|---|---|---|
grant_type |
Yes | Must be authorization_code |
code |
Yes | Authorization code from the identity provider |
provider |
Yes | Provider name as configured (e.g., google, apple) |
redirect_uri |
Yes | The redirect URI used in the original authorization request |
Response (200 OK):
{ "access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...", "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...", "token_type": "Bearer", "expires_in": 900}The access_token is a signed JWT with configurable lifetime (default 15 minutes). The refresh_token is an opaque token (256-bit random, base64url-encoded) with a longer lifetime (default 30 days). Only the SHA-256 hash of the refresh token is stored server-side.
POST /token (refresh)
Section titled “POST /token (refresh)”Use a refresh token to obtain a new access token without re-authenticating.
Request:
POST /tokenContent-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...| Parameter | Required | Description |
|---|---|---|
grant_type |
Yes | Must be refresh_token |
refresh_token |
Yes | The refresh token from a previous exchange |
Response (200 OK):
{ "access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...", "refresh_token": "bmV3LXJvdGF0ZWQtcmVmcmVzaC10b2tlbg...", "token_type": "Bearer", "expires_in": 900}By default ([token] refresh_rotation = true), each refresh rotates the token: the response carries a new refresh_token, the presented token is retired, and re-presenting a retired token is rejected as reuse (a short grace window covers a retried request). Setting [token] refresh_rotation = false is the only opt-out; it returns no new refresh_token and keeps the presented token valid until it expires or is revoked.
POST /token (direct ID-token assertion)
Section titled “POST /token (direct ID-token assertion)”Opt-in grant, served only when [grants] id_token = true. Exchange a provider ID token you already hold for access and refresh tokens, without an authorization code.
Request:
POST /tokenContent-Type: application/x-www-form-urlencoded
grant_type=id_token&provider=google&id_token=PROVIDER_ID_TOKEN&provider_access_token=PROVIDER_ACCESS_TOKEN| Parameter | Required | Description |
|---|---|---|
grant_type |
Yes | Must be id_token |
provider |
Yes | Provider name as configured |
id_token |
Yes | The provider ID token to exchange |
provider_access_token |
No | Provider access token co-issued with the ID token, used only to verify its at_hash binding |
When the grant is disabled (the default), any request carrying an id_token field is rejected with unsupported_grant_type.
POST /nonce
Section titled “POST /nonce”Mints a single-use nonce for the direct ID-token grant. Mounted only when [grants] id_token = true; it takes no request body.
Response (200 OK):
{ "nonce": "9c8b...", "expires_in": 600}POST /revoke
Section titled “POST /revoke”Revoke a token per RFC 7009. Always returns 200 OK, even if the token is unknown or already revoked.
Request:
POST /revokeContent-Type: application/x-www-form-urlencoded
token=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...&token_type_hint=refresh_token| Parameter | Required | Description |
|---|---|---|
token |
Yes | The token to revoke |
token_type_hint |
No | refresh_token or access_token. If a refresh token is revoked, only that session is invalidated. If an access token is revoked, only the single session (family) named by the token’s sid claim is invalidated, not all of the user’s sessions. |
Response (200 OK): Empty body.
GET /keys
Section titled “GET /keys”Returns the JSON Web Key Set containing the public key(s) used to sign access tokens. Downstream services use this endpoint to verify token signatures.
Response (200 OK):
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": "key-1", "x": "..." } ]}GET /.well-known/openid-configuration
Section titled “GET /.well-known/openid-configuration”Returns the standard OpenID Connect discovery document.
Response (200 OK):
{ "issuer": "https://auth.example.com", "jwks_uri": "https://auth.example.com/keys", "token_endpoint": "https://auth.example.com/token", "revocation_endpoint": "https://auth.example.com/revoke", "grant_types_supported": ["authorization_code", "refresh_token"], "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["EdDSA"]}The id_token_signing_alg_values_supported field is dynamically populated from the configured key manager’s algorithm.
GET /health
Section titled “GET /health”Returns 200 OK if the service is operational. No authentication required.
Internal endpoints
Section titled “Internal endpoints”Internal endpoints provide user CRUD and claims management. They are served on a dedicated admin listener (default 127.0.0.1:8081), and are mounted only when server.role is admin or all and internal_api.enabled = true; with either condition unmet, no /internal/* routes exist. All internal routes require authentication.
Authentication
Section titled “Authentication”Internal routes sit behind an operator-authentication gate that supports three mechanisms, tried in the order listed in internal_api.auth_methods:
operator_token(recommended): an operator JWT verified against this service’s own key manager, carrying the configured audience and required claim.mtls: the client-certificate subject asserted by a TLS-terminating proxy via a trusted header.shared_secret(legacy): a static secret presented asAuthorization: Bearer <shared_secret>and compared in constant time.
A successful attempt attaches the authenticated operator principal to the request; a failed attempt counts against a per-peer lockout that answers with 429 and a Retry-After header once exhausted.
Example using the legacy shared-secret mechanism:
[internal_api]enabled = trueauth_methods = ["shared_secret"]shared_secret = "${INTERNAL_API_SECRET}"Internal routes
Section titled “Internal routes”| Method | Path | Description |
|---|---|---|
| GET | /internal/stats |
Aggregate user and session counts |
| POST | /internal/sessions/cleanup |
Sweep expired sessions; returns { "deleted": <count> } |
| GET | /internal/users |
List users (query: cursor, limit) |
| POST | /internal/users |
Create a user |
| GET | /internal/users/{id} |
Get a user by ID |
| PATCH | /internal/users/{id} |
Update a user |
| DELETE | /internal/users/{id} |
Soft-delete a user (revokes all sessions) |
| GET | /internal/users/{id}/claims |
Get a user’s private claims |
| PUT | /internal/users/{id}/claims |
Replace all of a user’s private claims |
| PATCH | /internal/users/{id}/claims |
Merge into a user’s private claims |
| DELETE | /internal/users/{id}/claims |
Clear all of a user’s private claims |
POST /internal/users
Section titled “POST /internal/users”Create a new user. The user ID is generated server-side (usr_ prefix + ULID).
Request:
{ "external_id": "google-oauth2|123456", "provider": "google", "email": "user@example.com", "display_name": "Jane Doe"}GET /internal/users/{id}
Section titled “GET /internal/users/{id}”Returns the full user object including metadata and claims.
PATCH /internal/users/{id}
Section titled “PATCH /internal/users/{id}”Update user fields. Only provided fields are modified.
Request:
{ "display_name": "Jane Smith", "status": "suspended", "metadata": { "role": "admin" }}DELETE /internal/users/{id}
Section titled “DELETE /internal/users/{id}”Soft-deletes the user (sets status to Deleted) and revokes all active sessions.
Claims endpoints
Section titled “Claims endpoints”Per-user claims are merged on top of config-level [token.custom_claims] when issuing access tokens. Per-user claims take precedence over config claims with the same key.
- PUT
/internal/users/{id}/claimsreplaces the entire claims map - PATCH
/internal/users/{id}/claimsmerges new claims into existing ones - DELETE
/internal/users/{id}/claimsclears all per-user claims
Example (PUT):
{ "role": "admin", "tier": "enterprise"}Error response format
Section titled “Error response format”All error responses follow the OAuth 2.0 error format (RFC 6749 Section 5.2):
{ "error": "invalid_grant", "error_description": "Authorization code has expired"}Error codes
Section titled “Error codes”| Error | HTTP Status | Cause |
|---|---|---|
invalid_grant |
400 | Expired or invalid authorization code or refresh token |
invalid_request |
400 | Missing required parameter or unknown provider |
unsupported_grant_type |
400 | Unknown or empty grant_type, or an id_token field when the direct grant is disabled |
invalid_token |
401 | Malformed or expired token |
unauthorized |
401 | Missing or invalid authentication |
access_denied |
403 | Registration denied (domain not allowed, existing_users_only mode, or user suspended) |
slow_down |
429 | Rate limit exceeded; retry after the delay in the Retry-After header |
server_error |
500/502/504 | Internal failure, provider error, or provider timeout |
Internal details are never leaked to the client. server_error responses log the detail internally and return a generic message.