@mostajs/auth 2.5.1 → 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.
- package/README.md +838 -57
- package/dist/components/MfaChallenge.d.ts +17 -0
- package/dist/components/MfaChallenge.js +55 -0
- package/dist/components/MfaEnrollDialog.d.ts +18 -0
- package/dist/components/MfaEnrollDialog.js +72 -0
- package/dist/components/PasskeyLoginButton.d.ts +20 -0
- package/dist/components/PasskeyLoginButton.js +53 -0
- package/dist/components/PasskeyRegisterButton.d.ts +26 -0
- package/dist/components/PasskeyRegisterButton.js +47 -0
- package/dist/lib/account-lifecycle.d.ts +130 -0
- package/dist/lib/account-lifecycle.js +136 -0
- package/dist/lib/auth-events.d.ts +40 -0
- package/dist/lib/auth-events.js +37 -0
- package/dist/lib/auth-rate-limit.d.ts +80 -0
- package/dist/lib/auth-rate-limit.js +100 -0
- package/dist/lib/credentials-verify.d.ts +13 -0
- package/dist/lib/credentials-verify.js +14 -0
- package/dist/lib/magic-link.d.ts +88 -0
- package/dist/lib/magic-link.js +125 -0
- package/dist/lib/mfa-totp.d.ts +154 -0
- package/dist/lib/mfa-totp.js +193 -0
- package/dist/lib/oauth-linking.d.ts +69 -0
- package/dist/lib/oauth-linking.js +70 -0
- package/dist/lib/oauth-primitives.d.ts +27 -0
- package/dist/lib/oauth-primitives.js +46 -0
- package/dist/lib/oauth-providers.d.ts +92 -0
- package/dist/lib/oauth-providers.js +192 -0
- package/dist/lib/password.d.ts +18 -1
- package/dist/lib/password.js +48 -6
- package/dist/lib/refresh-tokens.d.ts +74 -0
- package/dist/lib/refresh-tokens.js +94 -0
- package/dist/lib/remote-credentials-provider.d.ts +1 -6
- package/dist/lib/remote-credentials-provider.js +14 -0
- package/dist/lib/webauthn.d.ts +159 -0
- package/dist/lib/webauthn.js +167 -0
- package/package.json +95 -4
package/README.md
CHANGED
|
@@ -1,17 +1,630 @@
|
|
|
1
1
|
# @mostajs/auth
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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-
|
|
84
|
-
TEST_AUTH_SECRET=test-specific-
|
|
85
|
-
PROD_AUTH_SECRET=${VAULT_AUTH_SECRET}
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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`.
|