@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,167 @@
|
|
|
1
|
+
// @mostajs/auth — WebAuthn / Passkeys (FIDO2) — Lot 5 / v2.10.0+
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// WebAuthn Level 3 (W3C CR 2024) via @simplewebauthn/server v9.
|
|
5
|
+
//
|
|
6
|
+
// DEUX modes d'usage couverts :
|
|
7
|
+
// 1. Primary login (passwordless) — `signInWithPasskey()` : pas de password,
|
|
8
|
+
// la passkey EST l'authentification.
|
|
9
|
+
// 2. 2nd factor (post-password) — `verifyAsFactor()` : après password OK, la
|
|
10
|
+
// passkey est requise comme deuxième touch (admin / banking / sensitive).
|
|
11
|
+
//
|
|
12
|
+
// Stockage agnostique : le consumer fournit `WebAuthnCredentialRepo` (DI) +
|
|
13
|
+
// `WebAuthnChallengeStore` (challenge transient, TTL ~5 min — cookie courte ou
|
|
14
|
+
// row éphémère côté DB).
|
|
15
|
+
//
|
|
16
|
+
// Recovery policy : si l'user perd son device, le fallback est piloté par
|
|
17
|
+
// `RecoveryPolicy` (DI) — typiquement password + email confirmation, ou backup
|
|
18
|
+
// codes via @mostajs/auth/lib/mfa-totp si l'user en a aussi en TOTP.
|
|
19
|
+
//
|
|
20
|
+
// Sécurité (cf. doc 07 §1.5) :
|
|
21
|
+
// - RP ID = eTLD+1 (ex: 'example.com'), partagé app.example.com et auth.example.com
|
|
22
|
+
// - Origin doit matcher la liste d'origines acceptées
|
|
23
|
+
// - Counter check : si counter retourné < counter stocké → cloning détecté → reject
|
|
24
|
+
// - Conditional UI : mediation='conditional' pour autofill (login form)
|
|
25
|
+
// - attestation 'none' par défaut (passkeys grand-public) ; 'direct' opt-in entreprise
|
|
26
|
+
import { generateRegistrationOptions as gen8nRegOpts, verifyRegistrationResponse as ver8nRegResp, generateAuthenticationOptions as gen8nAuthOpts, verifyAuthenticationResponse as ver8nAuthResp, } from '@simplewebauthn/server';
|
|
27
|
+
const CHALLENGE_DEFAULT_TTL_SEC = 300;
|
|
28
|
+
export async function startRegistration(args) {
|
|
29
|
+
const opts = await gen8nRegOpts({
|
|
30
|
+
rpName: args.config.rpName,
|
|
31
|
+
rpID: args.config.rpID,
|
|
32
|
+
userID: args.user.id,
|
|
33
|
+
userName: args.user.name,
|
|
34
|
+
userDisplayName: args.user.displayName,
|
|
35
|
+
attestationType: args.config.attestationType ?? 'none',
|
|
36
|
+
excludeCredentials: (args.existingCredentials ?? []).map((c) => ({
|
|
37
|
+
id: c.credentialId,
|
|
38
|
+
type: 'public-key',
|
|
39
|
+
transports: c.transports,
|
|
40
|
+
})),
|
|
41
|
+
authenticatorSelection: {
|
|
42
|
+
residentKey: args.config.residentKey ?? 'preferred',
|
|
43
|
+
userVerification: args.config.userVerification ?? 'preferred',
|
|
44
|
+
authenticatorAttachment: args.config.authenticatorAttachment,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const ttlSec = args.config.challengeTtlSec ?? CHALLENGE_DEFAULT_TTL_SEC;
|
|
48
|
+
await args.challengeStore.put(args.sessionKey, opts.challenge, new Date(Date.now() + ttlSec * 1000));
|
|
49
|
+
return opts;
|
|
50
|
+
}
|
|
51
|
+
export async function finishRegistration(repo, args) {
|
|
52
|
+
const challenge = await args.challengeStore.consume(args.sessionKey);
|
|
53
|
+
if (!challenge)
|
|
54
|
+
return { ok: false, reason: 'no_challenge' };
|
|
55
|
+
const verified = await ver8nRegResp({
|
|
56
|
+
response: args.response,
|
|
57
|
+
expectedChallenge: challenge.challenge,
|
|
58
|
+
expectedOrigin: args.config.expectedOrigins,
|
|
59
|
+
expectedRPID: args.config.rpID,
|
|
60
|
+
requireUserVerification: (args.config.userVerification ?? 'preferred') === 'required',
|
|
61
|
+
});
|
|
62
|
+
if (!verified.verified || !verified.registrationInfo) {
|
|
63
|
+
return { ok: false, reason: 'verification_failed' };
|
|
64
|
+
}
|
|
65
|
+
const info = verified.registrationInfo;
|
|
66
|
+
const credentialId = info.credential?.id ?? info.credentialID;
|
|
67
|
+
const credentialPublicKey = info.credential?.publicKey ?? info.credentialPublicKey;
|
|
68
|
+
const counter = info.credential?.counter ?? info.counter ?? 0;
|
|
69
|
+
const record = await repo.insert({
|
|
70
|
+
userId: args.userId,
|
|
71
|
+
credentialId: typeof credentialId === 'string' ? credentialId : Buffer.from(credentialId).toString('base64url'),
|
|
72
|
+
publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
|
|
73
|
+
counter,
|
|
74
|
+
transports: args.response.response.transports,
|
|
75
|
+
deviceName: args.deviceName,
|
|
76
|
+
usage: args.usage ?? 'both',
|
|
77
|
+
createdAt: new Date(),
|
|
78
|
+
lastUsedAt: null,
|
|
79
|
+
});
|
|
80
|
+
return { ok: true, record };
|
|
81
|
+
}
|
|
82
|
+
export async function startAuthentication(args) {
|
|
83
|
+
const opts = await gen8nAuthOpts({
|
|
84
|
+
rpID: args.config.rpID,
|
|
85
|
+
allowCredentials: args.allowedCredentials?.map((c) => ({
|
|
86
|
+
id: c.credentialId,
|
|
87
|
+
type: 'public-key',
|
|
88
|
+
transports: c.transports,
|
|
89
|
+
})),
|
|
90
|
+
userVerification: args.config.userVerification ?? 'preferred',
|
|
91
|
+
});
|
|
92
|
+
const ttlSec = args.config.challengeTtlSec ?? CHALLENGE_DEFAULT_TTL_SEC;
|
|
93
|
+
await args.challengeStore.put(args.sessionKey, opts.challenge, new Date(Date.now() + ttlSec * 1000));
|
|
94
|
+
return opts;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Vérifie une réponse d'authentification — utilisable en primary OU 2nd factor selon
|
|
98
|
+
* `expectedUsage`. Le caller décide ensuite quoi faire avec le `userId` retourné.
|
|
99
|
+
*/
|
|
100
|
+
export async function finishAuthentication(repo, args) {
|
|
101
|
+
const challenge = await args.challengeStore.consume(args.sessionKey);
|
|
102
|
+
if (!challenge)
|
|
103
|
+
return { ok: false, reason: 'no_challenge' };
|
|
104
|
+
const credentialId = args.response.id;
|
|
105
|
+
const stored = await repo.findByCredentialId(credentialId);
|
|
106
|
+
if (!stored)
|
|
107
|
+
return { ok: false, reason: 'unknown_credential' };
|
|
108
|
+
// Vérifie que le credential supporte le mode demandé
|
|
109
|
+
if (args.expectedUsage === 'primary' && stored.usage === 'factor') {
|
|
110
|
+
return { ok: false, reason: 'wrong_usage' };
|
|
111
|
+
}
|
|
112
|
+
if (args.expectedUsage === 'factor' && stored.usage === 'primary') {
|
|
113
|
+
return { ok: false, reason: 'wrong_usage' };
|
|
114
|
+
}
|
|
115
|
+
let verified;
|
|
116
|
+
try {
|
|
117
|
+
verified = await ver8nAuthResp({
|
|
118
|
+
response: args.response,
|
|
119
|
+
expectedChallenge: challenge.challenge,
|
|
120
|
+
expectedOrigin: args.config.expectedOrigins,
|
|
121
|
+
expectedRPID: args.config.rpID,
|
|
122
|
+
credential: {
|
|
123
|
+
id: stored.credentialId,
|
|
124
|
+
publicKey: new Uint8Array(Buffer.from(stored.publicKey, 'base64url')),
|
|
125
|
+
counter: stored.counter,
|
|
126
|
+
transports: stored.transports,
|
|
127
|
+
},
|
|
128
|
+
requireUserVerification: (args.config.userVerification ?? 'preferred') === 'required',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { ok: false, reason: 'verification_failed' };
|
|
133
|
+
}
|
|
134
|
+
if (!verified.verified || !verified.authenticationInfo) {
|
|
135
|
+
return { ok: false, reason: 'verification_failed' };
|
|
136
|
+
}
|
|
137
|
+
const newCounter = verified.authenticationInfo.newCounter ?? stored.counter;
|
|
138
|
+
// Anti-cloning : counter doit être strictement croissant (sauf cas counter = 0
|
|
139
|
+
// pour passkeys synced — Apple/Google laissent counter à 0 toujours).
|
|
140
|
+
const isPasskeySynced = stored.counter === 0 && newCounter === 0;
|
|
141
|
+
if (!isPasskeySynced && newCounter <= stored.counter) {
|
|
142
|
+
return { ok: false, reason: 'counter_mismatch' };
|
|
143
|
+
}
|
|
144
|
+
await repo.updateAfterUse(stored.id, { counter: newCounter, lastUsedAt: new Date() });
|
|
145
|
+
return { ok: true, userId: stored.userId, credentialId, method: 'webauthn' };
|
|
146
|
+
}
|
|
147
|
+
export async function listPasskeys(repo, userId) {
|
|
148
|
+
const all = await repo.findByUser(userId);
|
|
149
|
+
return all.map((c) => ({
|
|
150
|
+
id: c.id,
|
|
151
|
+
deviceName: c.deviceName,
|
|
152
|
+
usage: c.usage,
|
|
153
|
+
createdAt: c.createdAt,
|
|
154
|
+
lastUsedAt: c.lastUsedAt,
|
|
155
|
+
transports: c.transports,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
/** Le caller DOIT exiger une preuve fraîche d'identité avant d'appeler — on supprime point. */
|
|
159
|
+
export async function removePasskey(repo, args) {
|
|
160
|
+
const cred = await repo.findById(args.credentialId);
|
|
161
|
+
if (!cred)
|
|
162
|
+
return { ok: false, reason: 'not_found' };
|
|
163
|
+
if (cred.userId !== args.userId)
|
|
164
|
+
return { ok: false, reason: 'not_owner' };
|
|
165
|
+
await repo.delete(cred.id);
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/auth",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Authentication —
|
|
3
|
+
"version": "3.0.2",
|
|
4
|
+
"description": "Authentication — complete: email/password (Argon2id) + OAuth + magic link + MFA TOTP + WebAuthn/Passkeys + RGPD lifecycle + device_flow/pkce events + accountId propagation server↔client",
|
|
5
5
|
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
7
7
|
"type": "module",
|
|
@@ -78,6 +78,76 @@
|
|
|
78
78
|
"import": "./dist/lib/password-reset.js",
|
|
79
79
|
"default": "./dist/lib/password-reset.js"
|
|
80
80
|
},
|
|
81
|
+
"./lib/refresh-tokens": {
|
|
82
|
+
"types": "./dist/lib/refresh-tokens.d.ts",
|
|
83
|
+
"import": "./dist/lib/refresh-tokens.js",
|
|
84
|
+
"default": "./dist/lib/refresh-tokens.js"
|
|
85
|
+
},
|
|
86
|
+
"./lib/auth-rate-limit": {
|
|
87
|
+
"types": "./dist/lib/auth-rate-limit.d.ts",
|
|
88
|
+
"import": "./dist/lib/auth-rate-limit.js",
|
|
89
|
+
"default": "./dist/lib/auth-rate-limit.js"
|
|
90
|
+
},
|
|
91
|
+
"./lib/auth-events": {
|
|
92
|
+
"types": "./dist/lib/auth-events.d.ts",
|
|
93
|
+
"import": "./dist/lib/auth-events.js",
|
|
94
|
+
"default": "./dist/lib/auth-events.js"
|
|
95
|
+
},
|
|
96
|
+
"./lib/oauth-primitives": {
|
|
97
|
+
"types": "./dist/lib/oauth-primitives.d.ts",
|
|
98
|
+
"import": "./dist/lib/oauth-primitives.js",
|
|
99
|
+
"default": "./dist/lib/oauth-primitives.js"
|
|
100
|
+
},
|
|
101
|
+
"./lib/oauth-providers": {
|
|
102
|
+
"types": "./dist/lib/oauth-providers.d.ts",
|
|
103
|
+
"import": "./dist/lib/oauth-providers.js",
|
|
104
|
+
"default": "./dist/lib/oauth-providers.js"
|
|
105
|
+
},
|
|
106
|
+
"./lib/oauth-linking": {
|
|
107
|
+
"types": "./dist/lib/oauth-linking.d.ts",
|
|
108
|
+
"import": "./dist/lib/oauth-linking.js",
|
|
109
|
+
"default": "./dist/lib/oauth-linking.js"
|
|
110
|
+
},
|
|
111
|
+
"./lib/magic-link": {
|
|
112
|
+
"types": "./dist/lib/magic-link.d.ts",
|
|
113
|
+
"import": "./dist/lib/magic-link.js",
|
|
114
|
+
"default": "./dist/lib/magic-link.js"
|
|
115
|
+
},
|
|
116
|
+
"./lib/mfa-totp": {
|
|
117
|
+
"types": "./dist/lib/mfa-totp.d.ts",
|
|
118
|
+
"import": "./dist/lib/mfa-totp.js",
|
|
119
|
+
"default": "./dist/lib/mfa-totp.js"
|
|
120
|
+
},
|
|
121
|
+
"./lib/webauthn": {
|
|
122
|
+
"types": "./dist/lib/webauthn.d.ts",
|
|
123
|
+
"import": "./dist/lib/webauthn.js",
|
|
124
|
+
"default": "./dist/lib/webauthn.js"
|
|
125
|
+
},
|
|
126
|
+
"./lib/account-lifecycle": {
|
|
127
|
+
"types": "./dist/lib/account-lifecycle.d.ts",
|
|
128
|
+
"import": "./dist/lib/account-lifecycle.js",
|
|
129
|
+
"default": "./dist/lib/account-lifecycle.js"
|
|
130
|
+
},
|
|
131
|
+
"./components/MfaEnrollDialog": {
|
|
132
|
+
"types": "./dist/components/MfaEnrollDialog.d.ts",
|
|
133
|
+
"import": "./dist/components/MfaEnrollDialog.js",
|
|
134
|
+
"default": "./dist/components/MfaEnrollDialog.js"
|
|
135
|
+
},
|
|
136
|
+
"./components/MfaChallenge": {
|
|
137
|
+
"types": "./dist/components/MfaChallenge.d.ts",
|
|
138
|
+
"import": "./dist/components/MfaChallenge.js",
|
|
139
|
+
"default": "./dist/components/MfaChallenge.js"
|
|
140
|
+
},
|
|
141
|
+
"./components/PasskeyRegisterButton": {
|
|
142
|
+
"types": "./dist/components/PasskeyRegisterButton.d.ts",
|
|
143
|
+
"import": "./dist/components/PasskeyRegisterButton.js",
|
|
144
|
+
"default": "./dist/components/PasskeyRegisterButton.js"
|
|
145
|
+
},
|
|
146
|
+
"./components/PasskeyLoginButton": {
|
|
147
|
+
"types": "./dist/components/PasskeyLoginButton.d.ts",
|
|
148
|
+
"import": "./dist/components/PasskeyLoginButton.js",
|
|
149
|
+
"default": "./dist/components/PasskeyLoginButton.js"
|
|
150
|
+
},
|
|
81
151
|
"./lib/apikey-provider": {
|
|
82
152
|
"types": "./dist/lib/apikey-provider.d.ts",
|
|
83
153
|
"import": "./dist/lib/apikey-provider.js",
|
|
@@ -88,6 +158,16 @@
|
|
|
88
158
|
"import": "./dist/lib/credentials-provider.js",
|
|
89
159
|
"default": "./dist/lib/credentials-provider.js"
|
|
90
160
|
},
|
|
161
|
+
"./lib/credentials-verify": {
|
|
162
|
+
"types": "./dist/lib/credentials-verify.d.ts",
|
|
163
|
+
"import": "./dist/lib/credentials-verify.js",
|
|
164
|
+
"default": "./dist/lib/credentials-verify.js"
|
|
165
|
+
},
|
|
166
|
+
"./lib/remote-credentials-provider": {
|
|
167
|
+
"types": "./dist/lib/remote-credentials-provider.d.ts",
|
|
168
|
+
"import": "./dist/lib/remote-credentials-provider.js",
|
|
169
|
+
"default": "./dist/lib/remote-credentials-provider.js"
|
|
170
|
+
},
|
|
91
171
|
"./lib/check-request": {
|
|
92
172
|
"types": "./dist/lib/check-request.d.ts",
|
|
93
173
|
"import": "./dist/lib/check-request.js",
|
|
@@ -126,12 +206,22 @@
|
|
|
126
206
|
"scripts": {
|
|
127
207
|
"build": "tsc && npm run fix-esm",
|
|
128
208
|
"fix-esm": "find dist -name '*.js' -exec sed -i -E \"s|from '(\\\\.{1,2}/[^']+)'(;?)|from '\\\\1.js'\\\\2|g; s|from \\\"(\\\\.{1,2}/[^\\\"]+)\\\"(;?)|from \\\"\\\\1.js\\\"\\\\2|g\" {} \\; && find dist -name '*.js' -exec sed -i -E \"s|\\\\.js\\\\.js|.js|g; s|\\\\.json\\\\.js|.json|g; s|\\\\.css\\\\.js|.css|g\" {} \\;",
|
|
129
|
-
"prepublishOnly": "npm run build"
|
|
209
|
+
"prepublishOnly": "npm run build",
|
|
210
|
+
"test": "bash test-scripts/run-tests.sh",
|
|
211
|
+
"test:unit": "node --import tsx test-scripts/test-unit.ts",
|
|
212
|
+
"test:refresh": "node --import tsx test-scripts/test-refresh-tokens.ts",
|
|
213
|
+
"test:rate-limit": "node --import tsx test-scripts/test-rate-limit.ts",
|
|
214
|
+
"test:events": "node --import tsx test-scripts/test-auth-events.ts"
|
|
130
215
|
},
|
|
131
216
|
"dependencies": {
|
|
132
217
|
"@mostajs/config": "^1.0.0",
|
|
133
218
|
"@mostajs/net": "^2.0.0",
|
|
134
|
-
"
|
|
219
|
+
"@node-rs/argon2": "^2.0.2",
|
|
220
|
+
"@simplewebauthn/browser": "^9.0.1",
|
|
221
|
+
"@simplewebauthn/server": "^9.0.3",
|
|
222
|
+
"bcryptjs": "^2.4.3",
|
|
223
|
+
"otplib": "^12.0.1",
|
|
224
|
+
"qrcode": "^1.5.4"
|
|
135
225
|
},
|
|
136
226
|
"peerDependencies": {
|
|
137
227
|
"@mostajs/rbac": ">=1.0.0",
|
|
@@ -145,6 +235,7 @@
|
|
|
145
235
|
"@mostajs/rbac": "^2.0.3",
|
|
146
236
|
"@types/bcryptjs": "^2.4.0",
|
|
147
237
|
"@types/node": "^25.3.3",
|
|
238
|
+
"@types/qrcode": "^1.5.6",
|
|
148
239
|
"@types/react": "^19.0.0",
|
|
149
240
|
"next": "^15.0.0",
|
|
150
241
|
"react": "^19.0.0",
|