Aller au contenu

PD-28 — Plan d'implémentation

1. Objectif

Ce document décrit le plan d'implémentation technique pour la gestion des sessions d'authentification backend (validation, maintien, révocation, traçabilité) conformément à la spécification PD-28.


2. Choix techniques retenus

Décision Justification
Extension du guard OidcJwtAuthGuard existant (PD-26) Réutilisation du mécanisme de validation JWT, ajout de la vérification de révocation
Store de révocation Redis/mémoire Temps de réponse compatible avec les exigences de latence ; TTL automatique
Intégration via @nestjs/event-emitter Découplage entre émetteurs d'événements PD-27 et consommateurs PD-28
Extension de AuthAuditInterceptor Ajout des champs justification_code et pd27_event_ref à la taxonomie existante
Guard global appliqué par défaut Homogénéité de validation (§2.4) sur tous les endpoints protégés

3. Architecture ciblée

3.1 Composants impactés

┌─────────────────────────────────────────────────────────────────────┐
│                           HTTP Request                               │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                    SessionValidationGuard                            │
│  (extends OidcJwtAuthGuard)                                         │
│  - Validation JWT (V1: existence, V2: validité temporelle,          │
│    V3: intégrité)                                                   │
│  - Vérification révocation (V4: continuité)                         │
│  - Décision déterministe (V5)                                       │
└─────────────────────────────────────────────────────────────────────┘
                    ┌───────────────┴───────────────┐
                    ▼                               ▼
        ┌───────────────────┐           ┌───────────────────┐
        │  SessionRevocation │           │  AccessDecision    │
        │  Store (Redis)     │           │  AuditService      │
        │  - isRevoked()     │           │  - logDecision()   │
        │  - revoke()        │           │  - justification   │
        └───────────────────┘           │  - pd27_event_ref  │
                    ▲                   └───────────────────┘
        ┌───────────────────┐
        │  SessionRevocation │
        │  Listener          │
        │  @OnEvent(PD-27)   │
        └───────────────────┘
        ┌───────────────────┐
        │  SecurityEvent     │
        │  Emitter (PD-27)   │
        │  - LOGOUT_GLOBAL   │
        │  - SECURITY_RESET  │
        │  - FAILED_AUTH     │
        └───────────────────┘

3.2 Nouveaux modules

Module Responsabilité
SessionModule Orchestration de la gestion des sessions
SessionRevocationStore Persistance des sessions révoquées
SessionRevocationListener Écoute des événements PD-27 et déclenche les révocations
SessionValidationGuard Validation homogène des sessions
AccessDecisionAuditService Journalisation des décisions avec taxonomie PD-28
ProtectedEndpointRegistry Inventaire des endpoints protégés pour TC-NR-03

4. Découpage technique

Phase 1 : Infrastructure de révocation

4.1.1 SessionRevocationStore

Fichier : src/modules/auth/session/stores/session-revocation.store.ts

interface RevocationEntry {
  sessionId: string;          // jti du token
  userId: string;             // sub du token
  revokedAt: Date;
  reason: RevocationReason;
  pd27EventRef: Pd27EventRef;
  expiresAt: Date;            // TTL = token exp + marge sécurité
}

enum RevocationReason {
  LOGOUT_GLOBAL = 'LOGOUT_GLOBAL',
  SECURITY_RESET = 'SECURITY_RESET',
  FAILED_AUTH_THRESHOLD = 'FAILED_AUTH_THRESHOLD',
  ADMIN_REVOKE = 'ADMIN_REVOKE',
  TOKEN_EXPIRED = 'TOKEN_EXPIRED',
}

enum Pd27EventRef {
  NONE = 'NONE',
  PD27_R6_LOGOUT_GLOBAL = 'PD27-R6-LOGOUT_GLOBAL',
  PD27_R6_SECURITY_RESET = 'PD27-R6-SECURITY_RESET',
  PD27_R6_FAILED_AUTH_REPEATED = 'PD27-R6-FAILED_AUTH_REPEATED',
}

Méthodes : - isRevoked(sessionId: string): Promise<boolean> — INV-02 - getRevocationEntry(sessionId: string): Promise<RevocationEntry | null> — R-28-06 (contexte de révocation) - revokeSession(entry: RevocationEntry): Promise<void> — R-28-03 - revokeAllUserSessions(userId: string, reason: RevocationReason, pd27EventRef: Pd27EventRef): Promise<number> — R-28-04 (portée globale) - revokeDeviceSessions(userId: string, deviceId: string, reason: RevocationReason, pd27EventRef: Pd27EventRef): Promise<number> — R-28-04 (portée contextuelle)

