Skip to content

Module Authentification & Accès — Documentation technique

Epic E1 — Authentification & accès
User stories : US-1.1, US-1.2, US-1.3, US-1.4


1. Responsabilités du module

Le module auth gère l'identité de l'utilisateur à chaque requête HTTP, sans stocker aucun utilisateur en base. Contacts délègue entièrement l'authentification au portail SSO Heriade et à la Console Admin.

Ce que le module fait :

  • Validation du JWT X-App-Access-Token sur chaque requête (signature HS256, claims app/env)
  • Résolution du UserContext (userId, enterpriseId, rôle) depuis la Console Admin ou depuis les claims JWT
  • Cache LRU des contextes utilisateurs (évite un appel réseau par requête)
  • Contrôle d'accès par rôle via décorateurs (@RequireAdmin, @RequireSuperAdmin)
  • Vérification que le compte est de type enterprise (refus des comptes personnels)
  • Endpoint dev-only GET /auth/dev-login pour bypasser le SSO en développement

Ce que le module ne fait pas :

  • Stocker des utilisateurs ou des mots de passe en base
  • Gérer les sessions côté serveur — le JWT est le seul état d'authentification
  • Implémenter le flow SSO lui-même — c'est le portail Heriade qui émet les tokens

2. Flow d'authentification

2.1 Flow production (SSO Heriade)

François ouvre contacts.heriade.fr


middleware/auth.global.ts  →  non authentifié  →  navigateTo('/login')


/login  →  redirige vers  staging.auth.heriade.fr/auth-app/portal

        ▼  (après login SSO)
/login/callback  ←  hash fragment  #app_access_token=<JWT>


authStore.setToken(token)  →  localStorage
authStore.fetchMe()        →  GET /api/auth/me  →  UserContext


navigateTo(redirect || '/contacts')

