Skip to content

Connect an identity provider to the API Portal

The API Portal & MCP Hub delegates user login to any identity provider (IdP) that speaks OpenID Connect (OIDC). This guide is written for the administrator who deploys the portal, and it covers the configuration every IdP needs. For a worked example of these steps against one specific IdP, see Set up Asgardeo as your identity provider.

For how identity provider authentication compares with local authentication, see Authentication in the API Portal & MCP Hub.

How OIDC login works here

The portal itself is the OIDC client, not the browser. It runs the authorization code exchange server-side with PKCE and a state parameter, keeps the resulting tokens in a server-side session, and gives the browser only a session cookie. Register the portal as a confidential client, not a public or single-page application client.

Two points shape the rest of this guide:

  • There is no OIDC discovery. The portal never reads /.well-known/openid-configuration, so you configure each endpoint URL explicitly in [api_portal.auth.idp].
  • Two token types are read. Identity claims (organization, roles, groups, name, email) come from the ID token. The scope claim, used only in mode = "scope", comes from the access token.

Clicking Login in mode = "idp" redirects straight to the IdP's authorization endpoint—the built-in username and password form is never shown, and POST to the local login route returns 404.

What your identity provider must support

Check your IdP against these requirements before you start:

Requirement Details
OIDC endpoints Exposes authorization, token, userinfo, JWKS, and end-session endpoints. You supply each URL individually.
Confidential client Issues a client secret, and accepts PKCE on the authorization code exchange.
Authorization code and refresh token grants Both enabled on the application. The portal refreshes an expired access token before failing a Management API request.
JWT access tokens Access tokens are signed JWTs. The portal verifies a Bearer token's signature against the JWKS endpoint, so opaque tokens don't work for machine clients calling /api/v0.9.
Custom claims Emits the organization identifier and the user's roles in the ID token. Claim names are configurable; the claims themselves are required.

Step 1: Register the portal as a confidential client

In your IdP, create a confidential OIDC application. Replace <your-domain> with the address users reach the portal at, including the port when it isn't 443, and <org-handle> with the value of [api_portal.organization] handledefault in the packaged configuration.

  1. Set the authorized redirect URL to https://<your-domain>/<org-handle>/callback.
  2. Set the post-logout redirect URL to https://<your-domain>/<org-handle>. Most IdPs validate post_logout_redirect_uri against the same list as the login callback, so add both URLs there.
  3. Enable the Authorization Code and Refresh Token grants.
  4. Set the access token type to JWT.
  5. Configure the IdP to emit given_name, family_name, email, roles, and the claim carrying the organization identifier in the ID token. The portal reads user and organization identity from the ID token, not the access token, so a claim released only on the access token or the userinfo endpoint won't be seen.
  6. Record the client ID and client secret.

One redirect URL covers the whole portal. After the callback, the portal routes the user from the return path stored in their session, so there are no per-view or per-page redirect URLs to register.

Step 2: Map the claims the portal reads

The portal reads three claims out of the ID token by name, and [api_portal.auth.claim_mappings] says which name carries each one. Either configure your IdP to emit the default names, or keep your IdP's names and set the mappings to match.

Mapping key Default claim Carries
organization org_name The organization identifier, resolved against the organization this instance serves on every request. Required—a token without it is rejected with 403. See step 5.
roles roles The user's role names. Required when authorization.mode = "role"; startup fails if the mapping is empty.
groups groups The user's group names. Carried into the session for use in page and content rules.

Each value is either a flat top-level claim name or a dot-separated path into a nested claim. That path syntax accommodates providers that nest their claims—Keycloak puts roles under realm_access.roles, for example.

A roles or groups claim may arrive as a JSON array or as a space- or comma-separated string. The portal accepts both.

The portal also reads these fixed claims, whose names aren't configurable: sub for the user identity, given_name (falling back to nickname) and family_name for the display name, email for the address shown in the profile menu, and picture for the avatar.

Step 3: Choose how privileges reach the token

The portal's Management API (/api/v0.9) guards each operation with a dp:* scope. [api_portal.auth.authorization] mode decides where a request's effective scopes come from. Pick the one that matches what your IdP can put in a token.

Role mode (mode = "role", the default) expands the token's roles claim through a YAML grant table and ignores the token's own scope claim entirely. Your IdP only has to emit role names, which most products do out of the box, and a caller can't widen a role's grant by requesting extra scopes. Set role_to_scope_mapping to the path of the table; the image ships an editable copy, which docker-compose.yaml mounts at /etc/api-portal/role-to-scope-mapping.yaml. It defines two roles:

Role Grants
dp_admin Every action on the organization: content and theme, the API and MCP server catalog, views, labels, subscription plans, key managers, webhook subscribers, and every application and subscription
dp_subscriber The consumer persona: own applications, subscriptions, and keys, plus read access to the catalog

The table also aliases ap_admin and ap_subscriber onto those same grants, because those are the role names the Platform API mints. Map your IdP's groups onto any of the four names, or add an entry of your own for a narrower grant. The portal validates every scope in the table against its OpenAPI specification at startup, so an undeclared dp:* scope fails startup rather than surfacing later as a role that logs in and is denied every request.

Scope mode (mode = "scope") reads the access token's own scope claim. Use it when the IdP mints dp:* scopes directly, which means registering all of them in the IdP and granting them to the application. Browser sessions are preauthorized in this mode—the per-operation check is skipped, and page role gating is the authorization that applies to them.

Role mode is the lighter integration of the two. Editing the mapping file needs a restart, since the portal reads it at startup.

Page access tiers

Page access is separate from Management API scopes. [api_portal.auth.authorization.portal_roles] names the role that grants each of the portal's two page tiers, and page_role_validation switches the gate on:

[api_portal.auth.authorization]
page_role_validation = true

[api_portal.auth.authorization.portal_roles]
admin      = "ap_admin"
subscriber = "ap_subscriber"

Point these at your IdP's role names, or at names in the grant table so one set of roles drives both page access and Management API authorization.

Step 4: Configure the portal

Set the [api_portal.auth] tables in configs/config.toml. mode selects exactly one authentication backend, so "idp" also stops the local login form from being used:

[api_portal.auth]
mode = "idp"

[api_portal.auth.idp]
name                = "my-idp"                                          # friendly name, used in logs
issuer              = "https://idp.example.com"                         # exact "iss" claim value, not an endpoint
authorization_url   = "https://idp.example.com/oauth2/authorize"
token_url           = "https://idp.example.com/oauth2/token"
user_info_url       = "https://idp.example.com/oauth2/userinfo"
jwks_url            = "https://idp.example.com/oauth2/jwks"
client_id           = "<portal-client-id>"
client_secret       = '{{ env "APIP_AP_AUTH_IDP_CLIENT_SECRET" }}'
audience            = "<portal-client-id>"                              # expected "aud" claim
callback_url        = "https://<your-domain>/<org-handle>/callback"
logout_url          = "https://idp.example.com/oidc/logout"
logout_redirect_uri = "https://<your-domain>/<org-handle>"
scope               = "openid profile email roles"

# Which token claim carries each field. A sibling of [api_portal.auth.idp], not
# nested in it — dot notation reaches a nested claim, e.g. "realm_access.roles".
[api_portal.auth.claim_mappings]
organization = "org_name"
roles        = "roles"
groups       = "groups"

# Applies in both auth modes, which is why it is NOT under [api_portal.auth.idp].
[api_portal.auth.authorization]
enabled               = true
mode                  = "role"
role_to_scope_mapping = "/etc/api-portal/role-to-scope-mapping.yaml"
page_role_validation  = true

[api_portal.auth.authorization.portal_roles]
admin      = "ap_admin"
subscriber = "ap_subscriber"

Four things to get right:

  • callback_url must match the URL registered in step 1 exactly, character for character.
  • issuer is the IdP's issuer identifier—the exact string it puts in the token's iss claim—and is compared verbatim. It's a distinct value from token_url, though some providers do use their token endpoint URL as the issuer: Asgardeo and WSO2 Identity Server both issue iss as .../oauth2/token. Decode a token from your IdP and copy iss out of it rather than assuming either form.
  • audience must match the token's aud claim. Set it to your client ID rather than leaving it empty, so a token minted for a different application is rejected.
  • scope must request whatever the IdP needs to emit the organization and roles claims. Keep openid, and add the scope your IdP attaches role information to.

Two retired keys abort startup

Earlier versions configured roles under [api_portal.auth.idp.roles], with a third super_admin tier. That section is retired, along with auth.role_validation, and leaving either in config.toml fails startup rather than silently applying a default. Use [api_portal.auth.authorization.portal_roles] and auth.authorization.page_role_validation instead—see Authorization.

Supply the client secret

Never write the client secret as a literal in config.toml. The {{ env }} token above reads it from an environment variable instead, so it never has to be committed to source control:

export APIP_AP_AUTH_IDP_CLIENT_SECRET=<portal-client-secret>

In production, prefer a mounted secret file. Swap the token for '{{ file "/secrets/api-portal/oidc_client_secret" }}', then mount the secret at that path. Both forms fail closed: a missing variable or unreadable file aborts startup rather than falling back to an empty credential. See Interpolation tokens.

Step 5: Make the organization claim resolve to your organization