Observable : Compteur de révocations, état du store (health check), raison de révocation, portée (global/device)

4.1.2 SessionRevocationListener

Fichier : src/modules/auth/session/listeners/session-revocation.listener.ts

/**
 * Taxonomie de portée des événements PD-27 (R-28-04)
 */
enum EventScope {
  GLOBAL = 'GLOBAL',           // Révoque toutes les sessions de l'utilisateur
  DEVICE_SPECIFIC = 'DEVICE',  // Révoque uniquement les sessions du device concerné
}

/**
 * Mapping événement → portée (R-28-04 proportionnalité)
 */
const EVENT_SCOPE_MAP: Record<SecurityEventType, EventScope> = {
  [SecurityEventType.LOGOUT_GLOBAL]: EventScope.GLOBAL,
  [SecurityEventType.SECURITY_RESET]: EventScope.GLOBAL,
  [SecurityEventType.FAILED_AUTH_THRESHOLD]: EventScope.GLOBAL, // Conservateur par défaut
  [SecurityEventType.ADMIN_DEVICE_REVOKE]: EventScope.DEVICE_SPECIFIC,
  // Autres événements non liés à la révocation de session
};

@Injectable()
export class SessionRevocationListener {
  /**
   * R-28-04: Révocation proportionnée à la portée de l'événement
   */
  private async revokeProportionally(
    payload: SecurityEventPayload,
    reason: RevocationReason,
    pd27EventRef: Pd27EventRef,
    eventType: SecurityEventType,
  ): Promise<void> {
    const scope = EVENT_SCOPE_MAP[eventType] ?? EventScope.GLOBAL;

    if (scope === EventScope.DEVICE_SPECIFIC && payload.deviceId) {
      // Portée contextuelle : révoque uniquement les sessions du device
      await this.revocationStore.revokeDeviceSessions(
        payload.userId,
        payload.deviceId,
        reason,
        pd27EventRef,
      );
    } else {
      // Portée globale : révoque toutes les sessions de l'utilisateur
      await this.revocationStore.revokeAllUserSessions(
        payload.userId,
        reason,
        pd27EventRef,
      );
    }
  }

  @OnEvent(SecurityEventType.LOGOUT_GLOBAL)
  async handleLogoutGlobal(payload: LogoutGlobalEventPayload): Promise<void> {
    // R-28-03 + R-28-04: Révocation immédiate, portée GLOBALE
    await this.revokeProportionally(
      payload,
      RevocationReason.LOGOUT_GLOBAL,
      Pd27EventRef.PD27_R6_LOGOUT_GLOBAL,
      SecurityEventType.LOGOUT_GLOBAL,
    );
  }

  @OnEvent(SecurityEventType.SECURITY_RESET)
  async handleSecurityReset(payload: SecurityEventPayload): Promise<void> {
    // R-28-03 + R-28-04: Révocation immédiate, portée GLOBALE
    await this.revokeProportionally(
      payload,
      RevocationReason.SECURITY_RESET,
      Pd27EventRef.PD27_R6_SECURITY_RESET,
      SecurityEventType.SECURITY_RESET,
    );
  }

  @OnEvent(SecurityEventType.FAILED_AUTH_THRESHOLD)
  async handleFailedAuthThreshold(payload: SecurityEventPayload): Promise<void> {
    // R-28-03 + R-28-04: Révocation immédiate, portée GLOBALE (conservateur)
    await this.revokeProportionally(
      payload,
      RevocationReason.FAILED_AUTH_THRESHOLD,
      Pd27EventRef.PD27_R6_FAILED_AUTH_REPEATED,
      SecurityEventType.FAILED_AUTH_THRESHOLD,
    );
  }
}

Observable : Logs d'événements traités, compteur par type

Phase 2 : Guard de validation de session

4.2.1 SessionValidationGuard

Fichier : src/modules/auth/session/guards/session-validation.guard.ts

Responsabilités : - Étend OidcJwtAuthGuard pour ajouter la vérification de révocation - Applique le niveau homogène de validation (V1-V5) - Produit la décision déterministe (validation ou rejet)

