Aller au contenu

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 === falseJwtAuthGuard 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_ISSUERpv-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