@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.
- 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/anon-token.d.ts +40 -0
- package/dist/lib/anon-token.js +69 -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 +90 -4
|
@@ -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 {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @mostajs/auth — MFA TOTP (RFC 6238) — Lot 4 / v2.9.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// TOTP (Time-based One-Time Password, RFC 6238) — second factor compatible
|
|
5
|
+
// Google Authenticator / 1Password / Authy / Bitwarden / Microsoft Authenticator.
|
|
6
|
+
//
|
|
7
|
+
// Design clé :
|
|
8
|
+
// - Le secret TOTP (base32) est généré côté serveur, jamais transmis en clair
|
|
9
|
+
// deux fois — le QR code data-URL est émis UNE FOIS à l'enroll, puis perdu.
|
|
10
|
+
// - L'app cliente scanne le QR + entre un premier code TOTP pour confirmer
|
|
11
|
+
// que la lecture s'est bien faite (`verifyEnrollmentCode`) avant que le
|
|
12
|
+
// factor soit marqué `enabled` côté serveur.
|
|
13
|
+
// - Backup codes : 10 codes one-shot de 8 chars, hashés (argon2 — réutilise
|
|
14
|
+
// `lib/password.ts`), persistés dans le record. Marqués `consumed` à l'usage.
|
|
15
|
+
// - Storage agnostique : le consumer fournit `MfaFactorRepo` (DI).
|
|
16
|
+
//
|
|
17
|
+
// Stockage :
|
|
18
|
+
// - secret en clair dans `MfaFactorRecord.secret` — c'est un compromis
|
|
19
|
+
// standard (Supabase, Auth0 font pareil) ; chiffrement at-rest = job du DBA.
|
|
20
|
+
// Si la DB est compromise, MFA TOTP ne protège plus rien de toute façon.
|
|
21
|
+
//
|
|
22
|
+
// Rate-limit : à appliquer côté consumer via `lib/auth-rate-limit.ts` sur le
|
|
23
|
+
// endpoint /verify (max 5 tentatives/15min/userId). On ne ré-implémente pas ici.
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import { authenticator } from 'otplib';
|
|
26
|
+
import { toDataURL as qrToDataURL } from 'qrcode';
|
|
27
|
+
import { hashPassword, comparePassword } from './password.js';
|
|
28
|
+
// ─── otplib config — RFC 6238 défauts ───────────────────────────────────
|
|
29
|
+
authenticator.options = {
|
|
30
|
+
digits: 6,
|
|
31
|
+
step: 30, // 30 secondes par fenêtre TOTP
|
|
32
|
+
window: 1, // accepte la fenêtre courante ± 1 (= ± 30s) pour drift d'horloge
|
|
33
|
+
// sha1 = RFC 6238 baseline + Google Authenticator support (default otplib).
|
|
34
|
+
};
|
|
35
|
+
// ─── Backup codes ───────────────────────────────────────────────────────
|
|
36
|
+
const BACKUP_CODE_LENGTH = 8;
|
|
37
|
+
const BACKUP_CODES_COUNT = 10;
|
|
38
|
+
/** Génère 10 backup codes alphanum en clair — à montrer UNE FOIS à l'user. */
|
|
39
|
+
export function generateBackupCodes() {
|
|
40
|
+
return Array.from({ length: BACKUP_CODES_COUNT }, () => generateOneBackupCode());
|
|
41
|
+
}
|
|
42
|
+
function generateOneBackupCode() {
|
|
43
|
+
// Alphanum sans ambiguïté (pas de O/0, I/1) — UX scan papier.
|
|
44
|
+
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
45
|
+
const bytes = crypto.randomBytes(BACKUP_CODE_LENGTH);
|
|
46
|
+
let code = '';
|
|
47
|
+
for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
|
|
48
|
+
code += alphabet[bytes[i] % alphabet.length];
|
|
49
|
+
}
|
|
50
|
+
// Format groupé : "XXXX-XXXX"
|
|
51
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
52
|
+
}
|
|
53
|
+
/** Hash argon2 chacun des codes — pour persistance one-shot. */
|
|
54
|
+
export async function hashBackupCodes(codes) {
|
|
55
|
+
return Promise.all(codes.map((c) => hashPassword(normalizeBackupCode(c))));
|
|
56
|
+
}
|
|
57
|
+
/** Normalise (uppercase, trim, retire espaces et dashes) avant hash/verify. */
|
|
58
|
+
function normalizeBackupCode(input) {
|
|
59
|
+
return input.trim().toUpperCase().replace(/[\s-]+/g, '');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Démarre l'enrollment :
|
|
63
|
+
* 1. génère secret + backup codes
|
|
64
|
+
* 2. produit le QR code
|
|
65
|
+
* 3. persiste un MfaFactorRecord avec `enabled: false`
|
|
66
|
+
* L'user doit ensuite scanner le QR + valider un code via `verifyEnrollmentCode`
|
|
67
|
+
* pour que le factor soit `enabled = true`.
|
|
68
|
+
*/
|
|
69
|
+
export async function enrollTotp(repo, opts) {
|
|
70
|
+
const secretClear = authenticator.generateSecret();
|
|
71
|
+
const otpAuthUrl = authenticator.keyuri(opts.accountName, opts.issuer, secretClear);
|
|
72
|
+
const qrCodeDataUrl = await qrToDataURL(otpAuthUrl, { errorCorrectionLevel: 'M', margin: 1 });
|
|
73
|
+
const backupCodes = generateBackupCodes();
|
|
74
|
+
const backupCodesHashed = await hashBackupCodes(backupCodes);
|
|
75
|
+
// v2.9.1+ : si encrypter fourni, le secret persisté est chiffré ; le secret
|
|
76
|
+
// retourné dans EnrollResult reste en clair (utilisé pour QR + saisie manuelle).
|
|
77
|
+
const secretToStore = opts.encrypter
|
|
78
|
+
? await opts.encrypter.encrypt(secretClear)
|
|
79
|
+
: secretClear;
|
|
80
|
+
const factor = await repo.insert({
|
|
81
|
+
userId: opts.userId,
|
|
82
|
+
kind: 'totp',
|
|
83
|
+
label: opts.label ?? `${opts.issuer} — ${opts.accountName}`,
|
|
84
|
+
secret: secretToStore,
|
|
85
|
+
secretEncrypted: !!opts.encrypter,
|
|
86
|
+
backupCodesHashed,
|
|
87
|
+
enabled: false,
|
|
88
|
+
createdAt: new Date(),
|
|
89
|
+
lastUsedAt: null,
|
|
90
|
+
});
|
|
91
|
+
// Le factor retourné conserve le secret tel que stocké (chiffré le cas échéant) ;
|
|
92
|
+
// on retourne le secretClear séparément.
|
|
93
|
+
return { factor, secret: secretClear, otpAuthUrl, qrCodeDataUrl, backupCodes };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Helper interne : déchiffre le secret d'un record selon son flag `secretEncrypted`.
|
|
97
|
+
* Cohabitation v2.9.0 (clear) ↔ v2.9.1 (encrypted).
|
|
98
|
+
*/
|
|
99
|
+
async function readClearSecret(factor, encrypter) {
|
|
100
|
+
if (!factor.secretEncrypted)
|
|
101
|
+
return factor.secret;
|
|
102
|
+
if (!encrypter) {
|
|
103
|
+
throw new Error('mfa-totp: factor secret is encrypted but no SecretEncrypter provided to verify call');
|
|
104
|
+
}
|
|
105
|
+
return encrypter.decrypt(factor.secret);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Confirme l'enrollment : l'user a scanné le QR + saisi un code TOTP courant.
|
|
109
|
+
* Si OK, le factor passe `enabled = true`.
|
|
110
|
+
*/
|
|
111
|
+
export async function verifyEnrollmentCode(repo, args) {
|
|
112
|
+
const factor = await repo.findById(args.factorId);
|
|
113
|
+
if (!factor)
|
|
114
|
+
return { ok: false, reason: 'not_found' };
|
|
115
|
+
if (factor.enabled)
|
|
116
|
+
return { ok: false, reason: 'already_enabled' };
|
|
117
|
+
const secretClear = await readClearSecret(factor, args.encrypter);
|
|
118
|
+
const valid = authenticator.verify({ token: args.code.replace(/\s/g, ''), secret: secretClear });
|
|
119
|
+
if (!valid)
|
|
120
|
+
return { ok: false, reason: 'wrong_code' };
|
|
121
|
+
await repo.setEnabled(factor.id, true);
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Vérifie un code TOTP OU un backup code.
|
|
126
|
+
* Le caller est responsable du rate-limit en amont (cf. lib/auth-rate-limit.ts).
|
|
127
|
+
*/
|
|
128
|
+
export async function verifyMfaCode(repo, args) {
|
|
129
|
+
const factor = await repo.findActiveTotp(args.userId);
|
|
130
|
+
if (!factor)
|
|
131
|
+
return { ok: false, reason: 'no_factor' };
|
|
132
|
+
// 1) Tente d'abord TOTP courant.
|
|
133
|
+
const totpClean = args.code.replace(/\s/g, '');
|
|
134
|
+
if (totpClean.length === 6 && /^\d+$/.test(totpClean)) {
|
|
135
|
+
const secretClear = await readClearSecret(factor, args.encrypter);
|
|
136
|
+
const valid = authenticator.verify({ token: totpClean, secret: secretClear });
|
|
137
|
+
if (valid) {
|
|
138
|
+
await repo.updateAfterUse(factor.id, {
|
|
139
|
+
backupCodesHashed: factor.backupCodesHashed,
|
|
140
|
+
lastUsedAt: new Date(),
|
|
141
|
+
});
|
|
142
|
+
const remaining = factor.backupCodesHashed.filter((h) => h !== null).length;
|
|
143
|
+
return { ok: true, method: 'totp', remainingBackupCodes: remaining };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// 2) Sinon tente backup code (un par un, hash à hash — argon2 = constant-time).
|
|
147
|
+
const normalized = normalizeBackupCode(args.code);
|
|
148
|
+
for (let i = 0; i < factor.backupCodesHashed.length; i++) {
|
|
149
|
+
const hashed = factor.backupCodesHashed[i];
|
|
150
|
+
if (hashed === null)
|
|
151
|
+
continue; // déjà consommé
|
|
152
|
+
if (await comparePassword(normalized, hashed)) {
|
|
153
|
+
const updated = [...factor.backupCodesHashed];
|
|
154
|
+
updated[i] = null;
|
|
155
|
+
await repo.updateAfterUse(factor.id, { backupCodesHashed: updated, lastUsedAt: new Date() });
|
|
156
|
+
const remaining = updated.filter((h) => h !== null).length;
|
|
157
|
+
return { ok: true, method: 'backup_code', remainingBackupCodes: remaining };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { ok: false, reason: 'wrong_code' };
|
|
161
|
+
}
|
|
162
|
+
// ─── Disable ────────────────────────────────────────────────────────────
|
|
163
|
+
/**
|
|
164
|
+
* Désactive le TOTP d'un user. Le caller DOIT exiger une preuve fraîche d'identité
|
|
165
|
+
* (password ré-entré + code TOTP courant OU backup code) AVANT d'appeler cette
|
|
166
|
+
* fonction — ce module ne re-vérifie pas, il fait juste la suppression.
|
|
167
|
+
*/
|
|
168
|
+
export async function disableTotp(repo, args) {
|
|
169
|
+
const factors = await repo.findAllByUser(args.userId);
|
|
170
|
+
const totps = factors.filter((f) => f.kind === 'totp');
|
|
171
|
+
await Promise.all(totps.map((f) => repo.delete(f.id)));
|
|
172
|
+
return { deletedFactorIds: totps.map((f) => f.id) };
|
|
173
|
+
}
|
|
174
|
+
/** Pour l'UI compte / settings. */
|
|
175
|
+
export async function getMfaStatus(repo, userId) {
|
|
176
|
+
const factor = await repo.findActiveTotp(userId);
|
|
177
|
+
if (!factor) {
|
|
178
|
+
const all = await repo.findAllByUser(userId);
|
|
179
|
+
const pending = all.find((f) => f.kind === 'totp' && !f.enabled);
|
|
180
|
+
return {
|
|
181
|
+
totpEnrolled: !!pending,
|
|
182
|
+
totpEnabled: false,
|
|
183
|
+
remainingBackupCodes: 0,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
totpEnrolled: true,
|
|
188
|
+
totpEnabled: true,
|
|
189
|
+
remainingBackupCodes: factor.backupCodesHashed.filter((h) => h !== null).length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// Test-only helper — exporté pour les tests internes du module.
|
|
193
|
+
export const __testing = { normalizeBackupCode };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ExchangeResult } from './oauth-providers.js';
|
|
2
|
+
export interface OAuthAccountRecord {
|
|
3
|
+
id: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
providerId: string;
|
|
7
|
+
email?: string | null;
|
|
8
|
+
/** Stocké chiffré côté consumer si on souhaite réutiliser le token (refresh des feeds). */
|
|
9
|
+
accessToken?: string | null;
|
|
10
|
+
refreshToken?: string | null;
|
|
11
|
+
scope?: string | null;
|
|
12
|
+
linkedAt: Date | string;
|
|
13
|
+
}
|
|
14
|
+
export interface UserLite {
|
|
15
|
+
id: string;
|
|
16
|
+
email?: string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface OAuthLinkingRepo {
|
|
19
|
+
/** Lookup par (provider, providerId) — l'identité de la liaison. */
|
|
20
|
+
findByProviderAndProviderId(provider: string, providerId: string): Promise<OAuthAccountRecord | null>;
|
|
21
|
+
/** Toutes les liaisons d'un user (pour /account/security UI). */
|
|
22
|
+
findByUserId(userId: string): Promise<OAuthAccountRecord[]>;
|
|
23
|
+
insert(record: Omit<OAuthAccountRecord, 'id'>): Promise<OAuthAccountRecord>;
|
|
24
|
+
delete(id: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export interface UserRepo {
|
|
27
|
+
findByEmail(email: string): Promise<UserLite | null>;
|
|
28
|
+
}
|
|
29
|
+
export interface ResolveLoginConfig {
|
|
30
|
+
oauthRepo: OAuthLinkingRepo;
|
|
31
|
+
userRepo: UserRepo;
|
|
32
|
+
provider: string;
|
|
33
|
+
}
|
|
34
|
+
export type ResolveLoginResult = {
|
|
35
|
+
kind: 'existing_link';
|
|
36
|
+
userId: string;
|
|
37
|
+
oauthAccount: OAuthAccountRecord;
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'requires_link_confirmation';
|
|
40
|
+
existingUserId: string;
|
|
41
|
+
existingEmail: string;
|
|
42
|
+
profile: ExchangeResult;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'create_new';
|
|
45
|
+
profile: ExchangeResult;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Décide quoi faire après l'`exchangeCodeForUser`. Ne touche PAS la base utilisateur —
|
|
49
|
+
* retourne une décision que le consumer applique (créer user, demander confirmation, etc.).
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveOAuthLogin(config: ResolveLoginConfig, profile: ExchangeResult): Promise<ResolveLoginResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Crée la liaison OAuth pour un user EXISTANT (déjà authentifié OU dont l'identité
|
|
54
|
+
* vient d'être confirmée par la confirmation flow). À appeler AUSSI après création d'un
|
|
55
|
+
* user nouveau (cas `create_new`).
|
|
56
|
+
*/
|
|
57
|
+
export declare function linkOAuthAccount(config: {
|
|
58
|
+
oauthRepo: OAuthLinkingRepo;
|
|
59
|
+
provider: string;
|
|
60
|
+
}, args: {
|
|
61
|
+
userId: string;
|
|
62
|
+
profile: ExchangeResult;
|
|
63
|
+
/** Si false (défaut), refuse de créer un doublon (provider, providerId). */
|
|
64
|
+
allowDuplicate?: boolean;
|
|
65
|
+
}): Promise<OAuthAccountRecord>;
|
|
66
|
+
/** Délier un compte OAuth. Le consumer doit s'assurer qu'il reste au moins un moyen de login (password, autre OAuth, passkey). */
|
|
67
|
+
export declare function unlinkOAuthAccount(config: {
|
|
68
|
+
oauthRepo: OAuthLinkingRepo;
|
|
69
|
+
}, oauthAccountId: string): Promise<void>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @mostajs/auth — OAuth account linking (v2.7.0+)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Décide quoi faire après un OAuth callback réussi (cf. exchangeCodeForUser) :
|
|
5
|
+
// 1. Si (provider, providerId) déjà lié → log in as that user.
|
|
6
|
+
// 2. Si pas lié, MAIS l'email matche un user existant → REQUIRES_LINK_CONFIRMATION.
|
|
7
|
+
// ⚠️ Ne JAMAIS lier silencieusement (CVE-class issue, cf. Slack 2020) :
|
|
8
|
+
// un attaquant pourrait créer un compte Google avec l'email d'une victime,
|
|
9
|
+
// passer le flow OAuth, et se voir attribué le compte existant si linkage auto.
|
|
10
|
+
// → Demander confirmation explicite (re-login password / confirm email).
|
|
11
|
+
// 3. Si rien ne matche → créer un user + lier.
|
|
12
|
+
//
|
|
13
|
+
// Le module ne crée PAS le user lui-même : il retourne une décision typée que le
|
|
14
|
+
// consumer (Octonet) traduit en action (création account via @mostajs/orm + audit event).
|
|
15
|
+
/**
|
|
16
|
+
* Décide quoi faire après l'`exchangeCodeForUser`. Ne touche PAS la base utilisateur —
|
|
17
|
+
* retourne une décision que le consumer applique (créer user, demander confirmation, etc.).
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveOAuthLogin(config, profile) {
|
|
20
|
+
// 1. Liaison déjà existante ? Login direct.
|
|
21
|
+
const existingLink = await config.oauthRepo.findByProviderAndProviderId(config.provider, profile.providerId);
|
|
22
|
+
if (existingLink) {
|
|
23
|
+
return { kind: 'existing_link', userId: existingLink.userId, oauthAccount: existingLink };
|
|
24
|
+
}
|
|
25
|
+
// 2. Email connu mais pas encore lié ? Demander confirmation, NE PAS lier auto.
|
|
26
|
+
if (profile.email) {
|
|
27
|
+
const existingUser = await config.userRepo.findByEmail(profile.email);
|
|
28
|
+
if (existingUser) {
|
|
29
|
+
return {
|
|
30
|
+
kind: 'requires_link_confirmation',
|
|
31
|
+
existingUserId: existingUser.id,
|
|
32
|
+
existingEmail: profile.email,
|
|
33
|
+
profile,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// 3. Nouveau user à créer (le consumer s'en charge + appellera linkOAuthAccount ensuite).
|
|
38
|
+
return { kind: 'create_new', profile };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Crée la liaison OAuth pour un user EXISTANT (déjà authentifié OU dont l'identité
|
|
42
|
+
* vient d'être confirmée par la confirmation flow). À appeler AUSSI après création d'un
|
|
43
|
+
* user nouveau (cas `create_new`).
|
|
44
|
+
*/
|
|
45
|
+
export async function linkOAuthAccount(config, args) {
|
|
46
|
+
if (!args.allowDuplicate) {
|
|
47
|
+
const existing = await config.oauthRepo.findByProviderAndProviderId(config.provider, args.profile.providerId);
|
|
48
|
+
if (existing && existing.userId !== args.userId) {
|
|
49
|
+
throw new Error(`OAuth account already linked to a different user (provider=${config.provider}, providerId=${args.profile.providerId})`);
|
|
50
|
+
}
|
|
51
|
+
if (existing && existing.userId === args.userId) {
|
|
52
|
+
// Idempotent : déjà lié au bon user.
|
|
53
|
+
return existing;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return config.oauthRepo.insert({
|
|
57
|
+
userId: args.userId,
|
|
58
|
+
provider: config.provider,
|
|
59
|
+
providerId: args.profile.providerId,
|
|
60
|
+
email: args.profile.email ?? null,
|
|
61
|
+
accessToken: args.profile.accessToken ?? null,
|
|
62
|
+
refreshToken: args.profile.refreshToken ?? null,
|
|
63
|
+
scope: args.profile.scope ?? null,
|
|
64
|
+
linkedAt: new Date(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Délier un compte OAuth. Le consumer doit s'assurer qu'il reste au moins un moyen de login (password, autre OAuth, passkey). */
|
|
68
|
+
export async function unlinkOAuthAccount(config, oauthAccountId) {
|
|
69
|
+
await config.oauthRepo.delete(oauthAccountId);
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Génère un `code_verifier` 64 chars base64url (= 384 bits d'entropie).
|
|
3
|
+
*
|
|
4
|
+
* RFC 7636 §4.1 :
|
|
5
|
+
* code_verifier = high-entropy cryptographic random STRING
|
|
6
|
+
* ALPHA / DIGIT / "-" / "." / "_" / "~" (= base64url unpadded sans `=`)
|
|
7
|
+
* minLen 43, maxLen 128
|
|
8
|
+
*/
|
|
9
|
+
export declare function generateCodeVerifier(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Dérive le `code_challenge` à partir du verifier — méthode S256.
|
|
12
|
+
*
|
|
13
|
+
* RFC 7636 §4.2 :
|
|
14
|
+
* code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
|
|
15
|
+
*
|
|
16
|
+
* La méthode `plain` est volontairement non-supportée (RFC 7636 §7.2 — interdite
|
|
17
|
+
* en production 2025).
|
|
18
|
+
*/
|
|
19
|
+
export declare function deriveCodeChallenge(verifier: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Génère un `state` cryptographiquement aléatoire — 24 bytes = 192 bits.
|
|
22
|
+
*
|
|
23
|
+
* RFC 6749 §10.12 :
|
|
24
|
+
* The state parameter SHOULD be used to prevent CSRF attacks. The value should
|
|
25
|
+
* be unguessable.
|
|
26
|
+
*/
|
|
27
|
+
export declare function generateState(): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @mostajs/auth — OAuth2 PKCE primitives (RFC 7636 + RFC 6749 §10.12)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Ce sous-module expose UNIQUEMENT les primitives cryptographiques OAuth (PKCE
|
|
5
|
+
// generators + state CSRF) — sans tirer le module `oauth-providers.ts` complet
|
|
6
|
+
// (qui contient les SPECS Google/GitHub/Microsoft + `exchangeCodeForUser` qui
|
|
7
|
+
// font des `fetch` lourds).
|
|
8
|
+
//
|
|
9
|
+
// Conçu pour être importé par `@mostajs/auth-flow` côté client navigateur/edge,
|
|
10
|
+
// par tout SDK polyglotte qui implémente PKCE, par les tests cross-modules.
|
|
11
|
+
//
|
|
12
|
+
// Stable : ces 3 fonctions n'évoluent pas (RFC fige les algorithmes).
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
/**
|
|
15
|
+
* Génère un `code_verifier` 64 chars base64url (= 384 bits d'entropie).
|
|
16
|
+
*
|
|
17
|
+
* RFC 7636 §4.1 :
|
|
18
|
+
* code_verifier = high-entropy cryptographic random STRING
|
|
19
|
+
* ALPHA / DIGIT / "-" / "." / "_" / "~" (= base64url unpadded sans `=`)
|
|
20
|
+
* minLen 43, maxLen 128
|
|
21
|
+
*/
|
|
22
|
+
export function generateCodeVerifier() {
|
|
23
|
+
return crypto.randomBytes(48).toString('base64url');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Dérive le `code_challenge` à partir du verifier — méthode S256.
|
|
27
|
+
*
|
|
28
|
+
* RFC 7636 §4.2 :
|
|
29
|
+
* code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
|
|
30
|
+
*
|
|
31
|
+
* La méthode `plain` est volontairement non-supportée (RFC 7636 §7.2 — interdite
|
|
32
|
+
* en production 2025).
|
|
33
|
+
*/
|
|
34
|
+
export function deriveCodeChallenge(verifier) {
|
|
35
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Génère un `state` cryptographiquement aléatoire — 24 bytes = 192 bits.
|
|
39
|
+
*
|
|
40
|
+
* RFC 6749 §10.12 :
|
|
41
|
+
* The state parameter SHOULD be used to prevent CSRF attacks. The value should
|
|
42
|
+
* be unguessable.
|
|
43
|
+
*/
|
|
44
|
+
export function generateState() {
|
|
45
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
46
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { generateCodeVerifier, deriveCodeChallenge, generateState } from './oauth-primitives.js';
|
|
2
|
+
export { generateCodeVerifier, deriveCodeChallenge, generateState };
|
|
3
|
+
/** Spec d'un provider OAuth2/OIDC. Extensible : un consumer peut fournir sa propre spec. */
|
|
4
|
+
export interface OAuthProviderSpec {
|
|
5
|
+
id: string;
|
|
6
|
+
authorizationEndpoint: string;
|
|
7
|
+
tokenEndpoint: string;
|
|
8
|
+
/** Endpoint userinfo. Si omis, on tente de décoder le `id_token` JWT (OIDC). */
|
|
9
|
+
userInfoEndpoint?: string;
|
|
10
|
+
/** Scopes par défaut si l'app n'en passe pas (ex: ['openid','email','profile']). */
|
|
11
|
+
defaultScopes: string[];
|
|
12
|
+
/** Mapper du userInfo brut vers `{providerId, email, name}`. Souvent provider-specific. */
|
|
13
|
+
mapProfile: (raw: any) => {
|
|
14
|
+
providerId: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
};
|
|
18
|
+
/** Header `Accept` à passer à `tokenEndpoint`. GitHub veut 'application/json'. */
|
|
19
|
+
tokenAcceptHeader?: string;
|
|
20
|
+
/** Header `Authorization` à passer à `userInfoEndpoint`. Default = 'Bearer <access>'. */
|
|
21
|
+
userInfoAuthHeader?: (accessToken: string) => string;
|
|
22
|
+
}
|
|
23
|
+
/** Lookup d'une spec built-in. Retourne null si l'id n'est pas connu. */
|
|
24
|
+
export declare function getProviderSpec(id: string): OAuthProviderSpec | null;
|
|
25
|
+
/** Pour un IdP OIDC arbitraire — on construit la spec à partir du discovery. */
|
|
26
|
+
export interface OidcDiscoveryDocument {
|
|
27
|
+
issuer: string;
|
|
28
|
+
authorization_endpoint: string;
|
|
29
|
+
token_endpoint: string;
|
|
30
|
+
userinfo_endpoint?: string;
|
|
31
|
+
jwks_uri?: string;
|
|
32
|
+
scopes_supported?: string[];
|
|
33
|
+
}
|
|
34
|
+
/** Fetch le `.well-known/openid-configuration` d'un issuer et construit une spec OIDC. */
|
|
35
|
+
export declare function discoverOidcProvider(issuerUrl: string, fetchImpl?: typeof fetch): Promise<OAuthProviderSpec>;
|
|
36
|
+
export interface OAuthConfig {
|
|
37
|
+
spec: OAuthProviderSpec;
|
|
38
|
+
clientId: string;
|
|
39
|
+
clientSecret: string;
|
|
40
|
+
redirectUri: string;
|
|
41
|
+
/** Scopes additionnels au-delà de `spec.defaultScopes`. */
|
|
42
|
+
extraScopes?: string[];
|
|
43
|
+
/** Permet d'injecter un `fetch` custom (utile en tests). */
|
|
44
|
+
fetchImpl?: typeof fetch;
|
|
45
|
+
}
|
|
46
|
+
export interface AuthorizationStart {
|
|
47
|
+
/** URL vers laquelle rediriger l'utilisateur. */
|
|
48
|
+
url: string;
|
|
49
|
+
/** À persister côté server (ex: dans une session courte) — vérifié au callback contre le `state` retourné. */
|
|
50
|
+
state: string;
|
|
51
|
+
/** À persister aussi — utilisé au token exchange. */
|
|
52
|
+
codeVerifier: string;
|
|
53
|
+
}
|
|
54
|
+
export interface ExchangeResult {
|
|
55
|
+
providerId: string;
|
|
56
|
+
email?: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
rawProfile: unknown;
|
|
59
|
+
accessToken: string;
|
|
60
|
+
refreshToken?: string;
|
|
61
|
+
/** Secondes jusqu'à expiration de l'access token. */
|
|
62
|
+
expiresIn?: number;
|
|
63
|
+
/** id_token brut si OIDC. */
|
|
64
|
+
idToken?: string;
|
|
65
|
+
/** Liste des scopes effectivement accordés. */
|
|
66
|
+
scope?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Démarre le flow : génère state + PKCE verifier, retourne l'URL d'autorisation.
|
|
70
|
+
* Le caller doit persister `{ state, codeVerifier }` (ex: cookie httpOnly court) et
|
|
71
|
+
* comparer au callback.
|
|
72
|
+
*/
|
|
73
|
+
export declare function startAuthorization(config: OAuthConfig, opts?: {
|
|
74
|
+
state?: string;
|
|
75
|
+
loginHint?: string;
|
|
76
|
+
}): AuthorizationStart;
|
|
77
|
+
/**
|
|
78
|
+
* Callback : échange `code` contre access_token, fetch userinfo, retourne le profil.
|
|
79
|
+
* Le caller a la responsabilité de vérifier que `state` reçu == `state` persisté
|
|
80
|
+
* (anti-CSRF).
|
|
81
|
+
*/
|
|
82
|
+
export declare function exchangeCodeForUser(config: OAuthConfig, args: {
|
|
83
|
+
code: string;
|
|
84
|
+
codeVerifier: string;
|
|
85
|
+
}): Promise<ExchangeResult>;
|
|
86
|
+
/**
|
|
87
|
+
* Décode (sans vérifier la signature) le payload d'un JWT. Sécurité : à n'utiliser
|
|
88
|
+
* QUE quand le JWT vient d'un endpoint authentifié de confiance (ex: id_token reçu
|
|
89
|
+
* directement du tokenEndpoint). Pour vérifier un JWT venant d'un client, utiliser
|
|
90
|
+
* une lib comme `jose` avec le JWKS du provider.
|
|
91
|
+
*/
|
|
92
|
+
export declare function decodeJwtPayloadUnverified(jwt: string): unknown;
|