Login with Microsoft Entra ID (OIDC)¶
Status: built (M1-41; groups from the group claim M1-42, section 7). Normative are 08-identity.md sections 2.2, 2.3 and 2.6; where they differ, the chapter wins. Microsoft Entra ID is the reference example; every other OpenID Connect provider is set up the same way.
1. The flow in short¶
The server is a confidential OIDC client (relying party) at Entra and at the same time the
authorization service of its apps. The app opens the login in the system browser, Entra redirects
back to the server, the server checks the ID token and gives the app a one-time login_code that
only the app instance with its client_verifier can redeem. Provider tokens never reach a device
and are never stored. Both legs use PKCE S256.
2. App registration in Entra¶
- New app registration as a web application (confidential client).
- Exactly one redirect URI:
https://<host>/api/v1/auth/oidc/callback, for examplehttps://chat.example.com/api/v1/auth/oidc/callback. No further redirects are needed: the redirect back to the app (loopback or a private scheme) is handled by the server. - Under "Certificates & secrets" upload a certificate (client authentication
private_key_jwt, preferred). A client secret (client_secret_basic) works as well, but it expires and is one more secret. - Optionally release the
groupsclaim in the token; group sync is described by section 7 (08 section 3.4). Withoutgroups_claimin the configuration it is not read. - For an organization with several tenants choose "Accounts in any organizational directory" and
enter the allowed tenant IDs in
allowed_tenant_ids.
Sources (retrieved 2026-10-10): redirect URIs https://learn.microsoft.com/en-us/entra/identity-platform/reply-url, certificate assertion https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials [verified].
3. Storing the secrets¶
Client key and client secret never stand in the configuration of the provider, only as a reference
to the secret provider. Only files below <secrets.dir>/idp/ and environment variables with the
prefix BOTHY_IDP_ are allowed; a reference to a secret of the server itself (for example
auth.token_pepper_ref) is refused (docs/DECISIONS.md M1-36/14 and M1-41).
- Certificate: store the private key as PKCS#8 PEM at
<secrets.dir>/idp/entra_client_key.pem(mode0600).client_key_algisPS256(default),client_cert_x5t_s256the SHA-256 fingerprint of the certificate as base64url without padding (headerx5t#S256). - Secret: as the file
<secrets.dir>/idp/entra_client_secretor as the variableBOTHY_IDP_ENTRA_SECRET.
4. Outgoing connections¶
The server reaches discovery, keys (JWKS) and the token endpoint only through the egress client:
allowed are the host of the issuer and the hosts of token_endpoint and jwks_uri from the
checked discovery document, only https (19 SEC-NET-2). Entra needs no entry in
egress.allowed_hosts. Discovery and keys are cached for auth.oidc_jwks_cache_ttl; on an
unknown kid the server reloads the keys once.
For an issuer with {tenantid} the server reads the discovery document of the alias
organizations (https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration),
whose issuer is the template itself. Every token must then carry a tid from
allowed_tenant_ids and an iss with exactly that tenant (ID-T05).
5. Example configuration¶
Create it with createIdentityProvider (owner with step-up), kind oidc:
{
"kind": "oidc",
"name": "Entra ID",
"enabled": true,
"allow_provisioning": false,
"config": {
"issuer": "https://login.microsoftonline.com/{tenantid}/v2.0",
"client_id": "00000000-0000-0000-0000-EXAMPLE00001",
"client_auth": "private_key_jwt",
"client_key_ref": "file:<secrets.dir>/idp/entra_client_key.pem",
"client_key_alg": "PS256",
"client_key_id": "EXAMPLE-cert-1",
"client_cert_x5t_s256": "EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE0",
"allowed_tenant_ids": ["11111111-2222-3333-4444-EXAMPLE00002"],
"app_redirect_uris": ["com.example.chat:/oidc", "https://app.example.com/oidc"]
}
}
With a single tenant issuer is https://login.microsoftonline.com/<tid>/v2.0 and
allowed_tenant_ids is omitted. Desktop apps use the loopback redirect
http://127.0.0.1:<port>/callback or http://[::1]:<port>/callback, which is allowed without an
entry; localhost is never allowed.
6. Accounts¶
- Matching only through
(provider_id, issuer, subject)or an enrollment code (08 section 3.1); email,preferred_usernameandupnare display only. - A person without a linked identity logs in for the first time with an enrollment code
(
createEnrollmentCode, purposeidentity_linkorfirst_login); the app sends it in the fieldenrollment_codeofbeginOidcLogin. The code is redeemed only at completion. - With
allow_provisioning: truean unknown subject gets a person in the statuspendingwithout grants and without email; the owner activates it withenableUser. - A person disabled at the provider keeps their session at most until
auth.idp_recheck_interval; the owner blocks at once withuser.disable(08 section 3.5). - The person links a further identity themselves with
beginIdentityLinkandcompleteIdentityLink(step-up).
7. Groups from the group claim¶
Built in M1-42 (08 section 3.4). The provider names its group claim in groups_claim; without it
the server never reads groups and never changes memberships at a login of that provider.
- Claim in Entra. Under "Token configuration" add the groups claim to the ID token ("Security
groups" or "Groups assigned to the application"; the latter keeps the token small). Entra sends
the group object IDs as an array of strings in
groups, so the provider configuration gets"groups_claim": "groups". - Mapping. The owner creates one group per claim value with
createGroup,source: oidc_claim, theprovider_idof the provider and the claim value (the Entra object ID) asexternal_ref, and gives that group its grants. Claim values without such a group are ignored; the server never creates a group by itself. A group belongs to its provider: another provider with the same claim value never touches it. - At every login of the provider the memberships of the person in its claim groups follow the
claim: new ones get
expires_at = login time + auth.group_claim_max_age, present ones (also expired ones) are extended to that time, and ones whose value is gone are removed. Every change is a rights change (access.changed, audit actiongroup.member.syncwith group ids only); a login without change writes nothing. Members of claim groups cannot be added or removed by hand (409 conflict(reasonclaim_managed_group)). - Overage. With more groups than fit into the token Entra sends a reference in
_claim_namesinstead ofgroups. The server never calls Microsoft Graph: like a missing claim this changes nothing, and the memberships run out afterauth.group_claim_max_age. Limit the claim to the groups that matter (application groups) so the overage does not happen. - Wrong form. A
groupsclaim that is not an array of strings fails the login (error=login_failed). - I1. A sync that would let someone other than the credential holder trigger a personal agent
(for example a claim group with
triggeron a channel such an agent serves) fails the whole login with409 i1_violationand the hint "Sign-in blocked by a group mapping. An owner must switch the affected agent to organization first."; nothing of the login is kept, and the audit log has a deny entry ofgroup.member.syncnaming the person, the provider, the groups and the agents.
8. Configuration values¶
auth.login_flow_ttl, auth.server_code_ttl, auth.oidc_jwks_cache_ttl,
auth.oidc_client_assertion_ttl, auth.idp_recheck_interval, auth.group_claim_max_age, auth.clock_leeway,
egress.timeout, egress.max_response_bytes; defaults in the
configuration reference.