@Injectable()
export class SessionValidationGuard extends OidcJwtAuthGuard {
  constructor(
    reflector: Reflector,
    jwtValidationService: OidcJwtValidationService,
    oidcDiscoveryService: OidcDiscoveryService,
    private readonly revocationStore: SessionRevocationStore,
    private readonly auditService: AccessDecisionAuditService,
  ) {
    super(reflector, jwtValidationService, oidcDiscoveryService);
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();

    // 1. Validation JWT (V1, V2, V3) - avec logging PD-28 sur erreur
    try {
      const jwtValid = await super.canActivate(context);
      if (!jwtValid) {
        // Cas rare: parent retourne false sans throw (route publique)
        return false;
      }
    } catch (error) {
      // R-28-07/R-28-08: Logger avec taxonomie PD-28 AVANT de relancer
      await this.logJwtValidationError(error, request);
      throw error; // Re-throw pour conserver le comportement HTTP
    }

    const user = request.user;

    // 2. Vérification de révocation (V4: continuité)
    const sessionId = user.jti; // JWT ID = session ID
    const revocationEntry = await this.revocationStore.getRevocationEntry(sessionId);

    if (revocationEntry) {
      // R-28-02 + R-28-06: Rejet déterministe + ré-authentification explicite exigée
      const justificationCode = this.isFromTrustBreach(revocationEntry.pd27EventRef)
        ? JustificationCode.ACCESS_REJECTED_REAUTH_REQUIRED
        : JustificationCode.ACCESS_REJECTED_REVOKED_SESSION;

      await this.auditService.logDecision({
        decision: 'REJECTED',
        justificationCode,
        pd27EventRef: revocationEntry.pd27EventRef,
        ...this.extractAuditContext(request),
      });

      // E-03: Ré-authentification explicite exigée (R-28-06)
      throw new UnauthorizedException({
        error: 'Unauthorized',
        message: 'Session revoked - re-authentication required',
        code: 'REAUTH_REQUIRED',
        reauthRequired: true,  // Signal explicite pour le client
      });
    }

    // 3. Session valide - log de validation
    await this.auditService.logDecision({
      decision: 'VALIDATED',
      justificationCode: JustificationCode.ACCESS_VALIDATED,
      pd27EventRef: Pd27EventRef.NONE,
      ...this.extractAuditContext(request),
    });

    return true;
  }

  /**
   * Détermine si la révocation provient d'une rupture de confiance PD-27
   * R-28-06: Le renouvellement est interdit après rupture de confiance
   */
  private isFromTrustBreach(pd27EventRef: Pd27EventRef): boolean {
    return pd27EventRef !== Pd27EventRef.NONE;
  }

  /**
   * R-28-07/R-28-08: Log des erreurs de validation JWT avec taxonomie PD-28
   * Mappe les erreurs JWT vers justification_code normalisés
   */
  private async logJwtValidationError(error: unknown, request: Request): Promise<void> {
    const justificationCode = this.mapJwtErrorToJustificationCode(error);

    await this.auditService.logDecision({
      decision: 'REJECTED',
      justificationCode,
      pd27EventRef: Pd27EventRef.NONE, // E-01: Pas d'événement PD-27 associé
      ...this.extractAuditContext(request),
    });
  }

  /**
   * Mappe les erreurs JWT vers les codes de justification §5.4.1
   * - TOKEN_MISSING → ACCESS_REJECTED_NO_SESSION (session absente)
   * - Autres erreurs → ACCESS_REJECTED_INVALID_SESSION (session invalide/expirée)
   */
  private mapJwtErrorToJustificationCode(error: unknown): JustificationCode {
    if (error instanceof UnauthorizedException) {
      const response = error.getResponse() as Record<string, unknown>;
      const code = response?.code;

      // Session absente (E-01: "Session absente")
      if (code === JwtValidationError.TOKEN_MISSING) {
        return JustificationCode.ACCESS_REJECTED_NO_SESSION;
      }
    }

    // Session invalide ou expirée (E-01: "expirée ou invalide")
    return JustificationCode.ACCESS_REJECTED_INVALID_SESSION;
  }

  /**
   * Extrait le contexte d'audit depuis la requête
   */
  private extractAuditContext(request: Request): Partial<AccessDecisionLog> {
    const user = request.user;
    return {
      requestId: request.requestId ?? 'unknown',
      route: request.path,
      timestamp: new Date(),
      scope: user ? {
        sessionId: user.jti ?? 'unknown',
        deviceId: user.device_id,
        userId: user.sub,
      } : {
        sessionId: 'unknown',
        userId: 'unknown',
      },
      tenant: user?.tenant ?? 'unknown',
    };
  }
}

