npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.

License

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.25

Optional 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 / generateState ré-utilisables par @mostajs/auth-flow et 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 in

Server — 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 direct

Mé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.com

Profile 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 orchestrator

Cascade (premier non-vide gagne) :

  1. ${MOSTA_ENV}_AUTH_SECRET
  2. AUTH_SECRET
  3. NEXTAUTH_SECRET (alias)
  4. 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.accountFileMeta.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 resolveAccountId côté server → response identique à v3.0.1 (champ accountId absent)
  • Sans accountId dans 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_challengeMitM/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 potentielle

Tests

+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 via DataSubjectHook cross-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 factor
  • WebAuthnCredentialRepo + WebAuthnChallengeStore DI
  • 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 SecretEncrypter DI 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.ts HMAC + 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 CSRF
  • lib/oauth-linking.ts anti-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].