Appearance
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-Tokensur chaque requête (signature HS256, claimsapp/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-loginpour 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 = trueValidations entreprise (dans les deux chemins) :
enterprise_idabsent →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écorateur | Condition 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()compareDate.now() - lastVerifiedAtavecCONSOLE_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 Prisma3.6 Décorateurs
| Décorateur | Fichier | Effet |
|---|---|---|
@Public() | decorators/public.decorator.ts | Bypass complet de AuthGuard |
@RequireAdmin() | decorators/require-admin.decorator.ts | Active la vérification admin dans RolesGuard |
@RequireSuperAdmin() | decorators/require-super-admin.decorator.ts | Active la vérification super-admin |
@Ctx() | decorators/ctx.decorator.ts | Injecte request.userCtx en paramètre de handler |
3.7 AuthController
| Méthode | Route | Auth | Description |
|---|---|---|---|
GET | /api/auth/me | ✅ requis | Retourne 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 }
| Action | Description |
|---|---|
setToken(t) | Valide l'expiry JWT côté client (exp * 1000 > 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
UserContextestGET /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=<chemin>.
4.3 pages/login/callback.vue
- Lit
#app_access_token=dans le hash de l'URL (fragment côté client uniquement, jamais envoyé au serveur). authStore.setToken(token)authStore.fetchMe()window.history.replaceState()— efface le hash pour ne pas exposer le token dans l'historique navigateur.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)
| Variable | Requis | Description |
|---|---|---|
APP_ACCESS_TOKEN_SECRET | ✅ | Clé HS256 partagée avec le portail SSO |
APPLICATION | ✅ | Identifiant de l'app (ex: heriade-contacts) |
ENVIRONMENT | ✅ | Environnement (ex: production, staging, dev) |
CONSOLE_ADMIN_AUTH_ENABLED | ✅ | true en prod, false en dev |
CONSOLE_ADMIN_URL | prod only | URL de la Console Admin (ex: https://admin.heriade.fr) |
CONSOLE_ADMIN_CACHE_TTL_SECONDS | — | Fraîcheur du cache UserContext (défaut : 60 s) |
SESSION_COOKIE_MAX_AGE_SECONDS | — | TTL LRU du cache (défaut : 14400 = 4 h) |
NODE_ENV | ✅ | production bloque dev-login |
Frontend (apps/web/.env)
| Variable | Requis | Description |
|---|---|---|
NUXT_PUBLIC_API_URL | ✅ | URL de l'API NestJS (ex: http://localhost:3001) |
NUXT_PUBLIC_AUTH_APP_URL | ✅ | URL du portail SSO Heriade (login et logout) |
6. Multi-tenancy — rôles et permissions
| Rôle | Visibilité données | Bypass tenant Prisma |
|---|---|---|
member | Uniquement les siennes (ownerUserId = userId) | Non |
administrateur | Toutes celles de son enterpriseId | Non |
super-admin | Toutes (bypass complet du middleware Prisma) | Oui |
isAdmin vaut true si le rôle est administrateur ou super-admin.
7. Tests
Tests unitaires
| Fichier | Couverture |
|---|---|
auth/auth.guard.spec.ts | Token absent, expiré, mauvais app/env, mode ConsoleAdmin on/off, ForbiddenException entreprise |
auth/roles.guard.spec.ts | Bypass sans décorateur, member refusé sur admin, admin accepté sur super-admin |
auth/user-context.cache.spec.ts | get/set/isStale/invalidate, hash SHA-256, TTL |
auth/console-admin.client.spec.ts | Appel 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
/loginsans token - Login via
dev-login→ accès à/contacts - Token invalide → redirection
/login GET /api/auth/meretourne leUserContextattendu
bash
yarn test:e2e --grep "auth"8. User stories couvertes
| Story | Titre | Priorité | Statut |
|---|---|---|---|
| US-1.1 | Connexion via SSO Heriade | Critique MVP | ✅ Done (SSO câblé, dev-login en stub) |
| US-1.2 | Identification de l'enterprise active | Critique MVP | ✅ Done |
| US-1.3 | Rôle et permissions appliqués | Critique MVP | ✅ Done |
| US-1.4 | Session persistante et sécurisée | Important | ✅ Done |
Document v1 · mai 2026 · à synchroniser avec les user stories (heriade-contacts-USER-STORIES.md)