Observable : Décision d'accès (validation/rejet), code de justification

Phase 3 : Journalisation des décisions

4.3.1 AccessDecisionAuditService

Fichier : src/modules/auth/session/services/access-decision-audit.service.ts

Taxonomie normative (§5.4.1) :

enum JustificationCode {
  ACCESS_VALIDATED = 'ACCESS_VALIDATED',
  ACCESS_REJECTED_NO_SESSION = 'ACCESS_REJECTED_NO_SESSION',
  ACCESS_REJECTED_INVALID_SESSION = 'ACCESS_REJECTED_INVALID_SESSION',
  ACCESS_REJECTED_REVOKED_SESSION = 'ACCESS_REJECTED_REVOKED_SESSION',
  ACCESS_REJECTED_REAUTH_REQUIRED = 'ACCESS_REJECTED_REAUTH_REQUIRED',
}

interface AccessDecisionLog {
  // Champs obligatoires R-28-08
  decision: 'VALIDATED' | 'REJECTED';
  timestamp: Date;
  scope: {
    sessionId: string;
    deviceId?: string;
    userId: string;
  };
  justificationCode: JustificationCode;
  pd27EventRef: Pd27EventRef;

  // Champs contextuels
  requestId: string;
  route: string;
  tenant: string;
}

Méthodes : - logDecision(entry: AccessDecisionLog): Promise<void> — R-28-07, R-28-08 - getDecisionLogs(filter: AuditFilter): Promise<AccessDecisionLog[]> — Consultation audit

Observable : Entrées de log conformes à la taxonomie


4bis. Diagrammes Mermaid

4bis.1 Graphe de dependances des composants

graph TD
    subgraph "PD-28 Session Module"
        SVG["SessionValidationGuard<br/>(§4.2.1)"]
        SRS["SessionRevocationStore<br/>(§4.1.1, Redis)"]
        SRL["SessionRevocationListener<br/>(§4.1.2)"]
        ADAS["AccessDecisionAuditService<br/>(§4.3.1)"]
        PER["ProtectedEndpointRegistry<br/>(TC-NR-03)"]
    end

    subgraph "PD-26 Auth (externe)"
        OJAG["OidcJwtAuthGuard"]
        OJVS["OidcJwtValidationService"]
        ODS["OidcDiscoveryService"]
    end

    subgraph "PD-27 MFA/Trust (externe)"
        SEE["SecurityEvent Emitter<br/>(LOGOUT_GLOBAL, SECURITY_RESET,<br/>FAILED_AUTH_THRESHOLD)"]
    end

    subgraph "Infrastructure"
        REDIS["Redis<br/>(store de revocation)"]
        KC["Keycloak<br/>(IdP, JWT)"]
    end

    SVG -->|extends| OJAG
    SVG -->|isRevoked / getRevocationEntry| SRS
    SVG -->|logDecision| ADAS
    OJAG -->|validate JWT| OJVS
    OJAG -->|discover OIDC| ODS
    ODS -->|JWKS| KC
    SRL -->|revokeAllUserSessions / revokeDeviceSessions| SRS
    SEE -->|@OnEvent| SRL
    SRS -->|GET/SET revocation entries| REDIS
    PER -->|scan @Public decorator| OJAG

4bis.2 Sequence : validation de session (requete HTTP)

sequenceDiagram
    participant C as Client HTTP
    participant SVG as SessionValidationGuard
    participant OJAG as OidcJwtAuthGuard (PD-26)
    participant SRS as SessionRevocationStore
    participant ADAS as AccessDecisionAuditService
    participant H as Handler metier

    C->>SVG: HTTP Request (Authorization: Bearer <JWT>)

    Note over SVG: Phase 1 — Validation JWT (V1-V3)
    SVG->>OJAG: super.canActivate(context)
    OJAG-->>SVG: true / throw UnauthorizedException

    alt JWT invalide (E-01)
        SVG->>ADAS: logDecision(REJECTED, ACCESS_REJECTED_NO_SESSION | ACCESS_REJECTED_INVALID_SESSION, pd27EventRef=NONE)
        SVG-->>C: 401 Unauthorized
    end

    Note over SVG: Phase 2 — Verification revocation (V4)
    SVG->>SRS: getRevocationEntry(jti)
    SRS-->>SVG: RevocationEntry | null

    alt Session revoquee (E-02 / E-03)
        SVG->>ADAS: logDecision(REJECTED, ACCESS_REJECTED_REVOKED_SESSION | ACCESS_REJECTED_REAUTH_REQUIRED, pd27EventRef)
        SVG-->>C: 401 { code: REAUTH_REQUIRED, reauthRequired: true }
    end

    Note over SVG: Phase 3 — Session valide (V5)
    SVG->>ADAS: logDecision(VALIDATED, ACCESS_VALIDATED, pd27EventRef=NONE)
    SVG->>H: canActivate = true
    H-->>C: 200 OK (reponse metier)

