Skip to content

JWT Claims Reference

Section: 14 — Auth
Last Updated: 2026-05-30
Scope: Custom Keycloak claims, claim mapping SPIs, usage in services


Overview

Microtec ERP extends standard OIDC claims with custom claims added by two Keycloak protocol mapper SPIs. These claims are present in the Keycloak JWT and are read by the Auth Service when generating the ERP Access Context Token.


Standard OIDC Claims (Always Present)

ClaimTypeDescriptionExample
substringKeycloak user UUID"a1b2c3d4-..."
emailstringUser email"ahmed@acme.sa"
email_verifiedboolEmail verified flagtrue
preferred_usernamestringUsername"ahmed.ali"
given_namestringFirst name"Ahmed"
family_namestringLast name"Ali"
iatnumberIssued at (Unix timestamp)1748560000
expnumberExpiry (Unix timestamp)1748560900
issstringIssuer (realm URL)"https://auth.../realms/microtec"
audstring/arrayAudience"erp-api"
realm_access.rolesstring[]Keycloak realm roles["erp-user", "offline_access"]

Custom ERP Claims

These claims are added by custom Keycloak protocol mapper SPIs deployed from the KeycloakProviders/ project.

ClaimTypeAdded By SPIDescriptionExample
tenantstringerp-tenant-mapperTenant identifier (subdomain-based)"acme-corp"
subdomainstringerp-tenant-mapperTenant subdomain"acme"
erp_idstring (UUID)erp-user-mapperInternal ERP user ID (not Keycloak sub)"550e8400-..."
companyidstring (UUID)company-branch-mapperCurrently selected company GUID"6ba7b810-..."
branchIdstring (UUID)company-branch-mapperCurrently selected branch GUID"7c9e6679-..."
erp_policiesstringerp-policy-mapperComma-separated module:action permissions"accounting:read,hr:write"
account_idsstring[]multi-account-switcherAll company IDs accessible to this user["6ba7b810-...", "550e8400-..."]

Claim Details

tenant

The tenant identifier derived from the user's registered subdomain. This is the primary multi-tenancy discriminator.

json
{ "tenant": "acme-corp" }

Mapping: Keycloak user attribute tenantId → JWT claim tenant.

subdomain

Short form of the tenant, used for URL construction and domain routing.

json
{ "subdomain": "acme" }

erp_id

The internal ERP user ID (different from the Keycloak sub). This is used as the CreatedBy / UpdatedBy value in the AuditInterceptor.

json
{ "erp_id": "550e8400-e29b-41d4-a716-446655440000" }

IMPORTANT

Services must use erp_id for audit trail attribution, not sub. The Keycloak sub is an IdP-internal identifier; erp_id is the business domain identifier.

companyid / branchId

The company and branch the user selected at login (or via context switch). These are baked into the Keycloak JWT via the company-branch-mapper SPI.

json
{
  "companyid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
  "branchId":  "7c9e6679-f89b-12d3-a456-426655440000"
}

NOTE

companyid uses lowercase id (inconsistent casing — legacy). branchId uses camelCase. This is intentional and matches the SPI implementation. Do not "fix" casing in consumers.

erp_policies

A comma-separated list of module:action pairs representing the user's active permissions.

json
{ "erp_policies": "accounting:read,accounting:write,hr:read,inventory:read,inventory:write,inventory:export" }

Parsed by ICurrentUserService.HasPermission(module, action):

csharp
if (!_currentUser.HasPermission("accounting", "write"))
    return Result.Forbidden("You do not have permission to create journal entries");

Possible Actions

ActionDescription
readView records
writeCreate and update records
deleteDelete records
exportExport data to Excel/PDF
approveApprove workflows
adminModule administration

account_ids

All company IDs accessible to the user (for the multi-account switcher):

json
{ "account_ids": ["6ba7b810-...", "550e8400-...", "7c9e6679-..."] }

Claim Mapping SPIs

company-branch-mapper

Location: KeycloakProviders/src/CompanyBranchMapper/

Trigger: Runs during token issuance and token refresh.

Logic:

  1. Reads companyId from the user's last session (stored in Keycloak user attributes)
  2. Reads branchId from the user's last session
  3. Validates company/branch belong to the user's tenant
  4. Injects companyid and branchId claims

Configuration (in Keycloak Admin):

Mapper type:      Custom Script Mapper (Java SPI)
Token claim name: companyid
Claim JSON type:  String
Add to ID token:  ON
Add to access token: ON

erp-policy-mapper

Location: KeycloakProviders/src/ErpPolicyMapper/

Trigger: Runs during token issuance.

Logic:

  1. Calls ERP internal API: GET /api/v1/users/{erpId}/policies?companyId={companyId}
  2. Concatenates module:action pairs
  3. Injects as erp_policies string claim

WARNING

This SPI makes a synchronous HTTP call during token issuance. Ensure the ERP policy service is healthy before Keycloak starts, or login will fail. The SPI has a 3-second timeout and falls back to empty policies on timeout.

erp-tenant-mapper

Location: KeycloakProviders/src/TenantMapper/

Logic: Reads tenantId and subdomain from Keycloak user attributes (set during user provisioning) and injects as claims.


Sample Decoded JWT

json
{
  "exp": 1748560900,
  "iat": 1748560000,
  "jti": "ab12cd34-...",
  "iss": "https://auth.microtec-test.com/realms/microtec",
  "aud": ["erp-api", "account"],
  "sub": "keycloak-user-uuid",
  "typ": "Bearer",
  "azp": "erp-angular",
  "session_state": "...",
  "acr": "1",
  "realm_access": {
    "roles": ["erp-user", "offline_access", "uma_authorization"]
  },
  "scope": "openid email profile",
  "sid": "...",
  "email_verified": true,
  "tenant": "acme-corp",
  "subdomain": "acme",
  "erp_id": "550e8400-e29b-41d4-a716-446655440000",
  "companyid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
  "branchId": "7c9e6679-f89b-12d3-a456-426655440000",
  "erp_policies": "accounting:read,accounting:write,hr:read,inventory:read",
  "account_ids": ["6ba7b810-...", "550e8400-..."],
  "name": "Ahmed Ali",
  "preferred_username": "ahmed.ali",
  "given_name": "Ahmed",
  "family_name": "Ali",
  "email": "ahmed@acme.sa"
}

Consuming Claims in Services

Via ICurrentUserService

csharp
// Injected automatically by Microtec.Web.Core
public class CreateInvoiceHandler
{
    private readonly ICurrentUserService _currentUser;

    public async Task<Result> Handle(CreateInvoiceCommand cmd)
    {
        // Access claims
        var tenantId   = _currentUser.TenantId;     // from 'tenant' claim
        var companyId  = _currentUser.CompanyId;    // from 'companyid' claim
        var branchId   = _currentUser.BranchId;     // from 'branchId' claim
        var userId     = _currentUser.UserId;        // from 'erp_id' claim

        // Permission check
        if (!_currentUser.HasPermission("accounting", "write"))
            return Result.Forbidden();

        // ...
    }
}

Via ClaimsPrincipal (direct)

csharp
// Accessing claims directly when ICurrentUserService is not available
var tenant = User.FindFirstValue("tenant");
var erpId  = User.FindFirstValue("erp_id");

Known Issues and Quirks

QuirkDescription
companyid casingLowercase id — not companyId. Legacy from original SPI.
erp_policies timeoutPolicy mapper times out if ERP service is slow; results in empty permissions
Refresh token claimsaccount_ids is only added to the access token, not the refresh token. Re-issue access token to get updated company list.

Internal Documentation — Microtec Platform Team