@mostajs/auth 2.5.2 → 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 +85 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface MfaChallengeProps {
|
|
2
|
+
/** POST endpoint qui valide TOTP ou backup code. */
|
|
3
|
+
verifyEndpoint: string;
|
|
4
|
+
/** Callback quand la vérification réussit. */
|
|
5
|
+
onSuccess: (result: {
|
|
6
|
+
method: 'totp' | 'backup_code';
|
|
7
|
+
remainingBackupCodes: number;
|
|
8
|
+
}) => void;
|
|
9
|
+
/** Callback quand l'user annule (back to login). */
|
|
10
|
+
onCancel?: () => void;
|
|
11
|
+
/** Fetch implementation (test override). */
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
/** Préremplit l'input — utile en flow re-enter (ex: mobile autofill). */
|
|
14
|
+
initialCode?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function MfaChallenge(props: MfaChallengeProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
export default MfaChallenge;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @mostajs/auth — <MfaChallenge/> — Lot 4 / v2.9.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Composant invoqué pendant le login quand le user a un facteur TOTP enabled.
|
|
5
|
+
// Accepte soit un code TOTP 6 chiffres, soit un backup code "XXXX-XXXX" — le
|
|
6
|
+
// serveur fait la discrimination (cf. lib/mfa-totp.ts > verifyMfaCode).
|
|
7
|
+
//
|
|
8
|
+
// Le composant est volontairement headless côté visuel (zéro CSS framework
|
|
9
|
+
// imposé), branche-toi sur les classes `mostajs-mfa-*`.
|
|
10
|
+
'use client';
|
|
11
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
export function MfaChallenge(props) {
|
|
14
|
+
const fetchImpl = props.fetchImpl ?? fetch;
|
|
15
|
+
const [mode, setMode] = useState('totp');
|
|
16
|
+
const [code, setCode] = useState(props.initialCode ?? '');
|
|
17
|
+
const [error, setError] = useState(null);
|
|
18
|
+
const [submitting, setSubmitting] = useState(false);
|
|
19
|
+
async function handleSubmit(e) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
setError(null);
|
|
22
|
+
setSubmitting(true);
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetchImpl(props.verifyEndpoint, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'content-type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ code: code.trim() }),
|
|
28
|
+
});
|
|
29
|
+
const result = await res.json();
|
|
30
|
+
if (!res.ok || result.ok !== true) {
|
|
31
|
+
setError(result.reason ?? 'wrong_code');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
props.onSuccess({
|
|
35
|
+
method: result.method,
|
|
36
|
+
remainingBackupCodes: result.remainingBackupCodes ?? 0,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
setError(err.message);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
setSubmitting(false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const isTotp = mode === 'totp';
|
|
47
|
+
const placeholder = isTotp ? '000000' : 'XXXX-XXXX';
|
|
48
|
+
const inputMode = isTotp ? 'numeric' : 'text';
|
|
49
|
+
const pattern = isTotp ? '[0-9]{6}' : undefined;
|
|
50
|
+
const maxLength = isTotp ? 6 : 9;
|
|
51
|
+
return (_jsxs("form", { className: "mostajs-mfa-challenge", onSubmit: handleSubmit, role: "dialog", "aria-labelledby": "mfa-challenge-title", children: [_jsx("h2", { id: "mfa-challenge-title", children: "V\u00E9rification \u00E0 deux facteurs" }), _jsx("p", { children: isTotp
|
|
52
|
+
? 'Saisis le code à 6 chiffres affiché par ton application d\'authentification.'
|
|
53
|
+
: 'Saisis l\'un de tes 10 codes de secours.' }), _jsx("input", { type: "text", inputMode: inputMode, autoComplete: "one-time-code", autoFocus: true, pattern: pattern, maxLength: maxLength, value: code, onChange: (e) => setCode(e.target.value), placeholder: placeholder, className: "mostajs-mfa-code-input", "aria-label": isTotp ? 'Code TOTP' : 'Code de secours', required: true }), error && (_jsx("p", { className: "mostajs-mfa-error", role: "alert", children: "Code incorrect, r\u00E9essaie." })), _jsxs("div", { className: "mostajs-mfa-actions", children: [_jsx("button", { type: "submit", disabled: submitting || code.length === 0, children: submitting ? 'Vérification…' : 'Valider' }), _jsx("button", { type: "button", className: "mostajs-mfa-link", onClick: () => { setMode(isTotp ? 'backup' : 'totp'); setCode(''); setError(null); }, children: isTotp ? 'Utiliser un code de secours' : 'Utiliser un code TOTP' }), props.onCancel && (_jsx("button", { type: "button", className: "mostajs-mfa-link", onClick: props.onCancel, children: "Annuler" }))] })] }));
|
|
54
|
+
}
|
|
55
|
+
export default MfaChallenge;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface MfaEnrollDialogProps {
|
|
2
|
+
/** Issuer affiché dans l'authenticator (ex: "Octonet"). */
|
|
3
|
+
issuer: string;
|
|
4
|
+
/** Email/login de l'user — affiché dans l'authenticator. */
|
|
5
|
+
accountName: string;
|
|
6
|
+
/** POST endpoint qui démarre l'enroll (retourne EnrollResult). */
|
|
7
|
+
enrollEndpoint: string;
|
|
8
|
+
/** POST endpoint qui confirme l'enrollment avec un code TOTP. */
|
|
9
|
+
confirmEndpoint: string;
|
|
10
|
+
/** Callback quand l'user a fini (succès). */
|
|
11
|
+
onComplete?: () => void;
|
|
12
|
+
/** Callback quand l'user annule. */
|
|
13
|
+
onCancel?: () => void;
|
|
14
|
+
/** Fetch implementation (test override). */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export declare function MfaEnrollDialog(props: MfaEnrollDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export default MfaEnrollDialog;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @mostajs/auth — <MfaEnrollDialog/> — Lot 4 / v2.9.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Dialogue 3-étapes pour enroller un facteur TOTP :
|
|
5
|
+
// 1. Affiche le QR code + secret base32 fallback
|
|
6
|
+
// 2. Affiche les 10 backup codes UNE SEULE FOIS — l'user doit confirmer
|
|
7
|
+
// qu'il les a sauvegardés avant de continuer
|
|
8
|
+
// 3. Demande un premier code TOTP courant pour confirmer la lecture
|
|
9
|
+
//
|
|
10
|
+
// Composant 'use client' minimal (pas de framework UI imposé). Les classes CSS
|
|
11
|
+
// sont des hooks (`mostajs-mfa-*`) que le consumer style librement.
|
|
12
|
+
'use client';
|
|
13
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
|
+
import React, { useState } from 'react';
|
|
15
|
+
export function MfaEnrollDialog(props) {
|
|
16
|
+
const fetchImpl = props.fetchImpl ?? fetch;
|
|
17
|
+
const [step, setStep] = useState('loading');
|
|
18
|
+
const [data, setData] = useState(null);
|
|
19
|
+
const [code, setCode] = useState('');
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
const [acknowledged, setAcknowledged] = useState(false);
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
(async () => {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetchImpl(props.enrollEndpoint, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ accountName: props.accountName, issuer: props.issuer }),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
throw new Error(`Enroll failed (${res.status})`);
|
|
33
|
+
const payload = (await res.json());
|
|
34
|
+
if (cancelled)
|
|
35
|
+
return;
|
|
36
|
+
setData(payload);
|
|
37
|
+
setStep('show-qr');
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (cancelled)
|
|
41
|
+
return;
|
|
42
|
+
setError(e.message);
|
|
43
|
+
setStep('error');
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
return () => { cancelled = true; };
|
|
47
|
+
}, []);
|
|
48
|
+
async function handleConfirm() {
|
|
49
|
+
if (!data)
|
|
50
|
+
return;
|
|
51
|
+
setError(null);
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetchImpl(props.confirmEndpoint, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ factorId: data.factorId, code: code.replace(/\s/g, '') }),
|
|
57
|
+
});
|
|
58
|
+
const result = await res.json();
|
|
59
|
+
if (!res.ok || result.ok !== true) {
|
|
60
|
+
setError(result.reason ?? 'wrong_code');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setStep('done');
|
|
64
|
+
props.onComplete?.();
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
setError(e.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return (_jsxs("div", { className: "mostajs-mfa-enroll", role: "dialog", "aria-labelledby": "mfa-enroll-title", children: [_jsx("h2", { id: "mfa-enroll-title", children: "Activer l'authentification \u00E0 deux facteurs" }), step === 'loading' && _jsx("p", { children: "Pr\u00E9paration du facteur..." }), step === 'show-qr' && data && (_jsxs("div", { className: "mostajs-mfa-step", children: [_jsx("p", { children: "1. Scanne ce QR code avec ton application d'authentification (Google Authenticator, Authy, 1Password\u2026)." }), _jsx("img", { src: data.qrCodeDataUrl, alt: "QR code TOTP", width: 240, height: 240 }), _jsxs("details", { children: [_jsx("summary", { children: "Ne peux pas scanner ? Saisis ce code manuellement" }), _jsx("code", { className: "mostajs-mfa-secret", children: data.secret })] }), _jsx("button", { type: "button", onClick: () => setStep('show-backup-codes'), children: "Continuer" }), _jsx("button", { type: "button", onClick: () => props.onCancel?.(), children: "Annuler" })] })), step === 'show-backup-codes' && data && (_jsxs("div", { className: "mostajs-mfa-step", children: [_jsxs("p", { children: [_jsx("strong", { children: "2. Sauvegarde tes 10 codes de secours" }), " \u2014 utilise-les si tu perds ton appareil. Ils ne seront ", _jsx("strong", { children: "plus jamais affich\u00E9s" }), "."] }), _jsx("ul", { className: "mostajs-mfa-backup-codes", children: data.backupCodes.map((c) => _jsx("li", { children: _jsx("code", { children: c }) }, c)) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: acknowledged, onChange: (e) => setAcknowledged(e.target.checked) }), "J'ai sauvegard\u00E9 ces codes dans un endroit s\u00FBr."] }), _jsx("button", { type: "button", onClick: () => setStep('confirm-code'), disabled: !acknowledged, children: "Continuer" })] })), step === 'confirm-code' && (_jsxs("div", { className: "mostajs-mfa-step", children: [_jsx("p", { children: "3. Saisis un code de 6 chiffres affich\u00E9 par ton application pour confirmer." }), _jsx("input", { type: "text", inputMode: "numeric", autoComplete: "one-time-code", pattern: "[0-9]{6}", maxLength: 6, value: code, onChange: (e) => setCode(e.target.value), placeholder: "000000", className: "mostajs-mfa-code-input" }), error && _jsx("p", { className: "mostajs-mfa-error", role: "alert", children: "Code incorrect, r\u00E9essaie." }), _jsx("button", { type: "button", onClick: handleConfirm, disabled: code.length !== 6, children: "Confirmer" })] })), step === 'done' && (_jsx("div", { className: "mostajs-mfa-step", children: _jsx("p", { children: "\u2713 Authentification \u00E0 deux facteurs activ\u00E9e." }) })), step === 'error' && (_jsxs("div", { className: "mostajs-mfa-step", role: "alert", children: [_jsxs("p", { children: ["Erreur : ", error] }), _jsx("button", { type: "button", onClick: () => props.onCancel?.(), children: "Fermer" })] }))] }));
|
|
71
|
+
}
|
|
72
|
+
export default MfaEnrollDialog;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface PasskeyLoginButtonProps {
|
|
2
|
+
/** POST endpoint qui retourne PublicKeyCredentialRequestOptionsJSON. */
|
|
3
|
+
startEndpoint: string;
|
|
4
|
+
/** POST endpoint qui prend AuthenticationResponseJSON + retourne {ok, userId}. */
|
|
5
|
+
finishEndpoint: string;
|
|
6
|
+
/** Mode d'usage attendu — 'primary' pour login passwordless, 'factor' pour 2nd touch. */
|
|
7
|
+
expectedUsage: 'primary' | 'factor';
|
|
8
|
+
/** Callback succès. */
|
|
9
|
+
onSuccess?: (info: {
|
|
10
|
+
userId: string;
|
|
11
|
+
credentialId: string;
|
|
12
|
+
}) => void;
|
|
13
|
+
onError?: (err: Error) => void;
|
|
14
|
+
label?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
className?: string;
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
export declare function PasskeyLoginButton(props: PasskeyLoginButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
export default PasskeyLoginButton;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @mostajs/auth — <PasskeyLoginButton/> — Lot 5 / v2.10.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Bouton qui orchestre la cérémonie WebAuthn authentication côté client.
|
|
5
|
+
// Mode primary : l'user clique sans avoir saisi son email — le browser propose
|
|
6
|
+
// les passkeys discoverable. Mode factor : appelé après password OK pour 2nd touch.
|
|
7
|
+
//
|
|
8
|
+
// Pour l'autofill (conditional UI), utiliser à la place un useEffect avec
|
|
9
|
+
// `startAuthentication({ optionsJSON, useBrowserAutofill: true })` sur l'input email
|
|
10
|
+
// — exemple dans la doc.
|
|
11
|
+
'use client';
|
|
12
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
13
|
+
import { useState } from 'react';
|
|
14
|
+
import { startAuthentication } from '@simplewebauthn/browser';
|
|
15
|
+
export function PasskeyLoginButton(props) {
|
|
16
|
+
const fetchImpl = props.fetchImpl ?? fetch;
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
async function handleClick() {
|
|
19
|
+
setBusy(true);
|
|
20
|
+
try {
|
|
21
|
+
const optsRes = await fetchImpl(props.startEndpoint, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'content-type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ expectedUsage: props.expectedUsage }),
|
|
25
|
+
});
|
|
26
|
+
if (!optsRes.ok)
|
|
27
|
+
throw new Error(`start failed (${optsRes.status})`);
|
|
28
|
+
const opts = await optsRes.json();
|
|
29
|
+
const response = await startAuthentication(opts);
|
|
30
|
+
const finRes = await fetchImpl(props.finishEndpoint, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'content-type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ response, expectedUsage: props.expectedUsage }),
|
|
34
|
+
});
|
|
35
|
+
const result = await finRes.json();
|
|
36
|
+
if (!finRes.ok || result.ok !== true) {
|
|
37
|
+
throw new Error(result.reason ?? 'auth_failed');
|
|
38
|
+
}
|
|
39
|
+
props.onSuccess?.({ userId: result.userId, credentialId: result.credentialId });
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
props.onError?.(e);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
setBusy(false);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const defaultLabel = props.expectedUsage === 'primary'
|
|
49
|
+
? 'Se connecter avec une passkey'
|
|
50
|
+
: 'Confirmer avec ma passkey';
|
|
51
|
+
return (_jsx("button", { type: "button", onClick: handleClick, disabled: busy || props.disabled, className: `mostajs-passkey-login ${props.className ?? ''}`, children: busy ? 'Vérification…' : (props.label ?? defaultLabel) }));
|
|
52
|
+
}
|
|
53
|
+
export default PasskeyLoginButton;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PasskeyRegisterButtonProps {
|
|
2
|
+
/** POST endpoint qui retourne PublicKeyCredentialCreationOptionsJSON. */
|
|
3
|
+
startEndpoint: string;
|
|
4
|
+
/** POST endpoint qui prend la RegistrationResponseJSON + retourne {ok, record}. */
|
|
5
|
+
finishEndpoint: string;
|
|
6
|
+
/** Label utilisateur du device (rempli côté composant ou fourni). */
|
|
7
|
+
deviceName?: string;
|
|
8
|
+
/** Mode d'usage prévu (default 'both'). */
|
|
9
|
+
usage?: 'primary' | 'factor' | 'both';
|
|
10
|
+
/** Callback succès. */
|
|
11
|
+
onSuccess?: (info: {
|
|
12
|
+
credentialId: string;
|
|
13
|
+
}) => void;
|
|
14
|
+
/** Callback erreur. */
|
|
15
|
+
onError?: (err: Error) => void;
|
|
16
|
+
/** Texte du bouton. */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Disabled state externe. */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** className passthrough. */
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Fetch implementation (test override). */
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
}
|
|
25
|
+
export declare function PasskeyRegisterButton(props: PasskeyRegisterButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
export default PasskeyRegisterButton;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @mostajs/auth — <PasskeyRegisterButton/> — Lot 5 / v2.10.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Bouton qui orchestre la cérémonie WebAuthn registration côté client :
|
|
5
|
+
// 1. POST endpoint start → reçoit PublicKeyCredentialCreationOptionsJSON
|
|
6
|
+
// 2. startRegistration() de @simplewebauthn/browser ouvre le prompt natif
|
|
7
|
+
// 3. POST endpoint finish avec la réponse → user enrolled
|
|
8
|
+
'use client';
|
|
9
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import { startRegistration } from '@simplewebauthn/browser';
|
|
12
|
+
export function PasskeyRegisterButton(props) {
|
|
13
|
+
const fetchImpl = props.fetchImpl ?? fetch;
|
|
14
|
+
const [busy, setBusy] = useState(false);
|
|
15
|
+
const [name, setName] = useState(props.deviceName ?? '');
|
|
16
|
+
async function handleClick() {
|
|
17
|
+
setBusy(true);
|
|
18
|
+
try {
|
|
19
|
+
// Étape 1 : récupère les options du serveur.
|
|
20
|
+
const optsRes = await fetchImpl(props.startEndpoint, { method: 'POST' });
|
|
21
|
+
if (!optsRes.ok)
|
|
22
|
+
throw new Error(`start failed (${optsRes.status})`);
|
|
23
|
+
const opts = await optsRes.json();
|
|
24
|
+
// Étape 2 : prompt natif WebAuthn (TouchID / Face ID / YubiKey / passkey iCloud).
|
|
25
|
+
const response = await startRegistration(opts);
|
|
26
|
+
// Étape 3 : poste la réponse au serveur pour vérification + persistance.
|
|
27
|
+
const finRes = await fetchImpl(props.finishEndpoint, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'content-type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ response, deviceName: name || undefined, usage: props.usage ?? 'both' }),
|
|
31
|
+
});
|
|
32
|
+
const result = await finRes.json();
|
|
33
|
+
if (!finRes.ok || result.ok !== true) {
|
|
34
|
+
throw new Error(result.reason ?? 'finish_failed');
|
|
35
|
+
}
|
|
36
|
+
props.onSuccess?.({ credentialId: result.record?.credentialId ?? response.id });
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
props.onError?.(e);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
setBusy(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return (_jsxs("div", { className: `mostajs-passkey-register ${props.className ?? ''}`, children: [_jsx("input", { type: "text", placeholder: "Nom du device (ex: iPhone 15, YubiKey)", value: name, onChange: (e) => setName(e.target.value), disabled: busy, className: "mostajs-passkey-name-input" }), _jsx("button", { type: "button", onClick: handleClick, disabled: busy || props.disabled, children: busy ? 'Enregistrement…' : (props.label ?? 'Ajouter une passkey') })] }));
|
|
46
|
+
}
|
|
47
|
+
export default PasskeyRegisterButton;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chaque module sibling implémente cette interface pour participer au purge/export.
|
|
3
|
+
* Le consumer collecte les hooks et les passe à `lib/account-lifecycle` au boot.
|
|
4
|
+
*/
|
|
5
|
+
export interface DataSubjectHook {
|
|
6
|
+
/** Identifiant lisible du module pour audit ('rbac', 'auth', 'storage', 'payment', …). */
|
|
7
|
+
module: string;
|
|
8
|
+
/**
|
|
9
|
+
* Renvoie les données de l'user en une structure JSON-serializable.
|
|
10
|
+
* Format libre — chaque module met ce qu'il a (table users → row, table refresh_tokens → liste, …).
|
|
11
|
+
* Utilisé par export RGPD.
|
|
12
|
+
*/
|
|
13
|
+
exportUserData(userId: string): Promise<unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* Supprime DÉFINITIVEMENT toutes les données de l'user.
|
|
16
|
+
* Idempotent. Doit être atomique côté module (transaction si possible).
|
|
17
|
+
* Retourne le nombre de rows supprimées (audit trail).
|
|
18
|
+
*/
|
|
19
|
+
purgeUserData(userId: string): Promise<{
|
|
20
|
+
rowsDeleted: number;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export interface DeletionTokenConfig {
|
|
24
|
+
/** Secret HMAC (≥ 32 bytes). À fournir via @mostajs/config. */
|
|
25
|
+
secret: string;
|
|
26
|
+
/** TTL en secondes — RFC RGPD : pas de bornes ; choix safe ≤ 24h pour limiter exposition. */
|
|
27
|
+
ttlSec?: number;
|
|
28
|
+
}
|
|
29
|
+
declare function base64urlJSON(obj: unknown): string;
|
|
30
|
+
declare function hmacSign(payloadB64: string, secret: string): string;
|
|
31
|
+
export declare function generateDeletionToken(config: DeletionTokenConfig, userId: string): {
|
|
32
|
+
token: string;
|
|
33
|
+
nonce: string;
|
|
34
|
+
expiresAt: Date;
|
|
35
|
+
};
|
|
36
|
+
export type VerifyDeletionResult = {
|
|
37
|
+
ok: true;
|
|
38
|
+
userId: string;
|
|
39
|
+
nonce: string;
|
|
40
|
+
} | {
|
|
41
|
+
ok: false;
|
|
42
|
+
reason: 'malformed' | 'bad_signature' | 'expired';
|
|
43
|
+
};
|
|
44
|
+
export declare function verifyDeletionToken(config: DeletionTokenConfig, token: string): VerifyDeletionResult;
|
|
45
|
+
declare function constantTimeEqual(a: string, b: string): boolean;
|
|
46
|
+
export interface DeletionNonceRepo {
|
|
47
|
+
/** Persiste un nonce avec son expiration. Throw si déjà existant (collision freak case). */
|
|
48
|
+
insert(args: {
|
|
49
|
+
nonce: string;
|
|
50
|
+
userId: string;
|
|
51
|
+
expiresAt: Date;
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
/** Consume atomiquement : retourne true si le nonce existait et n'était pas expiré, et le supprime. */
|
|
54
|
+
consume(nonce: string): Promise<boolean>;
|
|
55
|
+
/** Cleanup périodique. */
|
|
56
|
+
deleteExpired(now: Date): Promise<number>;
|
|
57
|
+
}
|
|
58
|
+
export interface RequestDeletionResult {
|
|
59
|
+
ok: true;
|
|
60
|
+
/** Token à embarquer dans l'email de confirmation. */
|
|
61
|
+
token: string;
|
|
62
|
+
/** Date d'expiration absolue. */
|
|
63
|
+
expiresAt: Date;
|
|
64
|
+
}
|
|
65
|
+
export interface RequestDeletionArgs {
|
|
66
|
+
config: DeletionTokenConfig;
|
|
67
|
+
nonceRepo: DeletionNonceRepo;
|
|
68
|
+
userId: string;
|
|
69
|
+
/** Mailer optionnel — si fourni, envoie l'email immédiatement. */
|
|
70
|
+
mailer?: (args: {
|
|
71
|
+
token: string;
|
|
72
|
+
expiresAt: Date;
|
|
73
|
+
}) => Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
export declare function requestAccountDeletion(args: RequestDeletionArgs): Promise<RequestDeletionResult>;
|
|
76
|
+
export type ConfirmDeletionResult = {
|
|
77
|
+
ok: true;
|
|
78
|
+
purgeReports: {
|
|
79
|
+
module: string;
|
|
80
|
+
rowsDeleted: number;
|
|
81
|
+
}[];
|
|
82
|
+
} | {
|
|
83
|
+
ok: false;
|
|
84
|
+
reason: 'malformed' | 'bad_signature' | 'expired' | 'consumed_or_unknown' | 'partial_failure';
|
|
85
|
+
partialReports?: {
|
|
86
|
+
module: string;
|
|
87
|
+
rowsDeleted: number;
|
|
88
|
+
}[];
|
|
89
|
+
errors?: {
|
|
90
|
+
module: string;
|
|
91
|
+
error: string;
|
|
92
|
+
}[];
|
|
93
|
+
};
|
|
94
|
+
export interface ConfirmDeletionArgs {
|
|
95
|
+
config: DeletionTokenConfig;
|
|
96
|
+
nonceRepo: DeletionNonceRepo;
|
|
97
|
+
hooks: DataSubjectHook[];
|
|
98
|
+
token: string;
|
|
99
|
+
}
|
|
100
|
+
export declare function confirmAccountDeletion(args: ConfirmDeletionArgs): Promise<ConfirmDeletionResult>;
|
|
101
|
+
export interface ExportArgs {
|
|
102
|
+
hooks: DataSubjectHook[];
|
|
103
|
+
userId: string;
|
|
104
|
+
}
|
|
105
|
+
export interface ExportResult {
|
|
106
|
+
/** Données structurées par module — clé = module, valeur = ce que le hook retourne. */
|
|
107
|
+
byModule: Record<string, unknown>;
|
|
108
|
+
/** Métadonnées de l'export. */
|
|
109
|
+
metadata: {
|
|
110
|
+
userId: string;
|
|
111
|
+
generatedAt: string;
|
|
112
|
+
moduleCount: number;
|
|
113
|
+
errors: {
|
|
114
|
+
module: string;
|
|
115
|
+
error: string;
|
|
116
|
+
}[];
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Collecte les données via les hooks. Le consumer prend le résultat, ZIPe, upload via
|
|
121
|
+
* @mostajs/storage signed URL, et email à l'user. Ces étapes sont hors module pour
|
|
122
|
+
* garder la pureté du Lot 6 (pas de dep storage / mailer obligatoire).
|
|
123
|
+
*/
|
|
124
|
+
export declare function collectAccountExport(args: ExportArgs): Promise<ExportResult>;
|
|
125
|
+
export declare const __testing: {
|
|
126
|
+
hmacSign: typeof hmacSign;
|
|
127
|
+
base64urlJSON: typeof base64urlJSON;
|
|
128
|
+
constantTimeEqual: typeof constantTimeEqual;
|
|
129
|
+
};
|
|
130
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @mostajs/auth — Account lifecycle & RGPD — Lot 6 / v3.0.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Suppression définitive d'un compte + export RGPD ZIP async.
|
|
5
|
+
//
|
|
6
|
+
// Pattern Lots 1-5 : interfaces + DI, le module ne stocke rien lui-même.
|
|
7
|
+
//
|
|
8
|
+
// Suppression :
|
|
9
|
+
// 1. requestAccountDeletion(userId, email) → token signé HMAC + TTL 24h, envoyé
|
|
10
|
+
// par email à l'user (anti-erreur, anti-takeover si session compromise).
|
|
11
|
+
// 2. confirmAccountDeletion(token) → vérifie sig + TTL + nonce single-use,
|
|
12
|
+
// orchestre le purge cross-modules :
|
|
13
|
+
// - appelle chaque DataSubjectHook.purgeUserData(userId) enregistré
|
|
14
|
+
// (rbac/auth/storage/realtime/audit/payment/...)
|
|
15
|
+
// - chaque module sibling se nettoie atomiquement
|
|
16
|
+
// - emit AuthEvent 'account.deleted'
|
|
17
|
+
//
|
|
18
|
+
// Export RGPD :
|
|
19
|
+
// 1. requestAccountExport(userId) → job async, retourne un job_id.
|
|
20
|
+
// 2. La machine async appelle DataSubjectHook.exportUserData(userId) pour chaque
|
|
21
|
+
// module enregistré → concatène en ZIP → upload via @mostajs/storage signed URL
|
|
22
|
+
// (TTL 7 jours) → email à l'user avec le lien.
|
|
23
|
+
// 3. emit AuthEvent 'account.exported' à la complétion.
|
|
24
|
+
//
|
|
25
|
+
// Le module ne ship pas le ZIP-er ni le mailer — DI au consumer (typiquement Octonet
|
|
26
|
+
// branche un job runner BullMQ + nodemailer + @mostajs/storage).
|
|
27
|
+
import crypto from 'node:crypto';
|
|
28
|
+
const DELETION_TTL_DEFAULT = 24 * 3600;
|
|
29
|
+
function base64urlJSON(obj) {
|
|
30
|
+
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
31
|
+
}
|
|
32
|
+
function hmacSign(payloadB64, secret) {
|
|
33
|
+
return crypto.createHmac('sha256', secret).update(payloadB64).digest('base64url');
|
|
34
|
+
}
|
|
35
|
+
export function generateDeletionToken(config, userId) {
|
|
36
|
+
const ttl = config.ttlSec ?? DELETION_TTL_DEFAULT;
|
|
37
|
+
const nonce = crypto.randomBytes(16).toString('base64url');
|
|
38
|
+
const exp = Math.floor(Date.now() / 1000) + ttl;
|
|
39
|
+
const payload = { userId, nonce, exp };
|
|
40
|
+
const payloadB64 = base64urlJSON(payload);
|
|
41
|
+
const sig = hmacSign(payloadB64, config.secret);
|
|
42
|
+
return { token: `${payloadB64}.${sig}`, nonce, expiresAt: new Date(exp * 1000) };
|
|
43
|
+
}
|
|
44
|
+
export function verifyDeletionToken(config, token) {
|
|
45
|
+
const parts = token.split('.');
|
|
46
|
+
if (parts.length !== 2)
|
|
47
|
+
return { ok: false, reason: 'malformed' };
|
|
48
|
+
const [payloadB64, sig] = parts;
|
|
49
|
+
const expectedSig = hmacSign(payloadB64, config.secret);
|
|
50
|
+
if (!constantTimeEqual(sig, expectedSig))
|
|
51
|
+
return { ok: false, reason: 'bad_signature' };
|
|
52
|
+
let payload;
|
|
53
|
+
try {
|
|
54
|
+
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { ok: false, reason: 'malformed' };
|
|
58
|
+
}
|
|
59
|
+
if (typeof payload?.userId !== 'string' || typeof payload?.exp !== 'number' || typeof payload?.nonce !== 'string') {
|
|
60
|
+
return { ok: false, reason: 'malformed' };
|
|
61
|
+
}
|
|
62
|
+
if (Math.floor(Date.now() / 1000) >= payload.exp)
|
|
63
|
+
return { ok: false, reason: 'expired' };
|
|
64
|
+
return { ok: true, userId: payload.userId, nonce: payload.nonce };
|
|
65
|
+
}
|
|
66
|
+
function constantTimeEqual(a, b) {
|
|
67
|
+
if (a.length !== b.length)
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function requestAccountDeletion(args) {
|
|
77
|
+
const { token, nonce, expiresAt } = generateDeletionToken(args.config, args.userId);
|
|
78
|
+
await args.nonceRepo.insert({ nonce, userId: args.userId, expiresAt });
|
|
79
|
+
if (args.mailer) {
|
|
80
|
+
await args.mailer({ token, expiresAt });
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, token, expiresAt };
|
|
83
|
+
}
|
|
84
|
+
export async function confirmAccountDeletion(args) {
|
|
85
|
+
const v = verifyDeletionToken(args.config, args.token);
|
|
86
|
+
if (!v.ok)
|
|
87
|
+
return { ok: false, reason: v.reason };
|
|
88
|
+
const consumed = await args.nonceRepo.consume(v.nonce);
|
|
89
|
+
if (!consumed)
|
|
90
|
+
return { ok: false, reason: 'consumed_or_unknown' };
|
|
91
|
+
const purgeReports = [];
|
|
92
|
+
const errors = [];
|
|
93
|
+
// Purge sequentiel — chaque module nettoie le sien. On collecte les rapports même
|
|
94
|
+
// en cas d'échec partiel pour permettre re-tentatives ciblées.
|
|
95
|
+
for (const hook of args.hooks) {
|
|
96
|
+
try {
|
|
97
|
+
const report = await hook.purgeUserData(v.userId);
|
|
98
|
+
purgeReports.push({ module: hook.module, rowsDeleted: report.rowsDeleted });
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
errors.push({ module: hook.module, error: e.message });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (errors.length > 0) {
|
|
105
|
+
return { ok: false, reason: 'partial_failure', partialReports: purgeReports, errors };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, purgeReports };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Collecte les données via les hooks. Le consumer prend le résultat, ZIPe, upload via
|
|
111
|
+
* @mostajs/storage signed URL, et email à l'user. Ces étapes sont hors module pour
|
|
112
|
+
* garder la pureté du Lot 6 (pas de dep storage / mailer obligatoire).
|
|
113
|
+
*/
|
|
114
|
+
export async function collectAccountExport(args) {
|
|
115
|
+
const byModule = {};
|
|
116
|
+
const errors = [];
|
|
117
|
+
for (const hook of args.hooks) {
|
|
118
|
+
try {
|
|
119
|
+
byModule[hook.module] = await hook.exportUserData(args.userId);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
errors.push({ module: hook.module, error: e.message });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
byModule,
|
|
127
|
+
metadata: {
|
|
128
|
+
userId: args.userId,
|
|
129
|
+
generatedAt: new Date().toISOString(),
|
|
130
|
+
moduleCount: args.hooks.length,
|
|
131
|
+
errors,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// Test-only helpers
|
|
136
|
+
export const __testing = { hmacSign, base64urlJSON, constantTimeEqual };
|
|
@@ -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;
|