Intégration d’Angular, Vite et Storybook

Prérequis

Pour suivre ce tutoriel, assurez-vous d’avoir installé les outils suivants sur votre machine :

Problématique

Récemment chez mon client, on a souhaité changer d’architecture.

Historiquement notre application Angular était protégée par un serveur d’API Gateway qui gérait l’authentification et la sécurisation des accès via OpenID Connect (OIDC). C’était donc notre API Gateway qui était le client OIDC dans notre architecture, et le principe reposait sur une communication entre le client et le serveur d’API Gateway via un Cookie de manière classique.

Dans cette nouvelle architecture, nous avons choisi de faire communiquer directement le client Angular avec notre fournisseur d’identité (IDP/SSO) en utilisant OpenID Connect.

Je me suis aperçu qu’il n’y avait pas beaucoup de ressources sur le sujet, et j’ai donc décidé de partager la démarche que j’ai suivi pour mettre en place OpenID Connect sur un projet Angular.

Par le passé, en 2016, j’ai déjà eu besoin de le mettre en place pour la Banque de France pour leur espace entreprise puis ensuite chez Generali en 2018. En revanche, je l’avais mis en place sur des projets Angular sans State Management (NgRx).

Vous pouvez retrouver un exemple de ce type de projet ici.

NOTE : le projet ci-dessous n’est plus maintenu à jour et date d’il y a 8 ans.

Solution

Pour une application Angular moderne utilisant une gestion d’état (state management), l’idée est de déclencher une action d’authentification (login). Cette action active une série d’effets NgRx (NgRx Effects), qui gèrent le flux d’authentification avec le fournisseur d’identité (IDP/SSO) et récupèrent les informations utilisateur si nécessaire.

Tutoriel

Dans ce tutoriel, nous allons créer une API backend en Rust et un frontend en Angular.

1. Création du projet

Commencez par créer un nouveau projet :

mkdir simple-demo-oidc-app
export APP_ROOT_DIR=$(pwd)
cd $APP_ROOT_DIR

2. Setup du backend Rust.

Pour cela, on va s’inspirer du projet d’un de mes articles précédents qu’on modifiera légèrement pour les besoins de ce projet.

cargo init backend

Ajoutez les dépendances suivantes dans votre fichier Cargo.toml :

