@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export type MfaFactorKind = 'totp';
|
|
2
|
+
export interface MfaFactorRecord {
|
|
3
|
+
/** PK opaque. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** User propriétaire — clé d'isolation. */
|
|
6
|
+
userId: string;
|
|
7
|
+
kind: MfaFactorKind;
|
|
8
|
+
/** Label utilisateur (ex: "iPhone 15", "Authy 2026"). */
|
|
9
|
+
label: string;
|
|
10
|
+
/**
|
|
11
|
+
* Secret TOTP en base32 — utilisé pour générer les codes côté authentificator.
|
|
12
|
+
*
|
|
13
|
+
* v2.9.0 : stocké en clair (`secretEncrypted: false` ou champ absent).
|
|
14
|
+
* v2.9.1 : peut être chiffré at-rest si `SecretEncrypter` fourni au boot
|
|
15
|
+
* (`secretEncrypted: true`). Le module appelle transparentement encrypt/decrypt.
|
|
16
|
+
* Cohabitation : un record v2.9.0 sans flag est lu en clair, un record v2.9.1
|
|
17
|
+
* est déchiffré au verify.
|
|
18
|
+
*/
|
|
19
|
+
secret: string;
|
|
20
|
+
/** v2.9.1+ : true si `secret` est chiffré via SecretEncrypter. Default: false (legacy). */
|
|
21
|
+
secretEncrypted?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* 10 backup codes hashés argon2id. Chaque entrée :
|
|
24
|
+
* `null` = code consommé (one-shot)
|
|
25
|
+
* string = hash argon2 du code en clair
|
|
26
|
+
* On garde la longueur 10 stable pour l'audit ("3 backup codes restants").
|
|
27
|
+
*/
|
|
28
|
+
backupCodesHashed: (string | null)[];
|
|
29
|
+
/** `false` jusqu'à ce que l'user confirme un premier code → flip à `true`. */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
createdAt: Date | string;
|
|
32
|
+
/** Timestamp dernière vérification réussie (TOTP ou backup code). */
|
|
33
|
+
lastUsedAt?: Date | string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* v2.9.1+ — DI optionnelle pour chiffrer le secret TOTP at-rest.
|
|
37
|
+
*
|
|
38
|
+
* Le module ne ship PAS d'implémentation : c'est au host de fournir une stratégie
|
|
39
|
+
* (AWS KMS / GCP KMS / clé locale issue de @mostajs/config / HashiCorp Vault).
|
|
40
|
+
* Si non fourni, secrets stockés en clair (comportement v2.9.0 préservé).
|
|
41
|
+
*
|
|
42
|
+
* Contrat : encrypt → decrypt(encrypt(s)) === s. Idempotent. Constant-time recommandé.
|
|
43
|
+
*/
|
|
44
|
+
export interface SecretEncrypter {
|
|
45
|
+
/** Chiffre une valeur clair en string base64/hex (forme stockable en `string` DB). */
|
|
46
|
+
encrypt(plaintext: string): Promise<string>;
|
|
47
|
+
/** Déchiffre — throw si tampering / mauvaise clé. */
|
|
48
|
+
decrypt(ciphertext: string): Promise<string>;
|
|
49
|
+
}
|
|
50
|
+
export interface MfaFactorRepo {
|
|
51
|
+
insert(record: Omit<MfaFactorRecord, 'id'>): Promise<MfaFactorRecord>;
|
|
52
|
+
findById(id: string): Promise<MfaFactorRecord | null>;
|
|
53
|
+
/** Liste tous les factors d'un user (TOTP + futurs WebAuthn). */
|
|
54
|
+
findAllByUser(userId: string): Promise<MfaFactorRecord[]>;
|
|
55
|
+
/** Le premier factor TOTP enabled d'un user, ou null. */
|
|
56
|
+
findActiveTotp(userId: string): Promise<MfaFactorRecord | null>;
|
|
57
|
+
/** Marque le factor enabled (post-confirmation). */
|
|
58
|
+
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
59
|
+
/** Met à jour les backup codes (after consume) + lastUsedAt. */
|
|
60
|
+
updateAfterUse(id: string, args: {
|
|
61
|
+
backupCodesHashed: (string | null)[];
|
|
62
|
+
lastUsedAt: Date;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
/** Supprime un factor (disable totp / passkey deletion). */
|
|
65
|
+
delete(id: string): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
/** Génère 10 backup codes alphanum en clair — à montrer UNE FOIS à l'user. */
|
|
68
|
+
export declare function generateBackupCodes(): string[];
|
|
69
|
+
/** Hash argon2 chacun des codes — pour persistance one-shot. */
|
|
70
|
+
export declare function hashBackupCodes(codes: string[]): Promise<string[]>;
|
|
71
|
+
/** Normalise (uppercase, trim, retire espaces et dashes) avant hash/verify. */
|
|
72
|
+
declare function normalizeBackupCode(input: string): string;
|
|
73
|
+
export interface EnrollOptions {
|
|
74
|
+
userId: string;
|
|
75
|
+
/** Email/login de l'user — affiché dans l'authenticator app. */
|
|
76
|
+
accountName: string;
|
|
77
|
+
/** Nom du service — affiché dans l'authenticator app (ex: "Octonet"). */
|
|
78
|
+
issuer: string;
|
|
79
|
+
/** Label libre du factor (par défaut : `${issuer} — ${accountName}`). */
|
|
80
|
+
label?: string;
|
|
81
|
+
/** v2.9.1+ — si fourni, le secret est chiffré at-rest avant persistance. */
|
|
82
|
+
encrypter?: SecretEncrypter;
|
|
83
|
+
}
|
|
84
|
+
export interface EnrollResult {
|
|
85
|
+
/** Record persisté avec `enabled: false` — sera flip après verifyEnrollmentCode. */
|
|
86
|
+
factor: MfaFactorRecord;
|
|
87
|
+
/** Secret base32 (à montrer en fallback si QR non scannable). */
|
|
88
|
+
secret: string;
|
|
89
|
+
/** URL otpauth:// pour le QR code (RFC otpauth). */
|
|
90
|
+
otpAuthUrl: string;
|
|
91
|
+
/** Data-URL PNG du QR code (à afficher directement <img src=...>). */
|
|
92
|
+
qrCodeDataUrl: string;
|
|
93
|
+
/** Backup codes en clair — à montrer UNE FOIS à l'user, jamais ré-affichés. */
|
|
94
|
+
backupCodes: string[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Démarre l'enrollment :
|
|
98
|
+
* 1. génère secret + backup codes
|
|
99
|
+
* 2. produit le QR code
|
|
100
|
+
* 3. persiste un MfaFactorRecord avec `enabled: false`
|
|
101
|
+
* L'user doit ensuite scanner le QR + valider un code via `verifyEnrollmentCode`
|
|
102
|
+
* pour que le factor soit `enabled = true`.
|
|
103
|
+
*/
|
|
104
|
+
export declare function enrollTotp(repo: MfaFactorRepo, opts: EnrollOptions): Promise<EnrollResult>;
|
|
105
|
+
/**
|
|
106
|
+
* Confirme l'enrollment : l'user a scanné le QR + saisi un code TOTP courant.
|
|
107
|
+
* Si OK, le factor passe `enabled = true`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function verifyEnrollmentCode(repo: MfaFactorRepo, args: {
|
|
110
|
+
factorId: string;
|
|
111
|
+
code: string;
|
|
112
|
+
encrypter?: SecretEncrypter;
|
|
113
|
+
}): Promise<{
|
|
114
|
+
ok: boolean;
|
|
115
|
+
reason?: 'not_found' | 'already_enabled' | 'wrong_code';
|
|
116
|
+
}>;
|
|
117
|
+
export type VerifyResult = {
|
|
118
|
+
ok: true;
|
|
119
|
+
method: 'totp' | 'backup_code';
|
|
120
|
+
remainingBackupCodes: number;
|
|
121
|
+
} | {
|
|
122
|
+
ok: false;
|
|
123
|
+
reason: 'no_factor' | 'wrong_code' | 'no_backup_codes_left';
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Vérifie un code TOTP OU un backup code.
|
|
127
|
+
* Le caller est responsable du rate-limit en amont (cf. lib/auth-rate-limit.ts).
|
|
128
|
+
*/
|
|
129
|
+
export declare function verifyMfaCode(repo: MfaFactorRepo, args: {
|
|
130
|
+
userId: string;
|
|
131
|
+
code: string;
|
|
132
|
+
encrypter?: SecretEncrypter;
|
|
133
|
+
}): Promise<VerifyResult>;
|
|
134
|
+
/**
|
|
135
|
+
* Désactive le TOTP d'un user. Le caller DOIT exiger une preuve fraîche d'identité
|
|
136
|
+
* (password ré-entré + code TOTP courant OU backup code) AVANT d'appeler cette
|
|
137
|
+
* fonction — ce module ne re-vérifie pas, il fait juste la suppression.
|
|
138
|
+
*/
|
|
139
|
+
export declare function disableTotp(repo: MfaFactorRepo, args: {
|
|
140
|
+
userId: string;
|
|
141
|
+
}): Promise<{
|
|
142
|
+
deletedFactorIds: string[];
|
|
143
|
+
}>;
|
|
144
|
+
export interface MfaStatus {
|
|
145
|
+
totpEnrolled: boolean;
|
|
146
|
+
totpEnabled: boolean;
|
|
147
|
+
remainingBackupCodes: number;
|
|
148
|
+
}
|
|
149
|
+
/** Pour l'UI compte / settings. */
|
|
150
|
+
export declare function getMfaStatus(repo: MfaFactorRepo, userId: string): Promise<MfaStatus>;
|
|
151
|
+
export declare const __testing: {
|
|
152
|
+
normalizeBackupCode: typeof normalizeBackupCode;
|
|
153
|
+
};
|
|
154
|
+
export {};
|