Repository navigation
Entra Sso
Source of truth:
src/server/authz/entra/,src/lib/db/ssoIdentities.tsClient helper:scripts/cli/omniroute-sso.mjsTests:tests/unit/entra-sso-auth.test.ts,tests/unit/entra-sso-client-script.test.ts,tests/integration/entra-sso-e2e.int.test.ts(opt-in) Audience: Administrators rolling OmniRoute out to a Microsoft Entra ID tenant, and the developers signing in to it.
Developers authenticate to /v1/* with a short-lived Microsoft Entra ID token
instead of a static OmniRoute API key. Their Entra security groups map onto
OmniRoute key groups, and usage is attributed per person in the call log.
Key groups carry model allow/deny rules only (group_model_permissions).
Budgets, rate limits, usage caps, access schedules and combo permissions are
per-key columns on api_keys — group membership does not confer them, so an
SSO user's shadow key starts with the defaults for all of those.
| Control | Reaches SSO users via group mapping? |
|---|---|
| Model allow/deny | Yes — this is what key groups store |
Per-key rate limits (rateLimits, maxRequestsPerDay/PerMinute) |
No — per-key |
Budget / usage caps (budget, dailyUsageLimitUsd, …) |
No — per-key |
| Access schedule, endpoint allowlist, combo allowlist | No — per-key |
Call-log attribution (usage_history.api_key_id) |
Yes — the shadow key is a real key row |
To give SSO users any per-key control, set it on the provisioned sso:<upn> key
directly. Applying a group-level default to those columns at provisioning time
is a sensible follow-up, but this change does not do it.
Static API keys keep working. Enabling SSO only adds a way to authenticate.
Claude Code ──apiKeyHelper──► omniroute-sso.mjs token ──► Entra access token
│
Authorization: Bearer <token>
▼
clientApiPolicy (CLIENT_API)
│ looksLikeEntraJwt?
▼
verifyToken → resolveGroups → provisionSsoIdentity
│
shadow api_keys row + key groups
▼
the existing policy engine, unchanged
A user's first authenticated request provisions a shadow api_keys row
bound to their Entra oid. The row exists because key_group_members has a
foreign key to api_keys(id) — routing SSO users through a real key row means
group model rules and call-log attribution work with no SSO-specific branches
anywhere downstream.
The shadow key's secret is never issued to anyone. getApiKeys() and
getApiKeyById() blank it for source='sso' rows: exposing it would create a
permanent static credential that bypasses SSO and survives offboarding.
Create one app registration. It serves both the CLI (a public client) and the API (the audience tokens are minted for).
-
Entra admin center → App registrations → New registration.
- Name:
OmniRoute - Supported account types: Accounts in this organizational directory only
- Name:
-
Authentication → Add a platform → Mobile and desktop applications.
- Redirect URI:
http://localhost— the helper binds an ephemeral loopback port, so register the loopback redirect Entra reserves for public clients. - Allow public client flows: Yes. The helper ships to developer machines and therefore holds no client secret; it uses PKCE instead.
- Redirect URI:
-
Expose an API → Set the Application ID URI. Use
api://omniroute(or accept the generatedapi://<client-id>). This value isentraApiAudience.-
Add a scope, e.g.
access_as_user, admin-consent enabled.
-
Add a scope, e.g.
- API permissions → Add a permission → My APIs → your own app → the scope from step 3. Grant admin consent so developers see no consent prompt.
- Token configuration → Add groups claim → Security groups. Emit Group ID in the Access token.
Note the Directory (tenant) ID and Application (client) ID.
Entra omits the groups claim entirely for a user in more than 150 groups,
sending _claim_names/_claim_sources instead. In an established tenant
somebody will hit this. Two options:
-
Leave the fallback off. Those users are denied with a clear message
(
AUTH_SSO_GROUP_OVERAGE) rather than silently resolving to "no groups". -
Enable it. Add the Application permission
GroupMember.Read.All, grant admin consent, create a client secret, and set it asentraGraphClientSecret(stored encrypted at rest).
Create the key groups you want to map to first (they carry the actual policy),
then PATCH /api/settings. Every entra* setting is security-impacting, so the
request must include currentPassword; it must come from a dashboard session or the
local CLI (API keys and access tokens get 403), and the legacy JSON settings import
drops these keys.
curl -X PATCH https://omniroute.corp/api/settings \
-H 'Content-Type: application/json' \
-H "Cookie: auth_token=$TOKEN" \
-d '{
"currentPassword": "…",
"entraSsoEnabled": true,
"entraTenantId": "<directory-tenant-id>",
"entraClientId": "<application-client-id>",
"entraApiAudience": "api://omniroute",
"entraGroupMappings": [
{ "groupId": "<entra-group-object-id>", "keyGroupId": "<omniroute-key-group-id>" }
],
"entraDefaultKeyGroupId": null,
"entraGraphFallbackEnabled": false
}'| Setting | Meaning |
|---|---|
entraSsoEnabled |
Master switch. Default false. |
entraAuthorityHost |
Authority origin. Defaults to https://login.microsoftonline.com; set it for a sovereign cloud. |
entraTenantId |
Directory (tenant) ID. Asserted against the token's tid. |
entraClientId |
Application (client) ID the helper uses. |
entraApiAudience |
Application ID URI. Asserted against the token's aud. |
entraGroupMappings |
Entra group objectId → key_groups.id. A user gets the union of every match. |
entraDefaultKeyGroupId |
Key group for users matching no mapping. null denies them. |
entraGraphFallbackEnabled |
Resolve group-claim overage through Microsoft Graph. |
entraGraphClientSecret |
Client secret for the Graph fallback. Encrypted at rest. |
The endpoint rejects a configuration that cannot authenticate anyone: tenant and audience are required, and so is at least one group mapping or a default key group (otherwise every user would get a 403 that reads like a broken server).
Changes take effect on each user's next token refresh — at most one
CLAUDE_CODE_API_KEY_HELPER_TTL_MS window.
entraAuthorityHost selects the Entra instance. Leave it unset for the
commercial cloud; set it to https://login.microsoftonline.us (US Gov) or
https://login.partner.microsoftonline.cn (China). The value is published
through /api/auth/sso/config, so the sign-in helper follows it with no
per-machine configuration.
Plaintext http is rejected and falls back to the commercial cloud unless the
host is loopback. The authority determines which keys sign the tokens this
gateway trusts, so accepting an http authority over the network would let anyone
on the path mint identities it accepts. The loopback exception is what lets the
end-to-end test stand up a stub authority — see
tests/integration/entra-sso-e2e.int.test.ts.
Distribute scripts/cli/omniroute-sso.mjs. It needs nothing but Node 18+.
export OMNIROUTE_URL=https://omniroute.corp
node omniroute-sso.mjs login # opens the browser, stores a refresh token
node omniroute-sso.mjs install # writes the apiKeyHelper block
claude # /status shows the helper is activeinstall merges into ~/.claude/settings.json without disturbing other keys:
For a zero-touch rollout, distribute that same block through
managed settings instead;
developers then run only login.
Tokens are cached in ~/.omniroute-sso/<host>.json with mode 0600, matching
what gh and aws do. A standalone script cannot reach the OS keychain without
native dependencies.
| Command | Behavior |
|---|---|
login |
Authorization code + PKCE (S256) on an ephemeral 127.0.0.1 port. |
token |
Prints the access token to stdout and nothing else. Refreshes silently; exits non-zero with instructions on stderr when a fresh login is needed. |
install |
Merges the apiKeyHelper + env block into ~/.claude/settings.json. |
logout |
Deletes the stored tokens for one host. |
token prints the credential alone by design — Claude Code v2.1.227+ fails an
apiKeyHelper that emits a banner or log line alongside the key. Every
diagnostic in the script goes to stderr.
Understand this before relying on SSO for offboarding.
Disabling a user in Entra stops new tokens but does not invalidate one
already issued; it stays valid until exp (one hour by default, tunable in
Entra). OmniRoute's instant kill-switch is the shadow key: set it inactive and
the user stops authenticating on their very next request, even inside the
resolution-cache window.
UPDATE api_keys SET is_active = 0
WHERE id = (SELECT api_key_id FROM sso_identities WHERE oid = '<entra-oid>');Removing a user from every mapped Entra group has the same effect on their next token refresh.
Verified. Token verification runs against a locally generated RSA key pair and a stubbed JWKS,
so the real jose path — including createRemoteJWKSet — is exercised offline, along with every
negative case (wrong audience, wrong tenant, expired, signature not in the JWKS, missing oid,
app-only token, no delegated scope, algorithm substitution). The end-to-end suite runs the actual
sign-in helper as a subprocess against a stub authority that verifies the PKCE challenge the way
Entra does, covering the loopback callback, the code exchange, refresh rotation, authority pinning
and the 0600 token cache.
Not verified. No token in any of this was minted by Microsoft. Anything specific to the real authority is therefore unproven: consent behavior, the exact claim shapes a given tenant emits, Conditional Access interaction, and the group-overage path against real Graph responses.
Hard Rule #18 names OAuth upstream flows as needing real-environment validation, so treat a live-tenant run as a merge condition if you want one — it is not covered by the suites above.
# the opt-in end-to-end suite (stub authority, no network)
RUN_ENTRA_SSO_E2E=1 node --import tsx/esm --test tests/integration/entra-sso-e2e.int.test.ts/v1/models, /v1/embeddings and a few other handlers re-validate the raw
bearer inside the handler rather than relying on the central CLIENT_API
decision, so they answer 401 to an SSO token. /v1/chat/completions and
/v1/messages — the paths Claude Code uses — are unaffected. Folding those
handlers onto the central decision is a follow-up.
| Code | Status | Cause |
|---|---|---|
AUTH_SSO_DISABLED |
401 | SSO is off or incompletely configured. |
AUTH_SSO_EXPIRED |
401 | Token past exp. Re-run login if refresh also failed. |
AUTH_SSO_INVALID |
401 | Signature did not verify against the tenant JWKS. |
AUTH_SSO_CLAIM |
401 | Wrong iss or aud — usually a mismatched entraApiAudience. |
AUTH_SSO_TENANT |
401 | Token's tid is not the configured tenant. |
AUTH_SSO_NO_SUBJECT |
401 | Token carries no oid. |
AUTH_SSO_NO_GROUP |
403 | No mapped group matched and no default is set. |
AUTH_SSO_GROUP_OVERAGE |
403 | User is in >150 groups and the Graph fallback is off. |
AUTH_SSO_GRAPH_UNAVAILABLE |
503 | Graph lookup failed. |
AUTH_SSO_DEACTIVATED |
403 | Shadow key was deactivated on the gateway. |
AUTH_SSO_UNAVAILABLE |
503 | JWKS fetch failed — OmniRoute could not verify, so this is deliberately not a 401. |
AUTH_SSO_PROVISION_FAILED |
503 | Shadow key could not be created. |
An Entra outage never degrades to anonymous access, and never falls through to the API-key path: a bearer that presents itself as an Entra token gets a final verdict from the SSO branch.
Why tid is checked separately from aud. A multi-tenant app registration
mints tokens carrying our audience for other tenants' users too. Pinning tid
is what makes this a single-tenant gate.
Why the SSO branch must resolve before x-api-key. Claude Code sends the
apiKeyHelper value in both Authorization: Bearer and x-api-key. If
extractUngatedClientApiKey() won, the policy layer would receive the raw JWT,
resolve it to no key, and silently drop every limit the user is under — the same
auth-vs-policy divergence as GHSA-2phc-xp22-9f56. resolveSsoApiKeySecret()
therefore comes first in the chain in src/shared/utils/apiKeyPolicy.ts.
Why verdicts are cached. Verifying a signature is cheap once jose holds the
JWKS, but re-resolving groups and reconciling key_group_members per request
would mean a SQLite write per prompt per user. The cache in
src/server/authz/entra/evaluate.ts collapses that to roughly once per user per
helper refresh, and never outlives the token's own exp.
OmniRoute · Website · npm · Docker Hub
- Setup Guide
- User Guide
- Features
- Quick Start (Docker)
- Electron Desktop App
- Termux (Android)
- PWA Guide
- MCP Server
- A2A Server
- Agent Protocols
- OpenCode Plugin
- Webhooks
- Cloud Agents
- Skills
- Memory
- Evals
- Gamification
- Guardrails
- Compliance
- Error Sanitization
- Public Credentials
- Route Guard Tiers
- Stealth Guide
- CLI Token Auth
{ "apiKeyHelper": "node \"/path/to/omniroute-sso.mjs\" token --url https://omniroute.corp", "env": { "ANTHROPIC_BASE_URL": "https://omniroute.corp", "CLAUDE_CODE_API_KEY_HELPER_TTL_MS": "900000", }, }