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 SecurityEventType → Pd27EventRef |
| "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)