MesMy — Étape 1 : Architecture générale & schéma de données¶
Statut : à valider avant de passer à l'implémentation (étape 2). Document de conception. Aucun code applicatif n'est encore généré. Choix confirmés avec le commanditaire : issuer
auth.mesmy.fr, ORM Prisma, emails via Mailjet (API), monorepo.
1. Décisions d'architecture (et justifications)¶
| Sujet | Décision | Justification courte |
|---|---|---|
| Issuer OIDC | https://auth.mesmy.fr |
Sous-domaine dédié : isole l'IdP du portail, permet de le déplacer/scaler seul, découple l'identité cryptographique du SSO du domaine racine (qui peut évoluer). |
| Domaine portail | https://apps.mesmy.fr |
Le portail utilisateur + back-office. mesmy.fr reste un site vitrine indépendant. Les trois domaines partagent le suffixe .mesmy.fr, ce qui permet le partage contrôlé des cookies (voir §4). |
| Site vitrine | https://mesmy.fr |
Hors périmètre MesMy (site marketing indépendant). Non géré par cette stack. |
| ORM | Prisma | Typage fort de bout en bout, migrations déclaratives versionnées, bonne DX. Le moteur OIDC gère sa propre persistance via Redis, l'ORM ne couvre donc que la couche métier (users, rôles, clients, invitations, audit). |
| Structure dépôt | Monorepo (pnpm workspaces + Turborepo) | Types TypeScript partagés backend/frontend, un seul docker-compose, un seul pipeline CI, versionnage cohérent. Adapté à un projet interne mono-VPS. |
| Emails | Mailjet via API (node-mailjet) |
Bonne délivrabilité, pas de serveur mail à gérer sur le VPS, niveau gratuit suffisant pour ~20 utilisateurs. Encapsulé derrière une interface MailerService pour rester remplaçable. |
| Moteur OIDC | oidc-provider (Panva) v9.x |
Implémentation certifiée OpenID. ESM-only depuis la 9.7 → intégrée dans NestJS (CommonJS) via import() dynamique dans un provider dédié (voir §6). Adapter Redis obligatoire (pas d'in-memory). |
| Hash mots de passe | Argon2id (argon2) |
Recommandation OWASP. Paramètres configurables par variables d'env. |
| MFA | TOTP (otplib), optionnel |
Secret chiffré au repos (AES-256-GCM). Schéma pensé pour un futur MFA obligatoire par rôle sans migration lourde (voir §7). |
1.1 Versions clés vérifiées (25/07/2026)¶
oidc-provider: dernière version 9.10.0 (ESM-only). À épingler danspackage.json.- Exemple Laravel :
jumbojett/openid-connect-phpretenu — il fait un vrai flux de connexion par redirection avec PKCE S256 (setCodeChallengeMethod) tout en authentifiant le client confidentiel, ce qu'exige MesMy.maicol07/laravel-oidc-client(driver de guard Laravel-natif) est cité comme alternative, mais son support PKCE n'est pas garanti.devadamlar/laravel-oidca été écarté : c'est un valideur de jetons côté API (auth:api), pas un client de connexion. - Toutes les dépendances majeures seront épinglées et vérifiées (aucune non maintenue / CVE connue) au moment de la génération de chaque étape.
2. Structure du monorepo¶
mesmy/
├─ apps/
│ ├─ backend/ # NestJS : API métier + moteur OIDC + interactions
│ │ ├─ prisma/ # schema.prisma + migrations
│ │ └─ src/
│ │ ├─ oidc/ # intégration oidc-provider + adapter Redis
│ │ ├─ auth/ # login local, sessions portail, MFA
│ │ ├─ users/ roles/ clients/ invitations/ audit/
│ │ └─ mailer/ # abstraction Mailjet
│ └─ frontend/ # Next.js (App Router) + Tailwind
│ └─ src/app/
│ ├─ (auth)/ # login, interaction OIDC, mot de passe oublié
│ ├─ (portal)/ # dashboard, mon compte
│ └─ (admin)/ # back-office
├─ packages/
│ └─ shared/ # types partagés (DTO, enums) back <-> front
├─ examples/
│ ├─ client-node-express/ # exemple openid-client (étape 6)
│ └─ client-laravel/ # exemple Laravel OIDC (étape 6)
├─ docs/ # MkDocs Material (étape 7)
├─ docker-compose.yml # postgres, redis, backend, frontend (SANS proxy)
├─ .env.example
├─ turbo.json
├─ pnpm-workspace.yaml
└─ README.md
Pourquoi un seul backend pour l'API métier ET l'OIDC ? Le cahier des charges impose
oidc-provider intégré directement dans NestJS. Le moteur OIDC et la couche métier
partagent le même modèle utilisateur et la même base ; les séparer imposerait un appel
réseau interne à chaque findAccount. On garde donc un seul service backend, avec une
séparation nette par modules Nest.
3. Topologie Docker (sans reverse-proxy)¶
Le reverse-proxy externe existant gère le TLS et route les domaines vers les ports exposés. Les services MesMy exposent uniquement leurs ports sur le VPS.
┌──────────────────────── VPS ────────────────────────┐
Internet │ Reverse-proxy EXISTANT (hors compose, TLS + routing) │
│ │ │ │ │
▼ │ ▼ ▼ │
auth.mesmy.fr ──┼──> host:4000 (backend NestJS) host:3000 <── apps.mesmy.fr (frontend Next.js)
│ │ │ \__________________________/ │
│ │ ▼ ▼ │
│ │ postgres:5432 redis:6379 (réseau interne only) │
└──────────┴───────────────────────────────────────────────────────┘
| Service | Image | Port hôte exposé | Exposé au proxy ? | Rôle |
|---|---|---|---|---|
backend |
build local (Node 22) | 4000 |
Oui → auth.mesmy.fr + /api |
API métier + endpoints OIDC + pages d'interaction |
frontend |
build local (Node 22) | 3000 |
Oui → apps.mesmy.fr |
Portail + back-office |
postgres |
postgres:17-alpine |
interne uniquement | Non | Base de données métier |
redis |
redis:7-alpine |
interne uniquement | Non | Sessions/codes/tokens OIDC + rate limiting |
- Postgres et Redis ne sont pas publiés sur l'hôte (accès uniquement via le réseau Docker interne) → surface d'attaque réduite.
- Le proxy externe route
auth.mesmy.frversbackend:4000etapps.mesmy.frversfrontend:3000. Le frontend appelle le backend en interne (http://backend:4000) pour le SSR, et viaauth.mesmy.frcôté navigateur.
4. Domaines, sessions et cookies (le point délicat)¶
Le flux d'interaction (écran de login) de oidc-provider s'appuie sur des cookies de
session posés sur l'origine de l'issuer (auth.mesmy.fr). Or l'UI de login est servie
par Next.js sur apps.mesmy.fr. Solution retenue :
- Les cookies de l'
oidc-provider(session + interaction) sont configurés avecdomain: .mesmy.fr,SameSite=Lax,Secure,HttpOnly. - Ils sont ainsi partagés entre
apps.mesmy.fretauth.mesmy.fr(même site parent). - Le backend active CORS avec credentials pour l'origine
https://apps.mesmy.fr. - L'UI de login (Next.js) affiche le formulaire, POST les identifiants vers les
endpoints d'interaction du backend (
auth.mesmy.fr/interaction/:uid/...), qui valident et appellentprovider.interactionFinished().
Deux notions de « session » distinctes, à ne pas confondre :
- Session OIDC (SSO) : gérée par
oidc-provider, persistée dans Redis. C'est elle qui permet le « single sign-on » entre MyAccred, MyMojito, etc. - Session portail (table
Sessionen base) : trace des connexions au portail lui-même, pour la visibilité admin et la révocation. Voir §7.
5. Flux OIDC — Authorization Code + PKCE¶
App tierce (ex: MyAccred) Navigateur auth.mesmy.fr (MesMy IdP)
│ │ │
│ 1. redirect /authorize │ │
│ (client_id, PKCE challenge, │ │
│ redirect_uri, scope, state) │ │
├───────────────────────────────>│ 2. GET /authorize │
│ ├───────────────────────>│
│ │ 3. pas de session → │
│ │ redirect /interaction
│ │<───────────────────────┤
│ │ 4. login (email+mdp, │
│ │ MFA si activé) │
│ ├───────────────────────>│
│ │ 5. consent implicite │
│ │ (clients internes) │
│ │ 6. redirect_uri?code │
│ │<───────────────────────┤
│ 7. code renvoyé à l'app │ │
│<───────────────────────────────┤ │
│ 8. POST /token (code + │ │
│ code_verifier PKCE) │ │
├────────────────────────────────────────────────────────>│
│ 9. id_token + access_token │ │
│<────────────────────────────────────────────────────────┤
│ 10. GET /userinfo (Bearer) │ │
├────────────────────────────────────────────────────────>│
│ 11. profil (name, email, roles, apps…) │
Endpoints exposés (via .well-known/openid-configuration) :
authorization, token, userinfo, jwks, introspection, revocation, end_session.
- PKCE S256 obligatoire pour tous les clients.
- Validation stricte des
redirect_uri: correspondance exacte avec la liste déclarée par client (pas de wildcard). - Consentement auto-approuvé pour les clients internes de confiance (config par client), tout en gardant la mécanique de consentement disponible.
6. Intégration oidc-provider (ESM) dans NestJS (CommonJS)¶
oidc-provider v9 est ESM-only. NestJS compile en CommonJS. Contraintes et solution :
- Chargement via
import()dynamique dans unOidcProviderfactory (provider Nest asynchrone), pas viaimport ... from. - L'instance
Providerest configurée avec : adapter: implémentation Redis maison (un adapter par type de modèle :AccessToken,AuthorizationCode,RefreshToken,Session,Interaction,Grant,Clientoptionnel…). TTL alignés sur la durée de vie de chaque artefact.findAccount: branché sur la tableUser(Prisma) → renvoie les claims (name,email,phone,slack_uid,roles,apps).clients: chargés dynamiquement depuis la tableOidcClient(voir adapterClientRedis + source Postgres), pour permettre l'ajout de clients depuis l'admin sans redémarrage.jwks: clés de signature (RS256) chargées depuis un secret monté, rotation documentée (étape 7).cookies.keys: rotation de clés de signature de cookies.interactions.url: pointe vers les routes d'interaction du backend.ttl: fournies sous forme de fonctions (et non de constantes) qui lisent les durées de vie depuis leSettingsService(tableSetting, cache Redis courte durée). C'est ce qui rend les durées modifiables depuis l'admin sans redémarrer le provider (exigence du point 4). Un changement en admin invalide le cache et prend effet sur les prochains artefacts émis.- Le montage se fait via un middleware Express/Fastify sous NestJS (le
Providerexpose un handler HTTP standard).
7. Schéma de données¶
7.1 Diagramme entités-relations¶
erDiagram
User ||--o{ UserRole : "possède"
Role ||--o{ UserRole : "attribué à"
User ||--o{ AppAccess : "accède à"
OidcClient ||--o{ AppAccess : "autorise"
User ||--o{ Invitation : "invité par token"
User ||--o{ PasswordResetToken : "reset"
User ||--o{ Session : "sessions portail"
User ||--o{ AuditLog : "acteur"
User {
uuid id PK
string email UK
string name
string phone
string slackUid
string passwordHash "nullable avant activation"
enum status "INVITED|ACTIVE|DISABLED"
bool mfaEnabled
bytes mfaSecretEnc "chiffré, nullable"
datetime mfaEnrolledAt
datetime lastLoginAt
datetime createdAt
datetime updatedAt
}
Role {
uuid id PK
string name UK "superadmin|admin|user…"
string description
bool isAdmin "accès back-office"
bool isSuperAdmin
bool mfaRequired "anticipe MFA forcé par rôle"
datetime createdAt
}
UserRole {
uuid userId FK
uuid roleId FK
datetime assignedAt
}
OidcClient {
uuid id PK
string clientId UK
string clientSecretHash "jamais en clair après création"
string displayName
string logoUrl
string portalUrl
string[] redirectUris
string[] postLogoutRedirectUris
string[] allowedScopes
string[] grantTypes
string tokenEndpointAuthMethod
bool requirePkce "défaut true"
bool autoApproveConsent
bool isActive
datetime secretRotatedAt
datetime createdAt
datetime updatedAt
}
AppAccess {
uuid userId FK
uuid clientId FK
datetime grantedAt
}
Invitation {
uuid id PK
uuid userId FK
string tokenHash UK "SHA-256 du token"
datetime expiresAt
datetime usedAt "nullable"
uuid createdByAdminId FK
datetime createdAt
}
PasswordResetToken {
uuid id PK
uuid userId FK
string tokenHash UK
datetime expiresAt
datetime usedAt
datetime createdAt
}
Session {
uuid id PK
uuid userId FK
string tokenHash UK "hash du cookie de session portail"
string ip
string userAgent
datetime createdAt
datetime expiresAt
datetime revokedAt "nullable"
}
AuditLog {
uuid id PK
uuid actorUserId FK "nullable = système"
string action
string targetType
string targetId
json metadata
string ip
string userAgent
datetime createdAt
}
7.2 Choix de modélisation¶
- Rôles many-to-many (
UserRole) plutôt qu'un champ unique : un utilisateur peut cumuler des rôles, et la distinction superadmin / admin restreint est portée par les flagsisSuperAdmin/isAdminsurRole. Extensible sans migration. Role.mfaRequired: présent dès la v1 mais inexploité côté enforcement. Passer à un MFA obligatoire par rôle (ex. forcer les admins) = activer un flag + une règle dans le flux d'interaction, sans migration de schéma.User.mfaSecretEnc: secret TOTP chiffré (AES-256-GCM, clé applicative dédiée), jamais stocké en clair.mfaEnabledpiloté par l'utilisateur.OidcClient.clientSecretEnc: le secret n'est affiché qu'une fois à la création/rotation, puis jamais réexposé dans l'admin. Il est stocké chiffré (AES-256-GCM), et non haché :oidc-providera besoin du secret réel pour authentifier le client (client_secret_basic/client_secret_jwt), ce qu'un hash à sens unique interdirait.secretRotatedAttrace la rotation. La même clé applicative (APP_ENCRYPTION_KEY) chiffre les secrets clients et les secrets TOTP.AppAccess: table de jonction qui pilote à la fois le dashboard utilisateur (« mes apps ») et le filtrage des claims OIDC (appsrenvoyées à chaque client).Invitation/PasswordResetToken: on stocke le hash du token, jamais le token en clair. Usage unique (usedAt) + expiration (expiresAt, configurable). Régénérer une invitation = créer une nouvelle ligne et invalider les précédentes.Session: sessions portail (visibilité admin + révocation). Les artefacts OIDC (codes, access/refresh tokens, sessions SSO) vivent dans Redis, pas ici.AuditLog:actionen chaîne normalisée (user.created,user.disabled,client.secret_rotated,auth.login,role.changed…),metadataJSON pour le contexte. Append-only.
7.3 Extrait schema.prisma (proposition)¶
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum AccountStatus {
INVITED
ACTIVE
DISABLED
}
model User {
id String @id @default(uuid())
email String @unique
name String?
phone String?
slackUid String? @map("slack_uid")
passwordHash String? @map("password_hash")
status AccountStatus @default(INVITED)
mfaEnabled Boolean @default(false) @map("mfa_enabled")
mfaSecretEnc Bytes? @map("mfa_secret_enc")
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
roles UserRole[]
apps AppAccess[]
invitations Invitation[]
resetTokens PasswordResetToken[]
sessions Session[]
auditLogs AuditLog[] @relation("ActorAudit")
@@map("users")
}
model Role {
id String @id @default(uuid())
name String @unique
description String?
isAdmin Boolean @default(false) @map("is_admin")
isSuperAdmin Boolean @default(false) @map("is_super_admin")
mfaRequired Boolean @default(false) @map("mfa_required")
createdAt DateTime @default(now()) @map("created_at")
users UserRole[]
@@map("roles")
}
model UserRole {
userId String @map("user_id")
roleId String @map("role_id")
assignedAt DateTime @default(now()) @map("assigned_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@map("user_roles")
}
model OidcClient {
id String @id @default(uuid())
clientId String @unique @map("client_id")
clientSecretEnc Bytes @map("client_secret_enc") // chiffré AES-256-GCM
displayName String @map("display_name")
logoUrl String? @map("logo_url")
portalUrl String? @map("portal_url")
redirectUris String[] @map("redirect_uris")
postLogoutRedirectUris String[] @map("post_logout_redirect_uris")
allowedScopes String[] @map("allowed_scopes")
grantTypes String[] @default(["authorization_code"]) @map("grant_types")
tokenEndpointAuthMethod String @default("client_secret_basic") @map("token_endpoint_auth_method")
requirePkce Boolean @default(true) @map("require_pkce")
autoApproveConsent Boolean @default(true) @map("auto_approve_consent")
isActive Boolean @default(true) @map("is_active")
secretRotatedAt DateTime? @map("secret_rotated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
access AppAccess[]
@@map("oidc_clients")
}
model AppAccess {
userId String @map("user_id")
clientId String @map("client_id")
grantedAt DateTime @default(now()) @map("granted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
client OidcClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@id([userId, clientId])
@@map("app_access")
}
model Invitation {
id String @id @default(uuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdByAdminId String? @map("created_by_admin_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("invitations")
}
model PasswordResetToken {
id String @id @default(uuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("password_reset_tokens")
}
model Session {
id String @id @default(uuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
ip String?
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("sessions")
}
model AuditLog {
id String @id @default(uuid())
actorUserId String? @map("actor_user_id")
action String
targetType String? @map("target_type")
targetId String? @map("target_id")
metadata Json?
ip String?
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
actor User? @relation("ActorAudit", fields: [actorUserId], references: [id], onDelete: SetNull)
@@index([actorUserId])
@@index([action])
@@index([createdAt])
@@map("audit_logs")
}
enum SettingType {
STRING
INT
BOOL
JSON
}
// Configuration éditable depuis l'admin (durées de vie des tokens, expiration des
// invitations, paramètres de rate limiting, etc.). Lue via SettingsService + cache Redis.
model Setting {
key String @id
value String
type SettingType @default(STRING)
description String?
updatedAt DateTime @updatedAt @map("updated_at")
updatedById String? @map("updated_by_id")
@@map("settings")
}
8. Sécurité (traçabilité des exigences du cahier des charges)¶
| Exigence | Traitement |
|---|---|
| Hash Argon2id | argon2 (id), paramètres via env. |
| Rate limiting login | Compteur Redis par IP + par identifiant, backoff. @nestjs/throttler + store Redis. |
Validation redirect_uri |
Correspondance exacte à la liste par client, pas de wildcard. |
| Tokens d'invitation usage unique + expiration | Hash stocké, usedAt, expiresAt configurable (défaut 48 h). |
| Secrets clients rotables, jamais en clair après création | Secret chiffré (AES-256-GCM) persisté, montré une fois à la création/rotation, jamais réexposé ; secretRotatedAt. |
| MFA secret chiffré | AES-256-GCM, clé applicative dédiée (env), jamais en clair. |
| Croissance sans refonte | UUID, jointures normalisées, index sur clés d'accès fréquentes, rôles/apps en many-to-many. |
| Aucune dépendance non maintenue / CVE | Versions épinglées + vérifiées à chaque étape (voir §1.1). |
| TS strict | strict: true back + front. |
9. Points ouverts — RÉSOLUS (validés le 25/07/2026)¶
- Domaines ✅
mesmy.fr= site vitrine (hors périmètre) ·auth.mesmy.fr= IdP/OIDC ·apps.mesmy.fr= portail des apps + back-office. - Scopes OIDC ✅
openid profile email phone+ scope custommesmy_roles(rôles + apps autorisées). - Signature des tokens ✅ RS256 (clé asymétrique, vérifiable par les clients via JWKS).
- Durées de vie ✅ Valeurs de départ retenues (access 1 h, refresh 14 j, code 60 s,
session SSO 24 h, invitation 48 h) et modifiables depuis l'admin → entité
Setting - fonctions
ttldynamiques (voir §6 et §7.3). - Rôles seed ✅
superadmin,admin,user.
Session SSO / Postgres : conservation de la séparation par défaut — artefacts SSO
(codes, tokens, sessions OIDC) dans Redis ; table Session en Postgres pour la
visibilité admin et la révocation des sessions portail. (Non bloquant ; ajustable
plus tard si un miroir Postgres des sessions SSO devient nécessaire pour l'audit.)
➡️ Étape 1 validée. Passage à l'étape 2 : backend OIDC. ```