[package]
name = "simple-demo-oidc-app-backend"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "0.8.4"
axum-jwt-oidc = "0.1.1"
async-oidc-jwt-validator = "0.1.2"
dotenvy = "0.15.7"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
tokio = { version = "1.47.1", features = ["full"] }
tower-http = { version = "0.6.6", features = ["cors"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"

Ensuite, remplacez le contenu de src/main.rs par le code ci-dessous :

use async_oidc_jwt_validator::Algorithm;
use axum::http::{HeaderValue, StatusCode};
use axum::routing::{get};
use axum::{Extension, Json, Router};
use axum::http::header::{AUTHORIZATION, CONTENT_TYPE};
use axum_jwt_oidc::{OidcAuthLayer, OidcConfig, OidcValidator, Validation};
use dotenvy::dotenv;
use serde::{Deserialize, Serialize};
use tower_http::cors::{Any, CorsLayer};
use tracing::{debug, info};

#[tokio::main]
async fn main() {
    // initialize tracing for logging
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::DEBUG)
        .init();

    dotenv().ok();

    // Initialize OIDC validator
    let config = OidcConfig::new_with_discovery(
        std::env::var("ISSUER_URL").expect("ISSUER_URL must be set"),
        "".to_string(),
    )
    .await
    .unwrap();
    let oidc_validator = OidcValidator::new(config);
    // Configure validation rules
    let mut validation = Validation::new(Algorithm::RS256);
    validation.validate_aud = false;
    // Create the authentication layer
    let auth_layer = OidcAuthLayer::<CustomClaims>::new(oidc_validator, validation);
    // Cors layer
    let cors_layer = CorsLayer::new()
        .allow_methods(Any)
        .allow_origin("http://localhost:4200".parse::<HeaderValue>().unwrap())
        .allow_headers([AUTHORIZATION, CONTENT_TYPE]);

    // build our application with a route
    let app = Router::new()
        .route("/me", get(get_user_info))
        // Extension layer
        .layer(auth_layer)
        // Cors layer
        .layer(cors_layer);

    // run our app with hyper, listening globally on port 8000
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.expect("Failed to bind on port 8000");
    info!("Listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

#[derive(Serialize, Deserialize)]
struct UserInfo {
    sub: String,
    email: String,
}

async fn get_user_info(claims: Option<Extension<CustomClaims>>) -> Result<Json<UserInfo>, StatusCode> {
    if let Some(Extension(claims)) = claims {
        debug!("Sending messages for {}", claims.sub);
        let email = claims.email.unwrap_or("".to_string());
        Ok(Json(UserInfo{ sub: claims.sub, email: email }))
    } else {
        Err(StatusCode::UNAUTHORIZED)
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
struct CustomClaims {
    sub: String,
    email: Option<String>,
}

On va récupérer le fichier realms.json et le placer dans le dossier keycloak/ afin de pouvoir lancer le serveur keycloak prêt à l’emploi en local :

cd $APP_ROOT_DIR
mkdir keycloak
curl -s https://raw.githubusercontent.com/clevertechware/simple-demo-oidc-app/refs/heads/main/keycloak/realms.json > keycloak/realms.json

Il ne reste plus qu’à créer le fichier docker-compose.yml suivant :

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.3
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_HEALTH_ENABLED: "true"
    healthcheck:
      test: [ "CMD", "curl", "-f", "http://localhost:8080/health/ready" ]
      interval: 15s
      timeout: 2s
      retries: 15
    command: [ "start-dev", "--import-realm" ]
    ports:
      - "8080:8080"
    volumes:
      - ./keycloak:/opt/keycloak/data/import

Vous pouvez maintenant démarrer Keycloak et le backend Rust avec les commandes suivantes :

docker compose up -d && docker compose logs -f

Et enfin notre backend Rust:

cd backend
echo 'ISSUER_URL=http://localhost:8080/realms/demo' > .env 
cargo run

Pour tester notre backend, on peut utiliser un client REST comme Postman ou Insomnia. Dans mon cas, je vais utiliser HTTPie:

http :8000/me
HTTP/1.1 401 Unauthorized
content-length: 0
date: Fri, 19 Sep 2025 12:07:41 GMT

Et si on fournit un petit jeton JWT d’accès:

export ACCESS_TOKEN=$(  http --form -A basic -a cli:WOhxh2rBPgVrbeH8cXjjNSX4kp1MLFkd :8080/realms/demo/protocol/openid-connect/token   grant_type=password   username=bob   password=password|jq -r .access_token)
http -A bearer -a $ACCESS_TOKEN :8000/me
HTTP/1.1 200 OK
content-length: 75
content-type: application/json
date: Fri, 19 Sep 2025 12:08:55 GMT

{
    "email": "bob.bobby@test.com",
    "sub": "e3541a61-3e12-46c3-9201-f7afdfada47c"
}

3. Setup du frontend Angular.

Maintenant on va initialiser notre projet frontend Angular :

npx @angular/cli@latest new frontend --routing --style=scss --ssr=false --inline-style=false --inline-template=false --strict --zoneless=false --package-manager=bun
cd frontend
export FRONTEND_ROOT_DIR=$(pwd)

Ensuite on ajoute NgRx (module Store et Effects) pour la partie State Management et angular-auth-oidc-client pour la partie Authentification :

bun add @ngrx/{store,effects,store-devtools}@latest angular-auth-oidc-client@latest

Nous allons créer la structure suivante :

src
├── app
   ├── app.config.ts
   ├── app.routes.ts
   ├── app.ts
   ├── pages
   ├── auth-guard.ts
   ├── home
   ├── home.html
   ├── home.scss
   └── home.ts
   └── profile
       ├── profile.html
       ├── profile.scss
       └── profile.ts
   └── user
       ├── store
   ├── user.actions.ts
   ├── user.effects.ts
   ├── user.reducer.ts
   ├── user.selectors.ts
   └── user.state.ts
       └── user-service.ts
├── environments
   ├── environment.development.ts
   └── environment.ts
├── index.html
├── main.ts
└── styles.scss
3.1. Mise en place du module user dédiée à l’utilisateur de l’application.

On va créer le fichier src/app/user/store/user.state.ts suivant :

export interface UserState {
    isAuthenticated: boolean;
    isLoading: boolean;
    error: string | null;
}

export const initialUserState: UserState = {
    isAuthenticated: false,
    isLoading: false,
    error: null,
};

On place les selecteurs nous permettant d’accéder facilement à des parties du state dans le fichier src/app/user/store/user.selectors.ts :

import {createFeatureSelector, createSelector} from '@ngrx/store';
import {UserState} from './user.state';

export const selectUserFeature = createFeatureSelector<UserState>('user');

export const selectIsUserAuthenticated = createSelector(
    selectUserFeature,
    (state: UserState) => state.isAuthenticated
);

export const selectIsUserLoading = createSelector(
    selectUserFeature,
    (state: UserState) => state.isLoading
);

export const selectUserAuthError = createSelector(
    selectUserFeature,
    (state: UserState) => state.error
);

Ensuite on va déclarer les actions que l’on peut réaliser autour de ce state dans le fichier src/app/user/store/user.actions.ts :

import { createAction, props } from '@ngrx/store';
import { LoginResponse } from 'angular-auth-oidc-client';

export const login = createAction('[User] Login');

export const logout = createAction('[User] Logout');

export const logoutSuccess = createAction('[User] Logout Success');

export const logoutFailure = createAction(
    '[User] Logout Failure',
    props<{ error: string }>()
);

export const checkAuthState = createAction('[User] Check Auth State');

export const checkAuthStateSuccess = createAction(
    '[User] Check Auth State Success',
    props<{ loginResponse: LoginResponse }>()
);

export const checkAuthStateFailure = createAction(
    '[User] Check Auth State Failure',
    props<{ error: string }>()
);

Une fois qu’on a défini les actions pouvant interagir avec notre state, on va créer les impacts sur notre state provoqués par ces actions dans le fichier src/app/user/store/user.reducer.ts :

import { createReducer, on } from '@ngrx/store';
import {
    checkAuthState,
    checkAuthStateFailure,
    checkAuthStateSuccess,
    login,
    logout,
    logoutFailure,
    logoutSuccess
} from './user.actions';
import {initialUserState, UserState} from './user.state';

export const userReducer = createReducer(
    initialUserState,

    on(login, (state): UserState => ({
        ...state,
        isLoading: true,
        error: null
    })),

    on(logout, (state): UserState => ({
        ...state,
        isLoading: true,
        error: null
    })),

    on(logoutSuccess, (state): UserState => ({
        ...state,
        isLoading: false,
        isAuthenticated: false,
        error: null
    })),

    on(logoutFailure, (state, { error }): UserState => ({
        ...state,
        isLoading: false,
        isAuthenticated: false,
        error
    })),

    on(checkAuthState, (state): UserState => ({
        ...state,
        isLoading: true,
        error: null
    })),

    on(checkAuthStateSuccess, (state, { loginResponse }): UserState => ({
        ...state,
        isLoading: false,
        isAuthenticated: loginResponse.isAuthenticated,
        error: null
    })),

    on(checkAuthStateFailure, (state, { error }): UserState => ({
        ...state,
        isLoading: false,
        error
    }))
);

Au niveau du store, la dernière chose que l’on peut déclarer et dans notre cas, nous allons le déclarer sont les effets de bord pouvant survenir suite à des actions dans le fichier src/app/user/store/user.effects.ts :

import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { of } from 'rxjs';
import { map, catchError, switchMap } from 'rxjs/operators';
import {
    checkAuthState,
    checkAuthStateFailure,
    checkAuthStateSuccess,
    login,
    logout,
    logoutFailure,
    logoutSuccess
} from './user.actions';

@Injectable()
export class UserEffects {
    private actions$ = inject(Actions);
    private oidcSecurityService = inject(OidcSecurityService);

    login$ = createEffect(() =>
        this.actions$.pipe(
            ofType(login),
            switchMap(() => {
                this.oidcSecurityService.authorize();
                return of(null);
            })
        ), { dispatch: false }
    );

    logout$ = createEffect(() =>
        this.actions$.pipe(
            ofType(logout),
            switchMap(() =>
                this.oidcSecurityService.logoff().pipe(
                    map(() => logoutSuccess()),
                    catchError((error) => of(logoutFailure({ error: error.message })))
                )
            )
        )
    );

    checkAuthState$ = createEffect(() =>
        this.actions$.pipe(
            ofType(checkAuthState),
            switchMap(() =>
                this.oidcSecurityService.checkAuth().pipe(
                    map((loginResponse) =>
                        loginResponse.isAuthenticated
                            ? checkAuthStateSuccess({ loginResponse })
                            : checkAuthStateFailure({ error: 'Not authenticated' })
                    ),
                    catchError((error) => of(checkAuthStateFailure({ error: error.message })))
                )
            )
        )
    );
}
3.2. Récupération des claims JWT depuis un service backend.

Avant d’attaquer les pages, on va créer un service nous permettant d’accéder aux informations utilisateur depuis le backend. Pour cela, il va nous falloir configurer l’URL de notre backend :

cd ${FRONTEND_ROOT_DIR}/src/
npx @angular/cli@latest generate environments

On édite les fichiers src/environments/environment.development.ts :

export const environment = {
  production: false,
  userApiUrl: 'http://localhost:8000',
  issuerUrl: 'http://localhost:8080/realms/demo',
};

et src/environments/environment.ts :

export const environment = {
    production: true,
    userApiUrl: 'http://localhost:8000',
    issuerUrl: 'http://localhost:8080/realms/demo',
};

À partir de là, on peut créer notre service user-service.ts:

cd ${FRONTEND_ROOT_DIR}/src/app/user
npx @angular/cli@latest generate service user-service 

Ce service nous permettra d’appeler notre backend à l’aide du client HTTP d’Angular.

import { Injectable } from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {environment} from '../../environments/environment';

export interface UserClaims {
  sub: string;
  email: string;
}

@Injectable({
  providedIn: 'root'
})
export class UserService {
  constructor(private http: HttpClient) {}

  getInfo(): Observable<UserClaims> {
    return this.http.get<UserClaims>(`${environment.userApiUrl}/me`);
  }
}
3.3. Mise en place des pages.

On va créer notre page d’accueil.

cd ${FRONTEND_ROOT_DIR}
mkdir src/app/pages
cd src/app/pages
npx @angular/cli@latest generate component home

Avant de l’implémenter, comme il s’agit d’une page, on va la déclarer dans les routes de notre application dans src/app/app.routes.ts :

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '', 
    component: Home,
  },
  {
    path: '**',
    redirectTo: ''
  }
];

On implémente notre page src/app/pages/home/home.ts :

import {Component} from '@angular/core';
import {Store} from '@ngrx/store';
import {Observable} from 'rxjs';
import {selectIsUserAuthenticated, selectIsUserLoading} from '../../user/store/user.selectors';
import {login, logout} from '../../user/store/user.actions';
import {AsyncPipe} from '@angular/common';

@Component({
    selector: 'app-home',
    templateUrl: './home.html',
    imports: [AsyncPipe],
    styleUrl: './home.scss'
})
export class Home {
    isAuthenticated$: Observable<boolean>;
    isLoading$: Observable<boolean>;

    constructor(private store: Store) {
        this.isAuthenticated$ = this.store.select(selectIsUserAuthenticated);
        this.isLoading$ = this.store.select(selectIsUserLoading);
    }

    login(): void {
        this.store.dispatch(login());
    }

    logout(): void {
        this.store.dispatch(logout());
    }
}

On va définit le template src/app/pages/home/home.html:

@let isAuthenticated = isAuthenticated$ | async;
<div class="home-container">
    <div class="hero-section">
        <h1>Angular OIDC Demo</h1>
        <p>Démonstration d'une application Angular avec authentification OpenID Connect utilisant Keycloak</p>

        @if (isAuthenticated) {
        <div class="welcome-section">
            <h2>Bienvenue !</h2>
            <p>Vous êtes maintenant connecté. Vous pouvez accéder à la page protégée.</p>
            <div class="action-buttons">
                <a href="/profile" class="profile-btn">Voir mon profil</a>
                <button class="logout-btn" (click)="logout()">Se déconnecter</button>
            </div>
        </div>
        } @else {
        @let isLoading = isLoading$ | async;
        <div class="auth-section">
            <p>Vous n'êtes pas connecté. Cliquez sur le bouton ci-dessous pour vous authentifier via Keycloak.</p>
            <button
                    class="login-btn"
                    (click)="login()"
                    [disabled]="isLoading">
                @if (isLoading) {
                <span>Connexion en cours...</span>
                } @else {
                <span>Se connecter</span>
                }
            </button>
        </div>
        }

    </div>

    <div class="features-section">
        <h3>Fonctionnalités de cette démo :</h3>
        <ul>
            <li>Authentification OpenID Connect avec Keycloak</li>
            <li>Gestion d'état avec NgRx</li>
            <li>Pages protégées avec guards</li>
            <li>Récupération des claims JWT depuis un service backend</li>
            <li>Intercepteur HTTP automatique pour l'ajout du token</li>
            <li>Renouvellement automatique du token</li>
        </ul>
    </div>
</div>

Afin de rendre la page plus agréable, on va poser un style rapide dans src/app/pages/home/home.scss :

:host {
  .home-container {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  }

  .hero-section {
    text-align: center;
    margin-bottom: 3rem;
  }

  h1 {
    color: #2c3e50;
    font-size: 2.5rem;
    margin-bottom: 1rem;
  }

  .auth-section {
    background: #f8f9fa;
    padding: 2rem;
    border-radius: 8px;
    margin: 2rem 0;
  }

  .login-btn {
    background: #007bff;
    color: white;
    border: none;
    padding: 0.75rem 2rem;
    font-size: 1.1rem;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s;
  }

  .login-btn:hover:not(:disabled) {
    background: #0056b3;
  }

  .login-btn:disabled {
    background: #6c757d;
    cursor: not-allowed;
  }

  .welcome-section h2 {
    color: #28a745;
    margin-bottom: 1rem;
  }

  .action-buttons {
    display: flex;
    gap: 1rem;
    justify-content: center;
    margin-top: 1.5rem;
  }

  .profile-btn {
    background: #28a745;
    color: white;
    text-decoration: none;
    padding: 0.75rem 1.5rem;
    border-radius: 5px;
    transition: background-color 0.3s;
  }

  .profile-btn:hover {
    background: #1e7e34;
  }

  .logout-btn {
    background: #dc3545;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s;
  }

  .logout-btn:hover {
    background: #c82333;
  }

  .features-section {
    background: #e9ecef;
    padding: 2rem;
    border-radius: 8px;
  }

  .features-section h3 {
    color: #495057;
    margin-bottom: 1rem;
  }

  .features-section ul {
    list-style-type: none;
    padding: 0;
  }

  .features-section li {
    padding: 0.5rem 0;
    border-bottom: 1px solid #dee2e6;
  }

  .features-section li:before {
    content: "✓";
    color: #28a745;
    font-weight: bold;
    margin-right: 0.5rem;
  }
}

On va créer notre page profile :

cd ${FRONTEND_ROOT_DIR}/src/app/pages
npx @angular/cli@latest generate component profile

On l’ajoute au sein des routes de notre application dans src/app/app.routes.ts :

import { Routes } from '@angular/router';
import { Home } from './pages/home/home';

export const routes: Routes = [
    {
        path: '',
        component: Home,
    },
    {
        path: 'profile',
        loadComponent: () => import('./pages/profile/profile').then(m => m.Profile),
    },
    {
        path: '**',
        redirectTo: ''
    }
];

NOTE : on déclare notre route privée afin qu’elle soit chargée de manière lazy.

On édite le fichier src/app/pages/profile/profile.ts :

import {Component, inject} from '@angular/core';
import {catchError, switchMap, tap} from 'rxjs/operators';
import {AsyncPipe, SlicePipe} from '@angular/common';
import {BehaviorSubject, of} from 'rxjs';
import {UserClaims, UserService} from '../../user/user-service';
import {OidcSecurityService} from 'angular-auth-oidc-client';

@Component({
    selector: 'app-profile',
    imports: [
        AsyncPipe,
        SlicePipe
    ],
    templateUrl: './profile.html',
    styleUrl: './profile.scss'
})
export class Profile {
    private readonly oidcSecurityService = inject(OidcSecurityService);
    private readonly userService = inject(UserService);
    private readonly triggerBackendClaims = new BehaviorSubject(true);
    backendError: string | null = null;
    loadingBackendData = false;
    showFullToken = false;
    backendClaims$ = this.triggerBackendClaims.asObservable().pipe(
        tap(() => this.loadingBackendData = true),
        switchMap(() =>
            this.userService.getInfo().pipe(
                catchError((error) => {
                    this.backendError = error.message || 'Erreur lors du chargement des données';
                    this.loadingBackendData = false;
                    console.error('Erreur lors du chargement des claims:', error);
                    return of({ sub: '', email: ''} as UserClaims);
                })
            )
        )
    );

    accessToken$ = this.oidcSecurityService.getAccessToken();

    loadBackendClaims(): void {
        this.loadingBackendData = true;
        this.backendError = null;
        this.triggerBackendClaims.next(true);
    }

    toggleTokenVisibility(): void {
        this.showFullToken = !this.showFullToken;
    }
}

Puis le template src/app/pages/profile/profile.html :

<div class="profile-container">
    <div class="header">
        <h1>Profil Utilisateur</h1>
        <p>Informations récupérées depuis le token JWT et le service backend</p>
    </div>

    <div class="profile-content">
        <div class="backend-info">
            <h2>Informations du Service Backend</h2>
            @if (backendClaims$ | async; as backendClaims) {
                <div class="info-card">
                    <div class="info-item">
                        <label>Subject :</label>
                        <span>{{ backendClaims.sub }}</span>
                    </div>
                    <div class="info-item">
                        <label>Email :</label>
                        <span>{{ backendClaims.email }}</span>
                    </div>
                </div>
            } @else {
                @if (loadingBackendData) {
                    <div class="loading">Chargement des données du backend...</div>
                }
    
                @if (backendError) {
                    <div class="error">
                        <p>Erreur lors de la récupération des données du backend:</p>
                        <p>{{ backendError }}</p>
                        <button (click)="loadBackendClaims()" class="retry-btn">Réessayer</button>
                    </div>
                }
            }
        </div>

        <div class="token-display">
            <h2>Token d'Accès</h2>
            <div class="token-card">
                <div class="token-header">
                    <span>Access Token (premiers 50 caractères):</span>
                    <button (click)="toggleTokenVisibility()" class="toggle-btn">
                        {{ showFullToken ? 'Masquer' : 'Afficher tout' }}
                    </button>
                </div>
                @if (accessToken$ | async; as token) {
                    <div class="token-content">
                        <code>{{ showFullToken ? token : (token | slice:0:50) + '...' }}</code>
                    </div>
                }
            </div>
        </div>

        <div class="actions">
            <a href="/" class="back-btn">← Retour à l'accueil</a>
        </div>
    </div>
</div>

Pour rendre la page plus agréable, on va poser un style rapide dans src/app/pages/profile/profile.scss :

:host {
  .profile-container {
    max-width: 900px;
    margin: 0 auto;
    padding: 2rem;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  }

  .header {
    text-align: center;
    margin-bottom: 2rem;
  }

  h1 {
    color: #2c3e50;
    font-size: 2.2rem;
    margin-bottom: 0.5rem;
  }

  h2 {
    color: #34495e;
    font-size: 1.4rem;
    margin-bottom: 1rem;
    border-bottom: 2px solid #3498db;
    padding-bottom: 0.5rem;
  }

  .profile-content {
    display: grid;
    gap: 2rem;
  }

  .info-card, .token-card {
    background: #f8f9fa;
    border: 1px solid #dee2e6;
    border-radius: 8px;
    padding: 1.5rem;
  }

  .info-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.75rem 0;
    border-bottom: 1px solid #e9ecef;
  }

  .info-item:last-child {
    border-bottom: none;
  }

  .info-item label {
    font-weight: 600;
    color: #495057;
    min-width: 150px;
  }

  .info-item span {
    color: #212529;
    word-break: break-all;
  }

  .loading {
    text-align: center;
    color: #6c757d;
    font-style: italic;
    padding: 2rem;
  }

  .error {
    background: #f8d7da;
    color: #721c24;
    padding: 1rem;
    border-radius: 5px;
    border: 1px solid #f5c6cb;
  }

  .retry-btn {
    background: #dc3545;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 3px;
    cursor: pointer;
    margin-top: 0.5rem;
  }

  .retry-btn:hover {
    background: #c82333;
  }

  .token-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 1rem;
  }

  .toggle-btn {
    background: #007bff;
    color: white;
    border: none;
    padding: 0.4rem 0.8rem;
    border-radius: 3px;
    cursor: pointer;
    font-size: 0.9rem;
  }

  .toggle-btn:hover {
    background: #0056b3;
  }

  .token-content {
    background: #f1f3f4;
    padding: 1rem;
    border-radius: 5px;
    border: 1px solid #d1ecf1;
  }

  .token-content code {
    font-family: 'Courier New', monospace;
    word-break: break-all;
    color: #0c5460;
  }

  .actions {
    text-align: center;
    margin-top: 2rem;
  }

  .back-btn {
    background: #6c757d;
    color: white;
    text-decoration: none;
    padding: 0.75rem 1.5rem;
    border-radius: 5px;
    transition: background-color 0.3s;
  }

  .back-btn:hover {
    background: #545b62;
  }

  @media (max-width: 768px) {
    .info-item {
      flex-direction: column;
      align-items: flex-start;
      gap: 0.5rem;
    }

    .info-item label {
      min-width: auto;
    }

    .token-header {
      flex-direction: column;
      gap: 0.5rem;
      align-items: flex-start;
    }
  }
}
3.4. Protection des pages.

Afin d’empêcher l’accès aux pages nécessitant une authentification, on va créer un guard.

cd ${FRONTEND_ROOT_DIR}/src/app/pages
npx @angular/cli@latest generate guard auth

Dans le fichier src/app/pages/auth-guard.ts:

import {CanActivateFn, Router} from '@angular/router';
import {inject} from '@angular/core';
import {Store} from '@ngrx/store';
import {selectIsUserAuthenticated} from '../user/store/user.selectors';
import {map, switchMap, take} from 'rxjs/operators';
import {of} from 'rxjs';
import {checkAuthState} from '../user/store/user.actions';

export const authGuard: CanActivateFn = () => {
    let store = inject(Store);
    let router = inject(Router);

    return store.select(selectIsUserAuthenticated).pipe(
        take(1),
        switchMap(isAuthenticated => {
            if (isAuthenticated) {
                return of(true);
            } else {
                store.dispatch(checkAuthState());
                return store.select(selectIsUserAuthenticated).pipe(
                    take(1),
                    map(isAuth => {
                        if (!isAuth) {
                            router.navigate(['/']);
                            return false;
                        }
                        return true;
                    })
                );
            }
        })
    );
};

Il ne reste plus qu’à l’utiliser sur les pages à protéger dans les routes de notre application :

import { Routes } from '@angular/router';
import { authGuard } from './pages/auth-guard';
import { Home } from './pages/home/home';

export const routes: Routes = [
    {
        path: '',
        component: Home,
    },
    {
        path: 'profile',
        loadComponent: () => import('./pages/profile/profile').then(m => m.Profile),
        canActivate: [authGuard] // <-- on ajoute cette ligne
    },
    {
        path: '**',
        redirectTo: ''
    }
];
3.5 Configuration de l’application.

La dernière étape va consister à brancher l’ensemble des briques afin de configurer l’application pour qu’elle fonctionne avec notre IDP (Keycloak) en éditant le fichier src/app/app.config.ts :

import {ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {provideStore} from '@ngrx/store';
import {provideEffects} from '@ngrx/effects';
import {provideStoreDevtools} from '@ngrx/store-devtools';
import {authInterceptor, LogLevel, provideAuth} from 'angular-auth-oidc-client';

import {routes} from './app.routes';
import {userReducer} from './user/store/user.reducer';
import {UserEffects} from './user/store/user.effects';

import {environment} from '../environments/environment';

export const appConfig: ApplicationConfig = {
    providers: [
        provideBrowserGlobalErrorListeners(),
        provideZoneChangeDetection({eventCoalescing: true}),
        // 👇 on configure le router avec nos routes 
        provideRouter(routes),
        // 👇 on configure le client HTTP avec l'intercepteur permettant d'ajouter automatiquement l'access token
        provideHttpClient(withInterceptors([authInterceptor()])),
        // 👇 on configure NgRx avec notre store et nos effects
        provideStore({user: userReducer}),
        provideEffects([UserEffects]),
        // 👇 on configure le store devtools pour le debuggage (NOTE: on devrait ne le mettre que dans le mode dev)
        provideStoreDevtools({
            maxAge: 25,
            logOnly: false,
            autoPause: true,
            trace: false,
            traceLimit: 75
        }),
        provideAuth({
            config: {
                authority: environment.issuerUrl,
                redirectUrl: window.location.origin,
                postLogoutRedirectUri: window.location.origin,
                clientId: 'angular-client', // Your Keycloak client ID
                scope: 'openid profile email offline_access',
                responseType: 'code',
                silentRenew: true,
                useRefreshToken: true,
                logLevel: LogLevel.Error, // LogLevel.Debug in development
                secureRoutes: [environment.userApiUrl],
                autoUserInfo: false,
            }
        })
    ]
};

NOTE : la partie dev tools peut n’être activer que dans le mode dev (voir ci-dessous 👇).

import {ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {provideStore} from '@ngrx/store';
import {provideEffects} from '@ngrx/effects';
import {provideStoreDevtools} from '@ngrx/store-devtools';
import {authInterceptor, LogLevel, provideAuth} from 'angular-auth-oidc-client';

import {routes} from './app.routes';
import {userReducer} from './user/store/user.reducer';
import {UserEffects} from './user/store/user.effects';
import {environment} from '../environments/environment';

let providers = [
    provideBrowserGlobalErrorListeners(),
    provideZoneChangeDetection({eventCoalescing: true}),
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor()])),
    provideStore({user: userReducer}),
    provideEffects([UserEffects]),
    provideAuth({
        config: {
            authority: environment.issuerUrl,
            redirectUrl: window.location.origin,
            postLogoutRedirectUri: window.location.origin,
            clientId: 'angular-client',
            scope: 'openid profile email offline_access',
            responseType: 'code',
            silentRenew: true,
            useRefreshToken: true,
            logLevel: LogLevel.Error, // LogLevel.Debug in development
            secureRoutes: [environment.userApiUrl],
            autoUserInfo: false,
        }
    })
];

