Appearance
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
| Property | Keycloak JWT | ERP Access Context Token |
|---|---|---|
| Issued by | Keycloak (microtec realm) | ERP Auth Service |
| Contains | User identity, roles, tenant | Company, branch, module permissions |
| Signed with | Keycloak RSA key | ERP service key (from KV) |
| Lifetime | 5–15 minutes | 1 hour |
| Refresh via | Keycloak refresh token | Re-exchange with current KC JWT |
| Header name | Authorization: Bearer | X-Access-Context |
| Revocation | Keycloak session | Redis blacklist |
Why Not Embed Everything in Keycloak Claims?
Considered and rejected for these reasons:
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.
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.
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.
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 JWTThe 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:readServices 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
| Property | Mechanism |
|---|---|
| Token forgery prevention | RSA-256 signature (ERP Auth Service private key in KV) |
| Replay attacks | Short lifetime + jti uniqueness check |
| Session revocation | Redis revoked:{jti} key |
| Cross-tenant attack | Tenant claim validation at gateway |
| Permission bypass | Permissions 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"
}
}