A portal instance serves exactly one organization, and the database schema is multi-organization—so a token your IdP correctly signed for a different organization would pass every signature, expiry, and audience check. The portal closes that gap by resolving the mapped organization claim and confirming it names the organization the instance is pinned to. It does this at login and on every authenticated request, and a mismatch is a flat 403, whether the asserted organization is unknown or merely someone else's. A token carrying no organization claim at all is rejected the same way.

The claim's value has to resolve to your organization, which means it must equal either of these:

Value Matching
The organization handle, from [api_portal.organization] handle Case-insensitive
The organization display name, from Settings > Organization Exact

The handle is also the organization's IDP reference ID, which the portal writes when it first seeds the organization row. That field is fixed from then on: the Management API rejects a request that changes it, so IDP reference ID in Organization settings reflects the handle rather than offering a second value to match against.

So point the claim at the handle. Which way round you do that depends on what your IdP has to assert.

When your IdP asserts an organization identifier

Providers with a business-to-business organization model—Asgardeo sub-organizations, an Entra ID tenant—already put an organization identifier in the token. Set [api_portal.organization] handle to that identifier, and map the claim carrying it:

[api_portal.organization]
handle = "acme"          # the identifier the IdP asserts

[api_portal.auth.claim_mappings]
organization = "org_name"

This is the option to prefer when it's available to you. The claim keeps carrying a real organization identity, so a token minted for a different organization still resolves elsewhere and is still refused. It also keeps the authorization request correct: the portal appends org=<handle> to it, and a provider with a sub-organization model reads that parameter to scope the login session to the matching sub-organization.

The handle is the URL slug in /{handle}/views/{viewName}, so it becomes part of every portal URL. Choose it with that in mind—and note that the portal normalizes it to lowercase, though claim matching tolerates any case.

When your IdP has no organization concept

A single Keycloak realm, an Auth0 tenant, or an Okta org has one user population and nothing organization-shaped to assert. Add a custom claim to the token whose value is the portal's organization handle, and map that claim:

[api_portal.organization]
handle = "default"

[api_portal.auth.claim_mappings]
organization = "org_id"    # your custom claim, emitted with the constant value "default"

Configure the claim in the IdP as a constant—Keycloak calls this a hardcoded claim mapper, Auth0 an action that adds a custom claim. A fixed value is the accurate thing to assert here: the IdP serves one user population, the portal serves one organization, and the claim says which organization a token is for.

A constant claim asserts nothing about isolation

Every token the IdP issues then carries the value the portal expects, so the organization check passes for every user the IdP will authenticate. That's the intended outcome for a single-population IdP. It is not what you want from a provider serving several organizations through one shared application—there, set the handle to the asserted identifier instead, as described above, and let the check do its job.

Step 6: Restart and verify

Restart the portal so it reloads the configuration:

docker compose up -d --force-recreate

Open the portal and select Login. Instead of the username and password form, you're redirected to your IdP's hosted login page, and land back on the page you started from after signing in.

If sign-in fails, the mismatch is usually one of these:

  • callback_url differs from the redirect URL registered in the IdP.
  • issuer doesn't match the token's iss claim, or audience doesn't match its aud claim.
  • The ID token doesn't carry the claim names configured in [api_portal.auth.claim_mappings], so the organization or roles claim is never found.
  • The organization claim's value is neither the organization handle nor its display name, so it resolves to no organization the instance serves.
  • The IdP issues opaque access tokens rather than JWTs, which fails Bearer-token requests to /api/v0.9 while browser login still works.

Set [api_portal.logging] level = "debug" to see which claim or check fails.

Optional settings

These [api_portal.auth.idp] keys tune the login experience. Leave them at their defaults unless you need the behavior:

Key Default Effect
silent_sso true Attempts a prompt=none authorization on the first page load, so a user with a live IdP session arrives already signed in. Set to false to require an explicit Login.
org_callback false Sends a user with no stored return path to the organization's landing page after login, rather than the portal root.
sign_up_url empty The IdP's self-registration page. The portal's Sign up route redirects here; without it the route has nowhere to send the user.
token_refresh_timeout_ms 10000 How long the portal waits on the IdP's token endpoint when refreshing an expired access token.
certificate empty An X.509 certificate used to verify Bearer-token signatures instead of fetching the JWKS endpoint. Set it only for an IdP whose JWKS endpoint the portal can't reach; browser login always uses jwks_url.

Claim names for common identity providers

The roles claim is the one that most often differs. These paths are known to work:

Identity provider claim_mappings roles value
Asgardeo roles
Microsoft Entra ID roles
Keycloak realm_access.roles, or resource_access.<client>.roles