Authentication
IdentityMesh’s Admin API supports two caller-authentication schemes:
- Windows Negotiate (Kerberos / NTLM over SPNEGO). The default. Works with domain-joined hosts and AD-issued credentials. No configuration required.
- JWT Bearer (OIDC / Entra ID). Opt-in. Enables cloud IdP
scenarios — users signed in via Entra, tokens minted by the
Microsoft identity platform, presented as
Authorization: Bearer <jwt>.
Both can be active simultaneously. A "SmartAuth" policy scheme in
the pipeline inspects every request: if it carries a Bearer
token, the JwtBearer handler validates it; otherwise Negotiate
runs as before. Browsers continue to do SPNEGO even when the
JWT path is enabled.
When to enable JWT
- Your operators sign in through Entra ID or another OIDC provider and you don’t want them to hop onto a Windows session to reach the admin portal.
- Machine-to-machine callers (CI pipelines, automation) can acquire tokens via client credentials or managed identity, but can’t do Kerberos.
- You want a single auth story across Admin API + future surfaces.
If your deployment is entirely on an AD-joined LAN, the default Negotiate-only config is fine — skip this document.
Configuration
Set three keys under Authentication:Jwt in appsettings.json (or
the equivalent environment variables — ASP.NET Core’s default binding
uses Authentication__Jwt__… on Linux/containers):
"Authentication": {
"Jwt": {
"Enabled": true,
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"Audience": "<api-app-id-or-uri>"
}
}
Enabled— gate.falsekeeps the old Negotiate-only pipeline. Defaults tofalse.Authority— OIDC issuer URL. For Entra v2, that’shttps://login.microsoftonline.com/<tenantId>/v2.0. The handler downloads.well-known/openid-configurationfrom this URL at startup to pick up signing keys.Audience— the value the token’saudclaim must match. Either the API’s app-registration Application (client) ID, or the App ID URI (api://<guid>) — whichever your client apps request.
The service fails to start if Enabled=true and either
Authority or Audience is missing — prefer a loud startup error
over a silent fallback.
Entra ID setup (recommended path)
One app registration, two purposes: identifies the API (audience), and defines the app roles that map to IdentityMesh permissions.
1. Create the app registration
Entra portal → App registrations → New registration.
- Name: IdentityMesh Admin API
- Supported account types: single tenant (typical) or multi-tenant per your deployment policy
- Redirect URI: leave blank (the admin API doesn’t own a sign-in UI — client apps handle it)
After creation, grab:
- Application (client) ID → use as
Authentication:Jwt:Audience - Directory (tenant) ID → authority URL is
https://login.microsoftonline.com/<tenantId>/v2.0
2. Expose an API (optional but recommended)
App registration → Expose an API:
- Application ID URI:
api://<client-id>(use the default). If you set theAudienceto this URI rather than the raw client ID, tokens requestapi://<client-id>/.defaultas scope.
3. Define app roles
App registration → App roles → Create three:
| Display name | Value | Allowed member types |
|---|---|---|
| IdentityMesh Admin | IdentityMesh.Admin | Users/Groups (and optionally Applications for M2M) |
| IdentityMesh Operator | IdentityMesh.Operator | Users/Groups |
| IdentityMesh Viewer | IdentityMesh.Viewer | Users/Groups |
Entra emits the assigned app-role values into the token’s roles
claim. IdentityMesh reads the caller’s roles from that claim, so a
caller with the IdentityMesh.Admin assignment is recognised as
holding that role.
4. Wire the role names into IdentityMesh config
Map the role values to IdentityMesh’s built-in role names in
IdentityMesh:Roles:
"IdentityMesh": {
"Roles": {
"Admin": "IdentityMesh.Admin",
"Operator": "IdentityMesh.Operator",
"Viewer": "IdentityMesh.Viewer"
}
}
IdentityMesh checks these values against the caller’s role claims; the same resolver that handles Windows groups handles Entra app roles.
If you run a mixed deployment (some users on Windows groups, some on
Entra app roles), rename the Entra app roles to match your AD
group names — e.g. set both to Corp\IdentityMesh-Admins.
The check is an exact string comparison over every role claim the
caller carries, so one config value can satisfy both populations.
5. Assign users / groups
Entra portal → Enterprise applications → IdentityMesh Admin API →
Users and groups → Add user/group → pick the principal and the
role. Without an assignment the user authenticates successfully but
falls through to the Viewer default (which is what the existing
resolver does for unmapped Windows users).
Role rows: users and groups
Besides the three config-mapped groups above, any role (built-in or one
you created) can be handed out from Settings → User Role Assignments
(POST /api/user-roles). A row names either:
- A user — one sign-in,
DOMAIN\username(or the UPN for Entra callers).principalType: "User", the default. - A directory group —
principalType: "Group". Every member of the group holds the role when they sign in. Membership is read from the sign-in itself: the Windows token for Negotiate callers (so nested groups count, exactly as the config mapping behaves), or theroles/groupsclaims for JWT callers. For Entra, name the group by the object id itsgroupsclaim carries, or by the app-role value.
Nothing is imported or enumerated: the row is checked at each request against what the caller already carries.
The name box in the Assign Role dialog is a picker over the mesh:
type two or more letters and it lists live person objects (or, for a
group row, mesh groups) matching the display name, sAMAccountName,
accountName, UPN or mail (GET /api/user-roles/principals). Picking
one fills in the sign-in name the row must carry. Objects imported from
Active Directory carry their domain’s NetBIOS name as the default
attribute netbiosDomain (the connector reads it from the forest’s
Partitions container, or from the partition’s NetbiosDomain setting
when the service account cannot read the configuration partition), so
the name is {netbiosDomain}\sAMAccountName whatever domain you signed
in from. An object without the attribute falls back to the shape of your
own sign-in: your domain prefix, or the object’s UPN when you signed in
with a UPN. The box stays editable, so a principal the mesh has not
imported can still be typed by hand.
Resolution order, first match wins:
- A User row naming the sign-in.
- Group rows whose group the sign-in is in. With several matches the caller gets the union of those roles’ permissions; the role shown in the UI is the one with the most permissions.
- The
IdentityMesh:RolesAdmin / Operator / Viewer mapping. - The default role (
IdentityMesh:Roles:Default, built-inUser).
A user and a group may share a name; uniqueness is per kind and name.
The group-notification “administrators” list only counts Admin User
rows, since a group’s members cannot be listed from a token. The
installer’s database step adds the PrincipalType column to an
existing database; a database updated outside the installer needs
the database step run
before the Admin API starts.
Verification
End-to-end with a real token
# Acquire a token for yourself via Azure CLI (requires az login first)
$token = az account get-access-token `
--resource <audience-value> `
--query accessToken -o tsv
# Hit a gated endpoint
curl -H "Authorization: Bearer $token" https://<api-host>/api/license
A successful 200 + JSON body means: token validated, role resolved, permission check passed.
Smoke-check the scheme selector
With JWT enabled, the same host should still accept Negotiate — a
request from a domain-joined browser (no Authorization header) hits
the Negotiate branch, a request with a Bearer token hits JwtBearer.
The 401 WWW-Authenticate header on an anonymous call surfaces
whichever scheme the challenge scheme picked; confirms multi-scheme
wiring didn’t regress SPNEGO.
Operational notes
- Clock skew: JWT validation tolerates 5 minutes of skew by
default. Keep host clocks within that — an NTP-drifted host will
reject otherwise-valid tokens with a
SecurityTokenNotYetValiderror. - Metadata caching: The handler refreshes the OIDC metadata document every 24 h by default. Key rotations at the IdP are picked up automatically; a forced restart shortens that to zero.
- Audit trail: the actor-context resolver populates
IM_ObjectAudit.ActorUpnfrom theupn/preferred_usernameclaim andActorSidfrom the Entraoidclaim for JWT callers. That way the audit query “who touched connector X” works across both caller types without operators needing to know which scheme was used. - HTTPS metadata: JwtBearer downloads the OIDC metadata over HTTPS by default. In Development the check is relaxed so you can point at a local mock; in Production it’s enforced.
- Rate limiting: The
sensitivepolicy partitions by the caller’s user name, which for JWT callers is thepreferred_usernameclaim. A caller burning through the limit under one scheme can’t evade the cap by switching schemes in the same session, because the partition key is the same string.
Notes
- Group-GUID mapping. Entra’s
groupsclaim carries Object IDs (GUIDs). The matcher does an exact-string compare against the configured role values, so the cleanest mapping is via Entra app roles (which carry stable string names). Group-name translation via Graph lookup is on the roadmap. - Multi-tenant acceptance. Single-authority validation is the default. Multi-tenant acceptance for SaaS-style deployments is available on request — contact support.
Related
secrets-and-dpapi.md— token secrets (client credentials) should live in a secret store, notappsettings.json.