Skip to main content

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.

Two equivalent paths: the console and the API

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 nodeSetup complete, authenticated routes no longer returning 503. See bring up a node
Administrative accessThe 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 providerEnough to register an application and read its issuer, and to create a group you can test with
A secret referenceThe 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 providerRequired. Without one the provider cannot write login audit entries
A test userA real account at the identity provider, in a group you will map

Decide two things now, because neither can be changed later:

  • Scope. org replicates the provider to every node in the organization; node keeps 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

#StepEndpoint
1Register at the identity providerAt the IdP
2Create the providerPOST /providers
3Map claimsPUT /providers/{id}/claim-mappings
4Matching policyPUT /providers/{id}/matching-policy
5Group to role mappingsPUT /providers/{id}/group-mappings
6Assurance bindingPUT /providers/{id}/assurance
7TestPOST /providers/{id}/test
8EnablePOST /providers/{id}/enable
9VerifyA 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 needWhere it comes from
Issuerhttps://login.microsoftonline.com/{tenant-id}/v2.0
Client IDThe application (client) ID
Client secretA new client secret. Copy it now; Entra shows it once
Redirect URIYou 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:

ProtocolRequired in config_json
oidcissuer, client_id
samlsp_entity_id, idp_entity_id, idp_sso_url
ldaphosts, base_dn, bind_dn
caccac_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:

  • issuer must be concrete. A placeholder in it is rejected.
  • issuer_template must contain a placeholder, and must not be only a placeholder.
  • issuer must be a real instantiation of issuer_template.
  • tenant_claim without a template is rejected.
  • allowed_tenant_ids does 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:

ProviderIdentity keyGroups or roles claim
Entra IDoidgroups
Oktathe subject identifierconventionally scp, not roles
Googlesubper your configuration
SAMLpersistent NameIDthe 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_match is 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 is GET /reviews then POST /reviews/{id}/decide. Choosing review with 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:

ResultMeaning
HTTP 200, passed: trueGood. The provider is now enableable, against this configuration
HTTP 200, passed: falseThe test ran and the configuration is wrong. Read the failed checks
HTTP 502The 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.

#CheckHowWhy it matters
1The provider is enabledGET /providers/<id> shows status: enabled and is_enabled: trueBoth, the second is gated on the first plus a passing test
2The node advertises itGET /api/public/v1/auth/login-providers lists it, and a button for it appears on the login pageProves governance resolves it as a login option and the login page renders it
3A real person logs inOpen 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
4An entity exists for themFind the new entity in the directoryProves claim mapping and provisioning, not just authentication
5They hold the roles you mappedInspect their role assignments; the grants carry the provider's source tagThe step most often skipped, and the one that catches an empty groups claim
6The session is provider-stampedThe session carries the provider idWithout this stamp, back-channel logout cannot find the session
7The audit chain recorded itGET /audit shows the login and the grant reconciliationProves the provider's service entity is registered correctly
8Logout actually revokesSign out at the identity provider, confirm the platform session is goneProves 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.

  1. 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.
  2. In the identity provider, point provisioning at https://<node>/scim/v2/<provider-slug> with that token as the bearer.
  3. 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​

SymptomMost likely causeWhat to do
422 on create, issuer-relatedThe issuer / issuer_template splitissuer must be concrete, the template must contain a placeholder, and the issuer must instantiate the template
422 identity_key_invalidYou mapped email as the identity keyUse an immutable claim. Entra oid, SAML persistent NameID, AD object GUID
422 immutable_fieldAn edit to scope, org, node or slugThese are fixed at creation. Create a new provider
409 federation.test.required on enableNo passing testRun the test
409 enable_config_hash_mismatchThe configuration changed after the testRe-test. This is the TOCTOU gate, not a bug
A provider silently stopped serving loginsSomebody edited its configurationAny config edit demotes enabled to testing. Re-test and re-enable
redirect_uri_mismatch at the IdPThe two values are not byte-identical, or redirect_uri was left blankSet it explicitly and match it exactly at the IdP
401 state_invalid_or_usedA replayed or stale callback, or a user who back-buttoned into oneStart the login again. Single-use state is deliberate
401 iss_mismatchThe provider echoed an issuer that is not yoursMix-up defence. Check you are not pointing at the wrong tenant
403 acr_floor_not_metThe authenticator used does not clear the effective floorEither the user needs a stronger authenticator, or your floor is higher than your population supports
403 npg_deniedThe provisioning admission gate refusedA policy decision, not a configuration error. Check provisioning policy
Login succeeds, no rolesThe groups claim is absent, or mapped from the wrong nameCheck the IdP emits groups; check Okta's scp; re-read the test's claim preview
Logins hang in limbo, nobody can get inmatch_action: "review" with nobody clearing the queueClear it in the console's reviews page, or GET /reviews then POST /reviews/{id}/decide
Intermittent token_validation_failedHost clock driftCheck NTP. Tune clock_skew_leeway_seconds, default 60
429 rate_limitedA redirect loop or a misbehaving IdPFix the loop. Under one, you see rate limiting before you see the real error
SCIM sync stoppedQuarantine after five consecutive failuresFix the upstream cause. Sustained four weeks, the provider auto-disables and needs a fresh test
409 local_admin_path_requiredThe change would remove the last local administrator routeA guard, not a fault. It is refusing to let you lock yourself out
409 org_mandated_provider_in_useDisabling a provider the organization mandatesChange organization policy first, or use a different provider
Smartcard logins all refusedNo revocation source reachableMandatory and fail-closed. Wire OCSP or a CRL; a valid card is not sufficient

Across the deployment matrix​

CornerHow federation behaves
Single-cellEverything above works unchanged
Multi-cellAn 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-gappedUse LDAP against a local domain controller, direct smartcard authentication, or no federation at all. Local authentication is the baseline, not a degraded mode
Cloud-enabledThe common case, and the worked example above