[Tuto] oAuth2.0 via Keycloak pour une application NuxtJS et son API
21/12/2023 - 16 minutes
Enjeu
Nous avons une application web en interface avec une API. L’objectif est de mettre en place une authentification côté application web suivant le workflow oAuth2.0 de type Authorization Code tout en restreignant l’accès à l’API grâce aux jetons générés par le serveur d’authentification.
Stack
L’application web est basée sur le framework NuxtJS 2, nous utilisons le module Nuxt/auth dans sa version next pour gérer le flow d’authentification.
L’API est en NodeJS et utilise le framework KoaJS, le petit frère d’ExpressJS. Nous utilisons Typescript pour profiter un maximum des fonctionnalités de l’ORM Prisma pour l’interaction avec la base de données en PostgreSQL. Les extraits de code comprendront donc cette couche mais vous pourrez tout à fait les réutiliser sans Typescript et avec l’ORM/ODM de votre choix. Vous vous y retrouverez également facilement si votre API est basée sur ExpressJS.
Le serveur d’authentification est Keycloak, une solution open source de gestion des différents workflows de gestion d’accès.
1. Le workflow
1.1. Le standard
Nous souhaitons coller au plus proche de la RFC 6749 et du type de workflow Authorization Code. Cette méthode garantit de ne pas manipuler de données critiques au sein de nos applications via l’utilisation d’un code débloquant la génération du token.
1.2. Notre cas
Dans notre cas, il serait tout à fait possible de gérer le workflow uniquement depuis NuxtJS de façon très simple grâce au module nuxt/auth :
// nuxt.config.js
const KEYCLOAK_BASE_URL = `${VOTRE_HOST_KEYCLOAK}/auth/realms/${VOTRE_REALM_KEYCLOAK}/protocol/openid-connect`;
export default {
// Votre config nuxt...
modules: [
// vos modules...,
'@nuxtjs/auth-next',
],
auth: {
strategies: {
keycloak: {
scheme: 'oauth2',
endpoints: {
authorization: `${KEYCLOACK_BASE_URL}/auth`,
token: `${KEYCLOACK_BASE_URL}/token`,
userInfo: `${KEYCLOACK_BASE_URL}/auth/me`,
logout: `${KEYCLOACK_BASE_URL}/logout`,
},
token: {
property: 'access_token',
type: 'Bearer',
name: 'Authorization',
maxAge: ACCESS_TOKEN_MAX_AGE || 15,
},
refreshToken: {
property: 'refresh_token',
maxAge: REFRESH_TOKEN_MAX_AGE || 60 * 60 * 24 * 30,
},
responseType: 'code',
grantType: 'authorization_code',
clientId: VOTRE_KEYCLOAK_CLIENT_ID,
scope: ['openid', 'profile', 'email'],
codeChallengeMethod: 'S256',
},
},
},
}
⚠️ Pas de panique, nous reviendrons sur les différentes configurations au gré de l’article !
Toutefois, l’enjeu est de s’authentifier côté application web pour contrôler l’accès à notre API. Pour cela nous avons choisi d’utiliser la librairie openid-client qui permet d’interagir depuis le backend avec le serveur d’autorisation (Keycloak).
Ce besoin ajoute quelques contraintes :
- La validation du token côté API se fait via la méthode introspectqui n’est pas utilisable si l’accès au client Keycloak est public, il faut que celui-ci soit confidentiel et donc accessible grâce à un secret qu’il fournit
- L’émission du jeton n’est pas possible sans ce secret et ce n’est pas du tout une bonne pratique de le manipuler côté application web
Il est donc nécessaire de gérer l’ensemble du workflow via l’API qui va elle instancier un client openid avec le secret de façon sécurisée.
Nous nous retrouvons avec ce type de workflow :
2. Configuration
Nous vous présentons une configuration pour tester en local avec l’application NuxtJS et l’API tournant respectivement sur les ports 3000 et 1337 (sur http://localhost) et un conteneur Docker pour Keycloak exposé sur le port 8080.
2.1. Keycloak
Commencez par créer le conteneur Keycloak :
docker run --name keycloack -p 8080:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin -d quay.io/keycloak/keycloak:15.0.2\
Étant donné que nous sommes en local, nous utilisons un accès peu sécurisé avec l’utilisateur et le mot de passe admin. Pour le reste de la commande :
- –name keycloak : Facultatif, donne un nom au conteneur
- -p 8080:8080 : Map le port du conteneur sur le port 8080 de votre machine
- -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin : L’utilisateur administrateur et son mot de passe
- -d : Exécuter le conteneur en mode détaché pour qu’il tourne en tâche de fond
- quay.io/keycloak/keycloak:15.0.2\ : L’image Docker Keycloak
Une fois le conteneur lancé, vous pouvez y accéder depuis votre navigateur : http://localhost:8080/auth/
Pour vous connecter à l’interface administrateur, cliquez sur la tuile Administration Console et rentrez vos identifiants.
Je ne vais ensuite pas détailler la configuration du realm et du client, car tout cela est déjà très bien détaillé dans la documentation officielle.
⚠️ Dans la configuration de votre client, n’oubliez pas de mettre le type d’accès en confidentiel
2.2. L’ API
Nous n’allons pas reprendre toute la configuration d’une API NodeJS/KoaJS ici, les extraits de code suivant partent du principe que :
- Votre serveur et votre application sont initialisés avec les variables d’environnement disponible
- Vous avez une connexion à votre base de données
- Vous avez des routes vers vos différents contrôleurs (users, posts, …)
Pour ma part nous avons un objet config contenant toutes les variables d’environnement nécessaires au fonctionnement de l’API, sa configuration se fait dans le fichier env.ts du dossier config :
import { config as loadDotEnv } from 'dotenv';
loadDotEnv({ path: '.env' }); // A mettre à jour selon votre chemin vers le fichier .env
export interface Config {
version: string;
port: number;
host: string;
logLevel: string;
keycloakHost: string;
keycloakRealm: string;
keycloakClientId: string;
keycloakClientSecret: string;
}
const {
VERSION,
PORT,
HOST,
LOG_LEVEL,
KEYCLOAK_HOST,
KEYCLOAK_REALM,
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
} = process.env;
const config: Config = {
version: VERSION || 'Dummy version',
port: +(PORT || 1337),
host: HOST || 'http://localhost',
logLevel: LOG_LEVEL || 'info',
keycloakHost: KEYCLOAK_HOST || 'http://localhost:8080',
keycloakRealm: KEYCLOAK_REALM || 'master',
keycloakClientId: KEYCLOAK_CLIENT_ID || 'some dummy client id',
keycloakClientSecret: KEYCLOAK_CLIENT_SECRET || 'some dummy secret',
};
export default config;
2.2.1. Initialiser le client openid
Le client openid est configuré dans le fichier openId.ts du dossier config :
import { Client, Issuer } from 'openid-client';
// Nous utilisons des alias pour les chemins relatifs a l'API
import config from '@config/env';
// Cette constante sera réutilisée dans un contrôleur
export const OPEN_ID_REALM_URL = `${config.keycloakHost}/auth/realms/${config.keycloakRealm}`;
// On initialise la variable qui sera réassignée au lancement du serveur
let issuer: Issuer<Client>;
// Fonction qui initialise l'issuer au lancement du serveur
const initOpenIdIssuer = async (): Promise<void> => {
issuer = await Issuer.discover(OPEN_ID_REALM_URL);
};
// Fonction qui permet de récupérer le client openid parametré avec l'id et le secret dans l'API
export const getOpenIdClient = (): Client =>
new issuer.Client({
client_id: config.keycloakClientId,
client_secret: config.keycloakClientSecret,
});
export default initOpenIdIssuer;
L’initialisation se fait au lancement du serveur, de notre côté nous le faisons dans une IIFE :
import { createServer } from 'http';
import initApp from '@config/app';
import config from '@config/env';
import initOpenIdIssuer from '@config/openId';
(async () => {
// Initialisation de l'issuer
await initOpenIdIssuer();
// Initialisation de l'application KoaJS et du serveur HTTP
const app = initApp();
const server = createServer(app.callback());
// Lancement du serveur
server.listen(config.port);
})();
2.2.2. Création des routes
Vous adapterez la logique à votre architecture. Pour notre exemple nous avons les routes et les contrôleurs dans un dossier api/namespace, pour l’authentification nous travaillons dans api/auth :
src/api/auth
├── controllers
│ └── index.ts
├── routes.ts
└── tools
└── index.ts
Nos routes sont simplement configurées dans le fichier routes.ts, un middleware ajoutant toutes les routes de l’API est implémenté dans le paramétrage de l’application.
Pour notre workflow d’authentification nous avons besoin de trois routes : auth (GET /auth) pour la redirection vers le formulaire du serveur Keycloak, token (POST /auth/token) pour la génération du token et son rafraichissement, me (GET /auth/me) pour récupérer les informations utilisateurs :
import { Middleware } from 'koa';
import controllers from '@api/auth/controllers';
interface Route {
method: 'get' | 'post' | 'patch' | 'delete';
path: string;
handler: Middleware;
middlewares?: Middleware[];
}
interface RouteDefinition {
prefix: string;
routes: Route[];
}
const authRoutes: RouteDefinition = {
prefix: '/auth',
routes: [
{
method: 'get',
path: '/',
handler: controllers.redirectToOpenIdAuth,
},
{
method: 'post',
path: '/token',
handler: controllers.generateToken,
},
{
method: 'get',
path: '/me',
handler: controllers.me,
},
],
};
export default authRoutes;
Et le typage du contrôleur :
import { Middleware } from 'koa';
interface AuthController {
redirectToOpenIdAuth: Middleware;
generateToken: Middleware;
me: Middleware;
}
2.2.4. Redirection vers le formulaire d’authentification
Le rôle de ce contrôleur (de la route /auth) est de réceptionner la requête provenant de l’application web au clic sur le bouton “Se connecter” pour rediriger le client vers la page d’authentification du serveur Keycloak.
La requête en GET fourni des paramètres nécessaires a la redirection :
- L’id du client Keycloak
- Le type de réponse attendu, dans notre cas il s’agit de code (cf. la configuration NuxtJS ci-dessous)
- L’URI de redirection une fois l’authentification valide côté Keycloak (vers l’application web)
- Le state est une chaine de caractères random qui sert à la vérification du call pour se protéger des attaques XSS
async redirectToOpenIdAuth(ctx) {
const neededParams = ['client_id', 'response_type', 'state', 'redirect_uri'];
// Reduce qui permet le formattage et le filtrage des query params
const queryParams = Object.entries(ctx.request.query).reduce(
(acc, [key, value]) => (neededParams.includes(key) ? `${acc}&${key}=${value}` : acc),
''
);
// URI de redirection basee sur la variable definie precedemment
const redirectUri = `${OPEN_ID_REALM_URL}/protocol/openid-connect/auth?${queryParams}`;
// Redirection en reponse
ctx.redirect(redirectUri);
},
Suite à quoi le client est redirigé vers la page d’authentification Keycloak. L’étape suivante se fait donc à la validation du formulaire qui va renvoyer vers l’application web qui va elle directement requêter la route (configurée dans nuxt.config.js comme nous le verrons après) pour récupérer le jeton grâce à l’authorization code /auth/token.
2.2.5. Génération du token
Nous allons commencer par présenter le contrôleur dans sa version la plus simple, sans la gestion du refresh token. Nous avons fait le choix de séparer la logique liée au client openid dans un fichier outil pour clarifier le code dans le contrôleur.
La logique dans ce contrôleur est la suivante :
- Vérifier le type de grant
- Générer le token
- Valider le token
- Renvoyer le token en réponse
⚠️ Les nombreuses conditions font office de type guards étant donné que Typescript ne considère pas le ctx.throw comme une fin de script
async generateToken(ctx) {
const { body } = ctx.request;
const { grant_type } = body;
// Initialisation du tokenSet
let tokenSet: TokenSet | null = null;
// Generation du token si le grant type est bon
if (grant_type === 'authorization_code') {
tokenSet = await generateToken(body);
}
// Levee d'une exception si pas de token
if (!tokenSet || !tokenSet.access_token) {
// Revocation du token au cas ou il manque l'access token dans le set
if (tokenSet) {
await revokeTokenSet(tokenSet);
}
ctx.throw(400);
} else {
// Validation du token pour voir si l'utilisateur existe aussi dans la base de donnees metier
const isTokenValid = await validateToken(tokenSet.access_token);
// Si l'utilisateur existe en base, on renvoie le token, sinon on leve une exception apres avoir revoke le set de token
if (isTokenValid) {
ctx.body = tokenSet;
} else {
await revokeTokenSet(tokenSet);
ctx.throw(400);
}
}
},
Les outils utilisés ici sont simples :
type GenerateToken = (args: {
code: string;
redirect_uri: string;
grant_type: 'authorization_code';
}) => Promise<TokenSet>;
export const generateToken: GenerateToken = ({ code, redirect_uri, grant_type }) =>
getOpenIdClient().grant({
grant_type,
code,
redirect_uri,
});
type ValidateToken = (token: string) => Promise<boolean>;
export const validateToken: ValidateToken = async (token) => {
const decodedToken = decode(token);
if (!decodedToken || typeof decodedToken === 'string') {
return false;
}
const { email } = decodedToken;
const user = await userService.findUnique({ where: { email } });
return !!user;
};
type RevokeTokenSet = (tokenSet: TokenSet) => Promise<void>;
export const revokeTokenSet: RevokeTokenSet = async (tokenSet: TokenSet) => {
const { access_token, refresh_token } = tokenSet;
const openIdClient = getOpenIdClient();
if (access_token) {
await openIdClient.revoke(access_token);
}
if (refresh_token) {
await openIdClient.revoke(refresh_token);
}
};
Le set de token est renvoyé à l’application web, comprenant l’access token et le refresh token. La suite du workflow est la récupération des données utilisateurs sur la route /auth/me configurée dans le nuxt.config.js.
2.2.6. Récupération des informations utilisateurs
Cette route est protégée, nous allons voir après comment assurer la protection de l’API. L’accès est paramétré selon la présence d’un token Keycloak valide ou non sur l’ensemble des routes de l’API, excepté les routes /auth et /auth/token décrites plus haut.
Le contrôleur a pour rôle de retrouver l’utilisateur correspondant au token en base de données pour retourner le profil utilisateur qui sera stocké dans le state de notre application dans la variable $auth.user.
⚠️ On passe par le middleware openid sur cette route, son rôle est de checker la validité du token et de peupler la propriété state du context Koa avec les données utilisateurs
async me(ctx) {
// On recupere l'utilisateur stocké dans le state par le middleware
const { user } = ctx.state;
// Si pas d'utilisateur on lève une exception
if (!user) {
ctx.throw(404, 'Not found');
}
// Sinon on renvoie l'utilisateur en réponse
ctx.body = user;
},
2.2.7. Le middleware openid
Il est le gardien de l’API, toutes les requêtes passent par lui. Son rôle est de :
- Laisser passer les requêtes vers /auth et /auth/token
- Vérifier la validité du token pour toutes les autres routes
- Peupler le state du contexte Koa avec les données utilisateurs si le token est valide
- Renvoyer une erreur 403 si le token est invalide
import { Middleware } from 'koa';
import { getOpenIdClient } from '@config/openId';
import userService from '@api/user/service';
const openIdMiddleware: () => Middleware = () => async (ctx, next) => {
// On recupere le header authorization contenant le token et l'URL sans les query parametres
const { authorization } = ctx.headers;
const [baseUrl] = ctx.url.split('?');
// On check si la route est whiteliste ou non
if (!isRouteAuthorized(baseUrl)) {
// Si ce n'est pas le cas et qu'il n'y a pas de token, on leve une exception 403
if (!authorization) {
ctx.throw(403, 'Unauthorized');
} else {
// Sinon on recupere le token, on verifie sa validite et on recupere l'email utilisateur
const token = getToken(authorization);
const { active, email } = await getOpenIdClient().introspect(token, 'access_token');
// Si le token n'est pas actif ou si le mail n'existe pas, on leve une 403
if (!active || !email || typeof email !== 'string') {
ctx.throw(403, 'Unauthorized');
} else {
// Sinon on verifie que l'utilisateur soit bien existant cote metier
const user = await userService.findUnique({ where: { email }, include: { roles: true } });
// Si ce n'est pas le cas, on leve une 403, sinon on complete le state du contexte avec l'utilisateur
if (!user) {
ctx.throw(403, 'Unauthorized');
} else {
ctx.state.user = user;
}
}
}
}
// Standard pour un middleware KoaJS
await next();
};
// Les routes whitelistees
const isRouteAuthorized = (url: string): RegExpMatchArray | null =>
url.match(/^\/auth$/) || url.match(/^\/auth\/token$/);
const getToken = (authorizationHeader: string): string => authorizationHeader.split(' ')[1];
export default openIdMiddleware;
On va ensuite l’importer et l’initialiser dans la chaine de middleware de l’API, nous avons pour ça un fichier app.ts à la racine qui exporte la fonction initApp utilisée dans le fichier server.ts :
const initApp = (): Koa => {
const app = new Koa();
app.use(logger({ level: config.logLevel }));
app.use(helmet());
// Le middleware openid est apres helmet et avant les cors, cela evite d'aller trop loin dans la chaine si l'acces est refuse
app.use(openIdMiddleware());
app.use(cors());
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
return app;
};
Pour le moment on en a terminé avec la configuration côté API, le gros morceau est passé car la configuration côté NuxtJS est très simple grâce au module nuxt/auth !
3. NuxtJS avec nuxt/auth
La première étape est d’installer le module, la documentation recommande d’utiliser une version précise pour :
npm install --save-exact @nuxtjs/auth-next
⚠️ Il est nécessaire d’avoir installé le module @nuxtjs/axios également
Ensuite dans le fichier nuxt.config.js il faut ajouter le module à la liste de modules :
// nuxt.config.js
modules: [
'@nuxtjs/axios',
'@nuxtjs/auth-next',
],
Puis configurer le module via la propriété auth dans le même fichier
// nuxt.config.js
auth: {},
Cette configuration passe principalement par la définition de stratégies, nous allons définir une stratégie nommée keycloak :
auth: {
strategies: {
keycloak: {
scheme: '~/config/authScheme',
},
},
},
3.1. Le scheme custom
Vous avez remarqué ? Le scheme est custom :
import { Oauth2Scheme } from '@nuxtjs/auth-next/dist/runtime';
function encodeQuery(queryObject) {
return Object.entries(queryObject)
.reduce((acc, [key, value]) => {
if (typeof value !== 'undefined') {
acc.push(encodeURIComponent(key) + (value !== null ? '=' + encodeURIComponent(value) : ''));
}
return acc;
}, [])
.join('&');
}
export default class KeycloakScheme extends Oauth2Scheme {
logout() {
if (this.options.endpoints.logout) {
const opts = {
client_id: this.options.clientId + '',
redirect_uri: this.logoutRedirectURI,
};
const url = this.options.endpoints.logout + '?' + encodeQuery(opts);
window.location.replace(url);
}
return this.$auth.reset();
}
}
Il s’agit d’une extension du scheme oAuth2 du module. Cette extension ne devrait pas être nécessaire mais à l’heure actuelle il existe une issue ouverte à cause d’un problème de redirection au logout. Cet override de la méthode logout est donc un fix temporaire. Rien n’entrave le flow par défaut il s’agit simplement d’un fix de la redirection. À terme, on pourra utiliser le scheme oAuth2 à la place.
3.2. Les endpoints
Les endpoints ne correspondent pas à la configuration précédemment décrite dans cet article car l’API va faire office de proxy entre notre application web et keycloak. Ainsi, les routes authorization, token et userInfo correspondent aux trois routes côté API. Le logout ne nécessite pas de proxy car une fois la session keycloak révoquée, l’utilisateur est déconnecté de l’application web et les jetons stockés sont révoqués et ne passeront donc plus le middleware openid de l’API.
auth: {
strategies: {
keycloak: {
scheme: '~/config/authScheme',
},
endpoints: {
authorization: `${API_URL}/auth`,
token: `${API_URL}/auth/token`,
userInfo: `${API_URL}/auth/me`,
logout: `${KEYCLOACK_BASE_URL}/logout`,
},
},
},
API_URL correspond à l’URL de base de l’API (e.g. http://localhost:1337).KEYCLOAK_BASE_URL prend cette structure :
const KEYCLOACK_BASE_URL = `${process.env.KEYCLOAK_REMOTE_HOST}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect`;
3.3. Les autres paramètres
Je vous laisse vous référerauxréférencesduscheme pour les autres paramètres. Il faut juste obligatoirement renseigné le clientId que vous trouverez dans Keycloak et la propriété scope qui est configuré comme cela :
scope: ['openid', 'profile', 'email'],
Il faut également paramétrer le responseType à la valeur code et,car nous utilisons un flow intégrant PKCE avec Keycloak, ajouter la propriété codeChallengeMethod a S256
3.4. Configuration complète
La configuration complète reprend des valeurs par défaut pour être explicite :
const KEYCLOACK_BASE_URL = `${process.env.KEYCLOAK_REMOTE_HOST}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect`;
const { ACCESS_TOKEN_MAX_AGE, REFRESH_TOKEN_MAX_AGE, KEYCLOAK_CLIENT_ID, API_URL } = process.env;
export default {
telemetry: true,
// Votre configuration NuxtJS...
modules: [
'@nuxtjs/axios',
'@nuxtjs/auth-next',
],
router: {
middleware: ['auth'],
},
axios: {
baseURL: API_URL || 'http://localhost:1337',
},
auth: {
redirect: {
logout: '/login',
},
strategies: {
keycloak: {
scheme: '~/config/authScheme',
endpoints: {
authorization: `${API_URL}/auth`,
token: `${API_URL}/auth/token`,
userInfo: `${API_URL}/auth/me`,
logout: `${KEYCLOACK_BASE_URL}/logout`,
},
token: {
property: 'access_token',
type: 'Bearer',
name: 'Authorization',
maxAge: ACCESS_TOKEN_MAX_AGE || 15,
},
refreshToken: {
property: 'refresh_token',
maxAge: REFRESH_TOKEN_MAX_AGE || 60 * 60 * 24 * 30,
},
responseType: 'code',
grantType: 'authorization_code',
clientId: KEYCLOAK_CLIENT_ID,
scope: ['openid', 'profile', 'email'],
codeChallengeMethod: 'S256',
},
},
},
};
Le middleware auth fourni par le module est ajouté en tant que middleware global à toutes les routes, il fera ainsi son travail de redirection pour toutes les routes de notre application web. Ce fonctionnement peut être affiné en ajoutant le middleware au niveau des layouts ou au niveau des pages elles-mêmes.
Il ne manque plus que la dernière brique pour avoir un workflow complet : le refresh token !
En bonus : Le refresh token
Tout est déjà paramétré côté application web grâce au module nuxt qui utilise un scheme standard oAuth2.0. La seule chose à gérer est le double cas d’usage de la route token :
- Dans le cas d’une première connexion suivant le type de grant Authorization code
- Dans le cas d’un rafraichissement du token et donc du type de grant refresh token
Pour cela c’est assez simple, il suffit d’ajouter un traitement conditionnel selon le type de grant dans le contrôleur dédié :
async generateToken(ctx) {
const { body } = ctx.request;
const { grant_type } = body;
let tokenSet: TokenSet | null = null;
// On ajoute le traitement du refresh selon le type de grant
if (grant_type === 'authorization_code') {
tokenSet = await generateToken(body);
} else if (grant_type === 'refresh_token') {
tokenSet = await refreshToken(body);
}
if (!tokenSet || !tokenSet.access_token) {
if (tokenSet) {
await revokeTokenSet(tokenSet);
}
ctx.throw(400);
} else {
const isTokenValid = await validateToken(tokenSet.access_token);
if (isTokenValid) {
ctx.body = tokenSet;
} else {
await revokeTokenSet(tokenSet);
ctx.throw(400);
}
}
},
L’outil refresh token est très simple :
type RefreshToken = (args: {
refresh_token: string;
grant_type: 'refresh_token';
}) => Promise<TokenSet>;
export const refreshToken: RefreshToken = ({ refresh_token, grant_type }) =>
getOpenIdClient().grant({
grant_type,
refresh_token,
});
Grâce à cela, à chaque rafraichissement les deux tokens sont renouvelés, apportant ainsi un confort d’usage à l’utilisateur tout en garantissant une sécurité forte.
Le workflow oAuth2.0 de type Authorization Code est ainsi complet ! Les routes de l’API sont sécurisées grâce à son interfaçage avec Keycloak et la gestion du refresh token est automatique et transparente côté NuxtJS.