Skip to main content

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

TypeDescriptionAuthentication
confidentialServer-side apps that can keep a secret. Default.Sends client_secret (the client_secret_post method)
publicNative, mobile, or SPA apps that cannot keep a secretNo secret; PKCE is required

Client Fields

FieldTypeRequiredDescription
namestringYesApplication name (shown on the consent screen)
descriptionstringNoShort description
logo_urlstringNoLogo shown on the login/consent screen
homepage_urlstringNoApplication homepage
privacy_policy_urlstringNoPrivacy policy URL
terms_of_service_urlstringNoTerms of service URL
redirect_urisstring[]YesAllowed callback URLs. At least one required. Matched exactly at authorization time.
allowed_scopesstring[]No (REST)Scopes this client may request. Defaults to ["openid", "profile", "email"] in the REST API.
client_typestringNoconfidential (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" }
}
warning

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)

MethodPathDescriptionRequired
GET/v1/oauth/clientsList clients you own (paginated)Authenticated
GET/v1/oauth/clients/allList all clientsoauth_clients:read
GET/v1/oauth/clients/{id}Get a clientOwner or oauth_clients:read
POST/v1/oauth/clientsCreate a clientAuthenticated
PATCH/v1/oauth/clients/{id}Update a clientOwner or oauth_clients:update
POST/v1/oauth/clients/{id}/regenerate-secretRegenerate the secret (confidential only)Owner or oauth_clients:update
DELETE/v1/oauth/clients/{id}Delete a clientOwner 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:

ParameterRequiredDescription
response_typeYesMust be code
client_idYesYour client ID
redirect_uriYesMust exactly match a registered redirect URI
scopeYesSpace-separated list of scopes
stateRecommendedOpaque value echoed back for CSRF protection
code_challengePublic clients: YesPKCE challenge (Base64URL of SHA-256 of the verifier)
code_challenge_methodPublic clients: YesMust 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 302 redirect 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 302 redirect straight to your redirect_uri with the authorization code.
  • If consent is required, Heimdall responds 200 OK with 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": "..."
}
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:

ParameterRequiredDescription
grant_typeYesauthorization_code
codeYesThe authorization code from the redirect
redirect_uriYesMust match the redirect_uri used in step 1
client_idYesYour client ID
client_secretConfidential: YesSent in the body (client_secret_post)
code_verifierIf PKCE used: YesThe 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": "[email protected]",
"email_verified": true,
"roles": ["developer"],
"role_ids": ["role_developer"],
"permissions": ["oauth_clients:read", "api_keys:write"],
"preferred_locale": "en",
"is_super_admin": false
}
FieldScopeDescription
subalwaysUser ID
name, picture, provider, preferred_locale, is_super_adminprofileDisplay name, avatar, last-used login provider slug, preferred locale, super-admin flag
email, email_verifiedemailEmail address and verification status
roles, role_idsrolesRole display names and role IDs
permissionspermissionsPermission 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
ParameterRequiredDescription
tokenYesThe token to revoke
token_type_hintNoaccess_token or refresh_token. If omitted, the type is auto-detected from the token prefix (hm_at_ / hm_rt_).
client_idYesYour client ID
client_secretConfidential: YesClient 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"]
}
info

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

ScopeGrants access to
openidAuthenticate the user and return their user ID. Required to receive an id_token.
profileProfile information (name, avatar)
emailEmail address
rolesThe user's assigned roles
permissionsThe user's permissions
offline_accessAllow 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.

ClaimAlways / ScopeDescription
issalwaysIssuer — the authorization server's public URL
subalwaysSubject — the user ID
audalwaysAudience — your client ID
expalwaysExpiration (Unix time, 1 hour after issuance)
iatalwaysIssued-at (Unix time)
auth_timealwaysTime of authentication
nonceif providedEchoes the request nonce
name, pictureprofileDisplay name, avatar
email, email_verifiedemailEmail and verification status
rolesrolesRole names
permissionspermissionsPermission 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 codeTypical cause
invalid_requestMissing/invalid parameter (e.g. response_type not code, missing PKCE for a public client)
invalid_clientUnknown or inactive client
invalid_grantInvalid/expired authorization code, mismatched redirect_uri, bad code_verifier, or invalid refresh token
unsupported_grant_typegrant_type is not authorization_code or refresh_token
invalid_scopeA requested scope is unknown or not allowed for the client
access_deniedThe user denied the consent request
unauthorized_clientThe client is not authorized for the request
server_errorInternal 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.

Next Steps