Accueil / Articles

Décrypter un Angular AuthService : Gateway, Store, Facade et Ports

Diviser un AuthService d’Angular surchargé en un gateway, un stockage de signaux, une interface de stockage et une façade, avec un renouvellement de token en un seul passage, un intercepteur ciblé et des gardes testables.

Éditeur : REACTAPP.TOP17 min de lecture3866 mots

Sources: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters

Presque toutes les applications Angular disposent d’un AuthService, et dans de nombreux projets, c’est le fichier que personne ne veut modifier. Il combine des appels HTTP, la persistance de tokens, un état réactif, une logique de mise à jour et la navigation ; par conséquent, modifier l’un de ces éléments signifie devoir examiner tous les autres. Cet article explique comment restructurer ce service en petites couches présentant des dépendances unidirectionnelles, et montre deux bugs réels que les nouvelles limites ont mis en évidence : une défaillance de mise à jour qui évitait le déconnexion, ainsi qu’un intercepteur qui divulguait des tokens d’authentification à des API tierces. Vous obtiendrez ainsi un modèle que vous pourrez appliquer à vos propres fonctionnalités d’authentification, ainsi qu’une idée claire des cas où il n’est pas utile d’ajouter des fichiers supplémentaires.

Comment naît l’objet divin AuthService

Nul ne conçoit délibérément un service d’authentification composé de 300 lignes de code. Tout commence par une méthode login() qui appelle un seul point de terminaison. Ensuite, il faut stocker le jeton quelque part, d’où l’apparition d’un champ. Le jeton doit persister après un rechargement, c’est pourquoi localStorage est utilisé. Viennent ensuite les jetons de renouvellement, puis un BehaviorSubject afin que les composants puissent réagir à l’état de connexion ; enfin, on trouve un router.navigate('/login') lors de la déconnexion, car il ne semblait pas y avoir de meilleur endroit pour cela. Un an et demi plus tard, la classe importe Router, HttpClient et localStorage, et pour changer la stratégie de stockage des jetons, il faut comprendre l’ensemble du fichier.

Une version condensée de ce point de départ se présente comme suit. Faites attention au nombre de problématiques non liées qui partagent la même action tap dans la fonction login() : mettre à jour un champ, écrire deux clés de stockage, envoyer des informations d’état et naviguer.

@Injectable({ providedIn: 'root' })
export class AuthService {
  private token: string | null = localStorage.getItem('token');
  public isLoggedIn$ = new BehaviorSubject(!!this.token);

  constructor(
    private http: HttpClient,
    private router: Router,
  ) {}

  login(username: string, password: string) {
    return this.http
      .post<any>('https://api.example.com/auth/login', { username, password })
      .pipe(
        tap((res) => {
          this.token = res.accessToken;
          localStorage.setItem('token', res.accessToken);
          localStorage.setItem('refresh', res.refreshToken);
          this.isLoggedIn$.next(true);
          this.router.navigate(['/dashboard']);
        }),
      );
  }
  // ...and 250 more lines
}

Rien de tout cela n’est techniquement incorrect, et c’est justement là le piège. Tout fonctionne tant que l’une des exigences ne change pas, après quoi chaque problématique s’avère être liée à toutes les autres. Le reste de cet article décrit la restructuration de la fonctionnalité d’authentification dans un petit projet Angular 22. La structure résultante n’est pas nouvelle, et chaque élément possède un nom bien connu, mais son application à du code concret présentant des bugs concrets rend l’explication plus claire qu’un diagramme.

La structure cible

La classe unique se transforme en un petit nombre de fichiers bien définis, regroupés dans un dossier core commun à plusieurs fonctionnalités et dans un dossier dédié à l’authentification.

src/app/
  core/
    api-config.ts          ← injectable base URL
    auth-interceptor.ts    ← cross-cutting: attach token, retry on 401
  auth/
    auth-models.ts         ← types
    auth-api.ts            ← HTTP transport, nothing else
    auth-store.ts          ← state, and only state
    auth-storage.ts        ← storage port + adapters
    auth-service.ts        ← orchestration (the facade)
    auth-guard.ts          ← route-level policy
    login/
  dashboard/

Sept petits fichiers au lieu d’un seul grand n’est une bonne solution que si chaque fichier est plus facile à comprendre individuellement que l’ensemble original. Un test pratique consiste à voir si vous pouvez décrire la fonction de chaque fichier en une seule phrase. Ici, c’est possible, et le schéma des dépendances est suffisamment court pour être dessiné en quelques lignes :