4bis.3 Sequence : revocation sur evenement PD-27

sequenceDiagram
    participant PD27 as SecurityEvent Emitter (PD-27)
    participant SRL as SessionRevocationListener
    participant SRS as SessionRevocationStore
    participant REDIS as Redis

    PD27->>SRL: @OnEvent(LOGOUT_GLOBAL | SECURITY_RESET | FAILED_AUTH_THRESHOLD)

    Note over SRL: Determine portee via EVENT_SCOPE_MAP (R-28-04)

    alt Portee GLOBAL
        SRL->>SRS: revokeAllUserSessions(userId, reason, pd27EventRef)
        SRS->>REDIS: SET revocation entries (TTL = token exp + marge)
    else Portee DEVICE_SPECIFIC (ex: ADMIN_DEVICE_REVOKE)
        SRL->>SRS: revokeDeviceSessions(userId, deviceId, reason, pd27EventRef)
        SRS->>REDIS: SET revocation entry pour deviceId (TTL)
    end

    Note over REDIS: Les entrees expirent automatiquement via TTL

5. Mapping Invariants → Mécanismes

Invariant Mécanisme Observable Test(s)
INV-01 SessionValidationGuard appliqué globalement via APP_GUARD Rejet 401 si pas de session TC-INV-01
INV-02 SessionRevocationStore.isRevoked() vérifié avant chaque accès Rejet systématique post-révocation TC-INV-02
INV-03 SessionRevocationListener écoute événements PD-27, révoque immédiatement Sessions révoquées sur LOGOUT_GLOBAL, SECURITY_RESET TC-INV-03
INV-04 AccessDecisionAuditService.logDecision() appelé pour chaque décision : validation, révocation (canActivate), ET erreurs JWT (logJwtValidationError) Logs avec champs R-28-08 et taxonomie §5.4.1 TC-INV-04, TC-ERR-01

6. Mapping Critères d'acceptation → Tests

Critère Mécanisme Observable Test(s)
CA-01 SessionValidationGuard.canActivate() retourne false HTTP 401 Unauthorized TC-NOM-01, TC-ERR-01
CA-02 getRevocationEntry() retourne entry → rejet + reauthRequired HTTP 401 + REAUTH_REQUIRED + reauthRequired: true TC-ERR-02
CA-03 Pas d'événement PD-27 → session maintenue ; événement PD-27 → rejet + ré-auth Accès continu ou rejet avec ré-auth explicite TC-NOM-02, TC-ERR-03
CA-04 logDecision() appelé systématiquement Log présent avec taxonomie TC-NOM-03

7. Mapping Règles → Mécanismes

Règle Mécanisme Observable
R-28-01 SessionValidationGuard global avec critères V1-V5 Validation homogène
R-28-02 Guard retourne 401 sans effet de bord Pas d'exécution métier
R-28-03 SessionRevocationListener + revokeAllUserSessions() Révocation immédiate
R-28-04 EVENT_SCOPE_MAP + revokeProportionally() : portée GLOBAL ou DEVICE_SPECIFIC selon type événement Révocation ciblée (revokeDeviceSessions) ou globale (revokeAllUserSessions)
R-28-05 Absence de révocation = session valide Accès maintenu
R-28-06 Révocation PD-27 → 401 + REAUTH_REQUIRED + reauthRequired: true Ré-auth explicite exigée (TC-ERR-03)
R-28-07 logDecision() appelé sur : validation, révocation, ET erreurs JWT (E-01) via logJwtValidationError() Log systématique avec taxonomie §5.4.1
R-28-08 Structure AccessDecisionLog avec champs obligatoires Log auditable

8. Gestion des erreurs

