Skip to content

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.

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)

Exchange an authorization code from an identity provider for access and refresh tokens.

Request:

POST /token
Content-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.

Use a refresh token to obtain a new access token without re-authenticating.

Request:

POST /token
Content-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.

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 /token
Content-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.

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
}

Revoke a token per RFC 7009. Always returns 200 OK, even if the token is unknown or already revoked.

Request:

POST /revoke
Content-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.

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": "..."
}
]
}

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.

Returns 200 OK if the service is operational. No authentication required.

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.

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 as Authorization: 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 = true
auth_methods = ["shared_secret"]
shared_secret = "${INTERNAL_API_SECRET}"
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

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"
}

Returns the full user object including metadata and claims.

Update user fields. Only provided fields are modified.

Request:

{
"display_name": "Jane Smith",
"status": "suspended",
"metadata": {
"role": "admin"
}
}

Soft-deletes the user (sets status to Deleted) and revokes all active sessions.

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}/claims replaces the entire claims map
  • PATCH /internal/users/{id}/claims merges new claims into existing ones
  • DELETE /internal/users/{id}/claims clears all per-user claims

Example (PUT):

{
"role": "admin",
"tier": "enterprise"
}

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 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.