if (!environment.production) {
    providers = [
        ...providers,
        provideStoreDevtools({
            maxAge: 25,
            logOnly: false,
            autoPause: true,
            trace: false,
            traceLimit: 75
        }),
    ];
}

export const appConfig: ApplicationConfig = {
    providers: providers
};
3.6. Gestion du callback OAuth.

Une fois l’utilisateur authentifié auprès du SSO, il sera redirigé sur notre application sur une URL de callback. Cette URL de callback sert à vérifier l’état de connexion avec le SSO. D’un point de vue Angular, cela consiste à la méthode checkAuthState()du service OidcSecurityService de la librairie angular-auth-oidc-client dans le composant lié à cette callack.

On a principalement 2 options présentées ci-dessous 👇 :

3.6.1. Configuration du callback OAuth au chargement de l’application.

La première option est de déclarer la racine de notre application comme callback OAuth.

Les (📈) :

  • la vérification de la connexion au SSO se fait au démarrage de l’application,
  • si l’utilisateur recharge une page, il restera connecté s’il est connecté au SSO.

Les (📉) :

  • séparation de concern, la responsabilité vérifiant l’authentification est mélangé à notre application à son lancement,

Pour cela, on va donc modifier le fichier src/app/app.ts pour gérer ce callback :

import {Component, inject, OnInit, signal} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {checkAuthState} from './user/store/user.actions';
import {Store} from '@ngrx/store';