auth-interceptor  →  auth-service  →  auth-api    (talks to the network)
   (cross-cutting)      (facade)    →  auth-store  (holds the truth)
                                    →  auth-models (describes the shapes)

Chaque flèche pointe vers la droite. Cette règle, selon laquelle les dépendances s’écoulent dans une seule direction sans cycles, est ce qui permet au design de tenir ensemble. Chaque section ci-dessous en est une conséquence.

Le point d’entrée : les connaissances réseau en un seul endroit

La couche la plus basse encapsule les points de terminaison HTTP et ne fait rien d’autre. Chaque méthode mappe ses arguments sur une requête typée et renvoie l’objet observable résultant.

@Service()
export class AuthApi {
  private readonly httpClient = inject(HttpClient);
  private readonly baseUrl = inject(API_BASE_URL);

  public login(request: LoginInput) {
    return this.httpClient.post<AuthResponse>(`${this.baseUrl}/auth/login`, request);
  }

  public refresh(refreshToken: string, expiresInMins: number) {
    return this.httpClient.post<RefreshResponse>(`${this.baseUrl}/auth/refresh`, {
      refreshToken,
      expiresInMins,
    });
  }

  public me() {
    return this.httpClient.get<User>(`${this.baseUrl}/auth/me`);
  }
}

C’est le pattern Gateway tiré de Patterns of Enterprise Application Architecture de Martin Fowler. On l’appelle souvent repository, mais ce terme implique une abstraction ressemblant à un ensemble que l’on peut interroger. Un enveloppe typée autour d’appels distants est plus justement décrite comme un gateway.

L’avantage réside dans la mise en conteneur des connaissances relatives au protocole. Si l’endpoint de connexion passe de /auth/login à /v2/sessions, la modification se limite à cette classe ; le reste de l’application ne traite jamais d’URL.

Ce qui manque est tout aussi important que ce qui est présent : pas d’état, pas de localStorage, pas de Router, et aucune gestion des erreurs en dehors de ce que fournit HTTP lui-même. Chaque méthode transforme simplement ses arguments en un Observable inactif, ce qui rend la classe facile à remplacer par une version fictive lors des tests.

L’URL de base est injectée plutôt que codée en dur, à l’aide d’un jeton d’injection :

export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

Un jeton pour une seule chaîne de caractères peut sembler compliqué. Ce n’est pas le cas, et la section explicative montre pourquoi il devient l’une des lignes les plus importantes de cette fonctionnalité.

Une remarque sur le décorateur : les exemples utilisent @Service() tel qu’il est défini dans le projet. Si votre version d’Angular ne le propose pas, @Injectable({ providedIn: 'root' }) remplit la même fonction ; consultez la documentation de votre version.

Le store : un seul responsable pour l’état de session

Le store gère la session et est le seul élément autorisé à la modifier. Il conserve le jeton d’accès, le jeton de renouvellement ainsi que l’utilisateur sous forme de signaux.

@Service()
export class AuthStore {
  private readonly storage = inject(AUTH_STORAGE);

  private readonly accessTokenState = signal<string | null>(null);
  private readonly refreshTokenState = signal<string | null>(null);
  private readonly userState = signal<User | null>(null);

  readonly accessToken = this.accessTokenState.asReadonly();
  readonly refreshToken = this.refreshTokenState.asReadonly();
  readonly user = this.userState.asReadonly();

  readonly isAuthenticated = computed(() => this.accessTokenState() !== null);

  public setTokens(access: string, refresh: string): void {
    this.accessTokenState.set(access);
    this.refreshTokenState.set(refresh);
    this.persist();
  }

  public clear(): void {
    this.accessTokenState.set(null);
    this.refreshTokenState.set(null);
    this.userState.set(null);
    this.storage.removeItem(STORAGE_KEY);
  }
  // ...
}

Le détail le plus important est facile à manquer : chaque signal écrivable est privé, et tout signal public est exposé via asReadonly().

C’est là que la séparation commande-question est imposée par le système de types. Tout le monde peut lire ; l’écriture ne s’effectue que par des méthodes nommées en fonction d’une intention, comme setTokens et clear. Un composant ne peut pas définir directement un jeton. Ce n’est pas une convention à respecter, c’est simplement impossible, car le type Signal<T> en lecture seule ne possède pas de méthode set().

