@mostajs/auth
v3.3.0
Published
Authentication — complete: email/password (Argon2id) + OAuth + magic link + MFA TOTP + WebAuthn/Passkeys + RGPD lifecycle + device_flow/pkce events + accountId propagation server↔client
Maintainers
Readme
@mostajs/auth
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.
Auteur : Dr Hamid MADANI [email protected]
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).
Install
npm install @mostajs/auth @mostajs/rbac next-auth@^5.0.0-beta.25Optional pour MFA TOTP : otplib qrcode (déjà en dependencies).
Optional pour Passkeys : @simplewebauthn/server @simplewebauthn/browser (déjà en deps).
Capabilities — 6 méthodes de connexion + lifecycle
| # | Méthode | Lot | Version | Module |
|---|---|---|---|---|
| 1 | Email + password (Argon2id) | 1 | 2.5.x → 2.6.0 | lib/credentials-provider, lib/password |
| 2 | OAuth2 / OIDC (Google, GitHub, Microsoft, OIDC générique) | 2 | 2.7.0 | lib/oauth-providers, lib/oauth-linking |
| 3 | Magic link (passwordless email) | 3 | 2.8.0 | lib/magic-link |
| 4 | MFA TOTP (Google Authenticator, Authy, …) + backup codes | 4 | 2.9.0 + 2.9.1 (encryption at-rest) | lib/mfa-totp |
| 5 | WebAuthn / Passkeys — primary login + 2nd factor | 5 | 2.10.0 | lib/webauthn |
| 6 | Account lifecycle / RGPD delete + export | 6 | 3.0.0 | lib/account-lifecycle |
Transverse (Lot 1 — sécurité fondations) :
- Refresh tokens rotatifs avec détection replay →
lib/refresh-tokens - Rate-limit token bucket (Redis-pluggable + in-memory) →
lib/auth-rate-limit - AuthEvent vocabulaire 25+ types pour audit →
lib/auth-events
Module bonus (Lot 4 patch v2.9.1) :
- PKCE primitives
generateCodeVerifier/deriveCodeChallenge/generateStateré-utilisables par@mostajs/auth-flowet tout SDK polyglotte →lib/oauth-primitives
Méthode 1 — Email + password (Argon2id, avec rehash bcrypt → Argon2id transparent)
Server
import { hashPassword, comparePassword } from '@mostajs/auth/lib/password'
import { createCredentialsProvider } from '@mostajs/auth/lib/credentials-provider'
// Hashage à l'inscription
const hash = await hashPassword('plain-password') // → "$argon2id$v=19$..."
// Login (cohabitation argon2 + bcrypt legacy automatique)
const valid = await comparePassword('plain-password', userRecord.passwordHash)
// - Si hash commence par "$argon2id$" → vérification argon2id (rapide, sécurisé)
// - Si hash commence par "$2b$/$2a$" → vérification bcrypt legacy
// + l'app peut re-hasher en argon2id
// au prochain login OK (migration progressive)
// Provider NextAuth
const provider = createCredentialsProvider({
authorize: async (creds) => {
const user = await rbac.users.findByEmail(creds.email)
if (!user) return null
if (!await comparePassword(creds.password, user.password)) return null
return { id: user.id, email: user.email, accountId: user.accountId }
},
})Décision tête haute
- Argon2id par défaut (m=65536 KiB, t=3 itérations, p=4 lanes) — recommandation OWASP 2024.
- bcrypt legacy accepté en lecture pour migration douce (cf. AuthEventKind
password.rehash). - Rate-limit obligatoire sur
/login— voir Méthode transverse "Rate-limit" plus bas.
Méthode 2 — OAuth2 / OIDC (Google, GitHub, Microsoft, generic-OIDC)
Architecture
user → /auth/oauth/google → startAuthorization()
→ redirect Google
Google → /auth/oauth/google/callback?code=...
→ exchangeCodeForUser()
→ resolve linking (oauth-linking)
→ if email matches existing → REQUIRES_LINK_CONFIRMATION
→ if no match → createUser + link
→ if (provider, providerId) already linked → log inServer — start authorization
import {
getProviderSpec, startAuthorization,
type OAuthConfig,
} from '@mostajs/auth/lib/oauth-providers'
const spec = getProviderSpec('google')! // 'google' | 'github' | 'microsoft' | 'generic-oidc'
const config: OAuthConfig = {
spec,
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://app.example.com/api/auth/oauth/google/callback',
extraScopes: [], // au-delà des scopes par défaut du spec
}
// Dans /api/auth/oauth/google :
const { url, state, codeVerifier } = startAuthorization(config)
// → persiste { state, codeVerifier } en cookie httpOnly courte (5-10 min)
// → redirect 302 vers `url`Server — callback + linking
import { exchangeCodeForUser } from '@mostajs/auth/lib/oauth-providers'
import { resolveOAuthLinking } from '@mostajs/auth/lib/oauth-linking'
// Dans /api/auth/oauth/google/callback :
const code = searchParams.get('code')
const stateReceived = searchParams.get('state')
if (stateReceived !== cookieState) throw new Error('CSRF')
const profile = await exchangeCodeForUser(config, { code, codeVerifier: cookieVerifier })
// → { providerId, email, name, accessToken, refreshToken?, idToken? }
const decision = await resolveOAuthLinking({
provider: 'google',
providerProfile: profile,
findUserByEmail: (email) => rbac.users.findByEmail(email),
findOAuthAccount: (provider, providerId) => oauthRepo.findOne({ provider, providerId }),
})
switch (decision.kind) {
case 'LOGIN':
// Login existant — décision.userId est le user à connecter
return openSession(decision.userId)
case 'REQUIRES_LINK_CONFIRMATION':
// Anti CVE-class Slack 2020 : NE JAMAIS lier silencieusement
return redirect(`/oauth/confirm-link?email=${decision.matchedEmail}`)
case 'CREATE_AND_LINK':
// Nouveau user + lier
const newUser = await rbac.users.create({ email: profile.email, ... })
await oauthRepo.insert({ userId: newUser.id, provider: 'google', providerId: profile.providerId })
return openSession(newUser.id)
}Providers livrés v2.7.0
| Provider | ID | Spec | Scopes default |
|---|---|---|---|
| Google | 'google' | OIDC, PKCE | openid email profile |
| GitHub | 'github' | OAuth2 | read:user user:email |
| Microsoft | 'microsoft' | OIDC commercial+personal | openid email profile |
| Generic OIDC | 'generic-oidc' | discovery via ${issuer}/.well-known/openid-configuration | dépend du provider |
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).
Méthode 3 — Magic link (passwordless email)
Server — request
import { generateMagicLinkToken, type MagicLinkNonceRepo } from '@mostajs/auth/lib/magic-link'
const { token, nonce, expiresAt } = generateMagicLinkToken({
secret: process.env.MAGIC_LINK_SECRET!, // ≥ 32 bytes via @mostajs/config cascade
ttlSec: 15 * 60, // 15 min default
payload: { email: '[email protected]', intent: 'login' },
})
await nonceRepo.insert({ nonce, email: '[email protected]', expiresAt })
const link = `https://app.example.com/auth/magic?token=${encodeURIComponent(token)}`
await mailer.send({ to: '[email protected]', subject: 'Your login link', html: `<a href="${link}">Login</a>` })Server — verify
import { verifyMagicLinkToken } from '@mostajs/auth/lib/magic-link'
const result = await verifyMagicLinkToken({
secret: process.env.MAGIC_LINK_SECRET!,
token: tokenFromQuery,
nonceRepo, // consume atomique → empêche replay
})
if (!result.ok) {
// result.reason : 'malformed' | 'bad_signature' | 'expired' | 'consumed' | 'unknown_user'
return res.redirect('/login?error=link_invalid')
}
// result.userId est résolu ; openSession(userId)Anti-abuse
- HMAC-SHA256 signé sur
{ email, nonce, exp, intent } - Nonce single-use persisté → consume atomique → replay impossible
- TTL court 15 min default
- Rate-limit strict côté
/auth/magic-link/request: 5/h/email + 20/h/IP (cf. méthode transverse)
Méthode 4 — MFA TOTP + backup codes (avec encryption at-rest optionnelle)
Server — enroll
import { enrollTotp, type MfaFactorRepo } from '@mostajs/auth/lib/mfa-totp'
// L'user clique "Activer MFA" — POST /api/auth/mfa/totp/enroll
const result = await enrollTotp(mfaRepo, {
userId: session.user.id,
accountName: session.user.email,
issuer: 'Octonet',
})
// result.secret : base32 (à montrer en fallback si QR non scannable)
// result.qrCodeDataUrl : data:image/png;base64,... (à mettre dans <img src=...>)
// result.backupCodes : 10 codes "XXXX-XXXX" — montrés UNE SEULE FOIS à l'user
// result.factor : record persisté (enabled=false jusqu'à confirmation)
return Response.json({
factorId: result.factor.id,
qrCodeDataUrl: result.qrCodeDataUrl,
secret: result.secret,
backupCodes: result.backupCodes,
})Server — confirm enroll (l'user a scanné + saisi un code)
import { verifyEnrollmentCode } from '@mostajs/auth/lib/mfa-totp'
const ok = await verifyEnrollmentCode(mfaRepo, { factorId, code: '123456' })
// ok.ok : boolean
// ok.reason : 'not_found' | 'already_enabled' | 'wrong_code'Server — challenge au login
import { verifyMfaCode } from '@mostajs/auth/lib/mfa-totp'
// Après email/password OK : si l'user a un TOTP enabled, demander le code
const result = await verifyMfaCode(mfaRepo, { userId, code: 'user-input' })
// - Si code 6 chiffres → vérifie comme TOTP
// - Sinon → vérifie comme backup code (consume atomique, one-shot)
//
// result.ok : boolean
// result.method : 'totp' | 'backup_code'
// result.remainingBackupCodes: number (UI: "3 codes de secours restants")v2.9.1 — Encryption at-rest (optionnelle, recommandée prod)
import type { SecretEncrypter } from '@mostajs/auth/lib/mfa-totp'
// AWS KMS exemple
const kmsEncrypter: SecretEncrypter = {
encrypt: async (s) => (await kms.encrypt({ KeyId, Plaintext: Buffer.from(s) })).CiphertextBlob!.toString('base64'),
decrypt: async (s) => Buffer.from((await kms.decrypt({ KeyId, CiphertextBlob: Buffer.from(s, 'base64') })).Plaintext!).toString(),
}
// Enroll chiffré at-rest
await enrollTotp(mfaRepo, { /* ... */, encrypter: kmsEncrypter })
// Cohabitation transparente : les records v2.9.0 (clear) sont lus normalement,
// les records v2.9.1 (encrypted) nécessitent l'encrypter au verify.React components
import MfaEnrollDialog from '@mostajs/auth/components/MfaEnrollDialog'
import MfaChallenge from '@mostajs/auth/components/MfaChallenge'
// Dialogue 3-étapes (QR → backup codes → confirm code)
<MfaEnrollDialog
issuer="Octonet"
accountName={user.email}
enrollEndpoint="/api/auth/mfa/totp/enroll"
confirmEndpoint="/api/auth/mfa/totp/confirm"
onComplete={() => router.push('/account')}
/>
// Challenge login (TOTP OU backup code)
<MfaChallenge
verifyEndpoint="/api/auth/mfa/totp/verify"
onSuccess={(r) => router.push('/dashboard')}
/>Out-of-scope (décisions, pas vapor)
- SMS / phone OTP : coût + SIM-swap, préférer TOTP/passkey.
- TOTP en primary login (pas un 2nd factor) : non standard, pas demandé.
Méthode 5 — WebAuthn / Passkeys (primary login + 2nd factor)
Server — register (enroll d'une passkey)
import {
startRegistration, finishRegistration,
type WebAuthnConfig, type WebAuthnCredentialRepo, type WebAuthnChallengeStore,
} from '@mostajs/auth/lib/webauthn'
const config: WebAuthnConfig = {
rpID: 'example.com', // eTLD+1 (NE PAS inclure de port)
rpName: 'Octonet',
expectedOrigins: ['https://app.example.com'],
attestationType: 'none', // passkeys grand-public
residentKey: 'preferred',
userVerification: 'preferred',
}
// POST /api/auth/passkey/register/start
const opts = await startRegistration({
config,
challengeStore, // DI consumer
sessionKey: req.cookies.get('sid')!.value,
user: { id: session.user.id, name: session.user.email, displayName: session.user.name },
existingCredentials: await passkeyRepo.findByUser(session.user.id),
})
return Response.json(opts) // → consumed by startRegistration() côté browser
// POST /api/auth/passkey/register/finish
const result = await finishRegistration(passkeyRepo, {
config,
challengeStore,
sessionKey: req.cookies.get('sid')!.value,
userId: session.user.id,
response: bodyResponse, // RegistrationResponseJSON du browser
deviceName: bodyDeviceName, // "iPhone 15", "YubiKey", …
usage: 'both', // 'primary' | 'factor' | 'both'
})
if (!result.ok) return errorResponse(result.reason) // 'no_challenge' | 'verification_failed'
return Response.json({ ok: true, record: { credentialId: result.record.credentialId } })Server — auth (login avec passkey OU 2nd factor)
import { startAuthentication, finishAuthentication } from '@mostajs/auth/lib/webauthn'
// POST /api/auth/passkey/auth/start
// - Mode primary login : allowedCredentials = undefined → discoverable (l'user
// n'a pas encore tapé son email, le browser propose les passkeys disponibles)
// - Mode 2nd factor : on connaît userId via la session post-password →
// allowedCredentials = passkeyRepo.findByUser(userId)
const opts = await startAuthentication({
config, challengeStore,
sessionKey: req.cookies.get('sid')!.value,
allowedCredentials: bodyMode === 'primary' ? undefined : await passkeyRepo.findByUser(userId),
})
return Response.json(opts)
// POST /api/auth/passkey/auth/finish
const result = await finishAuthentication(passkeyRepo, {
config, challengeStore,
sessionKey: req.cookies.get('sid')!.value,
response: bodyResponse, // AuthenticationResponseJSON
expectedUsage: bodyMode, // 'primary' | 'factor'
})
if (!result.ok) {
// 'no_challenge' | 'unknown_credential' | 'wrong_usage' | 'verification_failed' | 'counter_mismatch'
return errorResponse(result.reason)
}
// result.userId est résolu ; openSession(result.userId)Counter check anti-cloning
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.
React components
import PasskeyRegisterButton from '@mostajs/auth/components/PasskeyRegisterButton'
import PasskeyLoginButton from '@mostajs/auth/components/PasskeyLoginButton'
<PasskeyRegisterButton
startEndpoint="/api/auth/passkey/register/start"
finishEndpoint="/api/auth/passkey/register/finish"
usage="both"
onSuccess={() => alert('Passkey enregistrée')}
/>
<PasskeyLoginButton
startEndpoint="/api/auth/passkey/auth/start"
finishEndpoint="/api/auth/passkey/auth/finish"
expectedUsage="primary" // ou "factor" si appelé après password OK
onSuccess={({ userId }) => router.push('/dashboard')}
/>Listing + suppression d'une passkey
import { listPasskeys, removePasskey } from '@mostajs/auth/lib/webauthn'
const all = await listPasskeys(passkeyRepo, session.user.id)
// → [{ id, deviceName, usage, createdAt, lastUsedAt, transports }, …]
await removePasskey(passkeyRepo, { credentialId: id, userId: session.user.id })
// → cross-tenant refusé : reason='not_owner'Conditional UI (autofill — bonus UX)
<input type="text" name="email" autoComplete="username webauthn" />Côté JS :
import { startAuthentication } from '@simplewebauthn/browser'
// Au load de la page de login, si browserSupportsWebAuthn()
const opts = await fetch('/api/auth/passkey/auth/start').then(r => r.json())
const response = await startAuthentication(opts) // affiche les passkeys dans le dropdown email
// → POST /finish, login directMéthode 6 — Account lifecycle (RGPD delete + export)
Server — request deletion (étape 1 : envoyer email avec token TTL 24h)
import { requestAccountDeletion, type DeletionNonceRepo } from '@mostajs/auth/lib/account-lifecycle'
const result = await requestAccountDeletion({
config: { secret: process.env.DELETION_SECRET!, ttlSec: 24 * 3600 },
nonceRepo,
userId: session.user.id,
mailer: async ({ token, expiresAt }) => {
const link = `https://app.example.com/account/delete/confirm?token=${token}`
await mailer.send({
to: session.user.email,
subject: 'Confirmation de suppression de compte',
html: `Cliquez pour confirmer (valable jusqu'au ${expiresAt}) : <a href="${link}">Supprimer</a>`,
})
},
})Server — confirm deletion (étape 2 : exécute la purge cross-modules)
import { confirmAccountDeletion, type DataSubjectHook } from '@mostajs/auth/lib/account-lifecycle'
// Chaque module sibling implémente DataSubjectHook
const hooks: DataSubjectHook[] = [
rbacHook, // efface User row
storageHook, // efface tous les fichiers de l'user
auditHook, // archive (au lieu de supprimer — exception RGPD : preuve forensique)
paymentHook, // anonymise les Payments (legal: garder 10 ans pour comptabilité)
]
const result = await confirmAccountDeletion({
config: { secret: process.env.DELETION_SECRET! },
nonceRepo,
hooks,
token: tokenFromQuery,
})
if (result.ok) {
// result.purgeReports : [{ module: 'rbac', rowsDeleted: 1 }, …]
return res.redirect('/goodbye')
}
// result.reason : 'malformed' | 'bad_signature' | 'expired' | 'consumed_or_unknown' | 'partial_failure'
// si partial_failure : result.partialReports + result.errors → permettent retry cibléServer — export RGPD (droit de portabilité)
import { collectAccountExport } from '@mostajs/auth/lib/account-lifecycle'
const data = await collectAccountExport({ hooks, userId: session.user.id })
// data.byModule : { rbac: {...}, storage: {...}, ... }
// data.metadata : { userId, generatedAt, moduleCount, errors }
// Le module ne ZIPe ni n'envoie email — au consumer de :
const zipBuffer = await createZip({
'metadata.json': JSON.stringify(data.metadata, null, 2),
...Object.fromEntries(Object.entries(data.byModule).map(([m, d]) => [`${m}.json`, JSON.stringify(d, null, 2)])),
})
const signedUrl = await storage.uploadAndSign({ key: `exports/${userId}.zip`, body: zipBuffer, ttlSec: 7 * 86400 })
await mailer.send({ to: user.email, subject: 'Votre export', html: `<a href="${signedUrl}">Télécharger</a>` })Implémenter DataSubjectHook dans un module sibling
import type { DataSubjectHook } from '@mostajs/auth/lib/account-lifecycle'
export const storageHook: DataSubjectHook = {
module: 'storage',
async exportUserData(userId) {
const files = await storage.listAllByUser(userId)
return files.map(f => ({ id: f.id, bucket: f.bucket, path: f.path, mimeType: f.mimeType, size: f.size }))
},
async purgeUserData(userId) {
const files = await storage.listAllByUser(userId)
for (const f of files) await storage.delete(f.id)
return { rowsDeleted: files.length }
},
}Méthodes transverses (Lot 1)
Refresh tokens rotatifs (anti-replay)
import {
issueRefreshToken, rotateRefreshToken, revokeAllByUser,
type RefreshTokenRepo,
} from '@mostajs/auth/lib/refresh-tokens'
// Login OK → émettre un refresh token
const { token, record } = await issueRefreshToken(refreshRepo, {
userId: user.id,
ttlSec: 30 * 86400, // 30 jours
ip: req.headers.get('x-forwarded-for') ?? undefined,
userAgent: req.headers.get('user-agent') ?? undefined,
})
// Rotation à chaque utilisation (anti-replay)
const result = await rotateRefreshToken(refreshRepo, { token: providedRefreshToken })
// - Si token n'existe pas → 'unknown'
// - Si déjà rotaté (replacedBy set) → 'replay_detected' → REVOKE TOUTE LA CHAÎNE
// (l'attaquant a réutilisé un token, on déconnecte tous les devices de l'user)
// - Si valide → nouveau token + ancien marqué replacedBy
// Logout → révoquer tous les refresh tokens de l'user
await revokeAllByUser(refreshRepo, user.id)Rate-limit token bucket
import { createAuthRateLimiter, type RateLimitStore } from '@mostajs/auth/lib/auth-rate-limit'
const rl = createAuthRateLimiter({
store: redisStore, // RateLimitStore DI ; fallback in-memory
})
const allowed = await rl.tryConsume({
key: `login:${ip}`,
capacity: 10, // burst max
refillPerSec: 1, // 1 token/s = 60/min
})
if (!allowed.ok) {
return new Response(JSON.stringify({ error: 'rate_limited', retryAfter: allowed.retryAfter }), {
status: 429,
headers: { 'Retry-After': String(allowed.retryAfter) },
})
}Presets recommandés :
| Endpoint | Capacité | Refill/s |
|---|---|---|
| /login | 10 | 1 (= 60/min) |
| /register | 3 | 0.05 (= 3/min) |
| /auth/magic-link/request (par email) | 5 | 0.0014 (= 5/h) |
| /auth/magic-link/request (par IP) | 20 | 0.0056 (= 20/h) |
| /auth/mfa/verify (par userId) | 5 | 0.005 (= 1 / 3 min) |
AuthEvent vocabulaire
import { type AuthEvent, type AuthEventEmitter, wrapEmitter } from '@mostajs/auth/lib/auth-events'
const emitter: AuthEventEmitter = {
async emit(event) {
await audit.insert({ kind: event.kind, userId: event.userId, ... })
},
}
// wrap pour ne JAMAIS faire échouer le flow auth si l'audit crash
const safe = wrapEmitter(emitter)
// Émissions typées (25+ kinds)
safe.emit({ kind: 'login.success', userId: user.id, ip, userAgent })
safe.emit({ kind: 'login.failure', email, ip, metadata: { reason: 'wrong_password' } })
safe.emit({ kind: 'mfa.verified', userId: user.id, metadata: { method: 'totp' } })
safe.emit({ kind: 'webauthn.authenticated', userId, metadata: { credentialId } })
safe.emit({ kind: 'refresh.replay_detected', userId, ip }) // 🚨 alerte sécurité
safe.emit({ kind: 'account.deleted', userId, metadata: { rowsDeleted: 42 } })Liste complète des AuthEventKind dans lib/auth-events.ts.
Wire-up NextAuth + RBAC
// src/lib/auth.ts
import { createAuthHandlers } from '@mostajs/auth/server'
const ROLE_PERMISSIONS = {
admin: ['*'],
agent: ['client:view', 'ticket:create'],
}
const { handlers, auth, signIn, signOut } = createAuthHandlers(ROLE_PERMISSIONS, {
pages: { signIn: '/login', error: '/login' },
})
export { handlers, auth, signIn, signOut }// API routes — auth checks
import { createAuthChecks } from '@mostajs/auth/server'
import { auth } from '@/lib/auth'
const { checkAuth, checkPermission } = createAuthChecks(auth, ROLE_PERMISSIONS)
const { error } = await checkPermission('client:view')
if (error) return error// Middleware
import { createAuthMiddleware } from '@mostajs/auth/server'
export default createAuthMiddleware({ publicPaths: ['/login'], protectedPrefixes: ['/dashboard'] })// Create admin (delegates to rbac)
import { createAdmin } from '@mostajs/auth/server'
await createAdmin({ email: '[email protected]', password: 'Admin123!', firstName: 'Admin', lastName: 'Test' })// Client components
import { usePermissions, PermissionGuard, SessionProvider } from '@mostajs/auth'Environment
AUTH_SECRET=your-32-bytes-secret # required — openssl rand -hex 32
# alias NextAuth compat :
NEXTAUTH_SECRET=your-32-bytes-secret
# Magic link (Lot 3)
MAGIC_LINK_SECRET=...
# Account deletion (Lot 6)
DELETION_SECRET=...
# OAuth (Lot 2) — pour chaque provider activé
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
# WebAuthn (Lot 5)
WEBAUTHN_RP_ID=example.com
WEBAUTHN_EXPECTED_ORIGINS=https://app.example.com,https://auth.example.comProfile cascade MOSTA_ENV (v2.2+)
Powered by @mostajs/config. Pattern Spring Boot profiles :
MOSTA_ENV=TEST
AUTH_SECRET=dev-fallback # 1. plain default
TEST_AUTH_SECRET=test-specific # 2. profile-prefixed override (gagne)
PROD_AUTH_SECRET=${VAULT_AUTH_SECRET} # injecté par orchestratorCascade (premier non-vide gagne) :
${MOSTA_ENV}_AUTH_SECRETAUTH_SECRETNEXTAUTH_SECRET(alias)- undefined → NextAuth raise
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.
Out-of-scope explicite v3.0.0 (décisions, pas vapor)
| Feature | Pourquoi pas | Quand | |---|---|---| | SAML 2.0 SP | Tier Enterprise | post-3.0 si demande customer | | SCIM 2.0 provisioning | Tier Enterprise | idem | | SMS / phone OTP | Coût + SIM-swap | préférer TOTP/passkey | | Anonymous sign-in (Supabase-style) | Pas de cas customer | ré-arbitrer si demande | | OIDC backchannel logout | Utile uniquement avec IdP externe | si on devient SAML SP | | Conditional UI components React (autofill bundled) | Optimisation UX, pas un blocker | v3.1.x |
Changelog
v3.0.2 — 2026-05-02 — Propagate accountId server↔client (remote credentials)
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.
lib/credentials-verify.ts — server-side (Octonet)
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 } }.
// Pattern recommandé côté Octonet
import { createCredentialsVerifyHandler } from '@mostajs/auth/server'
import { resolveUserAccountId } from '@mostajs/rbac/lib/account-resolver'
export const POST = createCredentialsVerifyHandler({
findUserByEmail: (email) => userRepo.findByEmail(email),
resolveAccountId: (user) => resolveUserAccountId(dialect, user.id, user.email),
})lib/remote-credentials-provider.ts — client-side (Octocloud)
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.
// Pattern recommandé côté Octocloud — propager dans NextAuth callbacks
NextAuth({
providers: [createRemoteCredentialsProvider({ verifyEndpoint, apiKey: portalApiKey })],
callbacks: {
async jwt({ token, user }) {
if (user?.accountId) token.accountId = (user as any).accountId
return token
},
async session({ session, token }) {
;(session.user as any).accountId = token.accountId
return session
},
},
})Désormais auth(req) retourne session.user.accountId directement utilisable par @mostajs/auth-flow/server.resolveUserSession, @mostajs/api-keys.create({ accountId }), etc.
Convention nommage (audit cross-modules)
Le nom accountId: string est cohérent avec :
@mostajs/api-keys/ApiKey.account(relation schema) →ApiKeyDTO.accountId(DTO)@mostajs/storage/File.account→FileMeta.accountId@mostajs/subscriptions-plan/{Subscription, UsageLog, Invoice}.account→*.accountId
Tous mappent une relation schema account: many-to-one → Account vers un DTO accountId: string.
Rétro-compat
- Sans
resolveAccountIdcôté server → response identique à v3.0.1 (champaccountIdabsent) - Sans
accountIddans le payload côté client → user retourné identique à v3.0.1
Suite agrégée auth : 288/288 ✅ (aucune régression).
Bump 3.0.1 → 3.0.2.
v3.0.1 — 2026-05-02 — Extend AuthEventKind with device_flow.* + pkce.* (10 new kinds)
Mini-PR pour permettre à @mostajs/[email protected]+ 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.
10 nouveaux kinds dans lib/auth-events.ts
Device Flow (RFC 8628) :
| Kind | Quand |
|---|---|
| device_flow.requested | POST /authorize — un client a démarré un device flow |
| device_flow.approved | user a cliqué Approve sur /device (accountId résolu) |
| device_flow.denied | user a cliqué Deny |
| device_flow.expired | expires_in dépassé sans approbation |
| device_flow.consumed | token émis et consumed (polling /token réussi) |
| device_flow.brute_force | 5+ tentatives wrong user_code/IP → alerte sécurité |
PKCE Authorization Code (RFC 8252 + 7636) :
| Kind | Quand |
|---|---|
| pkce.requested | GET /oauth/authorize avec code_challenge=S256 |
| pkce.consumed | POST /oauth/token avec code_verifier valide |
| pkce.denied | user a refusé le consent |
| pkce.bad_verifier | code_verifier ne match pas code_challenge → MitM/attaque |
Conventions metadata documentées (JSDoc AuthEvent)
- device_flow.requested: { clientId, scopes, deviceCode }
- device_flow.approved: { clientId, deviceCode, accountId }
- device_flow.denied: { clientId, deviceCode, accountId? }
- device_flow.expired: { clientId, deviceCode, expiresInSec }
- device_flow.consumed: { clientId, deviceCode, accountId, scopes }
- device_flow.brute_force: { ip, attempts, windowSec }
- pkce.requested: { clientId, scopes, redirectUri, state }
- pkce.consumed: { clientId, accountId, scopes }
- pkce.denied: { clientId, redirectUri, accountId? }
- pkce.bad_verifier: { clientId, redirectUri, ip } // attaque potentielleTests
+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).
Bump 3.0.0 → 3.0.1.
Cf. Entreprise/Octonet-as-Supabase/11-AUTOREGISTER-FLOW-ROADMAP.md §1 décision 9 + §3 phasing Session N+2 (a).
v3.0.0 — 2026-05-01 — RELEASE "complete auth tête haute"
Lot 6 — Account lifecycle / RGPD :
lib/account-lifecycle.ts: delete + export viaDataSubjectHookcross-modules- Token signé HMAC-SHA256, TTL 24h default, nonce single-use, timing-safe comparison
- Tests : +37 assertions (test-account-lifecycle.ts)
- Suite finale : 284/284 tests verts (Lots 1-6)
v2.10.0 — 2026-05-01
Lot 5 — WebAuthn / Passkeys :
lib/webauthn.ts(RFC L3 via @simplewebauthn/server v9), 2 modes : primary login + 2nd factorWebAuthnCredentialRepo+WebAuthnChallengeStoreDI- Counter check anti-cloning, conditional UI ready
- Composants
PasskeyRegisterButton+PasskeyLoginButton
v2.9.1 — 2026-05-01
Lot 4 patch + PKCE primitives extraction :
lib/oauth-primitives.ts(sous-module léger pour@mostajs/auth-flow)- MFA TOTP
SecretEncrypterDI optionnelle (encryption at-rest, KMS-pluggable) - Cohabitation transparente records v2.9.0 (clear) ↔ v2.9.1 (encrypted)
v2.9.0 — 2026-05-01
Lot 4 — MFA TOTP + backup codes :
lib/mfa-totp.ts(otplib v12, base32, SHA-1, RFC 6238)- 10 backup codes hashés argon2id, format
XXXX-XXXX - Composants
MfaEnrollDialog(3-étapes) +MfaChallenge
v2.8.0 — 2026-04-30
Lot 3 — Magic link login (passwordless) :
lib/magic-link.tsHMAC + nonce single-use + TTL 15 min
v2.7.0 — 2026-04-30
Lot 2 — OAuth providers + account linking :
lib/oauth-providers.ts(Google, GitHub, Microsoft, generic-OIDC) + PKCE + state CSRFlib/oauth-linking.tsanti-CVE Slack 2020 (linking explicite)
v2.6.0 — 2026-04-30
Lot 1 — Security hardening :
- Argon2id (rehash transparent depuis bcrypt)
- Refresh tokens rotatifs avec replay detection
- Rate-limit token bucket (Redis-pluggable)
- AuthEvent vocabulaire 25+ kinds
v2.4.0 — 2026-04-28
createCredentialsProvider (NextAuth email+password factorisé)
v2.2.0 — 2026-04-21
AUTH_SECRET resolution via @mostajs/config profile cascade.
License
AGPL-3.0-or-later + commercial — [email protected].
