ng-implementation/devops/design-system.md
2025-10-03 11:08:34 +02:00

14 KiB
Raw Blame History

Authentification Cognito pour webapps Angular

Ce guide décrit comment intégrer lauthentification AWS Cognito (OIDC Code Flow avec PKCE) dans nos applications Angular.

Prérequis côté Cognito

  • Un User Pool Cognito créé
  • Un App client « Public client » (sans secret) avec PKCE activé
  • Un domaine Cognito configuré (ex: my-tenant.auth.eu-west-1.amazoncognito.com)
  • Scopes OIDC: openid, email, profile (ajoutez dautres scopes si besoin)
  • URLs de redirection et de logout autorisées
    • Dev (Angular local):
      • Callback: http://localhost:4200/callback
      • Logout: http://localhost:4200
    • Prod: adaptez avec votre domaine (ex: https://app.example.com/callback et https://app.example.com)

Variables de configuration (Angular)

Exemple dentrée environment.ts:

export const environment = {
  production: false,
  cognito: {
    userPoolId: 'eu-west-1_XXXXXXXXX',
    userPoolWebClientId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx',
    domain: 'my-tenant.auth.eu-west-1.amazoncognito.com',
    redirectSignIn: 'http://localhost:4200/callback',
    redirectSignOut: 'http://localhost:4200',
    region: 'eu-west-1',
    scopes: ['openid', 'email', 'profile']
  }
};

Mettez à jour environment.prod.ts avec les URLs de production.

Option A — Hosted UI via AWS Amplify

Installation:

npm i aws-amplify @aws-amplify/ui-angular

Configuration (ex: main.ts ou app.module.ts):

import { Amplify } from 'aws-amplify';
import { environment } from './environments/environment';

Amplify.configure({
  Auth: {
    region: environment.cognito.region,
    userPoolId: environment.cognito.userPoolId,
    userPoolWebClientId: environment.cognito.userPoolWebClientId,
    oauth: {
      domain: environment.cognito.domain,
      scope: environment.cognito.scopes,
      redirectSignIn: environment.cognito.redirectSignIn,
      redirectSignOut: environment.cognito.redirectSignOut,
      responseType: 'code'
    }
  }
});

Initiation du login (Hosted UI) et gestion du callback (ex: dans un service dauth):

import { Auth } from 'aws-amplify';

export class AuthService {
  signIn(): Promise<void> {
    return Auth.federatedSignIn(); // redirige vers Hosted UI
  }

  async handleCallback(): Promise<void> {
    // Amplify gère automatiquement léchange du code contre les tokens
    const session = await Auth.currentSession();
    const idToken = session.getIdToken().getJwtToken();
    const accessToken = session.getAccessToken().getJwtToken();
    // Stockez/consommez les tokens selon vos besoins
  }

  signOut(): Promise<void> {
    return Auth.signOut(); // redirige vers redirectSignOut
  }
}

Routing du callback (ex: app-routing.module.ts):

{ path: 'callback', component: CallbackComponent }

Dans CallbackComponent, appelez handleCallback() puis redirigez lutilisateur vers lapp.

Option B — angular-oauth2-oidc (OIDC Code Flow + PKCE)

Installation:

npm i angular-oauth2-oidc

Configuration (ex: app.module.ts et un auth.config.ts):

// auth.config.ts
import { AuthConfig } from 'angular-oauth2-oidc';

export const authConfig: AuthConfig = {
  issuer: 'https://my-tenant.auth.eu-west-1.amazoncognito.com',
  clientId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx',
  responseType: 'code',
  redirectUri: 'http://localhost:4200/callback',
  postLogoutRedirectUri: 'http://localhost:4200',
  scope: 'openid email profile',
  showDebugInformation: false,
  strictDiscoveryDocumentValidation: false, // Cognito nexpose pas toujours toutes les métadonnées standard
};

Initialisation au démarrage (ex: app.module.ts):

import { OAuthService, OAuthModule } from 'angular-oauth2-oidc';
import { authConfig } from './auth.config';

providers: [
  {
    provide: APP_INITIALIZER,
    multi: true,
    deps: [OAuthService],
    useFactory: (oauthService: OAuthService) => () => {
      oauthService.configure(authConfig);
      oauthService.setupAutomaticSilentRefresh();
      return oauthService.loadDiscoveryDocumentAndLogin();
    }
  }
]

Déclenchement du login/logout:

login(): void { this.oauthService.initLoginFlow(); }
logout(): void { this.oauthService.logOut(); }
getIdToken(): string | null { return this.oauthService.getIdToken(); }

Protection de routes (ex: guard minimal):

canActivate(): boolean {
  return this.oauthService.hasValidAccessToken() || this.oauthService.hasValidIdToken();
}

Bonnes pratiques

  • Utilisez le flow « Authorization Code + PKCE » pour les SPAs (sécurité accrue)
  • Déclarez toutes les URLs de callback/logout (local, staging, prod) dans Cognito
  • Synchronisez lheure système (skew de plus de 5 min peut invalider les tokens)
  • Évitez dexposer des secrets côté frontend (client public uniquement)
  • Renouvelez silencieusement les tokens lorsque possible (silent refresh)

Dépannage

  • invalid_redirect_uri / redirect_mismatch: vérifiez les URLs exactes dans Cognito
  • Erreur « Not authorized » après login: facteurs possibles — scopes manquants, app client erroné
  • Boucle de redirection: callback non routé ou handler non exécuté
  • CORS/API: frontend authentifié ≠ API autorisée; configurez vos authorizers/API Gateway
  • Cookies SameSite: si vous utilisez des cookies, ajustez SameSite/Lax/None selon le contexte

Checklist déploiement

  • Mettre à jour les URLs de callback/logout en prod
  • Activer HTTPS sur le domaine de lapp
  • Vérifier que le domaine Cognito est public et résout correctement
  • Tester login/logout et rafraîchissement de token en environnement prod

Activation conditionnelle via variable denvironnement + Guard Angular

Nous supportons un mode où lauthentification Cognito est optionnelle selon une variable denvironnement, et une Guard Angular assure la redirection vers une page de login configurable.

Variables denvironnement

Dans environment.ts et environment.prod.ts:

export const environment = {
  production: false,
  requireAuth: true, // si false, la Guard laisse passer sans authentification
  loginRoute: '/login', // route Angular interne servant le bouton/flux de login
  cognito: {
    userPoolId: 'eu-west-1_XXXXXXXXX',
    userPoolWebClientId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx',
    domain: 'my-tenant.auth.eu-west-1.amazoncognito.com',
    redirectSignIn: 'http://localhost:4200/callback',
    redirectSignOut: 'http://localhost:4200',
    region: 'eu-west-1',
    scopes: ['openid', 'email', 'profile']
  }
};

Mettez requireAuth: false sur des environnements de démonstration internes si nécessaire. En production, laissez true.

Utilisation des fichiers .env dAngular

Nous utilisons les fichiers .env dAngular pour fournir ces variables au build. Créez les fichiers à la racine du projet Angular (même niveau que package.json) :

  • .env (valeurs par défaut communes)
  • .env.development (lues en dev)
  • .env.production (lues en prod)

Exemple .env.development :

NG_APP_REQUIRE_AUTH=true
NG_APP_LOGIN_ROUTE=/login
NG_APP_COGNITO_USER_POOL_ID=eu-west-1_XXXXXXXXX
NG_APP_COGNITO_WEB_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
NG_APP_COGNITO_DOMAIN=my-tenant.auth.eu-west-1.amazoncognito.com
NG_APP_COGNITO_REDIRECT_SIGNIN=http://localhost:4200/callback
NG_APP_COGNITO_REDIRECT_SIGNOUT=http://localhost:4200
NG_APP_COGNITO_REGION=eu-west-1
NG_APP_COGNITO_SCOPES=openid,email,profile

Exemple .env.production :

NG_APP_REQUIRE_AUTH=true
NG_APP_LOGIN_ROUTE=/login
NG_APP_COGNITO_USER_POOL_ID=eu-west-1_YYYYYYYYY
NG_APP_COGNITO_WEB_CLIENT_ID=yyyyyyyyyyyyyyyyyyyyyyyyyy
NG_APP_COGNITO_DOMAIN=prod-tenant.auth.eu-west-1.amazoncognito.com
NG_APP_COGNITO_REDIRECT_SIGNIN=https://app.example.com/callback
NG_APP_COGNITO_REDIRECT_SIGNOUT=https://app.example.com
NG_APP_COGNITO_REGION=eu-west-1
NG_APP_COGNITO_SCOPES=openid,email,profile

Conventions :

  • Préfixe recommandé: NG_APP_ pour exposer la variable au code frontend
  • Les .env.* sont sélectionnés selon la configuration Angular (ex: ng serve utilise development, ng build --configuration=production utilise production)

Lecture dans le code Angular (Angular 17+/Vite) :

const env = (import.meta as any).env as Record<string, string>;

export const environment = {
  production: env['MODE'] === 'production',
  requireAuth: String(env['NG_APP_REQUIRE_AUTH']).toLowerCase() === 'true',
  loginRoute: env['NG_APP_LOGIN_ROUTE'] || '/login',
  cognito: {
    userPoolId: env['NG_APP_COGNITO_USER_POOL_ID'],
    userPoolWebClientId: env['NG_APP_COGNITO_WEB_CLIENT_ID'],
    domain: env['NG_APP_COGNITO_DOMAIN'],
    redirectSignIn: env['NG_APP_COGNITO_REDIRECT_SIGNIN'],
    redirectSignOut: env['NG_APP_COGNITO_REDIRECT_SIGNOUT'],
    region: env['NG_APP_COGNITO_REGION'],
    scopes: (env['NG_APP_COGNITO_SCOPES'] || 'openid,email,profile').split(',')
  }
};

Note : selon la version de loutillage Angular, laccès peut être import.meta.env.NG_APP_... au lieu de lindexation par chaîne. Gardez le préfixe NG_APP_ pour garantir lexposition au build.

Injection des .env via Docker / docker-compose

Nos builds frontend sont statiques (servis par serve). Les variables doivent donc être connues au BUILD. Utilisez des build.args dans docker-compose.yml et, côté Dockerfile, écrivez un fichier .env.production avant le build Angular.

Exemple docker-compose.yml (extrait service frontend) :

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: ./frontend-dockerfile.yml
      args:
        NG_APP_REQUIRE_AUTH: "true"
        NG_APP_LOGIN_ROUTE: "/login"
        NG_APP_COGNITO_USER_POOL_ID: "eu-west-1_XXXXXXXXX"
        NG_APP_COGNITO_WEB_CLIENT_ID: "xxxxxxxxxxxxxxxxxxxxxxxxxx"
        NG_APP_COGNITO_DOMAIN: "my-tenant.auth.eu-west-1.amazoncognito.com"
        NG_APP_COGNITO_REDIRECT_SIGNIN: "https://app.example.com/callback"
        NG_APP_COGNITO_REDIRECT_SIGNOUT: "https://app.example.com"
        NG_APP_COGNITO_REGION: "eu-west-1"
        NG_APP_COGNITO_SCOPES: "openid,email,profile"

Adaptation optionnelle du frontend-dockerfile.yml (à ajouter avant npm run build) :

# Arguments build-time pour générer .env.production
ARG NG_APP_REQUIRE_AUTH
ARG NG_APP_LOGIN_ROUTE
ARG NG_APP_COGNITO_USER_POOL_ID
ARG NG_APP_COGNITO_WEB_CLIENT_ID
ARG NG_APP_COGNITO_DOMAIN
ARG NG_APP_COGNITO_REDIRECT_SIGNIN
ARG NG_APP_COGNITO_REDIRECT_SIGNOUT
ARG NG_APP_COGNITO_REGION
ARG NG_APP_COGNITO_SCOPES

# Écrire le fichier .env.production à partir des args
RUN printf "NG_APP_REQUIRE_AUTH=%s\nNG_APP_LOGIN_ROUTE=%s\nNG_APP_COGNITO_USER_POOL_ID=%s\nNG_APP_COGNITO_WEB_CLIENT_ID=%s\nNG_APP_COGNITO_DOMAIN=%s\nNG_APP_COGNITO_REDIRECT_SIGNIN=%s\nNG_APP_COGNITO_REDIRECT_SIGNOUT=%s\nNG_APP_COGNITO_REGION=%s\nNG_APP_COGNITO_SCOPES=%s\n" \
  "$NG_APP_REQUIRE_AUTH" \
  "$NG_APP_LOGIN_ROUTE" \
  "$NG_APP_COGNITO_USER_POOL_ID" \
  "$NG_APP_COGNITO_WEB_CLIENT_ID" \
  "$NG_APP_COGNITO_DOMAIN" \
  "$NG_APP_COGNITO_REDIRECT_SIGNIN" \
  "$NG_APP_COGNITO_REDIRECT_SIGNOUT" \
  "$NG_APP_COGNITO_REGION" \
  "$NG_APP_COGNITO_SCOPES" \
  > .env.production

Ainsi, le build Angular lit automatiquement ces variables via import.meta.env et les injecte dans le bundle.

Remarques :

  • Les environment.*.ts peuvent mapper import.meta.env pour exposer une API typed au reste de lapp.
  • Pour des valeurs sensibles, préférez des mécanismes backend (ne jamais exposer de secrets côté SPA).
  • Si vous avez besoin de « runtime config » (changer après build), utilisez une approche config.json servie par le serveur et chargée au démarrage de lapp.

Guard Angular (générique)

Exemple de Guard compatible avec Amplify ou angular-oauth2-oidc: elle vérifie requireAuth. Si lauth est requise mais absente/expirée, elle redirige vers loginRoute.

import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { environment } from '../environments/environment';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(private router: Router) {}

  async canActivate(): Promise<boolean | UrlTree> {
    if (!environment.requireAuth) {
      return true;
    }

    // 1) Vérification via Amplify (si utilisé)
    try {
      const { Auth } = await import('aws-amplify');
      const session = await Auth.currentSession();
      const valid = !!session?.getIdToken()?.getJwtToken();
      if (valid) return true;
    } catch (_) {
      // ignore si Amplify non configuré
    }

    // 2) Vérification via angular-oauth2-oidc (si utilisé)
    try {
      const { OAuthService } = await import('angular-oauth2-oidc');
      const oauth = (window as any).ngInjector?.get?.(OAuthService);
      // Note: exposez éventuellement l'injector globalement au bootstrap
      if (oauth && (oauth.hasValidAccessToken() || oauth.hasValidIdToken())) {
        return true;
      }
    } catch (_) {
      // ignore si non installé
    }

    return this.router.parseUrl(environment.loginRoute);
  }
}

Astuce: pour récupérer des services dans la Guard sans injection directe (quand on veut la rendre agnostique), on peut exposer lInjector Angular globalement au bootstrap: window.ngInjector = injector; et lutiliser comme cidessus.

Routage protégé

Protégez les routes nécessitant une session valide:

{
  path: 'app',
  canActivate: [AuthGuard],
  loadChildren: () => import('./features/app/app.module').then(m => m.AppModule)
}

Page de login (route loginRoute)

La page associée à environment.loginRoute doit offrir un bouton « Se connecter ». Selon limplémentation:

  • Avec Amplify:
import { Auth } from 'aws-amplify';

login(): Promise<void> {
  return Auth.federatedSignIn();
}
  • Avec angular-oauth2-oidc:
constructor(private oauthService: OAuthService) {}

login(): void {
  this.oauthService.initLoginFlow();
}

Au retour sur /callback, suivez la section correspondante (Amplify: currentSession(), OIDC: loadDiscoveryDocumentAndLogin() déjà appelé au bootstrap) puis redirigez vers lespace protégé.