Runbooks/Authentication

Authentication

IdentityMesh’s Admin API supports two caller-authentication schemes:

  1. Windows Negotiate (Kerberos / NTLM over SPNEGO). The default. Works with domain-joined hosts and AD-issued credentials. No configuration required.
  2. 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

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>"
  }
}

The service fails to start if Enabled=true and either Authority or Audience is missing — prefer a loud startup error over a silent fallback.

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.

After creation, grab:

App registration → Expose an API:

3. Define app roles

App registration → App roles → Create three:

Display nameValueAllowed member types
IdentityMesh AdminIdentityMesh.AdminUsers/Groups (and optionally Applications for M2M)
IdentityMesh OperatorIdentityMesh.OperatorUsers/Groups
IdentityMesh ViewerIdentityMesh.ViewerUsers/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:

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:

  1. A User row naming the sign-in.
  2. 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.
  3. The IdentityMesh:Roles Admin / Operator / Viewer mapping.
  4. The default role (IdentityMesh:Roles:Default, built-in User).

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

Notes