Cas Code erreur Réponse HTTP justification_code reauthRequired
E-01 : Session absente TOKEN_MISSING 401 ACCESS_REJECTED_NO_SESSION false
E-01 : Session expirée TOKEN_EXPIRED 401 ACCESS_REJECTED_INVALID_SESSION false
E-01 : Session invalide TOKEN_INVALID 401 ACCESS_REJECTED_INVALID_SESSION false
E-02 : Session révoquée (admin) SESSION_REVOKED 401 ACCESS_REJECTED_REVOKED_SESSION true
E-02/E-03 : Session révoquée (rupture confiance PD-27) REAUTH_REQUIRED 401 ACCESS_REJECTED_REAUTH_REQUIRED true
E-04 : Log impossible N/A Non-conformité Audit interne

Note R-28-06 : Toute révocation liée à un événement PD-27 (rupture de confiance) entraîne l'exigence explicite de ré-authentification. Le flag reauthRequired: true dans la réponse JSON signale au client que l'accès ne peut être restauré sans ré-authentification complète.

Note R-28-07/R-28-08 (E-01) : Les rejets E-01 (session absente/invalide) sont loggés via logJwtValidationError() avec : - justification_code : ACCESS_REJECTED_NO_SESSION ou ACCESS_REJECTED_INVALID_SESSION selon le type d'erreur JWT - pd27_event_ref : NONE (ces erreurs ne sont pas liées à un événement PD-27) - Tous les champs contextuels disponibles (requestId, route, timestamp)

Ceci garantit que toute décision de rejet E-01 est auditable avec la taxonomie normative §5.4.1.


9. Points de sécurité

Mesure Justification Invariant
Vérification révocation AVANT exécution métier Empêcher accès post-révocation INV-02
TTL sur entrées de révocation = token exp + marge Nettoyage automatique, pas de fuite mémoire
Store Redis avec TLS si distribué Confidentialité des données de révocation
Audit log immutable (append-only) Intégrité pour valeur probatoire INV-04
Pas de détails internes dans réponses 401 Éviter fuite d'information

10. Hypothèses explicites

ID Hypothèse Impact si faux Mitigation
H-01 Les événements PD-27 (LOGOUT_GLOBAL, SECURITY_RESET) sont correctement émis Sessions non révoquées sur rupture de confiance Tests d'intégration PD-27 ↔ PD-28
H-02 Le claim jti (JWT ID) est présent et unique par session Impossible d'identifier les sessions à révoquer Validation explicite du claim jti
H-03 Redis (ou store mémoire) disponible pour les vérifications Fail-open possible Health check + fail-closed si store indisponible
H-04 L'horloge serveur est synchronisée (NTP) Expiration incorrecte des sessions Monitoring NTP
H-05 Le mapping EVENT_SCOPE_MAP est conforme à la taxonomie PD-27 pour la portée des événements R-28-04 non respectée ; révocations sur/sous-dimensionnées Revue croisée PD-27 ↔ PD-28 ; TC-NOM-05
H-06 Les événements PD-27 incluent deviceId lorsque la portée est contextuelle Révocation globale au lieu de ciblée Validation présence deviceId pour événements DEVICE_SPECIFIC

11. Points de vigilance

11.1 Risques identifiés

Risque Impact Mitigation
Race condition : révocation pendant validation Accès accordé à session révoquée Vérification atomique, ordre : revoke avant validate
Store de révocation indisponible Bypass des révocations Fail-closed : rejeter si store KO
Volume de logs élevé Performance, stockage Rotation, sampling configurable
Latence de révocation Fenêtre d'accès post-rupture Révocation synchrone via événements

11.2 Points issus de la revue de spécification

Point de revue Traitement dans l'implémentation
Définition "endpoint protégé" (§2.3) Guard global + décorateur @Public() pour exclusions
Définition "niveau homogène" (§2.4) Critères V1-V5 implémentés dans SessionValidationGuard
R-28-04 non testable sans taxonomie PD-27 Mapping explicite SecurityEventTypePd27EventRef
"Dernière authentification valide" Timestamp iat du JWT = date d'authentification

12. Hors périmètre de cette implémentation

Élément Raison Référence
Durées exactes de validité/renouvellement Défini par politique de sécurité externe PD-28 §2.2, §10
Format interne des jetons Délégué à Keycloak (IdP) PD-28 §2.2
Mécanismes cryptographiques Couvert par PD-26 PD-28 §2.2
Architecture du store distribué Infrastructure, pas applicatif
Politique de conservation des logs Défini par politique de sécurité externe PD-28 §10

13. Plan de tests

13.1 Tests unitaires