@Component({
    selector: 'app-root',
    imports: [RouterOutlet],
    templateUrl: './app.html',
    styleUrl: './app.scss'
})
export class App implements OnInit {
    protected readonly title = signal('frontend');
    private readonly store = inject(Store);

    ngOnInit(): void {
        this.store.dispatch(checkAuthState())
    }
}

C’est cette option que je retiens principalament dans mes applications.

3.6.2. Configuration du callback OAuth sur une route dédiée

Une seconde manière de gérer l’authentification est de vérifier l’état de la session utilisateur avec le server OIDC sur une route dédiée.

Les (+) :

  • séparation de concern, un composant associée à la route est responsable uniquement de cela,
  • respecte la philosophie OAuth2 avec une redirect uri de callback.

Les (-) :

  • si l’utilisateur recharge une page, il ne sera plus connecté car la vérification ne se fera que sur la route de callback,

NOTE : la vérification de l’état peut se faire malgré tout techniquement sans passer par le callback OAuth.

Pour cela, on va créer un composant callback :

cd ${FRONTEND_ROOT_DIR}/src/app/pages
npx @angular/cli@latest generate component oidc-callback

On edite le composant src/app/pages/oidc-callback/oidc-callback.ts :

import {Component, inject, OnInit} from '@angular/core';
import {Store} from '@ngrx/store';
import {checkAuthState} from '../../user/store/user.actions';
import {selectIsUserAuthenticated} from '../../user/store/user.selectors';
import {filter, take, tap} from 'rxjs/operators';
import {Router} from '@angular/router';
import {AsyncPipe} from '@angular/common';

