PD-26 — Plan d'implémentation
📚 Navigation User Story
| Document | | | ---------- | -- | | 📋 [Spécification](PD-26-specification.md) | | | 🛠️ **Plan d'implémentation** | *(ce document)* | | 🧪 [Tests contractuels](PD-26-tests.md) | | | ✅ Critères d'acceptation | *(à venir)* | | 📝 Retour d'expérience | *(à venir)* | [← Retour à auth-identity](../PD-182-epic.md) · [↑ Index User Story](index.md)
1. Découpage en composants
1.1 Composants backend (NestJS)
| Composant | Responsabilité | Fichiers |
| AuthModule | Module principal d'authentification OIDC | src/modules/auth/auth.module.ts |
| JwtValidationService | Validation JWT (signature, claims, dates) | src/modules/auth/services/jwt-validation.service.ts |
| OidcDiscoveryService | Récupération JWKS et discovery | src/modules/auth/services/oidc-discovery.service.ts |
| AuthorizationService | Logique d'autorisation HYBRID (roles/scopes) | src/modules/auth/services/authorization.service.ts |
| JwtAuthGuard | Guard NestJS pour protection des routes | src/modules/auth/guards/jwt-auth.guard.ts |
| RolesGuard | Guard pour vérification des rôles | src/modules/auth/guards/roles.guard.ts |
| ScopesGuard | Guard pour vérification des scopes | src/modules/auth/guards/scopes.guard.ts |
| AuthAuditInterceptor | Interception et logging conforme | src/modules/auth/interceptors/auth-audit.interceptor.ts |
| AuthHealthIndicator | Health check OIDC/JWKS | src/modules/auth/health/auth-health.indicator.ts |
1.2 Composants Keycloak (infra)
| Composant | Responsabilité |
| Realm pv-prod | Realm production |
| Realm pv-dev | Realm développement |
| Client pv-backend | Client confidential backend |
| Client pv-api | Client pour API |
| Mappers | Exposition claims (tenant, authz.roles, authz.scopes) |
1.3 Configuration
| Fichier | Contenu |
src/config/oidc.config.ts | Paramètres OIDC (issuer, audiences, algos) |
src/config/auth.config.ts | Paramètres d'autorisation (model, claims) |
2. Flux techniques
2.1 Flux N1 — Découverte OIDC (startup)
┌─────────────────┐ ┌────────────────────┐ ┌──────────┐
│ NestJS Bootstrap│────▶│OidcDiscoveryService│────▶│ Keycloak │
└─────────────────┘ └────────────────────┘ └──────────┘
│ │ │
│ │ GET /.well-known/ │
│ │ openid-configuration │
│ │◀─────────────────────│
│ │ │
│ │ GET /protocol/ │
│ │ openid-connect/certs │
│ │◀─────────────────────│
│ │ │
│ JWKS cached │ │
│◀───────────────────────│ │
│ │ │
│ Health: READY │ │
2.2 Flux N3 — Appel API protégé
┌────────┐ ┌────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ Client │────▶│JwtAuthGuard│────▶│JwtValidationSvc │────▶│AuthorizationSvc │
└────────┘ └────────────┘ └──────────────────┘ └───────────────────┘
│ │ │ │
│ Bearer JWT │ │ │
│─────────────▶│ │ │
│ │ validate(token) │ │
│ │────────────────────▶│ │
│ │ │ │
│ │ │ 1. Decode header │
│ │ │ 2. Check alg ∈ RS256 │
│ │ │ 3. Verify signature │
│ │ │ 4. Check iss │
│ │ │ 5. Check aud │
│ │ │ 6. Check exp/nbf/iat │
│ │ │ 7. Check required │
│ │ │ claims │
│ │ │ │
│ │ payload │ │
│ │◀────────────────────│ │
│ │ │ │
│ │ authorize(payload, route) │
│ │─────────────────────────────────────────────▶│
│ │ │ │
│ │ │ Check authz.roles │
│ │ │ Check authz.scopes │
│ │ │ Apply AND/OR rule │
│ │ │ │
│ │ granted/denied │ │
│◀─────────────│◀────────────────────────────────────────────│
2.3 Flux N5 — Rotation des clés (JWKS refresh)
┌─────────────────────┐ ┌──────────┐
│OidcDiscoveryService │────▶│ Keycloak │
└─────────────────────┘ └──────────┘
│ │
│ (periodic or on-demand)│
│ GET /certs │
│───────────────────────▶│
│ │
│ new JWKS │
│◀───────────────────────│
│ │
│ Update cache │
│ Invalidate old kids │
2.4 Flux Fail-Closed JWKS/Discovery (INV-08, CA-11, TC-NOM-11)
Objectif : En cas d'indisponibilité JWKS/Discovery, le système DOIT : 1. Rejeter tout nouveau token (fail-closed) 2. Exposer un signal d'état dégradé observable
┌─────────────────────┐ ┌──────────┐ ┌─────────────────┐
│OidcDiscoveryService │────▶│ Keycloak │ │AuthHealthIndicator│
└─────────────────────┘ └──────────┘ └─────────────────┘
│ │ │
│ GET /certs (refresh) │ │
│───────────────────────▶│ │
│ │ │
│ ❌ Timeout / 5xx │ │
│◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │
│ │ │
│ jwksAvailable = false │ │
│────────────────────────────────────────────▶│
│ │ │
│ │ isHealthy() │
│ │ ───────────────▶│
│ │ │
│ │ { status: 'down',
│ │ oidc: 'unavailable' }
│ │ ◀───────────────│
Mécanisme détaillé :
| Composant | Responsabilité | Méthode/Propriété |
OidcDiscoveryService | Détection indisponibilité | jwksAvailable: boolean (propriété observable) |
OidcDiscoveryService | Retry avec backoff | refreshJwks() avec retry exponentiel (3 tentatives, max 30s) |
AuthHealthIndicator | Exposition état dégradé | check(): HealthIndicatorResult |
JwtAuthGuard | Rejet si JWKS indisponible | Vérifie jwksAvailable avant validation |
Points d'observation (TC-NOM-11) :
| Signal | Endpoint/Méthode | Valeur normale | Valeur dégradée |
| Health check | GET /health | { status: 'ok', oidc: { status: 'up' } } | { status: 'error', oidc: { status: 'down', message: 'JWKS unavailable' } } |
| Readiness probe | GET /health/ready | HTTP 200 | HTTP 503 |
| Metric Prometheus | auth_oidc_jwks_available | 1 | 0 |
| Réponse API | Toute route protégée | (selon auth) | HTTP 503 { error: 'Service Unavailable' } |
Comportement fail-closed : 1. Si jwksAvailable === false → JwtAuthGuard retourne immédiatement HTTP 503 2. Le health check passe en status: 'error' 3. La metric auth_oidc_jwks_available passe à 0 4. Les logs émettent { error: 'jwks_unavailable', ts } (conforme liste blanche)
2.5 Isolation Realm/Tenant (INV-07, CA-09, TC-NOM-09)
Objectif : Garantir l'isolation des environnements par realm et tenant, conformément aux politiques REALM_POLICY et TENANT_POLICY (Spec §12).
2.5.1 Paramètres contractuels (Spec §12, §16)
| Paramètre | Valeur | Source |
OIDC_ISSUER | https://auth.probatiovault.prod/realms/pv-prod | Spec §16 |
EXPECTED_REALM | Extrait de OIDC_ISSUER → pv-prod | Dérivé |
EXPECTED_TENANT | Claim tenant du token | Spec §16 (JWT_REQUIRED_CLAIMS) |
REALM_POLICY | 1 realm = 1 environnement (dev, staging, prod) | Spec §12 |
TENANT_POLICY | Claim tenant obligatoire, cross-tenant interdit | Spec §12 |
2.5.2 Configuration
// src/config/oidc.config.ts
export const oidcConfig = {
issuer: process.env.OIDC_ISSUER, // https://auth.probatiovault.prod/realms/pv-prod
expectedRealm: extractRealmFromIssuer(process.env.OIDC_ISSUER), // pv-prod
tenantClaim: 'tenant', // Nom du claim tenant (contractuel)
allowedTenants: process.env.ALLOWED_TENANTS?.split(',') || [], // Liste optionnelle
};
2.5.3 Mécanisme de validation
┌────────┐ ┌──────────────────┐
│ Token │────▶│JwtValidationSvc │
└────────┘ └──────────────────┘
│ │
│ iss claim │ 1. Vérifier iss === OIDC_ISSUER
│───────────────▶│ (inclut validation realm implicite)
│ │
│ tenant claim │ 2. Extraire tenant du token
│───────────────▶│
│ │ 3. Vérifier tenant non null/vide
│ │
│ │ 4. Si ALLOWED_TENANTS défini :
│ │ vérifier tenant ∈ ALLOWED_TENANTS
│ │
│ │ 5. Stocker tenant dans contexte
│ │ pour audit/RLS
Méthodes implémentées :
| Méthode | Composant | Responsabilité |
validateIssuer(token) | JwtValidationService | Vérifie iss === OIDC_ISSUER (inclut realm) |
validateTenant(token) | JwtValidationService | Vérifie présence et validité du claim tenant |
extractRealmFromIssuer(iss) | OidcConfigService | Extrait le realm de l'URL issuer |
2.5.4 Contrôles cross-realm/tenant
| Contrôle | Mécanisme | Observable |
| Cross-realm | Validation iss stricte → token d'un autre realm a un issuer différent → rejet | HTTP 401, log issuer_mismatch |
| Cross-tenant | Validation tenant claim → si tenant ne correspond pas à l'attendu → rejet | HTTP 401, log tenant_mismatch |
| Tenant absent | Claim tenant requis (JWT_REQUIRED_CLAIMS) → absence → rejet | HTTP 401, log claim_missing:tenant |
2.5.5 Points d'observation (TC-NOM-09)
| Scénario | Token | Attendu | Observable |
| Token env A sur env A | iss=.../realms/pv-prod, tenant=acme | ✅ Accepté | HTTP 200 |
| Token env A sur env B | iss=.../realms/pv-dev sur backend prod | ❌ Rejeté | HTTP 401, log issuer_mismatch |
| Token tenant X sur tenant Y | tenant=acme attendu, reçu tenant=other | ❌ Rejeté | HTTP 401, log tenant_mismatch |
| Token sans tenant | Claim tenant absent | ❌ Rejeté | HTTP 401, log claim_missing:tenant |
2.5.6 Hypothèse technique ajoutée
| ID | Hypothèse | Impact si faux |
| H-T08 | Le claim tenant est présent et non vide dans tous les tokens valides | Rejet systématique si absent |
| H-T09 | L'issuer contient le nom du realm dans le path (/realms/{realm}) | Extraction realm impossible |
2.6 Politique de Log/Audit Normative (INV-04, CA-10, TC-NOM-10, TC-NEG-04)
Objectif : Garantir que les logs d'authentification/autorisation respectent une liste blanche fermée de champs autorisés, sans fuite de données sensibles (tokens, secrets, claims non listés).
2.6.1 Paramètres contractuels (Spec §14)
Champs interdits (blacklist stricte) :
| Catégorie | Exemples | Raison |
| Tokens | access_token, refresh_token, id_token | Secrets réutilisables |
| Header Authorization | Bearer eyJ... complet | Contient le token |
| Secrets/signatures | Clés privées, signatures JWT | Sécurité cryptographique |
| Claims sensibles | Tout claim non listé (email, phone, etc.) | Minimisation données |
| Payload JWT | Token décodé en clair | Agrégat de données sensibles |
Champs autorisés (whitelist fermée) :
| Champ | Type | Description | Obligatoire |
requestId | string (UUID) | Identifiant unique de requête | ✅ |
sub | string | Identifiant utilisateur (claim sub) | ✅ si authentifié |
tenant | string | Identifiant tenant | ✅ si authentifié |
issuer | string | Issuer du token (sans secret) | ✅ si authentifié |
audience | string[] | Audience(s) du token | ✅ si authentifié |
clientId | string | Client OIDC | ✅ si authentifié |
error | string | Code d'erreur uniquement (pas de message détaillé) | ✅ si erreur |
route | string | Path de la ressource | ✅ |
ts | ISO8601 | Horodatage | ✅ |
2.6.2 Architecture du mécanisme
┌────────────┐ ┌─────────────────────┐ ┌────────────────┐
│ Request │────▶│ AuthAuditInterceptor│────▶│ Logger │
└────────────┘ └─────────────────────┘ └────────────────┘
│ │ │
│ │ 1. Extraire contexte │
│ │ (requestId, route) │
│ │ │
│ │ 2. Si authentifié : │
│ │ extraire claims │
│ │ autorisés seulement │
│ │ │
│ │ 3. Sanitize : │
│ │ - Supprimer headers │
│ │ Authorization │
│ │ - Filtrer claims │
│ │ │
│ │ 4. Construire entry │
│ │ conforme whitelist │
│ │ │
│ │ emit(auditEntry) │
│ │────────────────────────▶│
2.6.3 Implémentation AuthAuditInterceptor
// src/modules/auth/interceptors/auth-audit.interceptor.ts
@Injectable()
export class AuthAuditInterceptor implements NestInterceptor {
// Liste blanche fermée - AUCUN autre champ ne doit être loggé
private static readonly ALLOWED_FIELDS = [
'requestId', 'sub', 'tenant', 'issuer', 'audience',
'clientId', 'error', 'route', 'ts'
] as const;
// Patterns à détecter et rejeter
private static readonly FORBIDDEN_PATTERNS = [
/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/, // JWT
/^Bearer\s+/i, // Authorization header
];
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const startTime = Date.now();
return next.handle().pipe(
tap({
next: () => this.logSuccess(request, startTime),
error: (err) => this.logError(request, err, startTime),
}),
);
}
private buildAuditEntry(request: Request, error?: string): AuditEntry {
const user = request.user; // Populated by JwtAuthGuard
return this.sanitize({
requestId: request.id,
sub: user?.sub,
tenant: user?.tenant,
issuer: user?.iss,
audience: user?.aud,
clientId: user?.clientId,
error: error,
route: request.path,
ts: new Date().toISOString(),
});
}
private sanitize(entry: Record<string, unknown>): AuditEntry {
const sanitized: AuditEntry = {};
for (const [key, value] of Object.entries(entry)) {
// 1. Rejeter si champ non autorisé
if (!AuthAuditInterceptor.ALLOWED_FIELDS.includes(key)) {
continue; // Silently drop
}
// 2. Rejeter si valeur contient pattern interdit
if (typeof value === 'string') {
for (const pattern of AuthAuditInterceptor.FORBIDDEN_PATTERNS) {
if (pattern.test(value)) {
throw new Error(`Forbidden pattern detected in audit field: ${key}`);
}
}
}
sanitized[key] = value;
}
return sanitized;
}
}
2.6.4 Règles de granularité (Spec §14)
| Règle | Mécanisme | Observable |
| Chaque décision d'accès produit une entrée | tap() sur success ET error | 1 log par requête |
| Niveau de détail constant | Pas de if (DEBUG) conditionnel | Logs identiques dev/prod |
| Pas de données sensibles | sanitize() + whitelist | Audit conforme |
2.6.5 Points d'observation (TC-NOM-10, TC-NEG-04)
TC-NOM-10 : Vérification non-fuite standard
| Scénario | Action | Log attendu | Log interdit |
| Auth échouée | Token invalide | { requestId, route, error: "signature_invalid", ts } | Token complet, payload |
| Auth réussie | Accès accordé | { requestId, sub, tenant, route, ts } | Claims non listés |
| AuthZ échouée | Permissions insuffisantes | { requestId, sub, tenant, route, error: "access_denied", ts } | Roles/scopes détaillés |
TC-NEG-04 : Test adversarial injection
| Scénario | Injection | Résultat attendu | Observable |
| Token dans metadata | Claim custom contenant JWT | Claim filtré (non dans whitelist) | Log sans le claim |
| Authorization header loggé | Code tentant de logger req.headers.authorization | Erreur ou filtrage | Log sans header |
| Claim sensible | email, phone dans token | Claims filtrés | Log uniquement whitelist |
2.6.6 Configuration et validation startup
// src/config/audit.config.ts
export const auditConfig = {
// Liste normative - ne pas modifier sans revue sécurité
allowedFields: ['requestId', 'sub', 'tenant', 'issuer', 'audience', 'clientId', 'error', 'route', 'ts'],
// Validation au démarrage
validateConfig(): void {
// Vérifier que la liste correspond exactement à la spec
const specFields = new Set(['requestId', 'sub', 'tenant', 'issuer', 'audience', 'clientId', 'error', 'route', 'ts']);
const configFields = new Set(this.allowedFields);
if (!setsEqual(specFields, configFields)) {
throw new Error('Audit config does not match spec §14 whitelist');
}
}
};
2.6.7 Hypothèses techniques ajoutées
| ID | Hypothèse | Impact si faux |
| H-T10 | Le logger sous-jacent (Pino/Winston) ne sérialise pas automatiquement request ou headers | Fuite potentielle de secrets |
| H-T11 | Les claims extraits du token sont accessibles via request.user après JwtAuthGuard | Audit incomplet |
2.7 Claims Normatifs et Modèle HYBRID (INV-09, INV-10, INV-11, CA-07, CA-08, TC-NOM-07/08/12)
Objectif : Définir les structures normatives des claims requis et le modèle d'autorisation HYBRID avec règles AND/OR déterministes.
2.7.1 Claims requis (Spec §11, §16)
JWT_REQUIRED_CLAIMS (liste fermée contractuelle) :
| Claim | Type | Chemin JWT | Obligatoire | Validation |
sub | string | sub | ✅ | Non vide, format UUID ou string |
iss | string | iss | ✅ | === OIDC_ISSUER |
aud | string | string[] | aud | ✅ | ∩ OIDC_ALLOWED_AUDIENCES ≠ ∅ |
exp | number (Unix timestamp) | exp | ✅ | > now - JWT_CLOCK_SKEW_MAX |
iat | number (Unix timestamp) | iat | ✅ | ≤ now + JWT_CLOCK_SKEW_MAX |
tenant | string | tenant | ✅ | Non vide, conforme TENANT_POLICY |
authz | object | authz | ✅ | Contient roles et/ou scopes |
2.7.2 Structure du claim authz (Spec §16)
// src/modules/auth/dto/authz-claim.dto.ts
/**
* Structure normative du claim authz (AUTHORIZATION_MODEL = HYBRID)
* Spec §16 : JWT_ROLE_CLAIM = authz.roles, JWT_SCOPE_CLAIM = authz.scopes
*/
export interface AuthzClaim {
/**
* Rôles applicatifs (RBAC)
* Chemin JWT : authz.roles
* Type : string[] (peut être vide si scopes présents)
*/
roles?: string[];
/**
* Scopes OAuth2
* Chemin JWT : authz.scopes
* Type : string[] (peut être vide si roles présents)
*/
scopes?: string[];
}
/**
* DTO complet du payload JWT validé
*/
export interface JwtPayloadDto {
sub: string;
iss: string;
aud: string | string[];
exp: number;
iat: number;
tenant: string;
authz: AuthzClaim;
}
2.7.3 Validation des claims requis (INV-09, CA-07)
// src/modules/auth/services/jwt-validation.service.ts
@Injectable()
export class JwtValidationService {
// Claims requis selon Spec §16
private static readonly REQUIRED_CLAIMS: (keyof JwtPayloadDto)[] = [
'sub', 'iss', 'aud', 'exp', 'iat', 'tenant', 'authz'
];
validateRequiredClaims(payload: Record<string, unknown>): JwtPayloadDto {
for (const claim of JwtValidationService.REQUIRED_CLAIMS) {
if (payload[claim] === undefined || payload[claim] === null) {
throw new UnauthorizedException({
error: 'claim_missing',
claim: claim,
message: `Required claim '${claim}' is missing`,
});
}
}
// Validation spécifique authz (INV-10)
const authz = payload.authz as AuthzClaim;
if (!authz || typeof authz !== 'object') {
throw new UnauthorizedException({
error: 'claim_invalid',
claim: 'authz',
message: 'Claim authz must be an object',
});
}
// HYBRID : au moins roles OU scopes doit être présent et non vide
const hasRoles = Array.isArray(authz.roles) && authz.roles.length > 0;
const hasScopes = Array.isArray(authz.scopes) && authz.scopes.length > 0;
if (!hasRoles && !hasScopes) {
throw new UnauthorizedException({
error: 'authz_empty',
message: 'HYBRID model requires at least roles or scopes',
});
}
return payload as JwtPayloadDto;
}
}
2.7.4 Modèle HYBRID et règles AND/OR (INV-10, INV-11, CA-08)
AUTHORIZATION_MODEL = HYBRID (Spec §16) : - Combine rôles applicatifs (authz.roles) ET scopes OAuth2 (authz.scopes) - Chaque route déclare explicitement ses exigences via décorateurs - Les règles AND/OR sont déclarées, jamais implicites
Décorateurs d'autorisation :
// src/modules/auth/decorators/roles.decorator.ts
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// src/modules/auth/decorators/scopes.decorator.ts
export const Scopes = (...scopes: string[]) => SetMetadata('scopes', scopes);
// src/modules/auth/decorators/auth-rule.decorator.ts
export enum AuthRule {
AND = 'AND', // Tous les rôles ET tous les scopes requis
OR = 'OR', // Au moins un rôle OU un scope suffit
}
export const AuthorizationRule = (rule: AuthRule) => SetMetadata('authRule', rule);
Exemples de déclaration de routes :
// Route exigeant un rôle spécifique (règle implicite : rôle seul suffit)
@Get('documents')
@Roles('document:read')
async listDocuments() { }
// Route exigeant un scope spécifique
@Post('documents')
@Scopes('documents:write')
async createDocument() { }
// Route HYBRID avec règle AND (rôle ET scope requis)
@Delete('documents/:id')
@Roles('admin')
@Scopes('documents:delete')
@AuthorizationRule(AuthRule.AND)
async deleteDocument() { }
// Route HYBRID avec règle OR (rôle OU scope suffit)
@Get('documents/:id')
@Roles('document:read', 'admin')
@Scopes('documents:read')
@AuthorizationRule(AuthRule.OR)
async getDocument() { }
2.7.5 Implémentation des Guards (INV-11)
// src/modules/auth/guards/authorization.guard.ts
@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler()) || [];
const requiredScopes = this.reflector.get<string[]>('scopes', context.getHandler()) || [];
const authRule = this.reflector.get<AuthRule>('authRule', context.getHandler());
// Si aucune exigence déclarée → accès libre (authentification seule)
if (requiredRoles.length === 0 && requiredScopes.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user: JwtPayloadDto = request.user;
const userRoles = user.authz?.roles || [];
const userScopes = user.authz?.scopes || [];
// Déterminer la règle (défaut selon ce qui est déclaré)
const effectiveRule = this.determineRule(authRule, requiredRoles, requiredScopes);
const rolesMatch = this.checkRoles(requiredRoles, userRoles);
const scopesMatch = this.checkScopes(requiredScopes, userScopes);
let granted: boolean;
if (effectiveRule === AuthRule.AND) {
// AND : tous les rôles ET tous les scopes requis
granted = rolesMatch && scopesMatch;
} else {
// OR : au moins un rôle OU un scope suffit
granted = rolesMatch || scopesMatch;
}
if (!granted) {
throw new ForbiddenException({
error: 'access_denied',
required: { roles: requiredRoles, scopes: requiredScopes, rule: effectiveRule },
provided: { roles: userRoles, scopes: userScopes },
});
}
return true;
}
private determineRule(
declaredRule: AuthRule | undefined,
requiredRoles: string[],
requiredScopes: string[]
): AuthRule {
// Si règle explicitement déclarée → l'utiliser
if (declaredRule) return declaredRule;
// Sinon, déduire selon ce qui est requis :
// - Rôles seuls ou scopes seuls → pas de combinaison, retourne OR par défaut
// - Les deux déclarés sans règle → erreur de configuration
if (requiredRoles.length > 0 && requiredScopes.length > 0) {
throw new Error(
'Configuration error: both roles and scopes declared without explicit AuthorizationRule (AND/OR)'
);
}
return AuthRule.OR; // Défaut pour rôle seul ou scope seul
}
private checkRoles(required: string[], provided: string[]): boolean {
if (required.length === 0) return true;
return required.every(role => provided.includes(role));
}
private checkScopes(required: string[], provided: string[]): boolean {
if (required.length === 0) return true;
return required.every(scope => provided.includes(scope));
}
}
2.7.6 Points d'observation (TC-NOM-07, TC-NOM-08, TC-NOM-12)
TC-NOM-07 : Claims requis manquants
| Scénario | Token | Attendu | Observable |
Claim sub absent | { iss, aud, exp, iat, tenant, authz } | ❌ Rejeté | HTTP 401, { error: "claim_missing", claim: "sub" } |
Claim tenant absent | { sub, iss, aud, exp, iat, authz } | ❌ Rejeté | HTTP 401, { error: "claim_missing", claim: "tenant" } |
Claim authz absent | { sub, iss, aud, exp, iat, tenant } | ❌ Rejeté | HTTP 401, { error: "claim_missing", claim: "authz" } |
authz.roles et authz.scopes vides | { ..., authz: { roles: [], scopes: [] } } | ❌ Rejeté | HTTP 401, { error: "authz_empty" } |
TC-NOM-08 : Autorisation accordée (HYBRID)
| Scénario | Route | Token authz | Règle | Attendu | Observable |
| Rôle seul suffisant | @Roles('admin') | { roles: ['admin'] } | OR (implicite) | ✅ Accordé | HTTP 200 |
| Scope seul suffisant | @Scopes('read') | { scopes: ['read'] } | OR (implicite) | ✅ Accordé | HTTP 200 |
| AND : rôle ET scope | @Roles('admin') @Scopes('delete') @AuthorizationRule(AND) | { roles: ['admin'], scopes: ['delete'] } | AND | ✅ Accordé | HTTP 200 |
| OR : rôle OU scope | @Roles('admin') @Scopes('read') @AuthorizationRule(OR) | { roles: ['user'], scopes: ['read'] } | OR | ✅ Accordé | HTTP 200 |
TC-NOM-12 : Autorisation refusée (HYBRID)
| Scénario | Route | Token authz | Règle | Attendu | Observable |
| Rôle manquant | @Roles('admin') | { roles: ['user'] } | — | ❌ Refusé | HTTP 403, { error: "access_denied" } |
| Scope manquant | @Scopes('delete') | { scopes: ['read'] } | — | ❌ Refusé | HTTP 403, { error: "access_denied" } |
| AND : rôle OK, scope KO | @Roles('admin') @Scopes('delete') @AuthorizationRule(AND) | { roles: ['admin'], scopes: ['read'] } | AND | ❌ Refusé | HTTP 403 |
| OR : ni rôle ni scope | @Roles('admin') @Scopes('delete') @AuthorizationRule(OR) | { roles: ['user'], scopes: ['read'] } | OR | ❌ Refusé | HTTP 403 |
2.7.7 Configuration normative
// src/config/auth.config.ts
export const authConfig = {
// Modèle d'autorisation (Spec §16)
authorizationModel: 'HYBRID' as const,
// Chemins des claims d'autorisation
roleClaim: 'authz.roles',
scopeClaim: 'authz.scopes',
// Claims requis (Spec §16)
requiredClaims: ['sub', 'iss', 'aud', 'exp', 'iat', 'tenant', 'authz'],
// Validation au démarrage
validateConfig(): void {
if (this.authorizationModel !== 'HYBRID') {
throw new Error('Only HYBRID authorization model is supported (Spec §16)');
}
}
};
2.7.8 Hypothèses techniques ajoutées
| ID | Hypothèse | Impact si faux |
| H-T12 | Les claims authz.roles et authz.scopes sont des tableaux de strings | Parsing échoue, rejet token |
| H-T13 | Chaque route déclarant roles ET scopes spécifie explicitement @AuthorizationRule | Erreur de configuration au runtime |
| H-T14 | Les noms de rôles/scopes sont en minuscules avec format resource:action | Comparaison case-sensitive échoue |
2bis. Diagrammes Mermaid
2bis.1 Graphe de dépendances des composants
graph TD
subgraph "AuthModule"
JwtAuthGuard["JwtAuthGuard<br/>(jwt-auth.guard.ts)"]
RolesGuard["RolesGuard<br/>(roles.guard.ts)"]
ScopesGuard["ScopesGuard<br/>(scopes.guard.ts)"]
AuthorizationGuard["AuthorizationGuard<br/>(authorization.guard.ts)"]
JwtValidationService["JwtValidationService<br/>(jwt-validation.service.ts)"]
OidcDiscoveryService["OidcDiscoveryService<br/>(oidc-discovery.service.ts)"]
AuthorizationService["AuthorizationService<br/>(authorization.service.ts)"]
AuthAuditInterceptor["AuthAuditInterceptor<br/>(auth-audit.interceptor.ts)"]
AuthHealthIndicator["AuthHealthIndicator<br/>(auth-health.indicator.ts)"]
end
subgraph "Configuration"
OidcConfig["oidc.config.ts"]
AuthConfig["auth.config.ts"]
AuditConfig["audit.config.ts"]
end
subgraph "Externe"
Keycloak["Keycloak<br/>(IdP — PD-235)"]
end
JwtAuthGuard -->|"validate(token)"| JwtValidationService
JwtAuthGuard -->|"jwksAvailable?"| OidcDiscoveryService
RolesGuard -->|"checkRoles()"| AuthorizationService
ScopesGuard -->|"checkScopes()"| AuthorizationService
AuthorizationGuard -->|"canActivate()"| AuthorizationService
JwtValidationService -->|"getKey(kid)"| OidcDiscoveryService
OidcDiscoveryService -->|"GET /.well-known/<br/>openid-configuration"| Keycloak
OidcDiscoveryService -->|"GET /certs (JWKS)"| Keycloak
AuthHealthIndicator -->|"jwksAvailable"| OidcDiscoveryService
JwtValidationService -->|"issuer, audiences, algos"| OidcConfig
AuthorizationGuard -->|"authorizationModel,<br/>roleClaim, scopeClaim"| AuthConfig
AuthAuditInterceptor -->|"allowedFields"| AuditConfig
style Keycloak fill:#f9f,stroke:#333,stroke-width:2px
style JwtAuthGuard fill:#bbf,stroke:#333
style JwtValidationService fill:#bbf,stroke:#333
style OidcDiscoveryService fill:#bbf,stroke:#333
2bis.2 Diagramme de séquence — Appel API protégé (multi-service)
sequenceDiagram
participant Client
participant JwtAuthGuard
participant OidcDiscoverySvc as OidcDiscoveryService
participant JwtValidationSvc as JwtValidationService
participant AuthorizationGuard
participant AuthAuditInterceptor
participant Keycloak
participant Controller
Note over Client,Controller: Flux N3 — Requête API avec Bearer JWT
Client->>+JwtAuthGuard: GET /documents (Bearer JWT)
JwtAuthGuard->>OidcDiscoverySvc: jwksAvailable?
alt JWKS indisponible (INV-08)
OidcDiscoverySvc-->>JwtAuthGuard: false
JwtAuthGuard-->>Client: HTTP 503 Service Unavailable
end
OidcDiscoverySvc-->>JwtAuthGuard: true
JwtAuthGuard->>+JwtValidationSvc: validate(token)
JwtValidationSvc->>JwtValidationSvc: 1. validateAlgorithm(header.alg)
Note right of JwtValidationSvc: alg ∈ RS256 (INV-02, CA-06)
JwtValidationSvc->>OidcDiscoverySvc: getKey(header.kid)
OidcDiscoverySvc-->>JwtValidationSvc: publicKey (JWKS cache)
JwtValidationSvc->>JwtValidationSvc: 2. verifySignature(token, key)
Note right of JwtValidationSvc: INV-02, CA-02
JwtValidationSvc->>JwtValidationSvc: 3. validateIssuer(payload.iss)
Note right of JwtValidationSvc: iss === OIDC_ISSUER (INV-07, CA-03)
JwtValidationSvc->>JwtValidationSvc: 4. validateAudience(payload.aud)
Note right of JwtValidationSvc: aud ∩ ALLOWED ≠ ∅ (CA-04)
JwtValidationSvc->>JwtValidationSvc: 5. validateDates(exp, nbf, iat)
Note right of JwtValidationSvc: ±120s clock skew (CA-05)
JwtValidationSvc->>JwtValidationSvc: 6. validateRequiredClaims(payload)
Note right of JwtValidationSvc: sub, tenant, authz (INV-09)
JwtValidationSvc->>JwtValidationSvc: 7. validateTenant(payload.tenant)
Note right of JwtValidationSvc: INV-07, CA-09
JwtValidationSvc-->>-JwtAuthGuard: JwtPayloadDto (validated)
JwtAuthGuard->>JwtAuthGuard: request.user = payload
JwtAuthGuard->>+AuthorizationGuard: canActivate(context)
AuthorizationGuard->>AuthorizationGuard: Lire @Roles, @Scopes, @AuthorizationRule
AuthorizationGuard->>AuthorizationGuard: Evaluer AND/OR (INV-10, INV-11)
alt Accès refusé
AuthorizationGuard-->>Client: HTTP 403 Forbidden
end
AuthorizationGuard-->>-JwtAuthGuard: granted
JwtAuthGuard-->>-Controller: next()
Controller-->>Client: HTTP 200 (response)
AuthAuditInterceptor->>AuthAuditInterceptor: sanitize(auditEntry)
Note right of AuthAuditInterceptor: Whitelist fermée (INV-04)
AuthAuditInterceptor->>AuthAuditInterceptor: emit({ requestId, sub, tenant, route, ts })
2bis.3 Diagramme de séquence — Découverte OIDC et rotation JWKS
sequenceDiagram
participant NestJS as NestJS Bootstrap
participant OidcDiscoverySvc as OidcDiscoveryService
participant HealthIndicator as AuthHealthIndicator
participant Keycloak
Note over NestJS,Keycloak: Flux N1 — Startup + Flux N5 — Rotation JWKS
rect rgb(230, 245, 255)
Note over NestJS,Keycloak: Phase startup
NestJS->>+OidcDiscoverySvc: onModuleInit()
OidcDiscoverySvc->>Keycloak: GET /.well-known/openid-configuration
Keycloak-->>OidcDiscoverySvc: discovery document
OidcDiscoverySvc->>Keycloak: GET /protocol/openid-connect/certs
Keycloak-->>OidcDiscoverySvc: JWKS (clés publiques)
OidcDiscoverySvc->>OidcDiscoverySvc: Cache JWKS (jwksAvailable = true)
OidcDiscoverySvc-->>-NestJS: READY
end
rect rgb(255, 245, 230)
Note over OidcDiscoverySvc,Keycloak: Phase rotation périodique
loop Toutes les N secondes (TTL)
OidcDiscoverySvc->>Keycloak: GET /certs (refresh)
alt Keycloak disponible
Keycloak-->>OidcDiscoverySvc: new JWKS
OidcDiscoverySvc->>OidcDiscoverySvc: Update cache, invalidate old kids
else Keycloak indisponible (INV-08)
Keycloak-->>OidcDiscoverySvc: Timeout / 5xx
OidcDiscoverySvc->>OidcDiscoverySvc: Retry backoff (3 tentatives, max 30s)
OidcDiscoverySvc->>OidcDiscoverySvc: jwksAvailable = false
OidcDiscoverySvc->>HealthIndicator: signal dégradé
HealthIndicator->>HealthIndicator: status: down, oidc: unavailable
Note over HealthIndicator: GET /health → 503<br/>metric auth_oidc_jwks_available = 0
end
end
end
3. Mapping invariants → mécanismes
| Invariant ID | Exigence | Mécanisme | Composant | Observable | Risque |
| INV-01 | Config explicite | Variables d'env typées + validation au démarrage | oidc.config.ts | Échec startup si config invalide | Config incomplète → service non démarré |
| INV-02 | Validation JWT complète | Pipeline de validation séquentiel | JwtValidationService | Logs validation, code 401 | Faille si étape manquée |
| INV-03 | Rejet token invalide | Throw sur chaque échec de validation | JwtValidationService | Code 401 systématique | N/A |
| INV-04 | Non-fuite logs | Sanitizer + liste blanche fermée (voir §2.6) | AuthAuditInterceptor | Logs conformes à whitelist Spec §14 | Fuite si champ oublié ou pattern non détecté |
| INV-05 | Attributabilité | Extraction sub/tenant dans context | JwtAuthGuard | Logs avec sub/tenant | Audit incomplet |
| INV-06 | Déterminisme authZ | Règles déclaratives sur routes | RolesGuard, ScopesGuard | Accès/refus reproductible | Ambiguïté règles |
| INV-07 | Isolation realm/tenant | Validation issuer + claim tenant (voir §2.5) | JwtValidationService | Rejet cross-tenant (log issuer_mismatch, tenant_mismatch) | Fuite données |
| INV-08 | Fail-closed JWKS | Flag jwksAvailable + health indicator + rejet 503 (voir §2.4) | OidcDiscoveryService, AuthHealthIndicator, JwtAuthGuard | Health DEGRADED, HTTP 503, metric auth_oidc_jwks_available=0 | Indisponibilité totale |
| INV-09 | Claims requis | Validation JWT_REQUIRED_CLAIMS (voir §2.7.1, §2.7.3) | JwtValidationService.validateRequiredClaims() | HTTP 401, { error: "claim_missing", claim } | Token incomplet accepté |
| INV-10 | Modèle HYBRID | Structure AuthzClaim + validation roles/scopes non vides (voir §2.7.2, §2.7.3) | JwtValidationService, AuthorizationGuard | HTTP 401 authz_empty si absence simultanée | Mauvaise logique |
| INV-11 | Décision déterministe | @Roles, @Scopes, @AuthorizationRule(AND/OR) + AuthorizationGuard (voir §2.7.4, §2.7.5) | AuthorizationGuard.canActivate() | HTTP 403 access_denied si non-match | Config erreur si règle manquante |
Note PD-235 : Les invariants INV-02 (validation dates), INV-05/09 (claims requis) et INV-08 (fail-closed) couvrent également les cas délégués par PD-235 (infra IdP) — voir §5bis pour le mapping détaillé des tests TC-PD235-*.
4. Mapping critères d'acceptation → mécanismes
| Critère ID | Mécanisme(s) | Composant | Observable | Risque |
| CA-01 | Guard rejette si header absent | JwtAuthGuard | HTTP 401 | N/A |
| CA-02 | Vérification signature JWKS | JwtValidationService.verifySignature() | HTTP 401 | Clé expirée non gérée |
| CA-03 | Comparaison iss vs OIDC_ISSUER | JwtValidationService.validateIssuer() | HTTP 401 | N/A |
| CA-04 | Intersection aud ∩ ALLOWED_AUDIENCES | JwtValidationService.validateAudience() | HTTP 401 | Liste vide = fail |
| CA-05 | Comparaison exp/nbf/iat vs now ± JWT_CLOCK_SKEW_MAX (120s, Spec §16) | JwtValidationService.validateDates() | HTTP 401 token_expired ou token_not_yet_valid | Clock drift > 120s |
| CA-06 | Vérification alg ∈ ALLOWED, alg ∉ FORBIDDEN | JwtValidationService.validateAlgorithm() | HTTP 401 | Alg none |
| CA-07 | Vérification présence claims requis (voir §2.7) | JwtValidationService.validateRequiredClaims() + DTO JwtPayloadDto | HTTP 401, { error: "claim_missing" } ou { error: "authz_empty" } | Claim null/vide accepté |
| CA-08 | Évaluation roles/scopes selon règle route HYBRID (voir §2.7) | AuthorizationGuard.canActivate() + décorateurs @Roles, @Scopes, @AuthorizationRule | HTTP 200/403 selon match règle AND/OR | Règle non déclarée = erreur config |
| CA-09 | Validation tenant vs env attendu (voir §2.5) | JwtValidationService.validateIssuer(), JwtValidationService.validateTenant() | HTTP 401, log issuer_mismatch, tenant_mismatch | Mapping incomplet |
| CA-10 | Intercepteur sanitize logs (voir §2.6) | AuthAuditInterceptor.sanitize(), whitelist fermée | Logs conformes à Spec §14, pas de token/secret/claim non listé | Champ non filtré, pattern JWT non détecté |
| CA-11 | Health indicator + rejection + metric (voir §2.4) | AuthHealthIndicator, JwtAuthGuard, OidcDiscoveryService | HTTP 503, health oidc: down, metric auth_oidc_jwks_available=0 | Timeout JWKS |
5. Mapping tests (TC-*) → mécanismes + observables
| Test ID | Référence spec | Mécanisme(s) | Point(s) d'observation | Niveau de test visé |
| TC-NOM-01 | INV-01, CA-01 | JwtAuthGuard.canActivate() | HTTP 401, body { error: 'Unauthorized' } | Unit + Integration |
| TC-NOM-02 | INV-02, CA-02 | JwtValidationService.verifySignature() | HTTP 401, log "signature_invalid" | Unit + Integration |
| TC-NOM-03 | INV-02, CA-03 | JwtValidationService.validateIssuer() | HTTP 401, log "issuer_mismatch" | Unit |
| TC-NOM-04 | INV-02, CA-04 | JwtValidationService.validateAudience() | HTTP 401, log "audience_invalid" | Unit |
| TC-NOM-05 | INV-02, CA-05 | JwtValidationService.validateDates() avec JWT_CLOCK_SKEW_MAX = 120s (Spec §16) | HTTP 401, log token_expired (exp) ou token_not_yet_valid (nbf/iat) | Unit |
| TC-NOM-06 | INV-02, CA-06 | JwtValidationService.validateAlgorithm() | HTTP 401, log "algorithm_forbidden" | Unit |
| TC-NOM-07 | INV-05/09, CA-07 | JwtValidationService.validateRequiredClaims() (voir §2.7.6) | HTTP 401, { error: "claim_missing", claim } ou { error: "authz_empty" } | Unit |
| TC-NOM-08 | INV-10/11, CA-08 | AuthorizationGuard.canActivate() (voir §2.7.6) | HTTP 200, log "access_granted" — scénarios : rôle seul, scope seul, AND, OR | Unit + Integration |
| TC-NOM-09 | INV-07, CA-09 | JwtValidationService.validateIssuer(), JwtValidationService.validateTenant() (voir §2.5) | HTTP 401, log issuer_mismatch ou tenant_mismatch selon scénario | Integration |
| TC-NOM-10 | INV-04, CA-10 | AuthAuditInterceptor.sanitize() (voir §2.6) | Logs conformes whitelist : { requestId, sub, tenant, issuer, audience, clientId, error, route, ts } uniquement | Integration + Security |
| TC-NOM-11 | INV-08, CA-11 | OidcDiscoveryService.jwksAvailable, AuthHealthIndicator.check(), JwtAuthGuard (voir §2.4) | HTTP 503, health oidc: down, metric auth_oidc_jwks_available=0 | Integration + E2E |
| TC-NOM-12 | INV-10/11, CA-08 | AuthorizationGuard.canActivate() (voir §2.7.6) | HTTP 403, { error: "access_denied" } — scénarios : rôle manquant, scope manquant, AND partiel, OR aucun match | Unit + Integration |
| TC-ERR-01 | E10 | Validation config startup | Health NOT_READY, exit 1 | E2E |
| TC-NEG-01 | INV-02 | JwtValidationService.verifySignature() | HTTP 401 | Security |
| TC-NEG-02 | INV-02 | OidcDiscoveryService kid lookup | HTTP 401 | Security |
| TC-NEG-03 | INV-07 | JwtValidationService.validateIssuer() | HTTP 401 | Security |
| TC-NEG-04 | INV-04, CA-10 | AuthAuditInterceptor.sanitize() + FORBIDDEN_PATTERNS (voir §2.6) | Injection token/header neutralisée, claims non listés filtrés | Security |
5bis. Mapping tests PD-235 délégués → mécanismes backend
Les tests suivants sont délégués par PD-235 (infra IdP) au backend PD-26 (consumer). Ils assurent la traçabilité de la dépendance et la clôture des cas d'erreur côté consumer.
| Test PD-26 | Alias PD-235 | Référence spec | Mécanisme(s) backend | Point(s) d'observation | Niveau |
| TC-PD235-ERR-01 | PD-235 TC-ERR-01 | INV-08, CA-11, Spec §4.1 | OidcDiscoveryService.jwksAvailable, JwtAuthGuard, AuthHealthIndicator (voir §2.4) | HTTP 503 (fail-closed), health oidc: down, metric auth_oidc_jwks_available=0 — distinct de HTTP 401 token invalide | E2E |
| TC-PD235-ERR-03 | PD-235 TC-ERR-03 | INV-08, CA-11, Spec §4.1 | AuthHealthIndicator.check(), OidcDiscoveryService avec retry backoff | Endpoint /health expose { oidc: { status: 'down', message: 'JWKS unavailable' } } — défaillance persistante observable | E2E + Chaos |
| TC-NOM-05 | PD-235 TC-NEG-02 | INV-02, CA-05, Spec §4.1 | JwtValidationService.validateDates() avec JWT_CLOCK_SKEW_MAX = 120s | HTTP 401, log token_expired — rejet déterministe exp < now - skew | Unit |
| TC-PD235-NEG-04 | PD-235 TC-NEG-04 | INV-05, INV-09, CA-07, Spec §4.1 | JwtValidationService.validateRequiredClaims() (voir §2.7.3) | HTTP 401, { error: "claim_missing", claim: "sub" } ou tenant — token sans identité valide | Unit + Security |
Traçabilité de la délégation :
| Cas PD-235 | Responsabilité | Implémentation PD-26 |
| TC-ERR-01 (IdP indisponible) | Backend détecte indisponibilité JWKS/Discovery | OidcDiscoveryService retry + flag jwksAvailable |
| TC-ERR-03 (Signal explicite indispo) | Backend expose état dégradé distinct | AuthHealthIndicator + metric Prometheus |
| TC-NEG-02 (Token expiré) | Backend applique validation temporelle | JwtValidationService.validateDates() + clock skew |
| TC-NEG-04 (Token sans identité) | Backend rejette claims requis absents | JwtValidationService.validateRequiredClaims() |
6. Gestion des erreurs
| Code | Cas | Réponse HTTP | Body | Log (liste blanche) |
| E1 | Token absent | 401 | { "error": "Unauthorized", "message": "Missing authentication" } | { requestId, route, error: "token_missing", ts } |
| E2 | Token malformé | 401 | { "error": "Unauthorized", "message": "Invalid token format" } | { requestId, route, error: "token_malformed", ts } |
| E3 | Signature invalide | 401 | { "error": "Unauthorized", "message": "Invalid signature" } | { requestId, route, error: "signature_invalid", ts } |
| E4 | Issuer incorrect | 401 | { "error": "Unauthorized", "message": "Invalid issuer" } | { requestId, route, error: "issuer_mismatch", iss, ts } |
| E5 | Audience invalide | 401 | { "error": "Unauthorized", "message": "Invalid audience" } | { requestId, route, error: "audience_invalid", aud, ts } |
| E6 | Token expiré | 401 | { "error": "Unauthorized", "message": "Token expired" } | { requestId, route, error: "token_expired", ts } |
| E7 | Algo interdit | 401 | { "error": "Unauthorized", "message": "Invalid algorithm" } | { requestId, route, error: "algorithm_forbidden", alg, ts } |
| E8 | Claims manquants | 401 | { "error": "Unauthorized", "message": "Missing required claims" } | { requestId, route, error: "claim_missing", claim, ts } |
| E9 | Droits insuffisants | 403 | { "error": "Forbidden", "message": "Insufficient permissions" } | { requestId, sub, tenant, route, error: "access_denied", ts } |
| E10 | Config invalide | 503 | N/A (service non démarré) | Startup error log |
| JWKS indispo | Fail-closed | 503 | { "error": "Service Unavailable", "message": "Authentication service degraded" } | { requestId, route, error: "jwks_unavailable", ts } |
7. Impacts sécurité
7.1 Risques identifiés
| Risque | Probabilité | Impact | Mitigation |
| Alg=none | Haute (attaque connue) | Critique | Liste fermée JWT_ALLOWED_ALGORITHMS, rejection explicite none |
| Token forgé | Moyenne | Critique | Vérification signature JWKS, kid obligatoire |
| Token replay | Moyenne | Majeur | exp/nbf/iat validation, clock skew limité |
| Cross-tenant | Faible | Critique | Validation issuer + claim tenant |
| Timing attack | Faible | Mineur | Comparaisons constant-time sur secrets |
| Log injection | Moyenne | Majeur | Sanitizer, liste blanche stricte |
| JWKS spoofing | Faible | Critique | HTTPS only, validation issuer discovery |
7.2 Journalisation sécurisée
Champs autorisés (liste blanche fermée) : - requestId - sub - tenant - issuer - audience - clientId - error (code uniquement) - route - ts (horodatage)
Champs interdits : - Token complet (access/refresh/id) - Header Authorization - Secrets/signatures - Claims sensibles non listés - Payload JWT en clair
8. Hypothèses techniques
| ID | Hypothèse | Impact si faux |
| H-T01 | Keycloak accessible en HTTPS | Échec discovery, fail-closed |
| H-T02 | JWKS contient kid dans header JWT | Signature non vérifiable |
| H-T03 | Clock sync ≤ JWT_CLOCK_SKEW_MAX (120s, Spec §16) entre backend et Keycloak | Tokens valides rejetés si drift > 120s |
| H-T04 | Claims authz.roles et authz.scopes sont des arrays | Parsing échoue |
| H-T05 | Tenant claim présent dans tous les tokens | Rejet systématique |
| H-T06 | RS256 supporté par Keycloak | Validation impossible |
| H-T07 | Variables d'env disponibles au démarrage | Service non prêt |
9. Points de vigilance (risques, dette, pièges)
9.1 Risques techniques
| Point | Risque | Surveillance |
| Cache JWKS | Clés expirées non invalidées | TTL + refresh périodique |
| Clock skew | Tokens valides rejetés si drift > JWT_CLOCK_SKEW_MAX (120s) | Monitoring NTP drift, alerting si > 60s |
| Règles AND/OR | Ambiguïté combinaison | Tests exhaustifs |
| Health check | Faux positifs/négatifs | Tests chaos engineering |
9.2 Dette technique acceptée
| Dette | Raison | Plan de remédiation |
| Pas de cache distribué JWKS | Complexité | V2 si scaling horizontal |
| Pas de révocation token | Hors périmètre | PD-XX dédié |
| Pas de refresh token backend | client_credentials uniquement | N/A |
9.3 Pièges d'implémentation
- Alg=none : TOUJOURS vérifier que l'algorithme est dans la liste autorisée AVANT la vérification de signature
- Audience array :
aud peut être string ou array selon le token - Clock skew : Appliquer
JWT_CLOCK_SKEW_MAX = 120s (Spec §16) dans les DEUX sens : exp > now - 120s ET nbf < now + 120s ET iat ≤ now + 120s - Claims nested :
authz.roles est un path, pas un claim de premier niveau - Logging : Ne JAMAIS logger
request.headers.authorization
10. Hors périmètre
- Fédération SSO (Google, Microsoft, FranceConnect, SAML)
- MFA (TOTP, WebAuthn, SMS)
- Gestion cycle de vie identités (KYC, eIDAS)
- Politiques RGPD détaillées
- Rotation secrets applicatifs
- Cache distribué JWKS
- Révocation de tokens
- Refresh tokens côté backend
Références
- Epic : PD-182 — AUTH
- JIRA : PD-26
- Repos : backend / infra
- Spécification : PD-26-specification.md
- Tests : PD-26-tests.md
- Dépendance : PD-235 (IdP Keycloak infra) — tests délégués au backend : TC-ERR-01/03, TC-NEG-02/04