Composant Tests
SessionRevocationStore isRevoked(), getRevocationEntry(), revoke(), revokeAll(), TTL
SessionValidationGuard Validation, rejet, révocation, reauthRequired, déterminisme
SessionRevocationListener Handlers pour chaque événement PD-27
AccessDecisionAuditService Format des logs, taxonomie
ProtectedEndpointRegistry Scan endpoints, inventaire, métriques couverture

13.2 Tests d'intégration

Scénario Test ID
Session valide → accès accordé TC-INV-01, TC-NOM-01
Session révoquée → rejet systématique TC-INV-02, TC-ERR-02
Événement PD-27 → révocation immédiate TC-INV-03
Chaque décision → log présent TC-INV-04, TC-NOM-03
Rejets E-01 → log avec taxonomie PD-28 TC-ERR-01, TC-INV-04
Rejet sans effet de bord TC-NOM-04
Portée proportionnée (global vs device) TC-NOM-05

TC-NOM-05 : Implémentation détaillée (R-28-04)

Mécanisme : EVENT_SCOPE_MAP définit la portée de chaque événement PD-27

Événement PD-27 Portée Méthode invoquée
LOGOUT_GLOBAL GLOBAL revokeAllUserSessions()
SECURITY_RESET GLOBAL revokeAllUserSessions()
FAILED_AUTH_THRESHOLD GLOBAL revokeAllUserSessions()
ADMIN_DEVICE_REVOKE DEVICE_SPECIFIC revokeDeviceSessions()

Test TC-NOM-05 :

describe('TC-NOM-05: Portée proportionnée de la révocation', () => {
  it('should revoke all sessions on LOGOUT_GLOBAL (global scope)', async () => {
    // GIVEN: 2 sessions sur 2 devices différents
    // WHEN: événement LOGOUT_GLOBAL
    // THEN: toutes les sessions sont révoquées
  });

  it('should revoke only device sessions on ADMIN_DEVICE_REVOKE (device scope)', async () => {
    // GIVEN: 2 sessions sur 2 devices différents
    // WHEN: événement ADMIN_DEVICE_REVOKE pour device1
    // THEN: seule la session device1 est révoquée, device2 reste valide
  });
});

Observable : Scope de révocation dans les logs d'audit

TC-ERR-01 / TC-INV-04 : Logging E-01 avec taxonomie PD-28

Mécanisme : logJwtValidationError() dans SessionValidationGuard

Test TC-ERR-01 :

describe('TC-ERR-01: Logging des rejets E-01 avec taxonomie PD-28', () => {
  it('should log ACCESS_REJECTED_NO_SESSION when token is missing', async () => {
    // GIVEN: Requête sans header Authorization
    // WHEN: Appel à un endpoint protégé
    // THEN: logDecision() appelé avec :
    //   - decision: 'REJECTED'
    //   - justificationCode: 'ACCESS_REJECTED_NO_SESSION'
    //   - pd27EventRef: 'NONE'
  });

  it('should log ACCESS_REJECTED_INVALID_SESSION when token is expired', async () => {
    // GIVEN: Token JWT expiré
    // WHEN: Appel à un endpoint protégé
    // THEN: logDecision() appelé avec :
    //   - decision: 'REJECTED'
    //   - justificationCode: 'ACCESS_REJECTED_INVALID_SESSION'
    //   - pd27EventRef: 'NONE'
  });

  it('should log ACCESS_REJECTED_INVALID_SESSION when token signature is invalid', async () => {
    // GIVEN: Token JWT avec signature invalide
    // WHEN: Appel à un endpoint protégé
    // THEN: logDecision() appelé avec :
    //   - decision: 'REJECTED'
    //   - justificationCode: 'ACCESS_REJECTED_INVALID_SESSION'
    //   - pd27EventRef: 'NONE'
  });
});

Test TC-INV-04 :

describe('TC-INV-04: Traçabilité complète des décisions', () => {
  it('should log every decision including E-01 rejections', async () => {
    // GIVEN: Séquence de requêtes :
    //   1. Token absent (E-01)
    //   2. Token invalide (E-01)
    //   3. Token valide puis révoqué (E-02)
    //   4. Token valide (succès)
    // WHEN: Exécution de la séquence
    // THEN: 4 entrées de log avec taxonomie §5.4.1 :
    //   1. ACCESS_REJECTED_NO_SESSION, pd27EventRef: NONE
    //   2. ACCESS_REJECTED_INVALID_SESSION, pd27EventRef: NONE
    //   3. ACCESS_REJECTED_REVOKED_SESSION, pd27EventRef: (selon événement)
    //   4. ACCESS_VALIDATED, pd27EventRef: NONE
  });
});