@Component({
    selector: 'app-oidc-call-back',
    imports: [
        AsyncPipe
    ],
    template: `
    <p>OIDC Callback</p>
    @let isAuthenticated = isAuthenticated$ | async;
    @if (isAuthenticated) {
      <span>Redirecting to ...</span>
    }
  `
})
export class OidcCallBack implements OnInit {
    private readonly store = inject(Store);
    private readonly router = inject(Router);
    isAuthenticated$ = this.store.select(selectIsUserAuthenticated).pipe(
        take(1),
        tap(async () => await this.router.navigate(['']))
    );

    ngOnInit(): void {
        this.store.dispatch(checkAuthState())
    }
}

On ajoute la route dans src/app/app.routes.ts :

import {Routes} from '@angular/router';
import {authGuard} from './pages/auth-guard';
import {Home} from './pages/home/home';
import {OidcCallBack} from './pages/oidc-call-back/oidc-call-back';

export const routes: Routes = [
  {
    path: '',
    component: Home,
  },
  {
    path: 'profile',
    loadComponent: () => import('./pages/profile/profile').then(m => m.Profile),
    canActivate: [authGuard] // <-- on ajoute cette ligne
  },
  {
    path: 'oidc-callback',
    component: OidcCallBack,
  },
  {
    path: '**',
    redirectTo: ''
  }
];
3.7. Test de l’application.

