OAuth2 / OIDC Provider
Heimdall is an OAuth 2.0 Authorization Server and OpenID Connect (OIDC) Provider. External developers can register applications and use Heimdall to authenticate users ("Sign in with Heimdall") and to call the API on a user's behalf.
Overview
This page is for external developers who want to integrate their own applications with Heimdall. If you are looking for how the first-party web apps authenticate users, see Authentication Flow and the Authentication Overview.
Key Features
- OAuth 2.0 Authorization Code flow with the optional refresh token grant
- PKCE (Proof Key for Code Exchange, RFC 7636) — required for public clients
- OpenID Connect ID tokens (JWT) with user claims based on requested scopes
- OIDC Discovery document at
/.well-known/openid-configuration - Token revocation (RFC 7009)
- Consent screen for third-party apps; first-party apps skip consent
- Confidential and public client types
Intended Audience
Registering and managing OAuth applications is intended for users with the developer role, which grants the oauth_clients:* and api_keys:* permissions. Any authenticated user can register and manage applications they own (ownership is enforced per client). Cross-user / admin-wide management requires the oauth_clients:read, oauth_clients:update, or oauth_clients:delete permissions.
Registering an OAuth Application
An OAuth application is called an OAuth client. You can register and manage clients through either the REST API or the GraphQL API. Both require authentication.
Client Types
| Type | Description | Authentication |
|---|---|---|
confidential | Server-side apps that can keep a secret. Default. | Sends client_secret (the client_secret_post method) |
public | Native, mobile, or SPA apps that cannot keep a secret | No secret; PKCE is required |
Client Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Application name (shown on the consent screen) |
description | string | No | Short description |
logo_url | string | No | Logo shown on the login/consent screen |
homepage_url | string | No | Application homepage |
privacy_policy_url | string | No | Privacy policy URL |
terms_of_service_url | string | No | Terms of service URL |
redirect_uris | string[] | Yes | Allowed callback URLs. At least one required. Matched exactly at authorization time. |
allowed_scopes | string[] | No (REST) | Scopes this client may request. Defaults to ["openid", "profile", "email"] in the REST API. |
client_type | string | No | confidential (default) or public |
Redirect URIs must start with http://, https://, or be a custom scheme (e.g. myapp://callback) for mobile apps.
Register a Client (REST)
POST /v1/oauth/clients
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
{
"name": "My App",
"description": "Example integration",
"redirect_uris": ["https://myapp.example.com/callback"],
"allowed_scopes": ["openid", "profile", "email"],
"client_type": "confidential"
}
Response (201 Created):
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "My App",
"description": "Example integration",
"redirect_uris": ["https://myapp.example.com/callback"],
"allowed_scopes": ["openid", "profile", "email"],
"client_type": "confidential",
"is_active": true,
"is_first_party": false,
"client_secret": "hm_sec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"_links": { "self": "/v1/oauth/clients/550e8400-e29b-41d4-a716-446655440000" }
}
The client_secret is only returned once at creation (and on regeneration). The id is the client_id. Store the secret securely — it cannot be retrieved again. The stored secret_hash is never returned by any endpoint.
Client Management Endpoints (REST)
| Method | Path | Description | Required |
|---|---|---|---|
GET | /v1/oauth/clients | List clients you own (paginated) | Authenticated |
GET | /v1/oauth/clients/all | List all clients | oauth_clients:read |
GET | /v1/oauth/clients/{id} | Get a client | Owner or oauth_clients:read |
POST | /v1/oauth/clients | Create a client | Authenticated |
PATCH | /v1/oauth/clients/{id} | Update a client | Owner or oauth_clients:update |
POST | /v1/oauth/clients/{id}/regenerate-secret | Regenerate the secret (confidential only) | Owner or oauth_clients:update |
DELETE | /v1/oauth/clients/{id} | Delete a client | Owner or oauth_clients:delete |
First-party clients (is_first_party: true) cannot be modified or deleted, and only super admins / system keys can create them.
Regenerating the secret returns the new value once:
POST /v1/oauth/clients/{id}/regenerate-secret
Authorization: Bearer YOUR_TOKEN
{
"data": {
"client_id": "550e8400-e29b-41d4-a716-446655440000",
"client_secret": "hm_sec_...",
"message": "Client secret has been regenerated. Make sure to save it securely - it won't be shown again."
}
}
Client Management (GraphQL)
Equivalent operations are available over GraphQL. Selected operations:
# List your clients
query { myOauthClients { id name clientType allowedScopes redirectUris } }
# Create a client (clientSecret returned once)
mutation CreateClient($input: CreateOAuthClientInput!) {
createOauthClient(input: $input) {
client { id name clientType }
clientSecret
}
}
# Regenerate the secret
mutation { regenerateOauthClientSecret(id: "CLIENT_ID") { success clientSecret error } }
# Delete a client
mutation { deleteOauthClient(id: "CLIENT_ID") { success error } }
# List all available scopes
query { oauthScopes { name description } }
CreateOAuthClientInput fields mirror the REST body. clientType defaults to "confidential" and allowedScopes is required in GraphQL. Users can also review and revoke their own grants:
query { myOauthConsents { clientName scopes isFirstParty } }
query { myOauthTokens { clientName scopes expiresAt } }
mutation { revokeOauthConsent(clientId: "CLIENT_ID") } # deletes consent + revokes tokens
mutation { revokeOauthToken(tokenId: "TOKEN_ID") }
Authorization Code Flow with PKCE
1. Redirect the user to the authorization endpoint
GET /v1/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://myapp.example.com/callback
&scope=openid%20profile%20email
&state=RANDOM_STATE
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256
Query Parameters:
| Parameter | Required | Description |
|---|---|---|
response_type | Yes | Must be code |
client_id | Yes | Your client ID |
redirect_uri | Yes | Must exactly match a registered redirect URI |
scope | Yes | Space-separated list of scopes |
state | Recommended | Opaque value echoed back for CSRF protection |
code_challenge | Public clients: Yes | PKCE challenge (Base64URL of SHA-256 of the verifier) |
code_challenge_method | Public clients: Yes | Must be S256 |
For public clients, code_challenge and code_challenge_method=S256 are mandatory. For confidential clients PKCE is optional but, when a code_challenge was supplied, the matching code_verifier must be sent at the token step.
Behavior:
- If the user is not logged in, Heimdall issues a
302redirect to the ID app's login route, preserving the OAuth parameters. After login the flow resumes. - If the client is first-party or the user has already consented to the requested scopes, Heimdall issues a
302redirect straight to yourredirect_uriwith the authorizationcode. - If consent is required, Heimdall responds
200 OKwith a consent payload describing the app and requested scopes:
{
"consent_required": true,
"client": { "id": "...", "name": "My App", "logo_url": null },
"requested_scopes": [
{ "name": "openid", "description": "Authenticate you and get your user ID" },
{ "name": "profile", "description": "See your profile information (name, avatar)" }
],
"pending_authorization_id": "..."
}
2. Approve consent (if required)
POST /v1/oauth/authorize/consent
Authorization: Bearer USER_SESSION
Content-Type: application/json
{
"pending_authorization_id": "PENDING_ID_FROM_STEP_1",
"approved": true
}
On approval Heimdall stores the consent, generates the authorization code, and returns a 302 redirect to your redirect_uri with code (and state). If approved is false, the user is redirected back with error=access_denied. A pending authorization expires after 15 minutes.
3. Exchange the code for tokens
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=hm_ac_...
&redirect_uri=https://myapp.example.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=hm_sec_... # confidential clients only
&code_verifier=ORIGINAL_VERIFIER # required if PKCE was used
Form Parameters:
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | authorization_code |
code | Yes | The authorization code from the redirect |
redirect_uri | Yes | Must match the redirect_uri used in step 1 |
client_id | Yes | Your client ID |
client_secret | Confidential: Yes | Sent in the body (client_secret_post) |
code_verifier | If PKCE used: Yes | The original PKCE verifier (43–128 chars) |
Response (200 OK):
{
"access_token": "hm_at_...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "hm_rt_...",
"scope": "openid profile email",
"id_token": "eyJhbGciOiJIUzI1NiIs..."
}
The id_token is only present when the openid scope was requested. Authorization codes are single-use and expire after 10 minutes. Access tokens expire after 1 hour (expires_in: 3600); refresh tokens after 30 days.
4. Refresh tokens
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=hm_rt_...
&client_id=YOUR_CLIENT_ID
&client_secret=hm_sec_... # confidential clients only
The response has the same shape as the code exchange. Refresh tokens are rotated: the old refresh token and its access token are revoked, and a new access token and refresh token are issued. The new tokens inherit the original access token's scopes.
UserInfo Endpoint
Returns the current user's information based on the scopes granted to the access token. Data is read live from the database, so roles and permissions reflect the current state.
GET /v1/oauth/userinfo
Authorization: Bearer hm_at_...
Response (200 OK):
{
"sub": "user-uuid",
"name": "Jane Doe",
"picture": "https://.../avatar.png",
"provider": "discord",
"email_verified": true,
"roles": ["developer"],
"role_ids": ["role_developer"],
"permissions": ["oauth_clients:read", "api_keys:write"],
"preferred_locale": "en",
"is_super_admin": false
}
| Field | Scope | Description |
|---|---|---|
sub | always | User ID |
name, picture, provider, preferred_locale, is_super_admin | profile | Display name, avatar, last-used login provider slug, preferred locale, super-admin flag |
email, email_verified | email | Email address and verification status |
roles, role_ids | roles | Role display names and role IDs |
permissions | permissions | Permission strings (resource:action) |
Fields outside the granted scopes are omitted from the response.
Token Revocation
Revokes an access token or refresh token (RFC 7009). Always returns 200 OK, even if the token did not exist.
POST /v1/oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=hm_at_...
&token_type_hint=access_token
&client_id=YOUR_CLIENT_ID
&client_secret=hm_sec_... # confidential clients only
| Parameter | Required | Description |
|---|---|---|
token | Yes | The token to revoke |
token_type_hint | No | access_token or refresh_token. If omitted, the type is auto-detected from the token prefix (hm_at_ / hm_rt_). |
client_id | Yes | Your client ID |
client_secret | Confidential: Yes | Client secret |
Revoking an access token also revokes its associated refresh tokens, and revoking a refresh token also revokes its associated access token.
OIDC Discovery
GET /.well-known/openid-configuration
The discovery document is also served under the versioned scope at /v1/.well-known/openid-configuration; the root path is the canonical one advertised by the server.
Response (200 OK):
{
"issuer": "https://heimdall.example.com",
"authorization_endpoint": "https://heimdall.example.com/v1/oauth/authorize",
"token_endpoint": "https://heimdall.example.com/v1/oauth/token",
"userinfo_endpoint": "https://heimdall.example.com/v1/oauth/userinfo",
"revocation_endpoint": "https://heimdall.example.com/v1/oauth/revoke",
"scopes_supported": ["openid", "profile", "email", "roles", "permissions", "offline_access"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
"code_challenge_methods_supported": ["S256"]
}
There is no jwks_uri. ID tokens are signed with HS256 (a symmetric HMAC). For confidential clients the signing key is your client_secret, so you verify the ID token's signature using your client secret as the HMAC key. Public clients (which have no secret) fall back to a server-side key and should validate identity via the UserInfo endpoint instead of verifying the ID token signature locally.
Scopes
| Scope | Grants access to |
|---|---|
openid | Authenticate the user and return their user ID. Required to receive an id_token. |
profile | Profile information (name, avatar) |
email | Email address |
roles | The user's assigned roles |
permissions | The user's permissions |
offline_access | Allow refresh tokens for offline access |
A client can only request scopes that are in its allowed_scopes. Requesting a scope outside that set is rejected with error=invalid_scope.
ID Token (OIDC)
When the openid scope is requested, the token response includes a JWT id_token signed with HS256. Claims are included based on the granted scopes.
| Claim | Always / Scope | Description |
|---|---|---|
iss | always | Issuer — the authorization server's public URL |
sub | always | Subject — the user ID |
aud | always | Audience — your client ID |
exp | always | Expiration (Unix time, 1 hour after issuance) |
iat | always | Issued-at (Unix time) |
auth_time | always | Time of authentication |
nonce | if provided | Echoes the request nonce |
name, picture | profile | Display name, avatar |
email, email_verified | email | Email and verification status |
roles | roles | Role names |
permissions | permissions | Permission strings (resource:action) |
Error Responses
OAuth errors follow RFC 6749 and are returned as JSON (or appended to the redirect_uri query string for redirect-based errors):
{
"error": "invalid_grant",
"error_description": "redirect_uri does not match"
}
| Error code | Typical cause |
|---|---|
invalid_request | Missing/invalid parameter (e.g. response_type not code, missing PKCE for a public client) |
invalid_client | Unknown or inactive client |
invalid_grant | Invalid/expired authorization code, mismatched redirect_uri, bad code_verifier, or invalid refresh token |
unsupported_grant_type | grant_type is not authorization_code or refresh_token |
invalid_scope | A requested scope is unknown or not allowed for the client |
access_denied | The user denied the consent request |
unauthorized_client | The client is not authorized for the request |
server_error | Internal error while processing the request |
HTTP status codes accompany these: 400 Bad Request for invalid requests/grants, 401 Unauthorized for invalid client credentials or a missing/invalid access token at UserInfo.