Aller au contenu

Auth system — Keycloak + OIDC + RBAC/ABAC + MFA + step-up

Modules : platform/auth-svc (façade Keycloak Admin + endpoints /me, /sessions, /step-up), shared/auth-client-jvm (lib JWT validation + Authz pure functions partagée par tous services backend), Keycloak 25.x (déployé séparément).

ADRs : ADR-002, ADR-009, ADR-029, ADR-032, ADR-033.

Repo : vitakyc-platformplatform/auth-svc/, shared/auth-client-jvm/, infra/keycloak/realm-template/.

Cette page fixe la spec d’ingénierie complète du système auth VitaKYC. Un développeur qui la lit doit pouvoir construire auth-svc + intégrer auth-client-jvm dans un nouveau service backend sans question résiduelle :

  1. Architecture Keycloak realms + clients + flows
  2. Modèle JWT claims + lib validation
  3. Permissions catalog + RBAC/ABAC pure Kotlin
  4. MFA TOTP + WebAuthn enrollment + verification
  5. Step-up MFA pour actions sensibles
  6. Federation OIDC/SAML tenant tier-1
  7. Audit signed events
  8. API REST auth-svc + frontend OIDC integration

Flow nominal login agent :

  1. Agent ouvre back-office (https://kyc.banque.tn/admin) → redirect OIDC Authorization Code + PKCE vers Keycloak
  2. Keycloak affiche login (themed brand tenant) → password + TOTP MFA → JWT signed RS256
  3. Frontend stocke access (15 min) + refresh (8h, rotation à chaque usage) en httpOnly cookie
  4. À chaque requête API : Authorization: Bearer <jwt> → service backend valide JWT via JWKS cached + parse VitaKycPrincipal
  5. Authz pure functions enforcent RBAC/ABAC (Authz.require(principal, Permission.CASES_DECIDE))
  6. Pour action sensible (publish policy, 4-eyes confirm) : middleware exige step_up_until actif → sinon redirect vers POST /step-up/init (re-MFA)
  7. Tous les events Keycloak (login, MFA enroll, role change) → Kafka auth.audit.v1audit-svc signe Ed25519 + chaîne hash + Postgres append-only

2.1 Realm vitakyc-saas — fintechs et tenants light

Section intitulée « 2.1 Realm vitakyc-saas — fintechs et tenants light »

Single realm partagé pour tous les tenants qui n’ont pas d’IdP existant.

ItemValeur
Nomvitakyc-saas
Login URLhttps://auth.vitakyc.io/realms/vitakyc-saas/protocol/openid-connect/auth
Multi-tenantvia attribut user tenantId injecté dans JWT claims
MFATOTP obligatoire — WebAuthn opt-in
Brute force10 attempts → 15 min lockout
Sessions8h SSO, 30 min idle
Brandingthème par tenant via header X-Tenant-Hint ou paramètre URL ?tenant_hint=TN-BANQUEX

Banque tier-1 (BTE, ATB, Amen) a son AD/Azure AD existant avec contraintes propres (policy mots de passe, MFA hardware token, certs CA privés). Realm dédié :

ItemValeur
Nom<tenant_slug> (ex tn-banquex, tn-bte)
FederationOIDC ou SAML 2.0 broker vers IdP banque
MFAinherit IdP banque OR Keycloak MFA forcé en surcouche
Brandingfull custom realm theme (logo, palette, copyright)
ProvisioningSCIM 2.0 (V1) ou import CSV (V0)
// auth-svc resolves realm from request context
fun resolveRealm(ctx: RequestContext): String = when {
ctx.host.endsWith(".vitakyc.io") -> "vitakyc-saas"
ctx.host == "kyc.bte.com.tn" -> "tn-bte"
ctx.host == "kyc.banquex.tn" -> "tn-banquex"
else -> "vitakyc-saas"
}

Mapping host → realm géré par config tenant en Postgres tenant_config.auth_realm.


{
"iss": "https://auth.vitakyc.io/realms/tn-banquex",
"aud": ["vitakyc-back-office", "vitakyc-services"],
"sub": "user_8a7f3c1e9d4b2a6f",
"session_id": "sess_xxx",
"tenant_id": "TN-BANQUEX",
"tenant_realm": "tn-banquex",
"roles": ["agent_l2", "auditor"],
"permissions": [
"cases:read", "cases:claim", "cases:decide", "cases:confirm",
"audit:read", "audit:replay"
],
"skills": ["RETAIL", "PEP", "FATCA"],
"seniority": "L2",
"mfa_amr": ["pwd", "totp"],
"mfa_acr": "urn:mace:incommon:iap:silver",
"step_up_until": null,
"iat": 1745000000,
"exp": 1745000900,
"preferred_username": "leila.bensaid",
"email": "leila.bensaid@banquex.tn",
"email_verified": true,
"name": "Leïla Ben Saïd"
}

Mapping NIST AAL :

AALmfa_acrAuthenticators required
AAL1urn:mace:incommon:iap:bronzepassword seul
AAL2urn:mace:incommon:iap:silverpassword + TOTP / push mobile
AAL3urn:mace:incommon:iap:goldpassword + WebAuthn (FIDO2 hardware)

Politique :

  • agent_l1 : AAL2 minimum
  • agent_l2 / mlro / compliance_officer : AAL2 minimum, AAL3 recommandé
  • dsi_admin / auditor : AAL3 obligatoire
  • Step-up : élève temporairement AAL pour 15 min (logged dans step_up_until)

4.1 Catalogue exhaustif (16 permissions V0, étendable)

Section intitulée « 4.1 Catalogue exhaustif (16 permissions V0, étendable) »
PermissionResourceActionRoles par défaut
cases:readcasesreadagent_l1, agent_l2, mlro, auditor, dsi_admin
cases:claimcasesclaimagent_l1, agent_l2, mlro
cases:decidecasesdecideagent_l1, agent_l2, mlro
cases:confirmcasesconfirm (4-eyes)agent_l2, mlro
cases:bulkcasesbulk operationsmlro
policies:readpoliciesreadagent_l2, mlro, compliance_officer, auditor, dsi_admin
policies:editpoliciesedit draftmlro, compliance_officer
policies:publishpoliciespublish (dual-control)mlro, compliance_officer, dsi_admin
forms:readformsreadtous
forms:editformsedit draftdsi_admin, compliance_officer
forms:publishformspublishdsi_admin, compliance_officer
sanctions:screensanctionstrigger screeningagent_l1+
sanctions:adminsanctionsadmin lists + thresholdsmlro, compliance_officer
audit:readauditread trailtous
audit:replayauditreplay screeningmlro, auditor
audit:exportauditexport PDF (step-up)mlro, auditor, dsi_admin
users:readuserslistdsi_admin, auditor
users:manageusersCRUD usersdsi_admin
webhooks:readwebhooksread configdsi_admin, developer
webhooks:managewebhooksCRUD configdsi_admin
tenant:readtenantread configtous
tenant:managetenantedit tenant configdsi_admin

Au-delà du RBAC standard, on enforce :

  • Tenant isolation : principal.tenantId == resource.tenantId (requis sur 100 % des endpoints — RLS Postgres en backstop)
  • Seniority : principal.seniority.atLeast(case.minSeniority) pour escalades
  • Skills match : router cases requiert case.requiredSkills ⊆ principal.skills
  • Assurance : principal.mfa_acr >= action.minAcr pour actions sensibles
  • Step-up window : now < principal.step_up_until pour publish/confirm

4.3 Implémentation pure Kotlin (shared/auth-client-jvm)

Section intitulée « 4.3 Implémentation pure Kotlin (shared/auth-client-jvm) »
// Pure function enforcement — testable, deterministic, no side effects
object Authz {
fun require(principal: VitaKycPrincipal, permission: Permission)
fun requireSeniority(principal: VitaKycPrincipal, min: Seniority)
fun requireAssurance(principal: VitaKycPrincipal, min: AssuranceLevel)
fun requireStepUp(principal: VitaKycPrincipal, now: Instant = Clock.System.now())
fun requireSameTenant(principal: VitaKycPrincipal, resourceTenantId: String)
}
// Ktor middleware example (in each service)
authenticate("vitakyc-jwt") {
post("/cases/{id}/decide") {
val principal = call.vitaKycPrincipal()
Authz.require(principal, Permission.CASES_DECIDE)
Authz.requireSameTenant(principal, requestedCase.tenantId)
// ... actual logic
}
post("/cases/{id}/confirm") {
val principal = call.vitaKycPrincipal()
Authz.require(principal, Permission.CASES_CONFIRM)
Authz.requireStepUp(principal) // 4-eyes seconde sig requires recent MFA
// ...
}
}

Tests unitaires : 14 tests passants couvrant hasPermission, requireAll, requireAny, requireSeniority, requireAssurance, isStepUpActive, requireStepUp, requireSameTenant. Cf AuthzTest.kt.


Password + TOTP (6 digits temps-réel) ou password + WebAuthn (single tap YubiKey).

  1. Agent contacte DSI tenant via canal hors-bande (téléphone, email).
  2. DSI valide identité (questions vérification, doc ID).
  3. DSI back-office VitaKYC Reset MFA (action audited + step-up MFA DSI requis).
  4. Agent re-login → forced enrollment new TOTP/WebAuthn.

Keycloak realm vitakyc-saas browser flow :

1. Cookie (SSO si valide)
2. ALTERNATIVE
├── User Identifier
└── Password Form ALTERNATIVE
├── Password Form
└── WebAuthn Authenticator
3. CONDITIONAL
└── If MFA NOT enrolled → REQUIRED enroll TOTP/WebAuthn
4. REQUIRED OTP/WebAuthn challenge

Actions sensibles exigent que l’utilisateur ait re-prouvé son identité dans les 15 dernières minutes — même si la session SSO est encore active 7h.

PermissionPourquoi
policies:publishdual-control + audit BCT
cases:confirm4-eyes (cf ADR-029)
audit:exportexport PDF audit signé
audit:replayreplay screening (sensitive read)
secrets:rotatewebhook secret, API key tenant
users:managecréation/suppression users (privileged)
forms:publishdual-control + impact CPS (cf ADR-026)

Realm dédié tn-banquex :

{
"alias": "azure-ad-banquex",
"provider_id": "oidc",
"config": {
"authorization_url": "https://login.microsoftonline.com/<tenant-azure>/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/<tenant-azure>/oauth2/v2.0/token",
"client_id": "<azure-app-id>",
"client_secret": "<vault://tenant/TN-BANQUEX/azure-client-secret>",
"default_scope": "openid profile email",
"user_name_attribute": "email",
"sync_mode": "FORCE",
"trust_email": "true"
}
}

Mapping claims Azure → Keycloak roles via mappers (groupes Azure → roles VitaKYC).

Idem mais providerId saml. SP metadata exposé https://auth.vitakyc.io/realms/tn-banquex/broker/saml-banquex/endpoint/descriptor.

  • V0 MVP : import CSV agents (firstName, lastName, email, role, seniority, skills) → admin DSI VitaKYC. Email d’invitation magic-link. Forced MFA enrollment au premier login.
  • V1 : SCIM 2.0 endpoint exposé https://auth.vitakyc.io/realms/tn-banquex/scim/v2/Users, configurable côté Azure AD / Okta.

Chaque event Keycloak emis sur Kafka auth.audit.v1 :

{
"event_id": "evt_8a7f3c1e9d4b2a6f",
"type": "LOGIN",
"realm_id": "tn-banquex",
"user_id": "u_xxx",
"tenant_id": "TN-BANQUEX",
"session_id": "sess_xxx",
"ip_address": "41.226.xxx.xxx",
"user_agent": "Mozilla/5.0 ...",
"details": { "auth_method": "openid-connect", "amr": ["pwd","totp"] },
"occurred_at": "2026-04-28T11:42:00Z"
}

Types tracés : LOGIN, LOGIN_ERROR, LOGOUT, REGISTER, IDENTITY_PROVIDER_LOGIN, MFA_ENROLLED, MFA_USED, STEP_UP_REQUESTED, STEP_UP_COMPLETED, ROLE_ASSIGNED, ROLE_REVOKED, PASSWORD_CHANGED, MFA_RESET, BRUTE_FORCE_DETECTED.

Identique pattern ADR-029 / ADR-030 / ADR-032 : SHA-256 chainage previousEventHash + Ed25519 signature par tenant key.

CREATE TABLE auth_audit_event (
event_id UUID PRIMARY KEY,
realm_id VARCHAR(64) NOT NULL,
tenant_id UUID NOT NULL,
user_id VARCHAR(64),
session_id VARCHAR(64),
type VARCHAR(32) NOT NULL,
ip_address INET,
user_agent TEXT,
details JSONB,
occurred_at TIMESTAMPTZ NOT NULL,
previous_event_hash CHAR(74) NOT NULL,
hash CHAR(74) NOT NULL,
signature TEXT NOT NULL
);
ALTER TABLE auth_audit_event ENABLE ROW LEVEL SECURITY;
CREATE POLICY auth_audit_tenant_isolation ON auth_audit_event
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
REVOKE UPDATE, DELETE ON auth_audit_event FROM app_role;

Rétention 10 ans WORM (cf ADR-002).


MéthodeEndpointDescriptionAuth
GET/v1/meprincipal info (parsed from JWT)bearer
GET/v1/sessionslist user’s active sessionsbearer
DELETE/v1/sessions/:idrevoke a sessionbearer + step-up
POST/v1/step-up/initinitiate step-up MFA challengebearer
POST/v1/step-up/verifyverify TOTP/WebAuthn + issue new JWT with step_up_untilbearer
POST/v1/mfa/enroll/totpenroll TOTP (returns QR code + secret)bearer
POST/v1/mfa/enroll/webauthnenroll WebAuthn passkey (challenge)bearer
DELETE/v1/mfa/:authIdremove MFA factorbearer + step-up
MéthodeEndpointDescriptionAuth
GET/v1/userslist users tenantbearer + users:read
POST/v1/userscreate user (sends invitation magic-link)bearer + users:manage + step-up
PUT/v1/users/:idupdate roles/skills/senioritybearer + users:manage + step-up
DELETE/v1/users/:iddisable userbearer + users:manage + step-up
POST/v1/users/:id/reset-mfaforce MFA resetbearer + users:manage + step-up
POST/v1/users/:id/unlockunlock after brute force lockoutbearer + users:manage
GET/v1/users/:id/audituser-specific audit trailbearer + audit:read
MéthodeEndpointDescriptionAuth
GET/internal/jwksproxy Keycloak JWKS (cache 1h)mTLS
POST/internal/validatevalidate JWT + return parsed principalmTLS
GET/internal/healthlivenessnone
GET/internal/readyreadiness (Keycloak reachable + DB ok)none

Spec exposée https://auth.vitakyc.io/openapi/auth-svc.yaml. Contract tests Pact en CI.


Fenêtre de terminal
pnpm add react-oidc-context oidc-client-ts
main.tsx
import { AuthProvider } from "react-oidc-context";
const oidcConfig = {
authority: "https://auth.vitakyc.io/realms/vitakyc-saas",
client_id: "vitakyc-back-office",
redirect_uri: window.location.origin + "/auth/callback",
post_logout_redirect_uri: window.location.origin,
response_type: "code",
scope: "openid profile email",
loadUserInfo: true,
automaticSilentRenew: true,
monitorSession: true,
filterProtocolClaims: true,
};
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>;
// App.tsx — protected route
import { useAuth } from "react-oidc-context";
function ProtectedRoute({ children, requirePermission }: ...) {
const auth = useAuth();
if (!auth.isAuthenticated) return <Login />;
const principal = parsePrincipalFromJwt(auth.user.access_token);
if (requirePermission && !principal.permissions.includes(requirePermission)) return <Forbidden />;
return <>{children}</>;
}
// On 401 step_up_required from API:
function handleStepUpRequired(returnTo: string) {
await auth.signinPopup({ prompt: "login", acr_values: "urn:mace:incommon:iap:silver" });
// refreshed token now has step_up_until set
retry(returnTo);
}

Le SDK Web côté client final n’utilise pas Keycloak (le client n’est pas un user authentifié). Il utilise une API key tenant + session token éphémère :

const session = await vitakyc.startSession({
apiKey: "pk_live_TN-BANQUEX_xxx",
formId: "FORM_KYC_INDIVIDUAL",
externalRef: "CASE-42"
});
// session.token short-lived (TTL 30 min) bound to (tenantId, formId, externalRef)

# Stage 1 — build with JDK 21
FROM gradle:8.10.2-jdk21 AS build
WORKDIR /home/gradle/src
COPY settings.gradle.kts build.gradle.kts gradlew gradlew.bat ./
COPY gradle gradle
COPY platform/auth-svc platform/auth-svc
COPY shared/auth-client-jvm shared/auth-client-jvm
RUN ./gradlew :platform:auth-svc:buildFatJar --no-daemon
# Stage 2 — runtime Eclipse Temurin JRE 21
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build --chown=app:app /home/gradle/src/platform/auth-svc/build/libs/auth-svc.jar app.jar
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

Image résultat ~250 MB (JRE 21 alpine + 30 MB fat jar).


MetricMVP cibleV2 cible
Latence p95 login (incl MFA)≤ 3 s≤ 1.5 s
Latence p95 JWT validation côté services≤ 5 ms≤ 2 ms
Throughput auth-svc≥ 100 req/s≥ 500 req/s
JWKS cache hit rate≥ 99,9 %≥ 99,99 %
Disponibilité Keycloak99,5 %99,9 %
Token refresh latency p95≤ 200 ms≤ 100 ms

  • HTTPS partout TLS 1.2+ (TLS 1.0/1.1 refusés)
  • Cookies Secure; HttpOnly; SameSite=Strict
  • Tokens : stockés en memory + httpOnly cookie pour refresh, jamais localStorage (XSS)
  • Anti-CSRF : tokens sur tous endpoints mutating
  • CSP strict : default-src 'self'; script-src 'self' 'unsafe-inline' (dev only)
  • HSTS : max-age=31536000; includeSubDomains; preload
  • JWT signing : RS256 (asymétrique, services valident avec public key JWKS)
  • Brute force : Keycloak Admin Brute Force Detection — 10 fail / 5 min → 15 min lockout
  • Rate limit auth-svc : 60 login/min/IP, 10 step-up/min/user
  • PII : email + nom dans JWT seulement (jamais password, jamais TOTP secret)
  • Audit : signed Ed25519 chainée 10 ans WORM
  • Vault : tenant signing keys + IdP federation client secrets stockés HSM mode

ItemMVP (V0)V2 (S+12)
Realmshybride saas + tier-1+ multi-region (TN + UAE + EU)
MFATOTP + WebAuthn+ push mobile FreeOTP+
FederationOIDC + SAML+ SCIM 2.0 provisioning
Smart Cardnonoptionnel pour ANSI Tunisie tier-1 (V1)
OAuth scopes fine-grainedpermissions enum+ dynamic scopes par module
Anomaly detectionKeycloak brute force basique+ UEBA (User Entity Behavior Analytics)
Passkeys cross-deviceKeycloak 25 minimumsync passkeys iCloud/Google

  • Keycloak 25.x déployé HA (3 replicas + Postgres dédié)
  • Realm vitakyc-saas configuré + 7 roles + 16 permissions + browser flow MFA
  • auth-svc Ktor déployé, JWT validation lib testée
  • shared/auth-client-jvm publié + 14 tests verts
  • Themes brandés VitaKYC (login, MFA, error pages) FR/AR/EN
  • Audit pipeline Keycloak → Kafka → audit-svc signed Ed25519 chainée
  • Brute force detection + rate limit configurés
  • Recovery procedure documentée DSI
  • WebAuthn enrollment forcé DSI + MLRO
  • Step-up MFA enforcé sur 7 actions sensibles
  • Frontend back-office OIDC code+PKCE intégré + step-up modal
  • Federation OIDC tenant pilote tier-1 testée
  • DPIA mise à jour (cf DPIA)
  • Pilote tenant : 5 agents L1 + 2 L2 + 1 MLRO + 1 DSI MFA enrolled, 0 incident sécurité


Document de spec auth system — version 1.0 (2026-04-28).