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.