Integrate an external application with ScaiKey (OIDC / OAuth 2.0)
This is the canonical integration guide. ScaiKey is a standards-compliant OpenID Connect / OAuth 2.0 provider. If your app or its library supports "generic OIDC" or "OAuth 2.0 / OpenID Connect SSO", it works with ScaiKey.
Rule #1 — always start from the discovery document. Do not hardcode endpoint URLs; read them from the well-known document below and let your OIDC library configure itself. Endpoint paths and the
issuervalue are stable, but discovery is the single source of truth.
Live host: https://scaikey.scailabs.ai (replace with your ScaiKey host if
self-hosted).
1. Pick a surface: platform vs tenant#
ScaiKey serves two OIDC "front doors". Choose based on whether your app knows the user's tenant before login.
| Platform (recommended default) | Tenant-scoped | |
|---|---|---|
| Use when | You don't know the user's tenant — resolve it from their email at login (home-realm discovery) | Your app is pinned to exactly one tenant |
Paths below are relative to the live host, https://scaikey.scailabs.ai.
- Platform (recommended default)
- Discovery:
/api/v1/platform/.well-known/openid-configuration issuer:https://scaikey.scailabs.ai/platform
- Discovery:
- Tenant-scoped
- Discovery:
/api/v1/auth/tenants/{slug}/.well-known/openid-configuration issuer:https://scaikey.scailabs.ai/tenants/{slug}
- Discovery:
Validate the iss of received tokens against the issuer from the discovery
document you used — do not assume the bare host. (Platform tokens carry the
/platform suffix; tenant tokens carry /tenants/{slug}.)
The endpoints below are shown for the platform surface (paths relative to the
host); for the tenant surface, use the URLs from that tenant's discovery document
(they live under /api/v1/auth/tenants/{slug}/…).
| Purpose | Platform endpoint (path) |
|---|---|
| authorize | /api/v1/platform/oauth/authorize |
| token | /api/v1/platform/oauth/token |
| userinfo | /api/v1/platform/oauth/userinfo |
| jwks | /api/v1/platform/.well-known/jwks.json |
| end session (logout) | /api/v1/platform/oauth/logout |
| device authorization | /api/v1/platform/oauth/device/authorize |
2. Register your application#
Registration is operator-driven (there is no public dynamic-registration
endpoint). Ask the ScaiKey team — or use the admin API / scaikey CLI — to
register an application, choosing a client type:
| Type | For | Secret? | Auth |
|---|---|---|---|
WEB |
Server-side web apps | yes (confidential) | client secret + (recommended) PKCE |
SPA |
Browser single-page apps | no (public) | PKCE required |
NATIVE |
Desktop / mobile | no (public) | PKCE required, private-use-scheme redirect |
SERVICE |
Machine-to-machine, no user | yes | client_credentials |
You provide, and receive back:
- Provide:
redirect_uris(exact-match, incl. path), post-logout URIs (or rely on same-origin-as-a-redirect-URI matching), the scopes you need. - Receive:
client_id(andclient_secretforWEB/SERVICE, shown once).
Scopes for user login: openid profile email (+ groups for the group
claim, offline_access for a refresh token). For directory read/sync use
directory:read — not admin:read (that's super-admin). users:read /
groups:read do not exist.
3. Authorization Code + PKCE (the login flow)#
Response type is code only (implicit is not supported). Always use PKCE
(S256) — it is mandatory for public clients (SPA/NATIVE) and recommended
for WEB.
- Generate a
code_verifierandcode_challenge = BASE64URL(SHA256(verifier)). - Redirect the user to the authorize endpoint:carbon
1 2 3 4 5 6 7 8
GET https://scaikey.scailabs.ai/api/v1/platform/oauth/authorize ?response_type=code &client_id=<your client_id> &redirect_uri=<one of your registered redirect_uris> &scope=openid%20profile%20email &state=<csrf-random> &code_challenge=<challenge> &code_challenge_method=S256 - User authenticates (email → tenant resolved on the platform surface → password/MFA).
ScaiKey redirects back to your
redirect_uriwith?code=…&state=…. Verifystatematches. - Exchange the code at the token endpoint:→bash
1 2 3 4 5 6 7 8
curl -X POST https://scaikey.scailabs.ai/api/v1/platform/oauth/token \ -d grant_type=authorization_code \ -d code=<code> \ -d redirect_uri=<same redirect_uri as step 2> \ -d client_id=<your client_id> \ -d code_verifier=<verifier> \ # WEB/SERVICE clients also send: -d client_secret=<secret> # (public SPA/NATIVE clients send NO secret){ access_token, id_token, refresh_token?, token_type, expires_in, scope }.
The redirect_uri must be byte-identical at authorize and token (exact-match,
no normalization).
4. Validate tokens#
- Fetch signing keys from
jwks_uri(cache them; honour key rotation). - On the ID token, verify: signature (via JWKS),
iss== the discoveryissuer,aud== yourclient_id,exp, andnonceif you sent one. - Access tokens are self-contained JWTs (same JWKS). Note they are not
revocation-checked at validation time — a logged-out user's access token stays
valid until
exp(default 1 h). Keep access-token lifetime in mind for your own session model.
4a. GLOBAL applications: one app, many issuers (important)#
A GLOBAL application (one registration that serves users from many tenants
via the platform realm) does not see a single iss. This is by design, and a
relying party cannot discover it from the platform discovery document — so it's
stated here explicitly:
- User tokens are tenant-issued. After home-realm discovery resolves the user
to their tenant, the id_token and access_token carry
iss = https://scaikey.scailabs.ai/tenants/{slug}— the user's own tenant — even though the flow ran through the platform realm. Each token also carries atenant_slugclaim; verifyiss == …/tenants/{tenant_slug}to bind the issuer to the tenant the token claims. - Client-credentials tokens are platform-issued. A
client_credentialstoken minted by the same GLOBAL app carriesiss = https://scaikey.scailabs.ai/platform(it has no user, so no tenant). - The platform discovery document advertises
issuer = …/platform. That value matches only the client-credentials tokens, not user logins. A stock OIDC RP configured solely from platform discovery and pinning a singleisswill reject every user token. That is the trap; the rule below is the way through it.
The signing keys (JWKS) are global — there is only one key set. Every realm's
jwks_uri (platform and every tenant) returns the same keys. So you do not
need one JWKS per tenant: fetch one JWKS, verify the signature once, then check
the iss string against an allow-list you maintain:
- Verify the token signature against the (single, shared) JWKS. Any realm's
jwks_uriworks; the platform one is a fine choice. - Accept the token only if its
issis in your allow-list: the platform issuer (…/platform, for your own client-credentials tokens) plus the tenant issuer (…/tenants/{slug}) of each tenant your app serves. - For a user token, additionally require
iss == …/tenants/{tenant_slug}using the token's owntenant_slugclaim.
Restricting which tenants may log in. There is currently no per-tenant
allow-list on a GLOBAL application in ScaiKey — any user of any tenant can
complete a login unless you enable require_assignment on the app, which
limits login to explicitly assigned users/groups (assignments themselves may span
tenants). So today the enforcement point for "which tenants" is your RP's iss
allow-list (step 2); a first-class per-tenant restriction on the ScaiKey side is a
tracked enhancement, not yet available. If you need it, ask the ScaiKey team.
5. UserInfo, refresh, logout#
- UserInfo:
GET …/oauth/userinfowithAuthorization: Bearer <access_token>. - Refresh:
POST …/oauth/tokenwithgrant_type=refresh_token,refresh_token=…,client_id(+ secret for confidential). Refresh tokens rotate on use and enforce reuse-detection (a replayed rotated token revokes the whole family). Persist the newest token; don't run concurrent refreshes. - RP-initiated logout: redirect to the
end_session_endpointwithid_token_hint=<id_token>and (optionally)post_logout_redirect_uri. A post-logout URI is accepted if it exactly matches a registered logout URI or shares scheme+host+port with a registeredredirect_uri.
Claims by scope#
email→email,email_verified.profile→name,given_name,family_name,preferred_username,picture,locale,zoneinfo, and — when the user has a manager —manager(the manager's ScaiKey user id,usr_…) andmanager_display_name. The manager is populated from Active Directory on LDAP-synced tenants, or set by an admin on local tenants; both claims are omitted when no manager is set. (This is the supported way to render, e.g., a manager line in an email signature — readmanager_display_name.)groups→groups(security-group ids), anddistribution_groupswhen present.
6. Machine-to-machine (SERVICE / client_credentials)#
For a service with no user (e.g. directory sync), register a SERVICE/WEB
GLOBAL app and:
1 2 3 4 | |
See also the service-to-service tutorial.
Common pitfalls#
- Wrong base path. Endpoints live under
/api/v1/platform/…or/api/v1/auth/tenants/{slug}/…. There is noapi.scaikey.iohost and no bare/tenants/{slug}/oauth/…path — read the discovery document. - Broken discovery URL. The discovery document is at
…/api/v1/platform/.well-known/openid-configuration(or the tenant equivalent), not/.well-known/openid-configurationat the root or under/oauth/. issmismatch. Validate against the discoveryissuer(…/platformor…/tenants/{slug}), not the bare host.- Public client sending a secret.
SPA/NATIVEclients send no secret; PKCE is the proof. - Redirect URI not exact-match. Register every callback exactly, including path.