Connect an identity provider
This is usually the first integration on a new deployment. It is also the one with the most ways to look finished while being subtly wrong, so this guide ends by proving a real person can log in and lands with the roles you intended, not by confirming that a configuration was accepted.
The worked example is Microsoft Entra ID over OIDC, the most common case. SAML, Okta, Google, Ping and AWS IAM Identity Center differ only where noted.
For the model behind every step, see Identity Federation and SSO.
The Core System console carries a guided seven-step wizard at
/core/identity/federation, and a provider detail page with tabs for the
connection, claim mapping, matching, groups, assurance, SCIM tokens,
invitations, audit and health. If you are configuring one provider by hand, use
it.
This guide uses the operator API at /api/public/v1/admin/federation/*,
which is the same surface the console calls. It is written that way because the
API is what you script, what you put in a change record, and what shows exactly
which field caused a refusal. Each step names the endpoint, so either path works.
Before you start
| A running node | Setup complete, authenticated routes no longer returning 503. See bring up a node |
| Administrative access | The federation write permission at the scope you are configuring, plus the organization-scope permission if you intend to set organization policy |
| Access to the identity provider | Enough to register an application and read its issuer, and to create a group you can test with |
| A secret reference | The client secret goes into the platform's secret store and is referenced by an s9:// URI. A plaintext secret in the configuration is refused |
| A service entity for the provider | Required. Without one the provider cannot write login audit entries |
| A test user | A real account at the identity provider, in a group you will map |
Decide two things now, because neither can be changed later:
- Scope.
orgreplicates the provider to every node in the organization;nodekeeps it local to one. Organization is right for a corporate identity provider; node is right for a site-specific or sovereign one. - Slug. A short lowercase identifier, 3 to 63 characters. It appears in every URL for this provider, including the one you give your users and the one you register at the identity provider, so pick something you are willing to live with.
scope, org_id, node_id and provider_slug are immutable after creation.
Getting one wrong means creating a new provider.
The path
Figure: the order the steps have to happen in. The two amber stages are gates: the test must pass, and enable is refused unless that pass was over the exact configuration you are enabling. The dotted return edge is the one that surprises people, editing any configuration field after a passing test sends the provider back to testing.
Table equivalent
| # | Step | Endpoint |
|---|---|---|
| 1 | Register at the identity provider | At the IdP |
| 2 | Create the provider | POST /providers |
| 3 | Map claims | PUT /providers/{id}/claim-mappings |
| 4 | Matching policy | PUT /providers/{id}/matching-policy |
| 5 | Group to role mappings | PUT /providers/{id}/group-mappings |
| 6 | Assurance binding | PUT /providers/{id}/assurance |
| 7 | Test | POST /providers/{id}/test |
| 8 | Enable | POST /providers/{id}/enable |
| 9 | Verify | A browser, and the audit trail |
All paths are relative to /api/public/v1/admin/federation.
Step 1: Register System9 at Entra ID
In the Entra admin centre, register an application and collect:
| You need | Where it comes from |
|---|---|
| Issuer | https://login.microsoftonline.com/{tenant-id}/v2.0 |
| Client ID | The application (client) ID |
| Client secret | A new client secret. Copy it now; Entra shows it once |
| Redirect URI | You supply this. It is your node's callback for this provider |
The redirect URI must byte-match on both sides. System9 sends the configured value verbatim in both the authorization request and the token exchange, so a trailing slash difference is a failed login with an unhelpful provider-side error.
Grant the application the openid, email and profile scopes, and, if you
intend to map groups, configure the group claim to be emitted. An Entra
application that does not emit groups produces users who authenticate perfectly
and arrive with no roles.
There is no dynamic client registration. This step is manual by design.
Put the secret in the secret store
The configuration accepts a reference, not a value:
s9://secrets/federation/corp-entra/client-secret
Store the secret under that path first. A configuration carrying a literal secret is refused.
Step 2: Create the provider
curl -X POST https://<node>/api/public/v1/admin/federation/providers \
-H 'Content-Type: application/json' \
-b "$SESSION_COOKIE" \
-d '{
"provider_slug": "corp-entra",
"display_name": "Corporate Entra ID",
"scope": "org",
"org_id": "<org-id>",
"protocol": "oidc",
"provider_kind": "entra",
"subject_kind": "human",
"idp_service_entity_id": "<service-entity-id>",
"jit_enabled": true,
"config_json": {
"issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"client_id": "<client-id>",
"client_secret_uri": "s9://secrets/federation/corp-entra/client-secret",
"redirect_uri": "https://<node>/api/public/v1/auth/federation/oidc/corp-entra/callback",
"scopes": ["openid", "email", "profile"]
}
}'
The provider lands in draft. Required configuration keys differ by protocol:
| Protocol | Required in config_json |
|---|---|
oidc | issuer, client_id |
saml | sp_entity_id, idp_entity_id, idp_sso_url |
ldap | hosts, base_dn, bind_dn |
cac | cac_piv_mode |
If you have more than one Entra tenant
Use the issuer template rather than one provider per tenant:
{
"issuer": "https://login.microsoftonline.com/<a-real-tenant-id>/v2.0",
"issuer_template": "https://login.microsoftonline.com/{tenant}/v2.0",
"tenant_claim": "tid",
"allowed_tenant_ids": ["<tenant-a>", "<tenant-b>"]
}
The split is validated strictly, and it is the single most common rejection on create:
issuermust be concrete. A placeholder in it is rejected.issuer_templatemust contain a placeholder, and must not be only a placeholder.issuermust be a real instantiation ofissuer_template.tenant_claimwithout a template is rejected.allowed_tenant_idsdoes nothing without a template. This one fails silently in the sense that it is accepted and simply never consulted, so a single-tenant provider with an allow-list is not restricted by it.
For SAML instead
Register generic_saml, supply idp_entity_id and idp_sso_url, and paste the
identity provider's signing certificate, or point at its metadata URL and let the
platform parse it. Then fetch your side's metadata and give it to the identity
provider:
curl https://<node>/api/public/v1/admin/federation/providers/<id>/sp-metadata
During a certificate rotation, put both certificates in
idp_signing_certs_pem. The verifier treats the pair as a trust set, so the
overlap window costs you no downtime.
For Active Directory over LDAP instead
Register active_directory with protocol: "ldap", and supply hosts,
base_dn and bind_dn plus a bind-password reference, a user search filter such
as (sAMAccountName={username}), and the group search base and filter.
LDAP is a credential protocol rather than a redirect one, so it behaves differently at both ends. The login page shows a username and password form rather than a button, the platform binds to the directory itself, and the provider is resolved server-side, so one form covers every configured LDAP directory on the node.
TLS is required. Turning it off is refused unless the configuration carries an explicit air-gap acknowledgement, so an unencrypted bind is always a decision somebody recorded rather than a default nobody noticed.
Most Active Directory estates are better served by federating Entra ID over OIDC against the same directory. Direct bind is the right answer when there is no identity provider to reach, which usually means a sovereign or disconnected site.
Step 3: Map claims, and choose the identity key
curl -X PUT .../providers/<id>/claim-mappings \
-H 'Content-Type: application/json' -b "$SESSION_COOKIE" \
-d '{"mappings": [
{"source_claim": "oid", "target_attribute": "entity_match_key", "is_identity_key": true},
{"source_claim": "email", "target_attribute": "email", "transform": "lower"},
{"source_claim": "name", "target_attribute": "display_name"},
{"source_claim": "groups","target_attribute": "groups"}
]}'
The identity key is the decision that is expensive to change later. Exactly
one mapping must carry it, and it must be over a claim that never changes for a
person. For Entra that is oid. Email is rejected, because addresses get reassigned, and an identity key
that can be reassigned means a new employee can inherit a former employee's
entity.
Vendor differences that bite here:
| Provider | Identity key | Groups or roles claim |
|---|---|---|
| Entra ID | oid | groups |
| Okta | the subject identifier | conventionally scp, not roles |
sub | per your configuration | |
| SAML | persistent NameID | the attribute name or URI your IdP emits |
Okta on scp is the classic silent failure: authentication works, the person
arrives, and they have no roles because the platform read an empty roles claim.
Step 4: Decide who gets in
curl -X PUT .../providers/<id>/matching-policy \
-H 'Content-Type: application/json' -b "$SESSION_COOKIE" \
-d '{
"match_attributes": ["email", "upn"],
"normalize": "ci",
"require_verified": true,
"match_action": "auto_link",
"create_if_no_match": true,
"orphan_policy": "suspend"
}'
The two settings to think about:
create_if_no_matchis off by default. Leave it off and only people who already have an entity can log in, which is the conservative choice for a first rollout. Turn it on for just-in-time provisioning.match_action: "review"blocks the login and queues the subject for a decision. It is the right setting for a high-assurance deployment, as long as somebody clears the queue: the console has a reviews page, and the API isGET /reviewsthenPOST /reviews/{id}/decide. Choosingreviewwith nobody watching means people cannot log in and the reason is not on their screen.
Step 5: Map groups to roles
curl -X PUT .../providers/<id>/group-mappings \
-H 'Content-Type: application/json' -b "$SESSION_COOKIE" \
-d '{"mappings": [
{"idp_group": "S9-Analysts", "role_id": "<role-id>", "scope_type": "org", "scope_id": "<org-id>"},
{"idp_group": "S9-Cell-Ops", "role_id": "<role-id>", "scope_type": "node", "scope_id": "<node-id>"},
{"idp_group": "S9-Research", "role_id": "<role-id>", "scope_type": "workspace", "scope_id": "<workspace-id>"}
]}'
scope_type accepts org, node and workspace only. A workspace-scoped
mapping is how a federated person becomes a member of a
workspace.
Two refusals that are guards rather than faults:
- Mapping a group onto a governance or high-privilege role is refused. Group membership at an identity provider is not an administrative escalation path. Grant those roles deliberately, in the platform.
- Every grant passes the provisioning admission gate, so a mapping is an instruction to try, not a guarantee.
Grants a mapping creates are tagged with the provider, and reconciliation only ever revokes inside that tag, so grants you made by hand survive a federation sync.
Step 6: Set an assurance floor
Optional, and additive: a provider binding can raise the organization's floor and can never lower it.
curl -X PUT .../providers/<id>/assurance \
-H 'Content-Type: application/json' -b "$SESSION_COOKIE" \
-d '{"aal": 2, "phishing_resistant_required": false,
"acr_values_json": {"request": ["mfa"], "verify": ["mfa"]}}'
Before you require phishing resistance, know what qualifies: FIDO2/WebAuthn and PKI smartcards, and nothing else. Requiring it rejects TOTP, SMS and push with number matching. Confirm your population is enrolled on a qualifying authenticator first, or you will lock out everyone who is not.
Check the effective floor against break-glass too. Break-glass must also clear the floor, so raising it changes your emergency path as well as your daily one.
Step 7: Test
curl -X POST .../providers/<id>/test -b "$SESSION_COOKIE"
The test performs live discovery against the issuer, through the platform's egress guard, and previews claim resolution. Read the result carefully, because two different things look similar:
| Result | Meaning |
|---|---|
HTTP 200, passed: true | Good. The provider is now enableable, against this configuration |
HTTP 200, passed: false | The test ran and the configuration is wrong. Read the failed checks |
| HTTP 502 | The provider could not be reached at all. Network, DNS or egress policy, not configuration |
The non-blocking claim preview check is the one to actually read. It is where you find out that your groups claim is empty before your users do.
Step 8: Enable
curl -X POST .../providers/<id>/enable -b "$SESSION_COOKIE"
Refused with a 409 if there is no passing test, if the configuration has
changed since the test, or if the configuration hash does not match. That is the
gate doing its job, run the test again.
Verify it worked
A 200 on enable proves configuration, not login. Work down this list; each step
tells you which of the previous ones actually held.
| # | Check | How | Why it matters |
|---|---|---|---|
| 1 | The provider is enabled | GET /providers/<id> shows status: enabled and is_enabled: true | Both, the second is gated on the first plus a passing test |
| 2 | The node advertises it | GET /api/public/v1/auth/login-providers lists it, and a button for it appears on the login page | Proves governance resolves it as a login option and the login page renders it |
| 3 | A real person logs in | Open the login page, click the provider's button, and authenticate. (The direct entrypoint is /api/public/v1/auth/federation/oidc/<slug>/login) | The actual check. Everything before this is preparation |
| 4 | An entity exists for them | Find the new entity in the directory | Proves claim mapping and provisioning, not just authentication |
| 5 | They hold the roles you mapped | Inspect their role assignments; the grants carry the provider's source tag | The step most often skipped, and the one that catches an empty groups claim |
| 6 | The session is provider-stamped | The session carries the provider id | Without this stamp, back-channel logout cannot find the session |
| 7 | The audit chain recorded it | GET /audit shows the login and the grant reconciliation | Proves the provider's service entity is registered correctly |
| 8 | Logout actually revokes | Sign out at the identity provider, confirm the platform session is gone | Proves the back-channel path end to end |
Step 5 is the one to insist on. A person who authenticates and arrives with no roles is the single most common outcome of a federation that "worked", and it always traces back to a groups claim that the identity provider is not emitting or that is mapped from the wrong claim name.
Optional: push users with SCIM
Just-in-time provisioning creates a person's entity on their first login. SCIM creates it before they ever log in, and, more importantly, deactivates it when they leave rather than waiting for an orphan sweep. If your identity provider drives joiner-mover-leaver, wire it.
- Issue a token on the provider's SCIM tab in the console, or
POST /providers/{id}/scim/tokens. Copy it immediately. The raw value is shown once, on issue and on rotation, and is stored only as a hash. - In the identity provider, point provisioning at
https://<node>/scim/v2/<provider-slug>with that token as the bearer. - Run a sync and confirm entities appear.
The token is bound to one provider: presenting it against another provider's SCIM path fails, even though the token itself is valid.
Two behaviours to plan for rather than discover:
- Sync latches on failure. Five consecutive failed cycles quarantine the sync. A quarantine sustained for four weeks auto-disables the provider, which then needs a fresh passing test to come back, because the enable gate applies to every route back to enabled.
- Token expiry stops sync silently from the platform's side. Default lifetime is a year, and warnings escalate at 90, 30 and 7 days. Rotate before expiry; rotation issues a new value with lineage back to the old one.
When it does not work
| Symptom | Most likely cause | What to do |
|---|---|---|
422 on create, issuer-related | The issuer / issuer_template split | issuer must be concrete, the template must contain a placeholder, and the issuer must instantiate the template |
422 identity_key_invalid | You mapped email as the identity key | Use an immutable claim. Entra oid, SAML persistent NameID, AD object GUID |
422 immutable_field | An edit to scope, org, node or slug | These are fixed at creation. Create a new provider |
409 federation.test.required on enable | No passing test | Run the test |
409 enable_config_hash_mismatch | The configuration changed after the test | Re-test. This is the TOCTOU gate, not a bug |
| A provider silently stopped serving logins | Somebody edited its configuration | Any config edit demotes enabled to testing. Re-test and re-enable |
redirect_uri_mismatch at the IdP | The two values are not byte-identical, or redirect_uri was left blank | Set it explicitly and match it exactly at the IdP |
401 state_invalid_or_used | A replayed or stale callback, or a user who back-buttoned into one | Start the login again. Single-use state is deliberate |
401 iss_mismatch | The provider echoed an issuer that is not yours | Mix-up defence. Check you are not pointing at the wrong tenant |
403 acr_floor_not_met | The authenticator used does not clear the effective floor | Either the user needs a stronger authenticator, or your floor is higher than your population supports |
403 npg_denied | The provisioning admission gate refused | A policy decision, not a configuration error. Check provisioning policy |
| Login succeeds, no roles | The groups claim is absent, or mapped from the wrong name | Check the IdP emits groups; check Okta's scp; re-read the test's claim preview |
| Logins hang in limbo, nobody can get in | match_action: "review" with nobody clearing the queue | Clear it in the console's reviews page, or GET /reviews then POST /reviews/{id}/decide |
Intermittent token_validation_failed | Host clock drift | Check NTP. Tune clock_skew_leeway_seconds, default 60 |
429 rate_limited | A redirect loop or a misbehaving IdP | Fix the loop. Under one, you see rate limiting before you see the real error |
| SCIM sync stopped | Quarantine after five consecutive failures | Fix the upstream cause. Sustained four weeks, the provider auto-disables and needs a fresh test |
409 local_admin_path_required | The change would remove the last local administrator route | A guard, not a fault. It is refusing to let you lock yourself out |
409 org_mandated_provider_in_use | Disabling a provider the organization mandates | Change organization policy first, or use a different provider |
| Smartcard logins all refused | No revocation source reachable | Mandatory and fail-closed. Wire OCSP or a CRL; a valid card is not sufficient |
Across the deployment matrix
| Corner | How federation behaves |
|---|---|
| Single-cell | Everything above works unchanged |
| Multi-cell | An org-scoped provider converges to every node; the SCIM cursor, audit chain and test results stay local to the cell that wrote them |
| Sovereign / air-gapped | Use LDAP against a local domain controller, direct smartcard authentication, or no federation at all. Local authentication is the baseline, not a degraded mode |
| Cloud-enabled | The common case, and the worked example above |
Related
- Identity Federation and SSO, the model behind every step here.
- Authentication, the local surface federation sits beside.
- Sessions across a node's cells, what happens to the session once it is minted.
- Bring up a node, the prerequisite.
- Use workspaces and clean rooms, what a workspace-scoped group mapping grants into.