Observable : Tous les rejets E-01 ont un log auditable avec justification_code et pd27_event_ref

13.3 Tests de non-régression

Test Objet Mécanisme
TC-NR-01 Révocation persistante dans le temps Test de persistance store
TC-NR-02 Format des logs inchangé entre versions Snapshot des logs
TC-NR-03 Contrôle homogène inchangé lors d'ajout d'endpoints ProtectedEndpointRegistry + test de couverture

TC-NR-03 : Implémentation détaillée

Mécanisme : ProtectedEndpointRegistry

Fichier : src/modules/auth/session/services/protected-endpoint-registry.service.ts

@Injectable()
export class ProtectedEndpointRegistry implements OnModuleInit {
  private readonly protectedEndpoints: Set<string> = new Set();
  private readonly publicEndpoints: Set<string> = new Set();

  constructor(
    private readonly discoveryService: DiscoveryService,
    private readonly reflector: Reflector,
  ) {}

  onModuleInit(): void {
    // Scan tous les contrôleurs au démarrage
    const controllers = this.discoveryService.getControllers();

    for (const wrapper of controllers) {
      const instance = wrapper.instance;
      const prototype = Object.getPrototypeOf(instance);
      const methods = Object.getOwnPropertyNames(prototype);

      for (const methodName of methods) {
        const handler = prototype[methodName];
        const path = Reflect.getMetadata('path', handler);
        if (!path) continue;

        const isPublic = this.reflector.get<boolean>(IS_PUBLIC_KEY, handler);
        const fullPath = `${wrapper.metatype.name}::${methodName}`;

        if (isPublic) {
          this.publicEndpoints.add(fullPath);
        } else {
          this.protectedEndpoints.add(fullPath);
        }
      }
    }
  }

  /**
   * Retourne l'inventaire des endpoints protégés
   * Observable pour TC-NR-03
   */
  getProtectedEndpoints(): string[] {
    return Array.from(this.protectedEndpoints);
  }

  /**
   * Retourne l'inventaire des endpoints publics
   */
  getPublicEndpoints(): string[] {
    return Array.from(this.publicEndpoints);
  }

  /**
   * Vérifie qu'un endpoint est protégé
   */
  isProtected(endpoint: string): boolean {
    return this.protectedEndpoints.has(endpoint);
  }

  /**
   * Retourne les métriques de couverture
   * Observable pour TC-NR-03
   */
  getCoverageMetrics(): {
    totalEndpoints: number;
    protectedCount: number;
    publicCount: number;
    coverageRatio: number;
  } {
    const total = this.protectedEndpoints.size + this.publicEndpoints.size;
    return {
      totalEndpoints: total,
      protectedCount: this.protectedEndpoints.size,
      publicCount: this.publicEndpoints.size,
      coverageRatio: total > 0 ? this.protectedEndpoints.size / total : 1,
    };
  }
}

Test TC-NR-03 :

describe('TC-NR-03: Homogénéité du contrôle de session', () => {
  it('should have SessionValidationGuard applied to all non-public endpoints', async () => {
    const registry = app.get(ProtectedEndpointRegistry);
    const metrics = registry.getCoverageMetrics();

    // Vérifier que tous les endpoints non-publics sont protégés
    expect(metrics.protectedCount).toBeGreaterThan(0);

    // Snapshot de référence pour détecter les régressions
    const protectedList = registry.getProtectedEndpoints().sort();
    expect(protectedList).toMatchSnapshot('protected-endpoints-inventory');

    // Log pour audit
    console.log(`Coverage: ${metrics.protectedCount}/${metrics.totalEndpoints} endpoints protected`);
  });

  it('should reject access to all protected endpoints without valid session', async () => {
    const registry = app.get(ProtectedEndpointRegistry);
    const protectedEndpoints = registry.getProtectedEndpoints();

    for (const endpoint of protectedEndpoints) {
      // Vérifier que chaque endpoint protégé rejette sans session
      // (test paramétré)
    }
  });
});

Observable : - Inventaire des endpoints via getProtectedEndpoints() - Métriques de couverture via getCoverageMetrics() - Snapshot Jest pour détection de régression


Références

  • Spécification : PD-28-specification.md
  • Tests : PD-28-tests.md
  • Revue : PD-28-specification-review.md
  • Dépendances : PD-26 (JWT), PD-27 (MFA/trust events)