Il ne nous reste plus qu’à tester votre application :

cd $FRONTEND_ROOT_DIR
bun start

Essayer d’aller sur http://localhost:4200/profile, vous verrez que vous serez rediriger sur la page d’accueil.
Si vous essayez de vous connecter, vous serez rediriger sur la page de connexion de Keycloak.
Une fois authentifié, vous serez redirigés sur la page d’accueil qui vous permettra d’atteindre la page de profil.

NOTE : si vous souhaitez tester la seconde option avec le callback OAuth alors vous pouvez utiliser l’url http://localhost:4200/oidc-callback.

Petites touches de fin

On peut aussi se permettre de supprimer le template d’initialisation mise en place par le CLI Angular.
Pour cela, il suffit de supprimer tout ce qui est placé avant le tag <router-outlet /> dans src/app/app.ts.

Conclusion

C’est tout pour ce long tutoriel. Vous avez maintenant une application Angular qui utilise un SSO (Keycloak) pour l’authentification, NgRx pour la gestion d’état et un backend Rust avec Axum pour récupérer les claims JWT. N’hésitez pas à explorer davantage les fonctionnalités de angular-auth-oidc-client et NgRx pour enrichir votre application.

NOTE : le code source de ce tutoriel est disponible sur GitHub.

Crédits & Ressources