Skip to content

Dual-Token Design

Section: 14 — Auth
Last Updated: 2026-05-30
Scope: ERP Access Context Token design, rationale, flow, refresh


The Problem: OIDC Meets ERP Business Concepts

Standard OpenID Connect tokens carry user identity — who you are. But Microtec ERP has business concepts that Keycloak fundamentally cannot know:

  • Which company is the user operating in? (A user may belong to multiple companies)
  • Which branch is active?
  • What module-level permissions apply for this session?

The Keycloak JWT can carry custom claims (see jwt-claims.md), but those claims would need to be re-issued every time a user switches company or branch — which would require re-authentication, creating a poor UX.

Solution: A second, service-generated ERP Access Context Token that encodes the business context independently from the identity token.


Token Comparison

PropertyKeycloak JWTERP Access Context Token
Issued byKeycloak (microtec realm)ERP Auth Service
ContainsUser identity, roles, tenantCompany, branch, module permissions
Signed withKeycloak RSA keyERP service key (from KV)
Lifetime5–15 minutes1 hour
Refresh viaKeycloak refresh tokenRe-exchange with current KC JWT
Header nameAuthorization: BearerX-Access-Context
RevocationKeycloak sessionRedis blacklist

Why Not Embed Everything in Keycloak Claims?

Considered and rejected for these reasons:

  1. Claim bloat: ERP has 15+ modules × N branches × M companies per tenant. Encoding all permission combinations in a JWT would create tokens too large for HTTP headers.

  2. Context switching UX: Users switch company/branch without re-login. Re-issuing a Keycloak JWT requires a token exchange roundtrip to Keycloak — slow and operationally complex.

  3. Permission volatility: ERP permissions change frequently (admin grants/revokes). Keycloak claim mappers run on token issuance only — they can't reflect real-time permission changes without forcing re-login.

  4. Separation of concerns: Keycloak is an identity concern. Company/branch/module is a business domain concern. Mixing them couples ERP business logic to IdP configuration.


ERP Access Context Token Structure

json
{
  "sub": "<keycloak-user-uuid>",
  "tenant": "acme-corp",
  "companyId": "550e8400-e29b-41d4-a716-446655440000",
  "branchId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
  "modules": [
    { "code": "accounting", "permissions": ["read", "write", "export"] },
    { "code": "hr", "permissions": ["read"] },
    { "code": "inventory", "permissions": ["read", "write"] }
  ],
  "iat": 1748560000,
  "exp": 1748563600,
  "jti": "<unique-token-id>"
}

The jti (JWT ID) is stored in Redis on issuance. Revocation sets a Redis key revoked:{jti} — all token validation checks this key.


Token Exchange Flow

Initial Login

Company/Branch Switch (No Re-Login)

Token Refresh

The KC JWT and ERP Access Context Token refresh independently:

KC JWT lifetime:    5–15 min  → refreshed via Keycloak refresh_token
ERP ACT lifetime:  60 min    → re-issued by calling /auth/context with fresh KC JWT

The Angular app refreshes the KC JWT silently (iframe/silent renew), then calls /auth/context/refresh to extend the ERP ACT.


Gateway Validation

The Ocelot/YARP gateway validates both tokens on every inbound request:

The gateway injects merged claims as HTTP headers for downstream services — downstream services trust the gateway and do not re-validate tokens.


Downstream Service Contract

After gateway validation, services receive claims as headers:

X-Tenant-Id:     acme-corp
X-Company-Id:    550e8400-e29b-41d4-a716-446655440000
X-Branch-Id:     6ba7b810-9dad-11d1-80b4-00c04fd430c8
X-User-Id:       keycloak-user-uuid
X-User-Email:    user@acme.com
X-Permissions:   accounting:read,accounting:write,hr:read

Services extract these via ICurrentUserService (from Microtec.Web.Core):

csharp
public interface ICurrentUserService
{
    string UserId { get; }
    string TenantId { get; }
    Guid CompanyId { get; }
    Guid BranchId { get; }
    bool HasPermission(string module, string action);
}

Security Properties

PropertyMechanism
Token forgery preventionRSA-256 signature (ERP Auth Service private key in KV)
Replay attacksShort lifetime + jti uniqueness check
Session revocationRedis revoked:{jti} key
Cross-tenant attackTenant claim validation at gateway
Permission bypassPermissions embedded in signed token — not client-controlled

Configuration Reference

json
{
  "AccessContextToken": {
    "ExpiryMinutes": 60,
    "Issuer": "microtec-erp-auth",
    "Audience": "microtec-erp-services",
    "SigningKeySecret": "AccessContextToken--SigningKey"
  },
  "Keycloak": {
    "Authority": "https://auth.microtecstage.com/realms/microtec",
    "IntrospectionEndpoint": "https://auth.microtecstage.com/realms/microtec/protocol/openid-connect/token/introspect",
    "ClientId": "erp-auth-service",
    "ClientSecret": "Keycloak--ClientSecret"
  }
}

Internal Documentation — Microtec Platform Team