Ces avantages se manifestent lors du débogage. La question « Qui a modifié cet état ? » n’est plus une enquête, car seules quelques méthodes nommées dans un même fichier peuvent modifier la session. Comparez cela à un BehaviorSubject public, où n’importe lequel des dizaines de composants pourrait avoir appelé next().

C’est également le seul élément qui interagit avec la persistance, et il traite tout ce qu’il lit comme une entrée non fiable.

private restore(): void {
  const raw = this.storage.getItem(STORAGE_KEY);
  if (!raw) return;

  // Storage is user-writable, so treat anything in it as untrusted input.
  try {
    const session = JSON.parse(raw) as Partial<PersistedSession>;
    if (typeof session.accessToken !== 'string' || typeof session.refreshToken !== 'string') {
      this.storage.removeItem(STORAGE_KEY);
      return;
    }
    this.accessTokenState.set(session.accessToken);
    this.refreshTokenState.set(session.refreshToken);
    this.userState.set(session.user ?? null);
  } catch {
    this.storage.removeItem(STORAGE_KEY);
  }
}

localStorage est en fait un champ de texte que l’utilisateur peut modifier, et des extensions de navigateur ou des versions anciennes de votre propre application peuvent y laisser des données inattendues. Appeler JSON.parse sans utilisation de try/catch peut provoquer une panne au démarrage de l’application, et le mode strict de TypeScript ne vous avertira pas, car JSON.parse renvoie une valeur de type any. Vérifier la structure des données et supprimer l’entrée si elle ne correspond pas permet d’éviter que des sessions corrompues ne déconnectent l’utilisateur au lieu de le laisser face à un écran vide.

Le port de stockage : rendre la persistance interchangeable

La dépendance AUTH_STORAGE dans le store n’est pas localStorage lui-même. Il s’agit d’une interface, dotée d’un token d’injection dont la factory par défaut choisit une implémentation.

export interface AuthStorage {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}

export const AUTH_STORAGE = new InjectionToken<AuthStorage>('AUTH_STORAGE', {
  providedIn: 'root',
  factory: () => (typeof localStorage === 'undefined' ? new MemoryStorage() : localStorage),
});

C’est là l’idée des Ports et Adaptateurs (architecture hexagonale) réduite à ses éléments essentiels. L’interface AuthStorage définit le port. Le stockage réel du navigateur s’y connecte en tant qu’un adaptateur, tout comme la classe MemoryStorage en mémoire, composée de quelques lignes de code, qui s’y connecte en tant qu’un autre adaptateur.