Le token est transmis dans le hash fragment (#app_access_token=) et non en query param — il ne transitera pas dans les logs serveur ni les réponses HTTP.

2.2 Flow développement (dev-login)

bash
# Génère un JWT valide sans passer par le SSO
GET /api/auth/dev-login
# → { token: "eyJ..." }

# Avec redirect automatique (utilisé par les tests E2E)
GET /api/auth/dev-login?returnTo=http://localhost:3000/login/callback?redirect=/contacts
# → 302  →  /login/callback#app_access_token=<JWT>

Le devLogin est bloqué en production (NODE_ENV === 'production'ForbiddenException).


3. Backend — Architecture

3.1 AuthGuard

Fichier : apps/api/src/auth/auth.guard.ts
Enregistré globalement via APP_GUARD dans AuthModule.

Séquence d'exécution pour chaque requête :

1. isPublic(route) ?  →  oui → canActivate = true  (bypass total)
        │ non

2. token = headers['x-app-access-token']
   absent  →  UnauthorizedException('Token manquant')


3. verifyToken(token)
   • jwt.verify(secret, HS256)
   • payload.app !== APPLICATION  →  ForbiddenException
   • payload.env !== ENVIRONMENT  →  ForbiddenException
        │ payload valide

4a. CONSOLE_ADMIN_AUTH_ENABLED = true  (production)

        ▼  cache hit + non stale ?
        ├── oui  →  userCtx depuis cache
        └── non  →  ConsoleAdminClient.getMe(token)
                    buildUserContextFromDto(dto)
                    cache.set(token, userCtx)
                    ServiceUnavailableException si Console Admin injoignable

4b. CONSOLE_ADMIN_AUTH_ENABLED = false  (développement)


        buildUserContextFromPayload(jwtPayload)
        (rôles et enterprise extraits directement des claims JWT)


5. request.userCtx = userCtx
   canActivate = true

Validations entreprise (dans les deux chemins) :

  • enterprise_id absent → ForbiddenException('Aucune organisation rattachée à votre compte.')
  • enterprise.type !== 'enterprise'ForbiddenException('Heriade Contacts est réservé aux comptes professionnels.')

3.2 RolesGuard

Fichier : apps/api/src/auth/roles.guard.ts
Enregistré globalement après AuthGuard via APP_GUARD.

S'active uniquement si le handler ou le contrôleur porte @RequireAdmin() ou @RequireSuperAdmin(). Sans ces décorateurs, le guard laisse passer.

DécorateurCondition de refus
@RequireAdmin()!ctx.isAdmin && !ctx.isSuperAdmin
@RequireSuperAdmin()!ctx.isSuperAdmin

3.3 UserContextCache

Fichier : apps/api/src/auth/user-context.cache.ts

  • Implémenté avec lru-cache (max 1 000 entrées).
  • La clé de cache est le hash SHA-256 du token brut — le token ne persiste jamais en mémoire en clair.
  • TTL de l'entrée LRU = SESSION_COOKIE_MAX_AGE_SECONDS (défaut : 4 h).
  • Vérification de fraîcheur : isStale() compare Date.now() - lastVerifiedAt avec CONSOLE_ADMIN_CACHE_TTL_SECONDS (défaut : 60 s). Une entrée stale déclenche un nouvel appel à la Console Admin.
  • invalidate(token) permet de forcer la revalidation (non utilisé en v1 — prévu pour les révocations).

3.4 ConsoleAdminClient

Fichier : apps/api/src/auth/console-admin.client.ts

Appelle GET {CONSOLE_ADMIN_URL}/auth/me avec Authorization: Bearer {token}.

Réponse attendue (AuthMeDto) :

ts
interface AuthMeDto {
  user:        { id: string; email: string | null; lastSignInAt: string | null }
  profile:     { enterprise_id: number | null; enterprise?: { id; name; type } | null }
  permissions: { isAdmin: boolean; isSuperAdmin: boolean }
}

Lève Error si CONSOLE_ADMIN_URL n'est pas configuré. Propague les 401/403 de la Console Admin ; enveloppe les autres erreurs réseau en ServiceUnavailableException.

3.5 TenantInterceptor

Fichier : apps/api/src/prisma/tenant.interceptor.ts
Enregistré globalement via APP_INTERCEPTOR dans PrismaModule.

Fait le lien entre AuthGuard (qui peuple request.userCtx) et PrismaService (qui lit l'AsyncLocalStorage). Sans cet interceptor, le middleware Prisma ne saurait pas quel tenant filtrer.

AuthGuard peuple request.userCtx


TenantInterceptor.intercept()
│   prisma.withContext(userCtx, () => next.handle())
│   → AsyncLocalStorage.run(userCtx, fn)


PrismaService.tenantMiddleware
│   contextStorage.getStore()  →  userCtx
│   injecte enterpriseId/ownerUserId dans chaque requête Prisma

3.6 Décorateurs

DécorateurFichierEffet
@Public()decorators/public.decorator.tsBypass complet de AuthGuard
@RequireAdmin()decorators/require-admin.decorator.tsActive la vérification admin dans RolesGuard
@RequireSuperAdmin()decorators/require-super-admin.decorator.tsActive la vérification super-admin
@Ctx()decorators/ctx.decorator.tsInjecte request.userCtx en paramètre de handler

3.7 AuthController

MéthodeRouteAuthDescription
GET/api/auth/me✅ requisRetourne le UserContext de la session courante
GET/api/auth/dev-login@Public()Génère un JWT de dev. Bloqué en production.

4. Frontend — Architecture

4.1 stores/auth.ts (Pinia)

Fichier : apps/web/stores/auth.ts

État : { user: UserContext | null, token: string | null }

ActionDescription
setToken(t)Valide l'expiry JWT côté client (exp * 1000 &gt; Date.now()), stocke en localStorage
fetchMe()GET /api/auth/me → peuple user. En cas d'erreur : clear().
init()Au démarrage : relit le token du localStorage, vérifie l'expiry, appelle fetchMe()
logout()clear() puis redirige vers {AUTH_APP_URL}/logout?returnTo=/login
clear()Efface localStorage et réinitialise l'état

La source de vérité du UserContext est GET /api/auth/me, pas le décodage JWT. Le token décodé sert uniquement à vérifier l'expiry côté client avant de faire l'appel réseau.

4.2 middleware/auth.global.ts

Protège toutes les routes sauf /login et /login/callback. Si l'utilisateur n'est pas authentifié après init(), redirige vers /login?redirect=&lt;chemin&gt;.

4.3 pages/login/callback.vue

  1. Lit #app_access_token= dans le hash de l'URL (fragment côté client uniquement, jamais envoyé au serveur).
  2. authStore.setToken(token)
  3. authStore.fetchMe()
  4. window.history.replaceState() — efface le hash pour ne pas exposer le token dans l'historique navigateur.
  5. navigateTo(redirect || '/contacts')

4.4 plugins/api.ts

Instance $fetch globale préconfigurée. Injecte X-App-Access-Token: {token} sur chaque requête. Sur réponse 401 : authStore.clear(), toast "Votre session a expiré", navigateTo('/login').


5. Variables d'environnement

Backend (apps/api/.env)

VariableRequisDescription
APP_ACCESS_TOKEN_SECRETClé HS256 partagée avec le portail SSO
APPLICATIONIdentifiant de l'app (ex: heriade-contacts)
ENVIRONMENTEnvironnement (ex: production, staging, dev)
CONSOLE_ADMIN_AUTH_ENABLEDtrue en prod, false en dev
CONSOLE_ADMIN_URLprod onlyURL de la Console Admin (ex: https://admin.heriade.fr)
CONSOLE_ADMIN_CACHE_TTL_SECONDSFraîcheur du cache UserContext (défaut : 60 s)
SESSION_COOKIE_MAX_AGE_SECONDSTTL LRU du cache (défaut : 14400 = 4 h)
NODE_ENVproduction bloque dev-login

Frontend (apps/web/.env)

VariableRequisDescription
NUXT_PUBLIC_API_URLURL de l'API NestJS (ex: http://localhost:3001)
NUXT_PUBLIC_AUTH_APP_URLURL du portail SSO Heriade (login et logout)

6. Multi-tenancy — rôles et permissions

RôleVisibilité donnéesBypass tenant Prisma
memberUniquement les siennes (ownerUserId = userId)Non
administrateurToutes celles de son enterpriseIdNon
super-adminToutes (bypass complet du middleware Prisma)Oui

isAdmin vaut true si le rôle est administrateur ou super-admin.


7. Tests

Tests unitaires

FichierCouverture
auth/auth.guard.spec.tsToken absent, expiré, mauvais app/env, mode ConsoleAdmin on/off, ForbiddenException entreprise
auth/roles.guard.spec.tsBypass sans décorateur, member refusé sur admin, admin accepté sur super-admin
auth/user-context.cache.spec.tsget/set/isStale/invalidate, hash SHA-256, TTL
auth/console-admin.client.spec.tsAppel HTTP, propagation 401/403, gestion URL vide
bash
yarn workspace @heriade/api test:unit --testPathPattern="auth"

Tests E2E Playwright

Fichier : apps/e2e/tests/auth.spec.ts

Couverture :

  • Redirection vers /login sans token
  • Login via dev-login → accès à /contacts
  • Token invalide → redirection /login
  • GET /api/auth/me retourne le UserContext attendu
bash
yarn test:e2e --grep "auth"

8. User stories couvertes

StoryTitrePrioritéStatut
US-1.1Connexion via SSO HeriadeCritique MVP✅ Done (SSO câblé, dev-login en stub)
US-1.2Identification de l'enterprise activeCritique MVP✅ Done
US-1.3Rôle et permissions appliquésCritique MVP✅ Done
US-1.4Session persistante et sécuriséeImportant✅ Done

Document v1 · mai 2026 · à synchroniser avec les user stories (heriade-contacts-USER-STORIES.md)