Aller au contenu

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 dans package.json.
  • Exemple Laravel : jumbojett/openid-connect-php retenu — 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-oidc a é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 Ouiauth.mesmy.fr + /api API métier + endpoints OIDC + pages d'interaction
frontend build local (Node 22) 3000 Ouiapps.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.fr vers backend:4000 et apps.mesmy.fr vers frontend:3000. Le frontend appelle le backend en interne (http://backend:4000) pour le SSR, et via auth.mesmy.fr cô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 avec domain: .mesmy.fr, SameSite=Lax, Secure, HttpOnly.
  • Ils sont ainsi partagés entre apps.mesmy.fr et auth.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 appellent provider.interactionFinished().

Deux notions de « session » distinctes, à ne pas confondre :

  1. 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.
  2. Session portail (table Session en 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 un OidcProvider factory (provider Nest asynchrone), pas via import ... from.
  • L'instance Provider est configurée avec :
  • adapter : implémentation Redis maison (un adapter par type de modèle : AccessToken, AuthorizationCode, RefreshToken, Session, Interaction, Grant, Client optionnel…). TTL alignés sur la durée de vie de chaque artefact.
  • findAccount : branché sur la table User (Prisma) → renvoie les claims (name, email, phone, slack_uid, roles, apps).
  • clients : chargés dynamiquement depuis la table OidcClient (voir adapter Client Redis + 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 le SettingsService (table Setting, 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 Provider expose 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 flags isSuperAdmin / isAdmin sur Role. 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. mfaEnabled piloté 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-provider a besoin du secret réel pour authentifier le client (client_secret_basic / client_secret_jwt), ce qu'un hash à sens unique interdirait. secretRotatedAt trace 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 (apps renvoyé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 : action en chaîne normalisée (user.created, user.disabled, client.secret_rotated, auth.login, role.changed…), metadata JSON 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)

  1. Domainesmesmy.fr = site vitrine (hors périmètre) · auth.mesmy.fr = IdP/OIDC · apps.mesmy.fr = portail des apps + back-office.
  2. Scopes OIDCopenid profile email phone + scope custom mesmy_roles (rôles + apps autorisées).
  3. Signature des tokensRS256 (clé asymétrique, vérifiable par les clients via JWKS).
  4. 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
  5. fonctions ttl dynamiques (voir §6 et §7.3).
  6. Rôles seedsuperadmin, 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. ```