Celui-ci résout trois problèmes concrets :

  1. Les tests utilisent un store réel. En injectant MemoryStorage, on peut tester le véritable comportement de persistance, y compris l’écriture, la rechargement et le restauration, sans avoir à gérer un état global qui devrait être nettoyé entre les différents cas de test.
  • Le rendu côté serveur évite les plantages. localStorage n’existe pas sur le serveur, et la solution de rechange utilise automatiquement le stockage en mémoire.
  • La décision de sécurité devient auditable. Conserver un jeton de mise à jour dans localStorage signifie que tout script exécuté sur la page peut le lire, ce qui constitue une véritable exposition XSS. La solution fiable consiste à utiliser un cookie httpOnly défini par le backend. Avec le stockage protégé derrière un port, cette migration ne concerne qu’un adaptateur au lieu de nécessiter une recherche et un remplacement dans l’ensemble du code, et jusqu’à ce que cela soit fait, le risque reste contenu dans un fichier accompagné d’un commentaire l’expliquant.
  • Le troisième point est plus général. Une bonne architecture ne transforme pas une mauvaise décision en une bonne ; elle rend la décision locale, visible et facile à annuler.

    La façade : l’orchestration sans responsabilité

    AuthService persiste, mais avec des responsabilités beaucoup plus limitées. Il coordonne le gateway et le store sans en posséder aucun lui-même.

    @Service()
    export class AuthService {
      private readonly authApi = inject(AuthApi);
      private readonly store = inject(AuthStore);
    
      readonly accessToken = this.store.accessToken;
      readonly isAuthenticated = this.store.isAuthenticated;
      readonly user = this.store.user;
    
      public login(username: string, password: string): Observable<AuthResponse> {
        return this.authApi.login({ username, password, expiresInMins: TOKEN_LIFETIME_MINUTES }).pipe(
          tap((response) => {
            this.store.setTokens(response.accessToken, response.refreshToken);
            this.store.setUser({ /* ...map response to User... */ });
          }),
        );
      }
    
      public logout(): void {
        this.store.clear();
      }
    }
    

    Il s’agit de la façade Gang of Four, qui agit également en tant que médiateur : c’est le seul niveau qui sait à la fois que le gateway et le store existent.

    Réexporter les signaux du store depuis la façade relève d’une action simple ayant un impact majeur : les composants ne dépendent que de AuthService et ignorent l’existence d’un AuthStore distinct. Cela fait du store un détail d’implémentation de la fonctionnalité, ce qui permet de le remplacer par NgRx ultérieurement sans que aucun composant n’ait à être modifié.

    Remarquez que Router est toujours absent. La façade ne gère pas la navigation, car celle-ci relève de la couche présentation. La page de connexion souhaite se rendre vers une returnUrl, le bouton de déconnexion du tableau de bord veut aller vers /login, et une boîte de dialogue indiquant l’expiration de la session pourrait ne pas effectuer de navigation du tout. Appeler router.navigate() à l’intérieur de logout() imposerait une politique identique à tous les appels. Au lieu de cela, c’est le composant qui effectue l’appel qui décide :

    protected logout(): void {
      this.auth.logout();
      void this.router.navigateByUrl('/login');
    }
    

    Le coût se limite à deux lignes dans le composant. En contrepartie, AuthService peut être appelé depuis des intercepteurs, des gardes et des tests sans déclencher de redirection non sollicitée.

    Fusionner les mises à jour concurrentes en une seule requête

    Le meilleur argument en faveur d’une façade est un problème difficile à résoudre ailleurs. Un tableau de bord envoie huit requêtes en parallèle. Le token d’accès a expiré il y a une demi-minute, si bien que les huit requêtes retournent presque simultanément un code 401. Un intercepteur naïf répond en lançant huit appels de renouvellement. De nombreux backends rotent le token de renouvellement à chaque utilisation, de sorte que lorsque les appels ultérieurs arrivent, le token qu’ils contiennent a déjà été invalidé, et l’utilisateur est déconnecté de la page qu’il avait ouverte quelques secondes auparavant.

    La solution consiste à utiliser la méthode single-flight, également appelée regroupement des requêtes ou prévention du phénomène de troupeau tonitruant : le premier appelant lance le renouvellement, et tous les autres se joignent à la requête déjà en cours. La même idée dans le cadre d’Axios est abordée dans pourquoi les 401 simultanés déconnectent les utilisateurs.

    public refresh(): Observable<RefreshResponse> {
      const refreshToken = this.store.refreshToken();
    
      if (!refreshToken) {
        return throwError(() => new Error('No refresh token available'));
      }
    
      this.refreshRequest$ ??= this.authApi.refresh(refreshToken, TOKEN_LIFETIME_MINUTES).pipe(
        tap((response) => this.store.setTokens(response.accessToken, response.refreshToken)),
        finalize(() => (this.refreshRequest$ = undefined)),
        shareReplay({ bufferSize: 1, refCount: true }),
      );
    
      return this.refreshRequest$;
    }
    

    Ces trois opérateurs doivent apparaître dans cet ordre, pour trois raisons distinctes :

    • tap stocke les nouveaux tokens. Comme il se trouve avant shareReplay, il s’exécute une fois par exécution partagée plutôt qu’une fois par abonné. S’il était placé après, il réécrirait le stock pour chacun des huit appelsants.
    • finalize réinitialise le champ en mémoire une fois que la requête partagée est terminée ou qu’une erreur survient. Sans lui, l’expiration ultérieure d’un token entraînerait la réutilisation de la réponse ancienne déjà utilisée, au lieu de déclencher un nouvel appel.
    • shareReplay({ bufferSize: 1, refCount: true }) diffuse un seul appel HTTP à tous les abonnés et le termine une fois que le dernier abonné a quitté.

    L’attribution ??= est ce qui en fait un mécanisme à exécution unique : seul l’appelant qui constate que le champ est vide construit une requête, et tous ceux qui arrivent pendant que celle-ci est en attente reçoivent le même objet observable. Le résultat est une seule appel réseau et un seul renouvellement de token.

    Une exception synchrone qui a omis le déconnexion

    Le throwError dans la méthode de mise à jour n’est pas une simple question de style. La version précédente de la clause de protection ressemblait à ceci :

    if (!refreshToken) throw new Error('No refresh token available');
    

    Cela se lit naturellement, mais c’est incorrect. C’est la frontière entre les couches qui a rendu le bug visible. L’intercepteur consomme refresh() de la manière suivante :

    return auth.refresh().pipe(
      switchMap(/* retry the original request */),
      catchError((refreshError) => {
        auth.logout();
        return throwError(() => refreshError);
      }),
    );
    

    Un throw synchrone à l’intérieur de refresh() est déclenché tandis que l’expression auth.refresh() est encore évaluée, avant même que .pipe() ne soit appelé. L’opérateur catchError n’a jamais l’occasion de voir l’erreur, ce qui fait que auth.logout() ne s’exécute jamais. La situation pour laquelle la clause de protection a été écrite, à savoir l’absence de token de mise à jour, était précisément celle où l’utilisateur restait en état de demi-authentification sans qu’aucun message ne soit affiché.

    En retournant throwError(() => ...), on transforme l’échec en une notification d’erreur dans le flux, ce qui permet à la branche de déconnexion de l’intercepteur de fonctionner comme prévu. En règle générale, une méthode dont la signature retourne un Observable doit signaler les échecs via cet objet observable, et non en lançant des exceptions.

    La leçon architecturale est que ce bug se cache facilement dans une classe de 300 lignes où le traitement HTTP, de l’état et des erreurs sont entremêlés, car rien ne force quiconque à définir ce que telle méthode promet à ses appelsants. Lorsque refresh() devient une méthode de façade publique ayant une seule fonction claire, la question « renvoie-t-elle un flux défaillant ou lance-t-elle une exception ? » devient évidente, tout comme le test correspondant :

    it('emits an error stream instead of throwing when there is no refresh token', () => {
      const onError = vi.fn();
    
      // A synchronous throw here would bypass the interceptor's catchError/logout branch.
      expect(() => service.refresh().subscribe({ error: onError })).not.toThrow();
      expect(onError).toHaveBeenCalledOnce();
    });
    

    Les couches ne préviennent pas les bugs. Elles leur offrent un endroit où ils deviennent visibles, et c’est là toute leur valeur.

    L’intercepteur : règles transversales et jeton ciblé

    L’intercepteur applique les règles d’authentification à chaque requête sortante : il ajoute le jeton porteur, et en cas de réponse 401, il met à jour ce dernier et tente à nouveau une fois.

    const UNAUTHENTICATED_PATHS = ['/auth/login', '/auth/refresh'];
    
    export const authInterceptor: HttpInterceptorFn = (req, next) => {
      const baseUrl = inject(API_BASE_URL);
    
      if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
        return next(req);
      }
    
      const auth = inject(AuthService);
      const accessToken = auth.accessToken();
      const authReq = accessToken ? withBearer(req, accessToken) : req;
    
      return next(authReq).pipe(
        catchError((error: HttpErrorResponse) => {
          if (error.status !== 401) {
            return throwError(() => error);
          }
    
          return auth.refresh().pipe(
            switchMap(() => {
              const newToken = auth.accessToken();
              if (!newToken) {
                return throwError(() => new Error('Refresh succeeded without token'));
              }
              return next(withBearer(req, newToken));
            }),
            catchError((refreshError) => {
              auth.logout();
              return throwError(() => refreshError);
            }),
          );
        }),
      );
    };
    

    La chaîne d’intercepteurs d’Angular constitue une implémentation directe du principe de la chaîne de responsabilités, chaque fonction d’intercepteur décorant la requête au fur et à mesure de son passage. Plus important encore, la politique qu’elle applique (ajouter des identifiants et considérer un code 401 comme un signal pour renouveler la requête) s’applique à l’ensemble de l’API, ce qui en fait un exemple typique d’une préoccupation transversale. Elle ne doit pas être intégrée dans un appelant spécifique. Sans intercepteurs, chaque méthode de service qui accède à une extrémité authentifiée devrait répéter systématiquement les étapes de renouvellement du token et de tentative. Dès que la logique de réessai contient une petite erreur dans l’une des trente copies, cela provoque un bug qui apparaît une fois par semaine en production mais jamais en environnement de développement. Pour en savoir plus sur la gestion des requêtes échouées dans Angular, consultez la conception pour les requêtes échouées dans Angular.

    Examinez maintenant de près la clause de protection en haut de la fonction :

    if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
      return next(req);
    }
    

    C’est ici que l’usage de API_BASE_URL injecté se révèle utile. Elle résout deux problèmes concrets :

    1. Fuite de tokens vers d’autres origines. Un intercepteur ne vérifiant pas l’URL ajoute le token d’accès de l’utilisateur à chaque requête sortante, y compris les téléchargements vers des systèmes de stockage d’objets, les fournisseurs de tuiles cartographiques et les points de terminaison d’analyse. Cela permet à des tiers non concernés d’accéder aux identifiants, simplement parce que l’intercepteur était trop zélé.
    2. Appels de renouvellement utilisant un token expiré. Sans liste d’exclusion, la requête de renouvellement est envoyée avec le token d’accès obsolète. Un backend laxiste l’ignore ; un backend strict répond par une autre erreur 401, ce qui peut entraîner une boucle de renouvellement.

    Tantôt, il s’agit de corrections en une seule ligne dès que l’URL de base est injectable. Sans elle, l’intercepteur ne dispose d’aucun moyen fiable pour identifier quels sont vos requêtes.

    Un autre détail mérite d’être noté : la tentative de réessai est envoyée via next(...), et non en repassant par le début de la chaîne d’intercepteurs. Si la requête réessayée renvoie également un 401, l’erreur se propage une seule fois au appelant et s’arrête, de sorte que l’intercepteur ne peut pas entrer en boucle.

    Gardeurs : l’accès aux routes en tant que politique

    La dernière couche répond à une seule question pour le routeur : cette navigation peut-elle avoir lieu ?

    export const authGuard: CanActivateFn = (_route, state) => {
      const store = inject(AuthStore);
      const router = inject(Router);
    
      if (store.isAuthenticated()) {
        return true;
      }
    
      return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
    };
    
    export const guestGuard: CanActivateFn = () => {
      const store = inject(AuthStore);
      const router = inject(Router);
    
      return store.isAuthenticated() ? router.createUrlTree(['/dashboard']) : true;
    };
    

    En termes de sécurité, un garde représente un point d’application des politiques : la décision d’accès est prise en un seul endroit. Sans lui, les vérifications d’authentification ont tendance à se retrouver dans les constructeurs de composants, à être oubliées sur la route ajoutée sous pression de délai, et à afficher temporairement une interface ressemblant à celle réservée aux utilisateurs privilégiés avant de rediriger.

    Le fait de retourner un UrlTree plutôt que de naviguer de manière impérative est intentionnel. Le routeur abandonne la tentative de navigation et effectue la redirection en une seule étape, de sorte que la route protégée ne s’affiche jamais à l’écran. Le deuxième garde, guestGuard, fonctionne de manière similaire : il empêche les utilisateurs authentifiés d’accéder à la page de connexion.

    Ces gardes sont ensuite associés aux routes :

    export const routes: Routes = [
      {
        path: 'login',
        canActivate: [guestGuard],
        loadComponent: () => import('./login/login').then((m) => m.Login),
      },
      {
        path: 'dashboard',
        canActivate: [authGuard],
        loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard),
      },
      { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
      { path: '**', redirectTo: 'dashboard' },
    ];
    

    Le paramètre de requête returnUrl défini par authGuard atteint le composant de connexion sans aucun code manuel utilisant ActivatedRoute. Grâce à provideRouter(routes, withComponentInputBinding()), les paramètres de requête sont liés aux entrées de signal correspondantes :

    readonly returnUrl = input('/dashboard');
    // ...
    this.auth.login(username, password).subscribe({
      next: () => this.router.navigateByUrl(this.returnUrl()),
      error: () => { /* ... */ },
    });
    

    Un détail révélateur du projet : avant l’existence de ce mécanisme de protection, le store exposait déjà isAuthenticated, un signal calculé qui n’était jamais lu. Un état inutilisé de ce type est un signe d’un design inachevé. Ce n’est pas anodin ; c’est une promesse que l’application n’a pas encore tenue.

    Ce que les couches vous apportent en matière de tests

    Le principal avantage réside dans le faible niveau d’installation requis pour ces tests : il n’y a pas de fixtures de composants, aucune opération de nettoyage du localStorage, et pas non plus de mocks manuels pour HttpClient. La configuration ci-dessous fournit le backend de test HTTP d’Angular et remplace uniquement deux paramètres : l’URL de base et le port de stockage.

    beforeEach(() => {
      TestBed.configureTestingModule({
        providers: [
          provideHttpClient(),
          provideHttpClientTesting(),
          { provide: API_BASE_URL, useValue: BASE_URL },
          { provide: AUTH_STORAGE, useValue: new MemoryStorage() },
        ],
      });
      service = TestBed.inject(AuthService);
      store = TestBed.inject(AuthStore);
      http = TestBed.inject(HttpTestingController);
    });
    
    it('shares one in-flight request across concurrent refresh calls', () => {
      store.setTokens('access-1', 'refresh-1');
    
      service.refresh().subscribe();
      service.refresh().subscribe();
    
      const request = http.expectOne(`${BASE_URL}/auth/refresh`);
      request.flush({ accessToken: 'access-2', refreshToken: 'refresh-2' });
    
      expect(store.accessToken()).toBe('access-2');
    });
    

    Le test single-flight appelle refresh() deux fois, puis utilise http.expectOne, qui échoue si plus d’une requête correspondante est envoyée. Cela vérifie ainsi la garantie de cohésion en quelques lignes, et cette vérification reste effective même après que quelqu’un ait refactorisé refresh() un an plus tard. Les deux paramètres injectés constituent l’ensemble du framework de test, ce qui correspond précisément à l’objectif des ports.

    Ce que ce design n’est pas

    Être précis quant aux limites d’un modèle est tout aussi utile que de lui donner un nom.

    Ce n’est ni Redux ni NgRx. Il n’y a pas d’actions, de réducteurs, d’effets ni de cycle de dispatch. AuthStore est un état encapsulé avec des méthodes nommées selon leur intention. Si vous avez besoin de débogage par voyage dans le temps, de coordination des effets entre différentes fonctionnalités, ou d’un journal d’actions serialisé pour le rejouer, optez pour NgRx ; ce façade permet aux composants de ne pas remarquer le changement.

    Ce n’est pas une architecture Clean complète. Vous ne trouverez ici aucune classe de cas d’utilisation, et le modèle de domaine n’est pas séparé du format de communication. Le type User dans auth-models.ts correspond simplement à la structure de la réponse API. Une approche stricte d’architecture Clean définirait des types distincts et assurerait la traduction entre eux. Avec seulement quelques champs en jeu, cette couche de mappage coûte plus qu’elle ne rapporte, mais si la structure API et votre modèle de domaine commencent à diverger, c’est là qu’il convient d’introduire cette couche supplémentaire.

    Ce n’est pas gratuit. Sept fichiers, plusieurs intermédiaires et un jeton d’injection pour une seule chaîne de caractères représentent des coûts réels. Sur un prototype à deux écrans, c’est une sur-ingénierie ; il est donc judicieux d’écrire délibérément un seul service de grande taille. Le point d’équilibre est généralement atteint lorsque deux fonctionnalités ont besoin du même état de session, ou lorsqu’il faut pour la première fois expliquer la logique de mise à jour à un collègue.

    En résumé

    Tout le design se résume à une règle : les dépendances s’écoulent dans une seule direction, et chaque aspect logique se trouve dans un seul fichier.

    interceptor → service → { api, store } → models
    

    En pratique, cela signifie que le gateway n’a pas accès à l’état, le store ne gère aucune communication réseau, la façade laisse le routage à ses appelsants, et les composants ne communiquent qu’avec la façade. Aucun niveau n’accède aux niveaux supérieurs de la chaîne.

    C’est cette contrainte qui a transformé un throw synchrone lors d’un déconnexion partielle cachée en un test échouant rapidement. Elle a fait de la question « Quelles requêtes contiennent le token porteur ? » une question à réponse en une seule ligne. De plus, elle rendra futurement le passage de localStorage à des cookies httpOnly une simple modification d’un adaptateur, plutôt qu’une recherche approfondie dans le code.

    Les noms de ces patterns sonnent grandiosement : Gateway, Facade, Ports and Adapters, Chain of Responsibility, Policy Enforcement Point. En réalité, ils expriment tous la même idée : attribuer à chaque décision un emplacement unique et rendre évidente la direction des dépendances. Le reste en découle naturellement.