@mostajs/auth 2.5.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +838 -57
  2. package/dist/components/MfaChallenge.d.ts +17 -0
  3. package/dist/components/MfaChallenge.js +55 -0
  4. package/dist/components/MfaEnrollDialog.d.ts +18 -0
  5. package/dist/components/MfaEnrollDialog.js +72 -0
  6. package/dist/components/PasskeyLoginButton.d.ts +20 -0
  7. package/dist/components/PasskeyLoginButton.js +53 -0
  8. package/dist/components/PasskeyRegisterButton.d.ts +26 -0
  9. package/dist/components/PasskeyRegisterButton.js +47 -0
  10. package/dist/lib/account-lifecycle.d.ts +130 -0
  11. package/dist/lib/account-lifecycle.js +136 -0
  12. package/dist/lib/anon-token.d.ts +40 -0
  13. package/dist/lib/anon-token.js +69 -0
  14. package/dist/lib/auth-events.d.ts +40 -0
  15. package/dist/lib/auth-events.js +37 -0
  16. package/dist/lib/auth-rate-limit.d.ts +80 -0
  17. package/dist/lib/auth-rate-limit.js +100 -0
  18. package/dist/lib/credentials-verify.d.ts +13 -0
  19. package/dist/lib/credentials-verify.js +14 -0
  20. package/dist/lib/magic-link.d.ts +88 -0
  21. package/dist/lib/magic-link.js +125 -0
  22. package/dist/lib/mfa-totp.d.ts +154 -0
  23. package/dist/lib/mfa-totp.js +193 -0
  24. package/dist/lib/oauth-linking.d.ts +69 -0
  25. package/dist/lib/oauth-linking.js +70 -0
  26. package/dist/lib/oauth-primitives.d.ts +27 -0
  27. package/dist/lib/oauth-primitives.js +46 -0
  28. package/dist/lib/oauth-providers.d.ts +92 -0
  29. package/dist/lib/oauth-providers.js +192 -0
  30. package/dist/lib/password.d.ts +18 -1
  31. package/dist/lib/password.js +48 -6
  32. package/dist/lib/refresh-tokens.d.ts +74 -0
  33. package/dist/lib/refresh-tokens.js +94 -0
  34. package/dist/lib/remote-credentials-provider.d.ts +1 -6
  35. package/dist/lib/remote-credentials-provider.js +14 -0
  36. package/dist/lib/webauthn.d.ts +159 -0
  37. package/dist/lib/webauthn.js +167 -0
  38. package/package.json +90 -4
