@mostajs/auth 2.5.2 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +838 -57
  2. package/dist/components/MfaChallenge.d.ts +17 -0
  3. package/dist/components/MfaChallenge.js +55 -0
  4. package/dist/components/MfaEnrollDialog.d.ts +18 -0
  5. package/dist/components/MfaEnrollDialog.js +72 -0
  6. package/dist/components/PasskeyLoginButton.d.ts +20 -0
  7. package/dist/components/PasskeyLoginButton.js +53 -0
  8. package/dist/components/PasskeyRegisterButton.d.ts +26 -0
  9. package/dist/components/PasskeyRegisterButton.js +47 -0
  10. package/dist/lib/account-lifecycle.d.ts +130 -0
  11. package/dist/lib/account-lifecycle.js +136 -0
  12. package/dist/lib/auth-events.d.ts +40 -0
  13. package/dist/lib/auth-events.js +37 -0
  14. package/dist/lib/auth-rate-limit.d.ts +80 -0
  15. package/dist/lib/auth-rate-limit.js +100 -0
  16. package/dist/lib/credentials-verify.d.ts +13 -0
  17. package/dist/lib/credentials-verify.js +14 -0
  18. package/dist/lib/magic-link.d.ts +88 -0
  19. package/dist/lib/magic-link.js +125 -0
  20. package/dist/lib/mfa-totp.d.ts +154 -0
  21. package/dist/lib/mfa-totp.js +193 -0
  22. package/dist/lib/oauth-linking.d.ts +69 -0
  23. package/dist/lib/oauth-linking.js +70 -0
  24. package/dist/lib/oauth-primitives.d.ts +27 -0
  25. package/dist/lib/oauth-primitives.js +46 -0
  26. package/dist/lib/oauth-providers.d.ts +92 -0
  27. package/dist/lib/oauth-providers.js +192 -0
  28. package/dist/lib/password.d.ts +18 -1
  29. package/dist/lib/password.js +48 -6
  30. package/dist/lib/refresh-tokens.d.ts +74 -0
  31. package/dist/lib/refresh-tokens.js +94 -0
  32. package/dist/lib/remote-credentials-provider.d.ts +1 -6
  33. package/dist/lib/remote-credentials-provider.js +14 -0
  34. package/dist/lib/webauthn.d.ts +159 -0
  35. package/dist/lib/webauthn.js +167 -0
  36. package/package.json +85 -4
package/README.md CHANGED
@@ -1,17 +1,630 @@
1
1
  # @mostajs/auth
2
2
 
3
- > NextAuth authentication with RBAC delegates user management to @mostajs/rbac.
4
- > Author: Dr Hamid MADANI drmdh@msn.com
3
+ > **v3.0.0** — Complete authentication for @mostajs : email/password (Argon2id), OAuth2/OIDC, magic link, MFA TOTP, WebAuthn/Passkeys, RGPD lifecycle. RBAC delegated to `@mostajs/rbac`.
4
+
5
+ [![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
6
+
7
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
8
+ **Statut** : v3.0.0 release "complete auth tête haute" — 6 lots livrés (cf. doc `Octonet-as-Supabase/07-AUTH-AUDIT-ETAT-DE-L-ART.md` §4.1).
9
+
10
+ ---
5
11
 
6
12
  ## Install
7
13
 
8
14
  ```bash
9
- npm install @mostajs/auth @mostajs/rbac next-auth@5.0.0-beta.25
15
+ npm install @mostajs/auth @mostajs/rbac next-auth@^5.0.0-beta.25
16
+ ```
17
+
18
+ Optional pour MFA TOTP : `otplib qrcode` (déjà en dependencies).
19
+ Optional pour Passkeys : `@simplewebauthn/server @simplewebauthn/browser` (déjà en deps).
20
+
21
+ ---
22
+
23
+ ## Capabilities — 6 méthodes de connexion + lifecycle
24
+
25
+ | # | Méthode | Lot | Version | Module |
26
+ |---|---|---|---|---|
27
+ | 1 | **Email + password** (Argon2id) | 1 | 2.5.x → 2.6.0 | `lib/credentials-provider`, `lib/password` |
28
+ | 2 | **OAuth2 / OIDC** (Google, GitHub, Microsoft, OIDC générique) | 2 | 2.7.0 | `lib/oauth-providers`, `lib/oauth-linking` |
29
+ | 3 | **Magic link** (passwordless email) | 3 | 2.8.0 | `lib/magic-link` |
30
+ | 4 | **MFA TOTP** (Google Authenticator, Authy, …) + backup codes | 4 | 2.9.0 + 2.9.1 (encryption at-rest) | `lib/mfa-totp` |
31
+ | 5 | **WebAuthn / Passkeys** — primary login + 2nd factor | 5 | 2.10.0 | `lib/webauthn` |
32
+ | 6 | **Account lifecycle / RGPD** delete + export | 6 | 3.0.0 | `lib/account-lifecycle` |
33
+
34
+ Transverse (Lot 1 — sécurité fondations) :
35
+ - **Refresh tokens** rotatifs avec détection replay → `lib/refresh-tokens`
36
+ - **Rate-limit** token bucket (Redis-pluggable + in-memory) → `lib/auth-rate-limit`
37
+ - **AuthEvent** vocabulaire 25+ types pour audit → `lib/auth-events`
38
+
39
+ Module bonus (Lot 4 patch v2.9.1) :
40
+ - **PKCE primitives** `generateCodeVerifier` / `deriveCodeChallenge` / `generateState` ré-utilisables par `@mostajs/auth-flow` et tout SDK polyglotte → `lib/oauth-primitives`
41
+
42
+ ---
43
+
44
+ ## Méthode 1 — Email + password (Argon2id, avec rehash bcrypt → Argon2id transparent)
45
+
46
+ ### Server
47
+
48
+ ```ts
49
+ import { hashPassword, comparePassword } from '@mostajs/auth/lib/password'
50
+ import { createCredentialsProvider } from '@mostajs/auth/lib/credentials-provider'
51
+
52
+ // Hashage à l'inscription
53
+ const hash = await hashPassword('plain-password') // → "$argon2id$v=19$..."
54
+
55
+ // Login (cohabitation argon2 + bcrypt legacy automatique)
56
+ const valid = await comparePassword('plain-password', userRecord.passwordHash)
57
+ // - Si hash commence par "$argon2id$" → vérification argon2id (rapide, sécurisé)
58
+ // - Si hash commence par "$2b$/$2a$" → vérification bcrypt legacy
59
+ // + l'app peut re-hasher en argon2id
60
+ // au prochain login OK (migration progressive)
61
+
62
+ // Provider NextAuth
63
+ const provider = createCredentialsProvider({
64
+ authorize: async (creds) => {
65
+ const user = await rbac.users.findByEmail(creds.email)
66
+ if (!user) return null
67
+ if (!await comparePassword(creds.password, user.password)) return null
68
+ return { id: user.id, email: user.email, accountId: user.accountId }
69
+ },
70
+ })
71
+ ```
72
+
73
+ ### Décision tête haute
74
+
75
+ - **Argon2id** par défaut (m=65536 KiB, t=3 itérations, p=4 lanes) — recommandation OWASP 2024.
76
+ - **bcrypt legacy** accepté en lecture pour migration douce (cf. AuthEventKind `password.rehash`).
77
+ - **Rate-limit obligatoire** sur `/login` — voir Méthode transverse "Rate-limit" plus bas.
78
+
79
+ ---
80
+
81
+ ## Méthode 2 — OAuth2 / OIDC (Google, GitHub, Microsoft, generic-OIDC)
82
+
83
+ ### Architecture
84
+
85
+ ```
86
+ user → /auth/oauth/google → startAuthorization()
87
+ → redirect Google
88
+ Google → /auth/oauth/google/callback?code=...
89
+ → exchangeCodeForUser()
90
+ → resolve linking (oauth-linking)
91
+ → if email matches existing → REQUIRES_LINK_CONFIRMATION
92
+ → if no match → createUser + link
93
+ → if (provider, providerId) already linked → log in
10
94
  ```
11
95
 
12
- ## How to Use
96
+ ### Server start authorization
97
+
98
+ ```ts
99
+ import {
100
+ getProviderSpec, startAuthorization,
101
+ type OAuthConfig,
102
+ } from '@mostajs/auth/lib/oauth-providers'
103
+
104
+ const spec = getProviderSpec('google')! // 'google' | 'github' | 'microsoft' | 'generic-oidc'
105
+
106
+ const config: OAuthConfig = {
107
+ spec,
108
+ clientId: process.env.GOOGLE_CLIENT_ID!,
109
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
110
+ redirectUri: 'https://app.example.com/api/auth/oauth/google/callback',
111
+ extraScopes: [], // au-delà des scopes par défaut du spec
112
+ }
113
+
114
+ // Dans /api/auth/oauth/google :
115
+ const { url, state, codeVerifier } = startAuthorization(config)
116
+ // → persiste { state, codeVerifier } en cookie httpOnly courte (5-10 min)
117
+ // → redirect 302 vers `url`
118
+ ```
119
+
120
+ ### Server — callback + linking
121
+
122
+ ```ts
123
+ import { exchangeCodeForUser } from '@mostajs/auth/lib/oauth-providers'
124
+ import { resolveOAuthLinking } from '@mostajs/auth/lib/oauth-linking'
125
+
126
+ // Dans /api/auth/oauth/google/callback :
127
+ const code = searchParams.get('code')
128
+ const stateReceived = searchParams.get('state')
129
+ if (stateReceived !== cookieState) throw new Error('CSRF')
130
+
131
+ const profile = await exchangeCodeForUser(config, { code, codeVerifier: cookieVerifier })
132
+ // → { providerId, email, name, accessToken, refreshToken?, idToken? }
133
+
134
+ const decision = await resolveOAuthLinking({
135
+ provider: 'google',
136
+ providerProfile: profile,
137
+ findUserByEmail: (email) => rbac.users.findByEmail(email),
138
+ findOAuthAccount: (provider, providerId) => oauthRepo.findOne({ provider, providerId }),
139
+ })
140
+
141
+ switch (decision.kind) {
142
+ case 'LOGIN':
143
+ // Login existant — décision.userId est le user à connecter
144
+ return openSession(decision.userId)
145
+ case 'REQUIRES_LINK_CONFIRMATION':
146
+ // Anti CVE-class Slack 2020 : NE JAMAIS lier silencieusement
147
+ return redirect(`/oauth/confirm-link?email=${decision.matchedEmail}`)
148
+ case 'CREATE_AND_LINK':
149
+ // Nouveau user + lier
150
+ const newUser = await rbac.users.create({ email: profile.email, ... })
151
+ await oauthRepo.insert({ userId: newUser.id, provider: 'google', providerId: profile.providerId })
152
+ return openSession(newUser.id)
153
+ }
154
+ ```
155
+
156
+ ### Providers livrés v2.7.0
157
+
158
+ | Provider | ID | Spec | Scopes default |
159
+ |---|---|---|---|
160
+ | Google | `'google'` | OIDC, PKCE | `openid email profile` |
161
+ | GitHub | `'github'` | OAuth2 | `read:user user:email` |
162
+ | Microsoft | `'microsoft'` | OIDC commercial+personal | `openid email profile` |
163
+ | **Generic OIDC** | `'generic-oidc'` | discovery via `${issuer}/.well-known/openid-configuration` | dépend du provider |
164
+
165
+ Apple / Slack / Discord / Facebook : **out-of-scope explicite** v2.7.0 — porté on-demand quand un customer le demande (R5 du plan : pas de stub claims).
166
+
167
+ ---
168
+
169
+ ## Méthode 3 — Magic link (passwordless email)
170
+
171
+ ### Server — request
172
+
173
+ ```ts
174
+ import { generateMagicLinkToken, type MagicLinkNonceRepo } from '@mostajs/auth/lib/magic-link'
175
+
176
+ const { token, nonce, expiresAt } = generateMagicLinkToken({
177
+ secret: process.env.MAGIC_LINK_SECRET!, // ≥ 32 bytes via @mostajs/config cascade
178
+ ttlSec: 15 * 60, // 15 min default
179
+ payload: { email: 'alice@example.com', intent: 'login' },
180
+ })
181
+
182
+ await nonceRepo.insert({ nonce, email: 'alice@example.com', expiresAt })
183
+
184
+ const link = `https://app.example.com/auth/magic?token=${encodeURIComponent(token)}`
185
+ await mailer.send({ to: 'alice@example.com', subject: 'Your login link', html: `<a href="${link}">Login</a>` })
186
+ ```
187
+
188
+ ### Server — verify
189
+
190
+ ```ts
191
+ import { verifyMagicLinkToken } from '@mostajs/auth/lib/magic-link'
192
+
193
+ const result = await verifyMagicLinkToken({
194
+ secret: process.env.MAGIC_LINK_SECRET!,
195
+ token: tokenFromQuery,
196
+ nonceRepo, // consume atomique → empêche replay
197
+ })
198
+
199
+ if (!result.ok) {
200
+ // result.reason : 'malformed' | 'bad_signature' | 'expired' | 'consumed' | 'unknown_user'
201
+ return res.redirect('/login?error=link_invalid')
202
+ }
203
+
204
+ // result.userId est résolu ; openSession(userId)
205
+ ```
206
+
207
+ ### Anti-abuse
208
+
209
+ - **HMAC-SHA256** signé sur `{ email, nonce, exp, intent }`
210
+ - **Nonce single-use** persisté → consume atomique → replay impossible
211
+ - **TTL court** 15 min default
212
+ - **Rate-limit** strict côté `/auth/magic-link/request` : 5/h/email + 20/h/IP (cf. méthode transverse)
213
+
214
+ ---
215
+
216
+ ## Méthode 4 — MFA TOTP + backup codes (avec encryption at-rest optionnelle)
217
+
218
+ ### Server — enroll
219
+
220
+ ```ts
221
+ import { enrollTotp, type MfaFactorRepo } from '@mostajs/auth/lib/mfa-totp'
222
+
223
+ // L'user clique "Activer MFA" — POST /api/auth/mfa/totp/enroll
224
+ const result = await enrollTotp(mfaRepo, {
225
+ userId: session.user.id,
226
+ accountName: session.user.email,
227
+ issuer: 'Octonet',
228
+ })
229
+ // result.secret : base32 (à montrer en fallback si QR non scannable)
230
+ // result.qrCodeDataUrl : data:image/png;base64,... (à mettre dans <img src=...>)
231
+ // result.backupCodes : 10 codes "XXXX-XXXX" — montrés UNE SEULE FOIS à l'user
232
+ // result.factor : record persisté (enabled=false jusqu'à confirmation)
233
+
234
+ return Response.json({
235
+ factorId: result.factor.id,
236
+ qrCodeDataUrl: result.qrCodeDataUrl,
237
+ secret: result.secret,
238
+ backupCodes: result.backupCodes,
239
+ })
240
+ ```
241
+
242
+ ### Server — confirm enroll (l'user a scanné + saisi un code)
243
+
244
+ ```ts
245
+ import { verifyEnrollmentCode } from '@mostajs/auth/lib/mfa-totp'
246
+
247
+ const ok = await verifyEnrollmentCode(mfaRepo, { factorId, code: '123456' })
248
+ // ok.ok : boolean
249
+ // ok.reason : 'not_found' | 'already_enabled' | 'wrong_code'
250
+ ```
251
+
252
+ ### Server — challenge au login
253
+
254
+ ```ts
255
+ import { verifyMfaCode } from '@mostajs/auth/lib/mfa-totp'
256
+
257
+ // Après email/password OK : si l'user a un TOTP enabled, demander le code
258
+ const result = await verifyMfaCode(mfaRepo, { userId, code: 'user-input' })
259
+ // - Si code 6 chiffres → vérifie comme TOTP
260
+ // - Sinon → vérifie comme backup code (consume atomique, one-shot)
261
+ //
262
+ // result.ok : boolean
263
+ // result.method : 'totp' | 'backup_code'
264
+ // result.remainingBackupCodes: number (UI: "3 codes de secours restants")
265
+ ```
266
+
267
+ ### v2.9.1 — Encryption at-rest (optionnelle, recommandée prod)
268
+
269
+ ```ts
270
+ import type { SecretEncrypter } from '@mostajs/auth/lib/mfa-totp'
271
+
272
+ // AWS KMS exemple
273
+ const kmsEncrypter: SecretEncrypter = {
274
+ encrypt: async (s) => (await kms.encrypt({ KeyId, Plaintext: Buffer.from(s) })).CiphertextBlob!.toString('base64'),
275
+ decrypt: async (s) => Buffer.from((await kms.decrypt({ KeyId, CiphertextBlob: Buffer.from(s, 'base64') })).Plaintext!).toString(),
276
+ }
277
+
278
+ // Enroll chiffré at-rest
279
+ await enrollTotp(mfaRepo, { /* ... */, encrypter: kmsEncrypter })
280
+
281
+ // Cohabitation transparente : les records v2.9.0 (clear) sont lus normalement,
282
+ // les records v2.9.1 (encrypted) nécessitent l'encrypter au verify.
283
+ ```
284
+
285
+ ### React components
286
+
287
+ ```tsx
288
+ import MfaEnrollDialog from '@mostajs/auth/components/MfaEnrollDialog'
289
+ import MfaChallenge from '@mostajs/auth/components/MfaChallenge'
290
+
291
+ // Dialogue 3-étapes (QR → backup codes → confirm code)
292
+ <MfaEnrollDialog
293
+ issuer="Octonet"
294
+ accountName={user.email}
295
+ enrollEndpoint="/api/auth/mfa/totp/enroll"
296
+ confirmEndpoint="/api/auth/mfa/totp/confirm"
297
+ onComplete={() => router.push('/account')}
298
+ />
299
+
300
+ // Challenge login (TOTP OU backup code)
301
+ <MfaChallenge
302
+ verifyEndpoint="/api/auth/mfa/totp/verify"
303
+ onSuccess={(r) => router.push('/dashboard')}
304
+ />
305
+ ```
306
+
307
+ ### Out-of-scope (décisions, pas vapor)
308
+
309
+ - **SMS / phone OTP** : coût + SIM-swap, préférer TOTP/passkey.
310
+ - **TOTP en primary login** (pas un 2nd factor) : non standard, pas demandé.
311
+
312
+ ---
313
+
314
+ ## Méthode 5 — WebAuthn / Passkeys (primary login + 2nd factor)
315
+
316
+ ### Server — register (enroll d'une passkey)
317
+
318
+ ```ts
319
+ import {
320
+ startRegistration, finishRegistration,
321
+ type WebAuthnConfig, type WebAuthnCredentialRepo, type WebAuthnChallengeStore,
322
+ } from '@mostajs/auth/lib/webauthn'
323
+
324
+ const config: WebAuthnConfig = {
325
+ rpID: 'example.com', // eTLD+1 (NE PAS inclure de port)
326
+ rpName: 'Octonet',
327
+ expectedOrigins: ['https://app.example.com'],
328
+ attestationType: 'none', // passkeys grand-public
329
+ residentKey: 'preferred',
330
+ userVerification: 'preferred',
331
+ }
332
+
333
+ // POST /api/auth/passkey/register/start
334
+ const opts = await startRegistration({
335
+ config,
336
+ challengeStore, // DI consumer
337
+ sessionKey: req.cookies.get('sid')!.value,
338
+ user: { id: session.user.id, name: session.user.email, displayName: session.user.name },
339
+ existingCredentials: await passkeyRepo.findByUser(session.user.id),
340
+ })
341
+ return Response.json(opts) // → consumed by startRegistration() côté browser
342
+
343
+ // POST /api/auth/passkey/register/finish
344
+ const result = await finishRegistration(passkeyRepo, {
345
+ config,
346
+ challengeStore,
347
+ sessionKey: req.cookies.get('sid')!.value,
348
+ userId: session.user.id,
349
+ response: bodyResponse, // RegistrationResponseJSON du browser
350
+ deviceName: bodyDeviceName, // "iPhone 15", "YubiKey", …
351
+ usage: 'both', // 'primary' | 'factor' | 'both'
352
+ })
353
+
354
+ if (!result.ok) return errorResponse(result.reason) // 'no_challenge' | 'verification_failed'
355
+ return Response.json({ ok: true, record: { credentialId: result.record.credentialId } })
356
+ ```
357
+
358
+ ### Server — auth (login avec passkey OU 2nd factor)
359
+
360
+ ```ts
361
+ import { startAuthentication, finishAuthentication } from '@mostajs/auth/lib/webauthn'
362
+
363
+ // POST /api/auth/passkey/auth/start
364
+ // - Mode primary login : allowedCredentials = undefined → discoverable (l'user
365
+ // n'a pas encore tapé son email, le browser propose les passkeys disponibles)
366
+ // - Mode 2nd factor : on connaît userId via la session post-password →
367
+ // allowedCredentials = passkeyRepo.findByUser(userId)
368
+ const opts = await startAuthentication({
369
+ config, challengeStore,
370
+ sessionKey: req.cookies.get('sid')!.value,
371
+ allowedCredentials: bodyMode === 'primary' ? undefined : await passkeyRepo.findByUser(userId),
372
+ })
373
+ return Response.json(opts)
374
+
375
+ // POST /api/auth/passkey/auth/finish
376
+ const result = await finishAuthentication(passkeyRepo, {
377
+ config, challengeStore,
378
+ sessionKey: req.cookies.get('sid')!.value,
379
+ response: bodyResponse, // AuthenticationResponseJSON
380
+ expectedUsage: bodyMode, // 'primary' | 'factor'
381
+ })
382
+
383
+ if (!result.ok) {
384
+ // 'no_challenge' | 'unknown_credential' | 'wrong_usage' | 'verification_failed' | 'counter_mismatch'
385
+ return errorResponse(result.reason)
386
+ }
387
+
388
+ // result.userId est résolu ; openSession(result.userId)
389
+ ```
390
+
391
+ ### Counter check anti-cloning
392
+
393
+ `finishAuthentication` rejette automatiquement si le counter retourné <= counter stocké, **sauf** pour les passkeys synced (Apple iCloud, Google Password Manager) qui laissent toujours le counter à 0.
394
+
395
+ ### React components
396
+
397
+ ```tsx
398
+ import PasskeyRegisterButton from '@mostajs/auth/components/PasskeyRegisterButton'
399
+ import PasskeyLoginButton from '@mostajs/auth/components/PasskeyLoginButton'
400
+
401
+ <PasskeyRegisterButton
402
+ startEndpoint="/api/auth/passkey/register/start"
403
+ finishEndpoint="/api/auth/passkey/register/finish"
404
+ usage="both"
405
+ onSuccess={() => alert('Passkey enregistrée')}
406
+ />
407
+
408
+ <PasskeyLoginButton
409
+ startEndpoint="/api/auth/passkey/auth/start"
410
+ finishEndpoint="/api/auth/passkey/auth/finish"
411
+ expectedUsage="primary" // ou "factor" si appelé après password OK
412
+ onSuccess={({ userId }) => router.push('/dashboard')}
413
+ />
414
+ ```
415
+
416
+ ### Listing + suppression d'une passkey
417
+
418
+ ```ts
419
+ import { listPasskeys, removePasskey } from '@mostajs/auth/lib/webauthn'
420
+
421
+ const all = await listPasskeys(passkeyRepo, session.user.id)
422
+ // → [{ id, deviceName, usage, createdAt, lastUsedAt, transports }, …]
423
+
424
+ await removePasskey(passkeyRepo, { credentialId: id, userId: session.user.id })
425
+ // → cross-tenant refusé : reason='not_owner'
426
+ ```
427
+
428
+ ### Conditional UI (autofill — bonus UX)
429
+
430
+ ```html
431
+ <input type="text" name="email" autoComplete="username webauthn" />
432
+ ```
13
433
 
14
- ### 1. Create Auth Handlers
434
+ Côté JS :
435
+
436
+ ```ts
437
+ import { startAuthentication } from '@simplewebauthn/browser'
438
+
439
+ // Au load de la page de login, si browserSupportsWebAuthn()
440
+ const opts = await fetch('/api/auth/passkey/auth/start').then(r => r.json())
441
+ const response = await startAuthentication(opts) // affiche les passkeys dans le dropdown email
442
+ // → POST /finish, login direct
443
+ ```
444
+
445
+ ---
446
+
447
+ ## Méthode 6 — Account lifecycle (RGPD delete + export)
448
+
449
+ ### Server — request deletion (étape 1 : envoyer email avec token TTL 24h)
450
+
451
+ ```ts
452
+ import { requestAccountDeletion, type DeletionNonceRepo } from '@mostajs/auth/lib/account-lifecycle'
453
+
454
+ const result = await requestAccountDeletion({
455
+ config: { secret: process.env.DELETION_SECRET!, ttlSec: 24 * 3600 },
456
+ nonceRepo,
457
+ userId: session.user.id,
458
+ mailer: async ({ token, expiresAt }) => {
459
+ const link = `https://app.example.com/account/delete/confirm?token=${token}`
460
+ await mailer.send({
461
+ to: session.user.email,
462
+ subject: 'Confirmation de suppression de compte',
463
+ html: `Cliquez pour confirmer (valable jusqu'au ${expiresAt}) : <a href="${link}">Supprimer</a>`,
464
+ })
465
+ },
466
+ })
467
+ ```
468
+
469
+ ### Server — confirm deletion (étape 2 : exécute la purge cross-modules)
470
+
471
+ ```ts
472
+ import { confirmAccountDeletion, type DataSubjectHook } from '@mostajs/auth/lib/account-lifecycle'
473
+
474
+ // Chaque module sibling implémente DataSubjectHook
475
+ const hooks: DataSubjectHook[] = [
476
+ rbacHook, // efface User row
477
+ storageHook, // efface tous les fichiers de l'user
478
+ auditHook, // archive (au lieu de supprimer — exception RGPD : preuve forensique)
479
+ paymentHook, // anonymise les Payments (legal: garder 10 ans pour comptabilité)
480
+ ]
481
+
482
+ const result = await confirmAccountDeletion({
483
+ config: { secret: process.env.DELETION_SECRET! },
484
+ nonceRepo,
485
+ hooks,
486
+ token: tokenFromQuery,
487
+ })
488
+
489
+ if (result.ok) {
490
+ // result.purgeReports : [{ module: 'rbac', rowsDeleted: 1 }, …]
491
+ return res.redirect('/goodbye')
492
+ }
493
+
494
+ // result.reason : 'malformed' | 'bad_signature' | 'expired' | 'consumed_or_unknown' | 'partial_failure'
495
+ // si partial_failure : result.partialReports + result.errors → permettent retry ciblé
496
+ ```
497
+
498
+ ### Server — export RGPD (droit de portabilité)
499
+
500
+ ```ts
501
+ import { collectAccountExport } from '@mostajs/auth/lib/account-lifecycle'
502
+
503
+ const data = await collectAccountExport({ hooks, userId: session.user.id })
504
+ // data.byModule : { rbac: {...}, storage: {...}, ... }
505
+ // data.metadata : { userId, generatedAt, moduleCount, errors }
506
+
507
+ // Le module ne ZIPe ni n'envoie email — au consumer de :
508
+ const zipBuffer = await createZip({
509
+ 'metadata.json': JSON.stringify(data.metadata, null, 2),
510
+ ...Object.fromEntries(Object.entries(data.byModule).map(([m, d]) => [`${m}.json`, JSON.stringify(d, null, 2)])),
511
+ })
512
+
513
+ const signedUrl = await storage.uploadAndSign({ key: `exports/${userId}.zip`, body: zipBuffer, ttlSec: 7 * 86400 })
514
+ await mailer.send({ to: user.email, subject: 'Votre export', html: `<a href="${signedUrl}">Télécharger</a>` })
515
+ ```
516
+
517
+ ### Implémenter `DataSubjectHook` dans un module sibling
518
+
519
+ ```ts
520
+ import type { DataSubjectHook } from '@mostajs/auth/lib/account-lifecycle'
521
+
522
+ export const storageHook: DataSubjectHook = {
523
+ module: 'storage',
524
+ async exportUserData(userId) {
525
+ const files = await storage.listAllByUser(userId)
526
+ return files.map(f => ({ id: f.id, bucket: f.bucket, path: f.path, mimeType: f.mimeType, size: f.size }))
527
+ },
528
+ async purgeUserData(userId) {
529
+ const files = await storage.listAllByUser(userId)
530
+ for (const f of files) await storage.delete(f.id)
531
+ return { rowsDeleted: files.length }
532
+ },
533
+ }
534
+ ```
535
+
536
+ ---
537
+
538
+ ## Méthodes transverses (Lot 1)
539
+
540
+ ### Refresh tokens rotatifs (anti-replay)
541
+
542
+ ```ts
543
+ import {
544
+ issueRefreshToken, rotateRefreshToken, revokeAllByUser,
545
+ type RefreshTokenRepo,
546
+ } from '@mostajs/auth/lib/refresh-tokens'
547
+
548
+ // Login OK → émettre un refresh token
549
+ const { token, record } = await issueRefreshToken(refreshRepo, {
550
+ userId: user.id,
551
+ ttlSec: 30 * 86400, // 30 jours
552
+ ip: req.headers.get('x-forwarded-for') ?? undefined,
553
+ userAgent: req.headers.get('user-agent') ?? undefined,
554
+ })
555
+
556
+ // Rotation à chaque utilisation (anti-replay)
557
+ const result = await rotateRefreshToken(refreshRepo, { token: providedRefreshToken })
558
+ // - Si token n'existe pas → 'unknown'
559
+ // - Si déjà rotaté (replacedBy set) → 'replay_detected' → REVOKE TOUTE LA CHAÎNE
560
+ // (l'attaquant a réutilisé un token, on déconnecte tous les devices de l'user)
561
+ // - Si valide → nouveau token + ancien marqué replacedBy
562
+
563
+ // Logout → révoquer tous les refresh tokens de l'user
564
+ await revokeAllByUser(refreshRepo, user.id)
565
+ ```
566
+
567
+ ### Rate-limit token bucket
568
+
569
+ ```ts
570
+ import { createAuthRateLimiter, type RateLimitStore } from '@mostajs/auth/lib/auth-rate-limit'
571
+
572
+ const rl = createAuthRateLimiter({
573
+ store: redisStore, // RateLimitStore DI ; fallback in-memory
574
+ })
575
+
576
+ const allowed = await rl.tryConsume({
577
+ key: `login:${ip}`,
578
+ capacity: 10, // burst max
579
+ refillPerSec: 1, // 1 token/s = 60/min
580
+ })
581
+
582
+ if (!allowed.ok) {
583
+ return new Response(JSON.stringify({ error: 'rate_limited', retryAfter: allowed.retryAfter }), {
584
+ status: 429,
585
+ headers: { 'Retry-After': String(allowed.retryAfter) },
586
+ })
587
+ }
588
+ ```
589
+
590
+ **Presets recommandés** :
591
+
592
+ | Endpoint | Capacité | Refill/s |
593
+ |---|---|---|
594
+ | `/login` | 10 | 1 (= 60/min) |
595
+ | `/register` | 3 | 0.05 (= 3/min) |
596
+ | `/auth/magic-link/request` (par email) | 5 | 0.0014 (= 5/h) |
597
+ | `/auth/magic-link/request` (par IP) | 20 | 0.0056 (= 20/h) |
598
+ | `/auth/mfa/verify` (par userId) | 5 | 0.005 (= 1 / 3 min) |
599
+
600
+ ### AuthEvent vocabulaire
601
+
602
+ ```ts
603
+ import { type AuthEvent, type AuthEventEmitter, wrapEmitter } from '@mostajs/auth/lib/auth-events'
604
+
605
+ const emitter: AuthEventEmitter = {
606
+ async emit(event) {
607
+ await audit.insert({ kind: event.kind, userId: event.userId, ... })
608
+ },
609
+ }
610
+
611
+ // wrap pour ne JAMAIS faire échouer le flow auth si l'audit crash
612
+ const safe = wrapEmitter(emitter)
613
+
614
+ // Émissions typées (25+ kinds)
615
+ safe.emit({ kind: 'login.success', userId: user.id, ip, userAgent })
616
+ safe.emit({ kind: 'login.failure', email, ip, metadata: { reason: 'wrong_password' } })
617
+ safe.emit({ kind: 'mfa.verified', userId: user.id, metadata: { method: 'totp' } })
618
+ safe.emit({ kind: 'webauthn.authenticated', userId, metadata: { credentialId } })
619
+ safe.emit({ kind: 'refresh.replay_detected', userId, ip }) // 🚨 alerte sécurité
620
+ safe.emit({ kind: 'account.deleted', userId, metadata: { rowsDeleted: 42 } })
621
+ ```
622
+
623
+ Liste complète des `AuthEventKind` dans `lib/auth-events.ts`.
624
+
625
+ ---
626
+
627
+ ## Wire-up NextAuth + RBAC
15
628
 
16
629
  ```typescript
17
630
  // src/lib/auth.ts
@@ -29,99 +642,267 @@ const { handlers, auth, signIn, signOut } = createAuthHandlers(ROLE_PERMISSIONS,
29
642
  export { handlers, auth, signIn, signOut }
30
643
  ```
31
644
 
32
- ### 2. Auth Checks in API Routes
33
-
34
645
  ```typescript
646
+ // API routes — auth checks
35
647
  import { createAuthChecks } from '@mostajs/auth/server'
36
648
  import { auth } from '@/lib/auth'
37
649
 
38
650
  const { checkAuth, checkPermission } = createAuthChecks(auth, ROLE_PERMISSIONS)
39
-
40
- // In API route:
41
651
  const { error } = await checkPermission('client:view')
42
652
  if (error) return error
43
653
  ```
44
654
 
45
- ### 3. Middleware
46
-
47
655
  ```typescript
656
+ // Middleware
48
657
  import { createAuthMiddleware } from '@mostajs/auth/server'
49
658
  export default createAuthMiddleware({ publicPaths: ['/login'], protectedPrefixes: ['/dashboard'] })
50
659
  ```
51
660
 
52
- ### 4. Create Admin (delegates to rbac)
53
-
54
661
  ```typescript
662
+ // Create admin (delegates to rbac)
55
663
  import { createAdmin } from '@mostajs/auth/server'
56
664
  await createAdmin({ email: 'admin@test.com', password: 'Admin123!', firstName: 'Admin', lastName: 'Test' })
57
665
  ```
58
666
 
59
- ### 5. Client Components
60
-
61
667
  ```typescript
62
- import { usePermissions } from '@mostajs/auth'
63
- import { PermissionGuard, SessionProvider } from '@mostajs/auth'
668
+ // Client components
669
+ import { usePermissions, PermissionGuard, SessionProvider } from '@mostajs/auth'
64
670
  ```
65
671
 
672
+ ---
673
+
66
674
  ## Environment
67
675
 
68
676
  ```bash
69
677
  AUTH_SECRET=your-32-bytes-secret # required — openssl rand -hex 32
70
- # or alias for NextAuth compat:
678
+ # alias NextAuth compat :
71
679
  NEXTAUTH_SECRET=your-32-bytes-secret
680
+
681
+ # Magic link (Lot 3)
682
+ MAGIC_LINK_SECRET=...
683
+
684
+ # Account deletion (Lot 6)
685
+ DELETION_SECRET=...
686
+
687
+ # OAuth (Lot 2) — pour chaque provider activé
688
+ GOOGLE_CLIENT_ID=...
689
+ GOOGLE_CLIENT_SECRET=...
690
+ GITHUB_CLIENT_ID=...
691
+ GITHUB_CLIENT_SECRET=...
692
+
693
+ # WebAuthn (Lot 5)
694
+ WEBAUTHN_RP_ID=example.com
695
+ WEBAUTHN_EXPECTED_ORIGINS=https://app.example.com,https://auth.example.com
72
696
  ```
73
697
 
74
- ### Profile cascade with `MOSTA_ENV` (v2.2+)
698
+ ### Profile cascade `MOSTA_ENV` (v2.2+)
75
699
 
76
- Powered by [`@mostajs/config`](https://www.npmjs.com/package/@mostajs/config).
77
- Keep one `.env` with profile-prefixed overrides à la
78
- [Spring Boot profiles](https://docs.spring.io/spring-boot/reference/features/profiles.html)
79
- (`spring.profiles.active=test`) :
700
+ Powered by [`@mostajs/config`](https://www.npmjs.com/package/@mostajs/config). Pattern Spring Boot profiles :
80
701
 
81
702
  ```bash
82
703
  MOSTA_ENV=TEST
83
- AUTH_SECRET=dev-secret-fallback
84
- TEST_AUTH_SECRET=test-specific-secret
85
- PROD_AUTH_SECRET=${VAULT_AUTH_SECRET} # injected by orchestrator
704
+ AUTH_SECRET=dev-fallback # 1. plain default
705
+ TEST_AUTH_SECRET=test-specific # 2. profile-prefixed override (gagne)
706
+ PROD_AUTH_SECRET=${VAULT_AUTH_SECRET} # injecté par orchestrator
86
707
  ```
87
708
 
88
- **Resolution cascade** (first non-empty value wins) :
709
+ **Cascade** (premier non-vide gagne) :
710
+ 1. `${MOSTA_ENV}_AUTH_SECRET`
711
+ 2. `AUTH_SECRET`
712
+ 3. `NEXTAUTH_SECRET` (alias)
713
+ 4. undefined → NextAuth raise
714
+
715
+ Same pattern pour `MAGIC_LINK_SECRET`, `DELETION_SECRET`, `GOOGLE_CLIENT_SECRET`, etc. Garde **un** `.env` avec dev/test fallbacks et fait injecter `PROD_*` par Vault / Kubernetes Secrets / Scaleway Secrets.
89
716
 
90
- 1. `${MOSTA_ENV}_AUTH_SECRET` — profile-prefixed override
91
- 2. `AUTH_SECRET` — plain default
92
- 3. `NEXTAUTH_SECRET` — NextAuth-compat alias
93
- 4. `undefined` — NextAuth raises its own configuration error
717
+ ---
94
718
 
95
- Missing profile overrides silently fall back to the plain variable — no
96
- crash if the profiled key is absent. Empty strings (`TEST_AUTH_SECRET=`)
97
- are treated as "not set" so they don't silently leak a blank value to
98
- the signer.
719
+ ## Out-of-scope explicite v3.0.0 (décisions, pas vapor)
99
720
 
100
- ### Why this matters for auth
721
+ | Feature | Pourquoi pas | Quand |
722
+ |---|---|---|
723
+ | SAML 2.0 SP | Tier Enterprise | post-3.0 si demande customer |
724
+ | SCIM 2.0 provisioning | Tier Enterprise | idem |
725
+ | SMS / phone OTP | Coût + SIM-swap | préférer TOTP/passkey |
726
+ | Anonymous sign-in (Supabase-style) | Pas de cas customer | ré-arbitrer si demande |
727
+ | OIDC backchannel logout | Utile uniquement avec IdP externe | si on devient SAML SP |
728
+ | Conditional UI components React (autofill bundled) | Optimisation UX, pas un blocker | v3.1.x |
101
729
 
102
- Routing secret resolution through `@mostajs/config` lets you keep **one**
103
- `.env` file in your repo with non-secret profile defaults (dev/test keys)
104
- and have the orchestrator (Vault, Scaleway Secrets, Kubernetes Secrets,
105
- Docker env) inject the real `PROD_AUTH_SECRET` at runtime. No more
106
- juggling `.env.test` / `.env.development` / `.env.production` and
107
- forgetting to sync them. Users who already defined `AUTH_SECRET` or
108
- `NEXTAUTH_SECRET` keep working unchanged — the cascade is fully
109
- backward-compatible.
730
+ ---
110
731
 
111
732
  ## Changelog
112
733
 
734
+ ### v3.0.2 — 2026-05-02 — Propagate `accountId` server↔client (remote credentials)
735
+
736
+ Mini-PR pour combler une lacune du wire-up Octocloud↔Octonet : la frontière de tenancy `accountId` (cf. `@mostajs/rbac/account-resolver`) n'était **pas propagée** depuis le verify endpoint server jusqu'au session NextAuth client. Conséquence : `auth(req)` côté Octocloud n'exposait pas `accountId`, forçant les consumers (`@mostajs/auth-flow`, `@mostajs/api-keys`, `@mostajs/storage`) à ré-résoudre l'`accountId` via une query DB extra. Maintenant : 1 round-trip suffit.
737
+
738
+ #### `lib/credentials-verify.ts` — server-side (Octonet)
739
+
740
+ Nouveau callback DI optionnel **`resolveAccountId?: (user) => string | null | Promise<string | null>`** sur `CredentialsVerifyConfig`. Quand fourni, l'`accountId` résolu est inclus dans la response `200 { ok: true, user: { id, email, name, role, accountId } }`.
741
+
742
+ ```ts
743
+ // Pattern recommandé côté Octonet
744
+ import { createCredentialsVerifyHandler } from '@mostajs/auth/server'
745
+ import { resolveUserAccountId } from '@mostajs/rbac/lib/account-resolver'
746
+
747
+ export const POST = createCredentialsVerifyHandler({
748
+ findUserByEmail: (email) => userRepo.findByEmail(email),
749
+ resolveAccountId: (user) => resolveUserAccountId(dialect, user.id, user.email),
750
+ })
751
+ ```
752
+
753
+ #### `lib/remote-credentials-provider.ts` — client-side (Octocloud)
754
+
755
+ Le provider propage **`accountId`** du payload Octonet → user retourné à NextAuth. Si Octonet ne le renvoie pas (rétro-compat v2.5.x → v3.0.1), le champ est simplement absent.
756
+
757
+ ```ts
758
+ // Pattern recommandé côté Octocloud — propager dans NextAuth callbacks
759
+ NextAuth({
760
+ providers: [createRemoteCredentialsProvider({ verifyEndpoint, apiKey: portalApiKey })],
761
+ callbacks: {
762
+ async jwt({ token, user }) {
763
+ if (user?.accountId) token.accountId = (user as any).accountId
764
+ return token
765
+ },
766
+ async session({ session, token }) {
767
+ ;(session.user as any).accountId = token.accountId
768
+ return session
769
+ },
770
+ },
771
+ })
772
+ ```
773
+
774
+ Désormais `auth(req)` retourne `session.user.accountId` directement utilisable par `@mostajs/auth-flow/server.resolveUserSession`, `@mostajs/api-keys.create({ accountId })`, etc.
775
+
776
+ #### Convention nommage (audit cross-modules)
777
+
778
+ Le nom **`accountId: string`** est cohérent avec :
779
+ - `@mostajs/api-keys/ApiKey.account` (relation schema) → `ApiKeyDTO.accountId` (DTO)
780
+ - `@mostajs/storage/File.account` → `FileMeta.accountId`
781
+ - `@mostajs/subscriptions-plan/{Subscription, UsageLog, Invoice}.account` → `*.accountId`
782
+
783
+ Tous mappent une relation schema `account: many-to-one → Account` vers un DTO `accountId: string`.
784
+
785
+ #### Rétro-compat
786
+
787
+ - Sans `resolveAccountId` côté server → response identique à v3.0.1 (champ `accountId` absent)
788
+ - Sans `accountId` dans le payload côté client → user retourné identique à v3.0.1
789
+
790
+ Suite agrégée auth : **288/288 ✅** (aucune régression).
791
+
792
+ Bump `3.0.1 → 3.0.2`.
793
+
794
+ ---
795
+
796
+ ### v3.0.1 — 2026-05-02 — Extend AuthEventKind with device_flow.* + pkce.* (10 new kinds)
797
+
798
+ Mini-PR pour permettre à `@mostajs/auth-flow@0.1.0-alpha+` d'émettre des événements typés sur le **device flow RFC 8628** + **PKCE RFC 8252**. Le module auth **ne change pas son comportement** — il étend juste son vocabulaire d'audit pour les modules consumer.
799
+
800
+ #### 10 nouveaux kinds dans `lib/auth-events.ts`
801
+
802
+ **Device Flow (RFC 8628)** :
803
+
804
+ | Kind | Quand |
805
+ |---|---|
806
+ | `device_flow.requested` | `POST /authorize` — un client a démarré un device flow |
807
+ | `device_flow.approved` | user a cliqué Approve sur `/device` (`accountId` résolu) |
808
+ | `device_flow.denied` | user a cliqué Deny |
809
+ | `device_flow.expired` | `expires_in` dépassé sans approbation |
810
+ | `device_flow.consumed` | token émis et consumed (polling `/token` réussi) |
811
+ | `device_flow.brute_force` | 5+ tentatives wrong `user_code`/IP → alerte sécurité |
812
+
813
+ **PKCE Authorization Code (RFC 8252 + 7636)** :
814
+
815
+ | Kind | Quand |
816
+ |---|---|
817
+ | `pkce.requested` | `GET /oauth/authorize` avec `code_challenge=S256` |
818
+ | `pkce.consumed` | `POST /oauth/token` avec `code_verifier` valide |
819
+ | `pkce.denied` | user a refusé le consent |
820
+ | `pkce.bad_verifier` | `code_verifier` ne match pas `code_challenge` → **MitM/attaque** |
821
+
822
+ #### Conventions metadata documentées (JSDoc `AuthEvent`)
823
+
824
+ ```ts
825
+ - device_flow.requested: { clientId, scopes, deviceCode }
826
+ - device_flow.approved: { clientId, deviceCode, accountId }
827
+ - device_flow.denied: { clientId, deviceCode, accountId? }
828
+ - device_flow.expired: { clientId, deviceCode, expiresInSec }
829
+ - device_flow.consumed: { clientId, deviceCode, accountId, scopes }
830
+ - device_flow.brute_force: { ip, attempts, windowSec }
831
+ - pkce.requested: { clientId, scopes, redirectUri, state }
832
+ - pkce.consumed: { clientId, accountId, scopes }
833
+ - pkce.denied: { clientId, redirectUri, accountId? }
834
+ - pkce.bad_verifier: { clientId, redirectUri, ip } // attaque potentielle
835
+ ```
836
+
837
+ #### Tests
838
+
839
+ +4 assertions dans `test-auth-events.ts` (T10.6) — émet les 10 kinds, vérifie que la `metadata` est propagée, count discriminé `device_flow.*` (6) + `pkce.*` (4). Suite agrégée auth : **284 → 288 ✅** (Lots 1-6 toujours verts).
840
+
841
+ Bump `3.0.0 → 3.0.1`.
842
+
843
+ Cf. `Entreprise/Octonet-as-Supabase/11-AUTOREGISTER-FLOW-ROADMAP.md` §1 décision 9 + §3 phasing Session N+2 (a).
844
+
845
+ ---
846
+
847
+ ### v3.0.0 — 2026-05-01 — RELEASE "complete auth tête haute"
848
+
849
+ Lot 6 — Account lifecycle / RGPD :
850
+ - `lib/account-lifecycle.ts` : delete + export via `DataSubjectHook` cross-modules
851
+ - Token signé HMAC-SHA256, TTL 24h default, nonce single-use, timing-safe comparison
852
+ - Tests : +37 assertions (test-account-lifecycle.ts)
853
+ - Suite finale : 284/284 tests verts (Lots 1-6)
854
+
855
+ ### v2.10.0 — 2026-05-01
856
+
857
+ Lot 5 — WebAuthn / Passkeys :
858
+ - `lib/webauthn.ts` (RFC L3 via @simplewebauthn/server v9), 2 modes : primary login + 2nd factor
859
+ - `WebAuthnCredentialRepo` + `WebAuthnChallengeStore` DI
860
+ - Counter check anti-cloning, conditional UI ready
861
+ - Composants `PasskeyRegisterButton` + `PasskeyLoginButton`
862
+
863
+ ### v2.9.1 — 2026-05-01
864
+
865
+ Lot 4 patch + PKCE primitives extraction :
866
+ - `lib/oauth-primitives.ts` (sous-module léger pour `@mostajs/auth-flow`)
867
+ - MFA TOTP `SecretEncrypter` DI optionnelle (encryption at-rest, KMS-pluggable)
868
+ - Cohabitation transparente records v2.9.0 (clear) ↔ v2.9.1 (encrypted)
869
+
870
+ ### v2.9.0 — 2026-05-01
871
+
872
+ Lot 4 — MFA TOTP + backup codes :
873
+ - `lib/mfa-totp.ts` (otplib v12, base32, SHA-1, RFC 6238)
874
+ - 10 backup codes hashés argon2id, format `XXXX-XXXX`
875
+ - Composants `MfaEnrollDialog` (3-étapes) + `MfaChallenge`
876
+
877
+ ### v2.8.0 — 2026-04-30
878
+
879
+ Lot 3 — Magic link login (passwordless) :
880
+ - `lib/magic-link.ts` HMAC + nonce single-use + TTL 15 min
881
+
882
+ ### v2.7.0 — 2026-04-30
883
+
884
+ Lot 2 — OAuth providers + account linking :
885
+ - `lib/oauth-providers.ts` (Google, GitHub, Microsoft, generic-OIDC) + PKCE + state CSRF
886
+ - `lib/oauth-linking.ts` anti-CVE Slack 2020 (linking explicite)
887
+
888
+ ### v2.6.0 — 2026-04-30
889
+
890
+ Lot 1 — Security hardening :
891
+ - Argon2id (rehash transparent depuis bcrypt)
892
+ - Refresh tokens rotatifs avec replay detection
893
+ - Rate-limit token bucket (Redis-pluggable)
894
+ - AuthEvent vocabulaire 25+ kinds
895
+
896
+ ### v2.4.0 — 2026-04-28
897
+
898
+ `createCredentialsProvider` (NextAuth email+password factorisé)
899
+
113
900
  ### v2.2.0 — 2026-04-21
114
901
 
115
- **Added** : `AUTH_SECRET` / `NEXTAUTH_SECRET` resolution routed through
116
- [`@mostajs/config`](https://www.npmjs.com/package/@mostajs/config). Users
117
- who set `MOSTA_ENV=TEST` now get `TEST_AUTH_SECRET` preferred over plain
118
- `AUTH_SECRET`, with silent fallback to the plain variable when the
119
- profiled override is absent. Matches Spring Boot profile semantics
120
- (`spring.profiles.active=test`).
121
-
122
- - `lib/auth.ts` : secret resolution via `getEnv()` instead of
123
- `process.env.X`
124
- - `package.json` : add `@mostajs/config ^1.0.0` dependency, bump to
125
- `2.2.0`
126
- - `README` : document the Environment section + profile cascade +
127
- changelog
902
+ `AUTH_SECRET` resolution via `@mostajs/config` profile cascade.
903
+
904
+ ---
905
+
906
+ ## License
907
+
908
+ **AGPL-3.0-or-later** + commercial — `drmdh@msn.com`.