@@ -0,0 +1,69 @@
1
+ // @mostajs/auth — Anonymous token primitives — v3.1.0+
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Pour les questionnaires / pages publiques `visibility=public-anon` qui
5
+ // veulent retrouver les réponses entre sessions sans demander d'email.
6
+ // Le module fournit uniquement les **primitives** : génération d'un token
7
+ // aléatoire et formatage d'un email synthétique stable. Le **stockage du
8
+ // cookie** est laissé au consumer car la signature dépend du framework
9
+ // (Next.js `cookies()`, Express `res.cookie()`, etc.).
10
+ //
11
+ // Design clé :
12
+ // - 16 bytes de hasard cryptographique → 32 chars hex. Suffisant contre
13
+ // la collision (probabilité < 2⁻⁶⁴ pour 10⁹ tokens).
14
+ // - Email synthétique de la forme `anon-<token12>@<scope>.anon.local` :
15
+ // stable, reconnaissable comme anon dans les exports / dashboards,
16
+ // non-routable (TLD `.local` réservé) → impossible d'envoyer un mail
17
+ // dessus par accident.
18
+ // - Aucune dépendance externe — uniquement `node:crypto`.
19
+ import { randomBytes } from 'node:crypto';
20
+ /**
21
+ * Génère un nouveau token anonyme — 32 caractères hexadécimaux issus de
22
+ * 16 bytes de `randomBytes` (CSPRNG).
23
+ */
24
+ export function generateAnonToken() {
25
+ return randomBytes(16).toString('hex');
26
+ }
27
+ /**
28
+ * Formate un email synthétique stable pour un token + un scope.
29
+ * Le scope évite qu'un même token "fuite" inter-projets *(domaine
30
+ * différent → email différent → respondent distinct par projet)*.
31
+ *
32
+ * Le résultat est non-routable (`.anon.local`) — pas de risque
33
+ * d'envoi mail par accident.
34
+ *
35
+ * @example
36
+ * anonEmail('a1b2c3d4e5f6', 'survey-2026')
37
+ * → 'anon-a1b2c3d4e5f6@survey-2026.anon.local'
38
+ */
39
+ export function anonEmail(token, scope) {
40
+ const t = String(token ?? '').slice(0, 12);
41
+ const s = String(scope ?? 'default').toLowerCase().replace(/[^a-z0-9-]+/g, '-');
42
+ return `anon-${t}@${s}.anon.local`;
43
+ }
44
+ /**
45
+ * Vrai si l'email donné a la forme générée par `anonEmail` — utile
46
+ * pour repérer les respondents anonymes dans les dashboards / exports
47
+ * sans avoir à stocker un flag séparé *(quoique le flag reste une
48
+ * bonne pratique pour la requête DB)*.
49
+ */
50
+ export function isAnonEmail(email) {
51
+ if (!email)
52
+ return false;
53
+ return /^anon-[a-f0-9]{12}@[a-z0-9-]+\.anon\.local$/.test(email);
54
+ }
55
+ /**
56
+ * Convention de nom du cookie à utiliser. Le consumer reste libre de
57
+ * choisir le sien, mais cette constante factorise la pratique courante.
58
+ */
59
+ export const ANON_COOKIE_DEFAULT_NAME = 'anon_tk';
60
+ /**
61
+ * Options de cookie recommandées (utilisées par les implémentations
62
+ * Next/Express). Le consumer applique celles-ci à son cookie store.
63
+ */
64
+ export const ANON_COOKIE_DEFAULT_OPTIONS = {
65
+ httpOnly: true,
66
+ sameSite: 'lax',
67
+ path: '/',
68
+ maxAge: 60 * 60 * 24 * 365, // 1 an
69
+ };
@@ -0,0 +1,40 @@
1
+ export type AuthEventKind = 'login.success' | 'login.failure' | 'logout' | 'register' | 'password.changed' | 'password.reset.requested' | 'password.reset.completed' | 'password.rehash' | 'email.verification.sent' | 'email.verification.confirmed' | 'magic_link.requested' | 'magic_link.consumed' | 'mfa.enrolled' | 'mfa.verified' | 'mfa.failure' | 'mfa.disabled' | 'webauthn.registered' | 'webauthn.authenticated' | 'oauth.linked' | 'oauth.unlinked' | 'refresh.issued' | 'refresh.rotated' | 'refresh.revoked' | 'refresh.replay_detected' | 'rate_limit.hit' | 'account.deleted' | 'account.exported' | 'device_flow.requested' | 'device_flow.approved' | 'device_flow.denied' | 'device_flow.expired' | 'device_flow.consumed' | 'device_flow.brute_force' | 'pkce.requested' | 'pkce.consumed' | 'pkce.denied' | 'pkce.bad_verifier';
2
+ export interface AuthEvent {
3
+ kind: AuthEventKind;
4
+ /** Sujet (user account) — null pour les événements pré-auth (rate-limit, etc.). */
5
+ userId?: string | null;
6
+ /** Email cible si relevant (login KO sans userId connu, magic-link request). */
7
+ email?: string | null;
8
+ /** IP de l'appel HTTP (extraite par le consumer depuis les headers). */
9
+ ip?: string | null;
10
+ userAgent?: string | null;
11
+ /** Timestamp UTC ; le consumer peut écraser pour back-fill / replay. */
12
+ ts?: Date;
13
+ /** Payload libre — par convention :
14
+ * - login.failure: `{ reason: 'wrong_password' | 'unknown_user' | 'inactive' | 'mfa_required' }`
15
+ * - mfa.failure: `{ reason: 'wrong_code' | 'expired' }`
16
+ * - rate_limit.hit: `{ endpoint, retryAfter }`
17
+ * - device_flow.requested: `{ clientId, scopes, deviceCode }`
18
+ * - device_flow.approved: `{ clientId, deviceCode, accountId }`
19
+ * - device_flow.denied: `{ clientId, deviceCode, accountId? }`
20
+ * - device_flow.expired: `{ clientId, deviceCode, expiresInSec }`
21
+ * - device_flow.consumed: `{ clientId, deviceCode, accountId, scopes }`
22
+ * - device_flow.brute_force: `{ ip, attempts, windowSec }`
23
+ * - pkce.requested: `{ clientId, scopes, redirectUri, state }`
24
+ * - pkce.consumed: `{ clientId, accountId, scopes }`
25
+ * - pkce.denied: `{ clientId, redirectUri, accountId? }`
26
+ * - pkce.bad_verifier: `{ clientId, redirectUri, ip }` ← attaque potentielle
27
+ */
28
+ metadata?: Record<string, unknown>;
29
+ }
30
+ export interface AuthEventEmitter {
31
+ emit(event: AuthEvent): void | Promise<void>;
32
+ }
33
+ /** No-op : utilisé par défaut si le consumer ne branche pas d'audit. */
34
+ export declare const noopAuthEventEmitter: AuthEventEmitter;
35
+ /**
36
+ * Wrapper safe : encapsule l'`emit` du consumer dans un try/catch + non-blocking.
37
+ * Une faille de l'audit ne doit JAMAIS faire échouer le flow auth utilisateur
38
+ * (un emit qui crash → on log, on continue).
39
+ */
40
+ export declare function wrapEmitter(emitter: AuthEventEmitter): AuthEventEmitter;
@@ -0,0 +1,37 @@
1
+ // @mostajs/auth — Auth event emission for audit (v2.6.0+)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Émet des événements typés à chaque étape sensible (login, logout, password change,
5
+ // MFA, token rotation, replay detection, etc.). Le consumer branche un emitter
6
+ // adossé à `@mostajs/audit` ou n'importe quel sink (fichier, syslog, Kafka).
7
+ //
8
+ // Le module ne *décide pas* de la persistance — il définit le vocabulaire et le payload.
9
+ // Permet à `@mostajs/audit` de stocker, et à un middleware d'alerter en temps réel
10
+ // (ex: 5 logins KO en 1 min sur le même email → notify admin).
11
+ /** No-op : utilisé par défaut si le consumer ne branche pas d'audit. */
12
+ export const noopAuthEventEmitter = {
13
+ emit() { },
14
+ };
15
+ /**
16
+ * Wrapper safe : encapsule l'`emit` du consumer dans un try/catch + non-blocking.
17
+ * Une faille de l'audit ne doit JAMAIS faire échouer le flow auth utilisateur
18
+ * (un emit qui crash → on log, on continue).
19
+ */
20
+ export function wrapEmitter(emitter) {
21
+ return {
22
+ emit(event) {
23
+ try {
24
+ const r = emitter.emit({ ts: new Date(), ...event });
25
+ if (r && typeof r.catch === 'function') {
26
+ ;
27
+ r.catch((err) => {
28
+ console.error('[mostajs/auth] audit emit failed (async):', err);
29
+ });
30
+ }
31
+ }
32
+ catch (err) {
33
+ console.error('[mostajs/auth] audit emit failed (sync):', err);
34
+ }
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,80 @@
1
+ export interface BucketState {
2
+ tokens: number;
3
+ lastRefillMs: number;
4
+ }
5
+ export interface RateLimitStore {
6
+ /** Lecture atomique du bucket. Retourne null si inexistant. */
7
+ get(key: string): Promise<BucketState | null>;
8
+ /** Écriture atomique. TTL court (= 2× le temps de remplissage complet). */
9
+ set(key: string, state: BucketState, ttlSec: number): Promise<void>;
10
+ }
11
+ export interface RateLimitOptions {
12
+ /** Capacité max du bucket (= burst). */
13
+ capacity: number;
14
+ /** Tokens regagnés par seconde. */
15
+ refillPerSec: number;
16
+ /** Coût d'une opération (défaut 1). Permet d'en facturer 10 pour les opérations chères. */
17
+ cost?: number;
18
+ }
19
+ export interface RateLimitResult {
20
+ ok: boolean;
21
+ /** Nombre de tokens restants après la décision. */
22
+ remaining: number;
23
+ /** Si `ok=false`, secondes à attendre avant qu'un token soit disponible. */
24
+ retryAfter: number;
25
+ }
26
+ /** Store in-memory par défaut. Process-local — pas adapté multi-instance. */
27
+ export declare class MemoryRateLimitStore implements RateLimitStore {
28
+ private map;
29
+ private gcEveryMs;
30
+ private lastGcMs;
31
+ get(key: string): Promise<BucketState | null>;
32
+ set(key: string, state: BucketState, ttlSec: number): Promise<void>;
33
+ private maybeGc;
34
+ }
35
+ export interface AuthRateLimiter {
36
+ tryConsume(args: {
37
+ key: string;
38
+ } & RateLimitOptions): Promise<RateLimitResult>;
39
+ }
40
+ /**
41
+ * Crée un rate-limiter token-bucket.
42
+ *
43
+ * @param config.store - Store partagé. Défaut: `MemoryRateLimitStore` avec warning.
44
+ * @param config.warnIfMemoryFallback - Si true (défaut), log une fois le warning au boot.
45
+ */
46
+ export declare function createAuthRateLimiter(config?: {
47
+ store?: RateLimitStore;
48
+ warnIfMemoryFallback?: boolean;
49
+ }): AuthRateLimiter;
50
+ /**
51
+ * Presets recommandés OWASP — points de départ raisonnables, à ajuster selon
52
+ * le profil de trafic du tenant.
53
+ */
54
+ export declare const RATE_LIMIT_PRESETS: {
55
+ /** /login : 10 tentatives → puis 1 token / 6 secondes. Couvre login + magic-link. */
56
+ readonly loginByIp: {
57
+ readonly capacity: 10;
58
+ readonly refillPerSec: number;
59
+ };
60
+ /** /login : 5 tentatives par email → 1 token / 60 secondes. Anti-credential-stuffing ciblé. */
61
+ readonly loginByEmail: {
62
+ readonly capacity: 5;
63
+ readonly refillPerSec: number;
64
+ };
65
+ /** /register : 3 par IP → 1 token / 5 minutes. Anti-spam comptes. */
66
+ readonly registerByIp: {
67
+ readonly capacity: 3;
68
+ readonly refillPerSec: number;
69
+ };
70
+ /** /reset : 3 par email → 1 token / 5 minutes. */
71
+ readonly resetByEmail: {
72
+ readonly capacity: 3;
73
+ readonly refillPerSec: number;
74
+ };
75
+ /** /magic-link : 5 par email → 1 token / 5 minutes. Anti email-bombing. */
76
+ readonly magicLinkByEmail: {
77
+ readonly capacity: 5;
78
+ readonly refillPerSec: number;
79
+ };
80
+ };
@@ -0,0 +1,100 @@
1
+ // @mostajs/auth — Rate limiting middleware (v2.6.0+)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Token bucket par clé (typ. IP+endpoint, ou email+endpoint).
5
+ // Storage agnostique :
6
+ // - Défaut : in-memory `Map` (process-local, single-instance only).
7
+ // - Production multi-instance : passer un `RateLimitStore` adossé à Redis
8
+ // (le consumer fournit l'implémentation — DI cohérent avec le reste du module).
9
+ //
10
+ // Usage typique :
11
+ // const rl = createAuthRateLimiter({ store: redisStore })
12
+ // const allowed = await rl.tryConsume({ key: `login:${ip}`, capacity: 10, refillPerSec: 1 })
13
+ // if (!allowed.ok) return Response.json({ error: 'rate_limited', retryAfter: allowed.retryAfter }, { status: 429 })
14
+ /** Store in-memory par défaut. Process-local — pas adapté multi-instance. */
15
+ export class MemoryRateLimitStore {
16
+ map = new Map();
17
+ gcEveryMs = 60_000;
18
+ lastGcMs = Date.now();
19
+ async get(key) {
20
+ this.maybeGc();
21
+ const v = this.map.get(key);
22
+ if (!v)
23
+ return null;
24
+ if (v.expiresAtMs < Date.now()) {
25
+ this.map.delete(key);
26
+ return null;
27
+ }
28
+ return v.state;
29
+ }
30
+ async set(key, state, ttlSec) {
31
+ this.map.set(key, { state, expiresAtMs: Date.now() + ttlSec * 1000 });
32
+ }
33
+ maybeGc() {
34
+ const now = Date.now();
35
+ if (now - this.lastGcMs < this.gcEveryMs)
36
+ return;
37
+ this.lastGcMs = now;
38
+ for (const [k, v] of this.map) {
39
+ if (v.expiresAtMs < now)
40
+ this.map.delete(k);
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Crée un rate-limiter token-bucket.
46
+ *
47
+ * @param config.store - Store partagé. Défaut: `MemoryRateLimitStore` avec warning.
48
+ * @param config.warnIfMemoryFallback - Si true (défaut), log une fois le warning au boot.
49
+ */
50
+ export function createAuthRateLimiter(config) {
51
+ let store = config?.store;
52
+ if (!store) {
53
+ store = new MemoryRateLimitStore();
54
+ if (config?.warnIfMemoryFallback !== false) {
55
+ console.warn('[mostajs/auth] createAuthRateLimiter: using in-memory store ' +
56
+ '(single-process). Provide a Redis-backed store for multi-instance deployments.');
57
+ }
58
+ }
59
+ const s = store;
60
+ async function tryConsume({ key, capacity, refillPerSec, cost = 1, }) {
61
+ const now = Date.now();
62
+ const prev = (await s.get(key)) ?? { tokens: capacity, lastRefillMs: now };
63
+ // Refill basé sur le temps écoulé depuis la dernière modification.
64
+ const elapsedSec = Math.max(0, (now - prev.lastRefillMs) / 1000);
65
+ const refilled = Math.min(capacity, prev.tokens + elapsedSec * refillPerSec);
66
+ if (refilled < cost) {
67
+ const deficit = cost - refilled;
68
+ const retryAfter = Math.ceil(deficit / refillPerSec);
69
+ // On persiste le state refilled pour ne pas perdre l'avancée du remplissage.
70
+ await s.set(key, { tokens: refilled, lastRefillMs: now }, ttlForCapacity(capacity, refillPerSec));
71
+ return { ok: false, remaining: Math.floor(refilled), retryAfter };
72
+ }
73
+ const next = { tokens: refilled - cost, lastRefillMs: now };
74
+ await s.set(key, next, ttlForCapacity(capacity, refillPerSec));
75
+ return { ok: true, remaining: Math.floor(next.tokens), retryAfter: 0 };
76
+ }
77
+ return { tryConsume };
78
+ }
79
+ /** TTL = 2× le temps qu'il faudrait pour remplir le bucket à fond. Évite la fuite. */
80
+ function ttlForCapacity(capacity, refillPerSec) {
81
+ if (refillPerSec <= 0)
82
+ return 3600;
83
+ return Math.max(60, Math.ceil((capacity / refillPerSec) * 2));
84
+ }
85
+ /**
86
+ * Presets recommandés OWASP — points de départ raisonnables, à ajuster selon
87
+ * le profil de trafic du tenant.
88
+ */
89
+ export const RATE_LIMIT_PRESETS = {
90
+ /** /login : 10 tentatives → puis 1 token / 6 secondes. Couvre login + magic-link. */
91
+ loginByIp: { capacity: 10, refillPerSec: 1 / 6 },
92
+ /** /login : 5 tentatives par email → 1 token / 60 secondes. Anti-credential-stuffing ciblé. */
93
+ loginByEmail: { capacity: 5, refillPerSec: 1 / 60 },
94
+ /** /register : 3 par IP → 1 token / 5 minutes. Anti-spam comptes. */
95
+ registerByIp: { capacity: 3, refillPerSec: 1 / 300 },
96
+ /** /reset : 3 par email → 1 token / 5 minutes. */
97
+ resetByEmail: { capacity: 3, refillPerSec: 1 / 300 },
98
+ /** /magic-link : 5 par email → 1 token / 5 minutes. Anti email-bombing. */
99
+ magicLinkByEmail: { capacity: 5, refillPerSec: 1 / 300 },
100
+ };
@@ -13,6 +13,19 @@ export interface CredentialsVerifyConfig {
13
13
  resolveRole?: (user: any) => string | Promise<string>;
14
14
  /** Custom display name. Default: "<firstName> <lastName>" trimmed → email. */
15
15
  resolveName?: (user: any) => string;
16
+ /**
17
+ * v3.0.2+ — Optionnel : résolveur de l'`accountId` (frontière de tenancy
18
+ * Octonet) pour le user authentifié. Si fourni, l'`accountId` est inclus
19
+ * dans la response sanitisée → propagé jusqu'à NextAuth côté client via
20
+ * `createRemoteCredentialsProvider`.
21
+ *
22
+ * Pattern recommandé côté Octonet :
23
+ * import { resolveUserAccountId } from '@mostajs/rbac/lib/account-resolver'
24
+ * resolveAccountId: (user) => resolveUserAccountId(dialect, user.id, user.email)
25
+ *
26
+ * Si non fourni → `accountId` absent de la response (rétro-compat v2.5.x→v3.0.1).
27
+ */
28
+ resolveAccountId?: (user: any) => string | null | Promise<string | null>;
16
29
  }
17
30
  /**
18
31
  * Build a Web standard Request → Response handler that verifies (email, password)
@@ -78,6 +78,19 @@ export function createCredentialsVerifyHandler(config) {
78
78
  }
79
79
  const role = await resolveRole(user);
80
80
  const name = resolveName(user);
81
+ // v3.0.2+ : si un resolver d'accountId est fourni, on l'inclut dans la response.
82
+ // Pattern recommandé côté Octonet : resolveUserAccountId(dialect, user.id, user.email).
83
+ // Sans resolver → champ absent (rétro-compat v2.5.x → v3.0.1).
84
+ let accountId;
85
+ if (config.resolveAccountId) {
86
+ try {
87
+ accountId = await config.resolveAccountId(user);
88
+ }
89
+ catch (e) {
90
+ console.warn('[auth/verify] resolveAccountId failed (non-fatal):', e?.message || e);
91
+ accountId = null;
92
+ }
93
+ }
81
94
  return Response.json({
82
95
  ok: true,
83
96
  user: {
@@ -85,6 +98,7 @@ export function createCredentialsVerifyHandler(config) {
85
98
  email: user.email,
86
99
  name,
87
100
  role,
101
+ ...(accountId ? { accountId } : {}),
88
102
  },
89
103
  });
90
104
  }
@@ -0,0 +1,88 @@
1
+ export interface MagicLinkPayload {
2
+ /** Email cible — la résolution userId se fait au verify (le user peut avoir été supprimé entre-temps). */
3
+ email: string;
4
+ /** Nonce unique single-use. */
5
+ nonce: string;
6
+ /** Unix seconds expiration. */
7
+ exp: number;
8
+ /** Optionnel : intent — useful pour distinguer /auth/login vs /auth/recover-password. */
9
+ intent?: 'login' | 'recover';
10
+ }
11
+ export interface MagicLinkNonceRepo {
12
+ /** Persiste le nonce avec son expiration. Throw si déjà existant. */
13
+ insert(args: {
14
+ nonce: string;
15
+ email: string;
16
+ expiresAt: Date;
17
+ }): Promise<void>;
18
+ /** Consume atomiquement : retourne true si le nonce existait ET n'était pas expiré, et le supprime. */
19
+ consume(nonce: string): Promise<boolean>;
20
+ /** Cleanup périodique (job/cron). */
21
+ deleteExpired(now: Date): Promise<number>;
22
+ }
23
+ export interface UserLite {
24
+ id: string;
25
+ email: string;
26
+ status?: string;
27
+ }
28
+ export interface UserLookup {
29
+ findByEmail(email: string): Promise<UserLite | null>;
30
+ }
31
+ export interface MagicLinkConfig {
32
+ /** Secret HMAC (≥ 32 bytes). Idéalement le même `AUTH_SECRET` que le reste du module. */
33
+ secret: string;
34
+ /** TTL du lien (défaut 15 min). */
35
+ ttlSec?: number;
36
+ /** Repo nonce single-use. */
37
+ nonceRepo: MagicLinkNonceRepo;
38
+ /** Lookup user par email. */
39
+ userRepo: UserLookup;
40
+ /** Hook envoi email. Le consumer formate le mail comme il veut. */
41
+ sendEmail: (args: {
42
+ to: string;
43
+ magicUrl: string;
44
+ expiresAt: Date;
45
+ }) => Promise<void>;
46
+ /** Base URL frontend qui sait gérer le callback. Ex: 'https://app.example.com/auth/magic'. */
47
+ baseUrl: string;
48
+ }
49
+ export interface RequestMagicLinkArgs {
50
+ email: string;
51
+ intent?: 'login' | 'recover';
52
+ /** Optionnel : metadata logguée (ip, ua) — propagée via le sendEmail si voulu. */
53
+ meta?: Record<string, string>;
54
+ }
55
+ export type RequestMagicLinkResult = {
56
+ ok: true;
57
+ expiresAt: Date;
58
+ } | {
59
+ ok: false;
60
+ reason: 'unknown_email' | 'inactive_user';
61
+ };
62
+ /**
63
+ * Émet un magic link et l'envoie par email.
64
+ *
65
+ * **Important** : on retourne `unknown_email` quand le compte n'existe pas plutôt
66
+ * que d'envoyer silencieusement OK comme certains autres modules (la divergence
67
+ * permet à l'app cliente d'afficher un message neutre `"si l'email existe, un lien
68
+ * a été envoyé"` en convertissant `ok=false` en HTTP 200 — anti-énumération côté
69
+ * caller). Le rate-limit côté consumer protège du brute-force.
70
+ */
71
+ export declare function requestMagicLink(config: MagicLinkConfig, args: RequestMagicLinkArgs): Promise<RequestMagicLinkResult>;
72
+ export type VerifyMagicLinkResult = {
73
+ ok: true;
74
+ userId: string;
75
+ email: string;
76
+ intent?: 'login' | 'recover';
77
+ } | {
78
+ ok: false;
79
+ reason: 'malformed' | 'bad_signature' | 'expired' | 'consumed' | 'unknown_user' | 'inactive_user';
80
+ };
81
+ /**
82
+ * Vérifie un magic-link token. Single-use (nonce consommé atomiquement).
83
+ *
84
+ * Le consumer, sur `ok: true`, ouvre la session (JWT cookie) et renvoie l'user.
85
+ */
86
+ export declare function verifyMagicLink(config: MagicLinkConfig, token: string): Promise<VerifyMagicLinkResult>;
87
+ /** Helper exporté pour tests / introspection — décode SANS vérifier la signature. */
88
+ export declare function _peekToken(token: string): MagicLinkPayload | null;
@@ -0,0 +1,125 @@
1
+ // @mostajs/auth — Magic link login (passwordless email) — Lot 3 / v2.8.0+
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Flow :
5
+ // 1. requestMagicLink({ email }) → token signé HMAC + persist nonce + envoi email
6
+ // 2. l'utilisateur clique le lien → /auth/magic?token=<...>
7
+ // 3. verifyMagicLink({ token }) → vérifie sig + TTL + single-use, retourne userId
8
+ // (le consumer ouvre la session JWT/cookie ensuite, comme après un login password OK)
9
+ //
10
+ // Design clé :
11
+ // - Le token est un JWT-like signé HMAC : header.payload.signature (canon stable).
12
+ // - Single-use garanti par un nonceRepo (DI) : chaque token a un nonce unique persisté ;
13
+ // verify supprime le nonce (ou le marque consumed) — un replay → not_found.
14
+ // - TTL court (15 min par défaut), pas de prolongation.
15
+ // - Rate-limit appliqué côté consumer (cf. RATE_LIMIT_PRESETS.magicLinkByEmail dans
16
+ // auth-rate-limit.ts) — anti email-bombing.
17
+ // - L'audit emit `magic_link.requested` + `magic_link.consumed` (cf. auth-events.ts).
18
+ import crypto from 'node:crypto';
19
+ const DEFAULT_TTL_SEC = 15 * 60;
20
+ function generateNonce() {
21
+ return crypto.randomBytes(18).toString('base64url');
22
+ }
23
+ function canonicalString(p) {
24
+ // Ordre stable, encode strict — les changements de format invalident la signature.
25
+ const parts = [
26
+ `email=${encodeURIComponent(p.email)}`,
27
+ `nonce=${p.nonce}`,
28
+ `exp=${p.exp}`,
29
+ ];
30
+ if (p.intent)
31
+ parts.push(`intent=${p.intent}`);
32
+ return parts.join('&');
33
+ }
34
+ function signToken(secret, payload) {
35
+ const canon = canonicalString(payload);
36
+ const sig = crypto.createHmac('sha256', secret).update(canon).digest('base64url');
37
+ // Format auto-contenu : payload-base64url + signature
38
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
39
+ return `${payloadB64}.${sig}`;
40
+ }
41
+ function decodeToken(token) {
42
+ const parts = token.split('.');
43
+ if (parts.length !== 2)
44
+ throw new Error('Invalid token format');
45
+ const [payloadB64, sig] = parts;
46
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
47
+ if (typeof payload.email !== 'string' || typeof payload.nonce !== 'string' || typeof payload.exp !== 'number') {
48
+ throw new Error('Invalid token payload shape');
49
+ }
50
+ return { payload, sig };
51
+ }
52
+ /**
53
+ * Émet un magic link et l'envoie par email.
54
+ *
55
+ * **Important** : on retourne `unknown_email` quand le compte n'existe pas plutôt
56
+ * que d'envoyer silencieusement OK comme certains autres modules (la divergence
57
+ * permet à l'app cliente d'afficher un message neutre `"si l'email existe, un lien
58
+ * a été envoyé"` en convertissant `ok=false` en HTTP 200 — anti-énumération côté
59
+ * caller). Le rate-limit côté consumer protège du brute-force.
60
+ */
61
+ export async function requestMagicLink(config, args) {
62
+ const user = await config.userRepo.findByEmail(args.email);
63
+ if (!user)
64
+ return { ok: false, reason: 'unknown_email' };
65
+ if (user.status && user.status !== 'active')
66
+ return { ok: false, reason: 'inactive_user' };
67
+ const ttl = config.ttlSec ?? DEFAULT_TTL_SEC;
68
+ const exp = Math.floor(Date.now() / 1000) + ttl;
69
+ const nonce = generateNonce();
70
+ const expiresAt = new Date(exp * 1000);
71
+ await config.nonceRepo.insert({ nonce, email: args.email, expiresAt });
72
+ const token = signToken(config.secret, { email: args.email, nonce, exp, intent: args.intent });
73
+ const magicUrl = `${config.baseUrl}?token=${encodeURIComponent(token)}`;
74
+ await config.sendEmail({ to: args.email, magicUrl, expiresAt });
75
+ return { ok: true, expiresAt };
76
+ }
77
+ /**
78
+ * Vérifie un magic-link token. Single-use (nonce consommé atomiquement).
79
+ *
80
+ * Le consumer, sur `ok: true`, ouvre la session (JWT cookie) et renvoie l'user.
81
+ */
82
+ export async function verifyMagicLink(config, token) {
83
+ let parsed;
84
+ try {
85
+ parsed = decodeToken(token);
86
+ }
87
+ catch {
88
+ return { ok: false, reason: 'malformed' };
89
+ }
90
+ const { payload, sig } = parsed;
91
+ // 1. Vérification signature en temps constant.
92
+ const expectedSig = crypto.createHmac('sha256', config.secret)
93
+ .update(canonicalString(payload))
94
+ .digest('base64url');
95
+ const sigBuf = Buffer.from(sig, 'base64url');
96
+ const expBuf = Buffer.from(expectedSig, 'base64url');
97
+ if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
98
+ return { ok: false, reason: 'bad_signature' };
99
+ }
100
+ // 2. Expiration
101
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
102
+ return { ok: false, reason: 'expired' };
103
+ }
104
+ // 3. Single-use atomique
105
+ const consumed = await config.nonceRepo.consume(payload.nonce);
106
+ if (!consumed) {
107
+ return { ok: false, reason: 'consumed' };
108
+ }
109
+ // 4. User toujours valide
110
+ const user = await config.userRepo.findByEmail(payload.email);
111
+ if (!user)
112
+ return { ok: false, reason: 'unknown_user' };
113
+ if (user.status && user.status !== 'active')
114
+ return { ok: false, reason: 'inactive_user' };
115
+ return { ok: true, userId: user.id, email: user.email, intent: payload.intent };
116
+ }
117
+ /** Helper exporté pour tests / introspection — décode SANS vérifier la signature. */
118
+ export function _peekToken(token) {
119
+ try {
120
+ return decodeToken(token).payload;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }