@adminforth/two-factors-auth 4.4.5 → 4.4.6
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/dist/index.js +8 -3
- package/dist/services/passkeyService.d.ts +6 -3
- package/dist/services/passkeyService.js +43 -16
- package/index.ts +14 -3
- package/package.json +1 -1
- package/services/passkeyService.ts +42 -15
package/dist/index.js
CHANGED
|
@@ -198,16 +198,21 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
198
198
|
if (!this.options.passkeys.credentialMetaFieldName) {
|
|
199
199
|
throw new Error('Passkeys credentialMetaFieldName is required');
|
|
200
200
|
}
|
|
201
|
-
const metaResource = adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialMetaFieldName);
|
|
202
201
|
const metaField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialMetaFieldName);
|
|
203
202
|
if (!metaField) {
|
|
204
|
-
const similar = suggestIfTypo(
|
|
205
|
-
throw new Error(`Passkeys credentialMetaFieldName '${this.options.passkeys.credentialMetaFieldName}' not found in resource '${this.options.passkeys.
|
|
203
|
+
const similar = suggestIfTypo(credentialResource.columns.map(c => c.name), this.options.passkeys.credentialMetaFieldName);
|
|
204
|
+
throw new Error(`Passkeys credentialMetaFieldName '${this.options.passkeys.credentialMetaFieldName}' not found in resource '${this.options.passkeys.credentialResourceID}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
|
|
206
205
|
}
|
|
207
206
|
metaField.backendOnly = true;
|
|
208
207
|
if (!this.options.passkeys.credentialUserIdFieldName) {
|
|
209
208
|
throw new Error('Passkeys credentialUserIdFieldName is required');
|
|
210
209
|
}
|
|
210
|
+
const credentialUserIdField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialUserIdFieldName);
|
|
211
|
+
if (!credentialUserIdField) {
|
|
212
|
+
const similar = suggestIfTypo(credentialResource.columns.map(c => c.name), this.options.passkeys.credentialUserIdFieldName);
|
|
213
|
+
throw new Error(`Passkeys credentialUserIdFieldName '${this.options.passkeys.credentialUserIdFieldName}' not found in resource '${this.options.passkeys.credentialResourceID}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
|
|
214
|
+
}
|
|
215
|
+
credentialUserIdField.backendOnly = true;
|
|
211
216
|
if (!this.options.passkeys.settings) {
|
|
212
217
|
throw new Error('Passkeys settings are required when passkeys option is enabled');
|
|
213
218
|
}
|
|
@@ -3,7 +3,7 @@ import type { PluginOptions } from "../types.js";
|
|
|
3
3
|
import { PasskeyRepository } from "../repositories/passkeyRepository.js";
|
|
4
4
|
import { UserRepository } from "../repositories/userRepository.js";
|
|
5
5
|
import { CookieService } from "./cookieService.js";
|
|
6
|
-
import type { CookieList } from "../utils/types.js";
|
|
6
|
+
import type { AnyRecord, CookieList } from "../utils/types.js";
|
|
7
7
|
type PasskeyAuthenticationPayload = {
|
|
8
8
|
response: string;
|
|
9
9
|
origin: string;
|
|
@@ -29,11 +29,14 @@ export declare class PasskeyService {
|
|
|
29
29
|
};
|
|
30
30
|
error?: string;
|
|
31
31
|
}>;
|
|
32
|
-
|
|
32
|
+
private decodeUserHandle;
|
|
33
|
+
private resolveCredentialUserPk;
|
|
34
|
+
verifyPasskeyResponse(body: PasskeyAuthenticationPayload, expectedUserPk: string | null, cookies: {
|
|
33
35
|
challenge: string;
|
|
34
36
|
}): Promise<import("../utils/types.js").ErrorResponse | {
|
|
35
37
|
ok: boolean;
|
|
36
38
|
passkeyConfirmed: boolean;
|
|
39
|
+
userPk: string;
|
|
37
40
|
}>;
|
|
38
41
|
createRegistrationOptions(mode: PluginAuthenticatorAttachment, adminUser: AdminUser, response: IAdminForthHttpResponse): Promise<import("../utils/types.js").ErrorResponse | {
|
|
39
42
|
ok: boolean;
|
|
@@ -77,7 +80,7 @@ export declare class PasskeyService {
|
|
|
77
80
|
user?: undefined;
|
|
78
81
|
} | {
|
|
79
82
|
ok: boolean;
|
|
80
|
-
user:
|
|
83
|
+
user: AnyRecord;
|
|
81
84
|
error?: undefined;
|
|
82
85
|
}>;
|
|
83
86
|
}
|
|
@@ -59,9 +59,29 @@ export class PasskeyService {
|
|
|
59
59
|
return { ok: true, decodedPasskeysCookies };
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
decodeUserHandle(userHandle) {
|
|
63
|
+
if (typeof userHandle !== 'string' || !userHandle) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return isoUint8Array.toUTF8String(isoBase64URL.toBuffer(userHandle));
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
resolveCredentialUserPk(cred) {
|
|
74
|
+
const credMeta = cred[this.options.passkeys.credentialMetaFieldName];
|
|
75
|
+
const storedUserHandle = credMeta === null || credMeta === void 0 ? void 0 : credMeta.user_handle;
|
|
76
|
+
if (storedUserHandle) {
|
|
77
|
+
return { userPk: String(storedUserHandle), boundByUserHandle: true };
|
|
78
|
+
}
|
|
79
|
+
const columnUserPk = cred[this.options.passkeys.credentialUserIdFieldName];
|
|
80
|
+
return { userPk: columnUserPk ? String(columnUserPk) : undefined, boundByUserHandle: false };
|
|
81
|
+
}
|
|
82
|
+
verifyPasskeyResponse(body, expectedUserPk, cookies) {
|
|
63
83
|
return __awaiter(this, void 0, void 0, function* () {
|
|
64
|
-
var _a, _b, _c, _d, _e, _f;
|
|
84
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
65
85
|
const settingsOrigin = (_a = this.options.passkeys) === null || _a === void 0 ? void 0 : _a.settings.expectedOrigin;
|
|
66
86
|
const expectedOrigin = body.origin;
|
|
67
87
|
const expectedChallenge = cookies.challenge;
|
|
@@ -79,12 +99,22 @@ export class PasskeyService {
|
|
|
79
99
|
if (!credMeta || !credMeta.public_key) {
|
|
80
100
|
throw new Error('Credential public key not found.');
|
|
81
101
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
const { userPk: credentialUserPk, boundByUserHandle } = this.resolveCredentialUserPk(cred);
|
|
103
|
+
if (!credentialUserPk) {
|
|
104
|
+
throw new Error('Credential is not bound to any user.');
|
|
105
|
+
}
|
|
106
|
+
const presentedUserHandle = this.decodeUserHandle((_e = response.response) === null || _e === void 0 ? void 0 : _e.userHandle);
|
|
107
|
+
if (boundByUserHandle && presentedUserHandle && presentedUserHandle !== credentialUserPk) {
|
|
108
|
+
throw new Error('Passkey user handle does not match the credential owner.');
|
|
109
|
+
}
|
|
110
|
+
if (expectedUserPk !== null && (!expectedUserPk || String(expectedUserPk) !== credentialUserPk)) {
|
|
111
|
+
throw new Error('Passkey does not belong to this user.');
|
|
112
|
+
}
|
|
113
|
+
const user = yield this.userRepository.getAuthUser(credentialUserPk);
|
|
114
|
+
if (!user) {
|
|
85
115
|
throw new Error('User not found.');
|
|
86
116
|
}
|
|
87
|
-
const counter = (
|
|
117
|
+
const counter = (_f = credMeta.sign_count) !== null && _f !== void 0 ? _f : 0;
|
|
88
118
|
const { verified, authenticationInfo } = yield verifyAuthenticationResponse({
|
|
89
119
|
response,
|
|
90
120
|
expectedChallenge,
|
|
@@ -96,7 +126,7 @@ export class PasskeyService {
|
|
|
96
126
|
counter,
|
|
97
127
|
transports: credMeta.transports,
|
|
98
128
|
},
|
|
99
|
-
requireUserVerification: ((
|
|
129
|
+
requireUserVerification: ((_g = this.options.passkeys) === null || _g === void 0 ? void 0 : _g.settings.authenticatorSelection.userVerification) === 'discouraged' ? false : true,
|
|
100
130
|
});
|
|
101
131
|
if (!verified) {
|
|
102
132
|
return errorResult('User verification failed.');
|
|
@@ -104,7 +134,7 @@ export class PasskeyService {
|
|
|
104
134
|
credMeta.sign_count = authenticationInfo.newCounter;
|
|
105
135
|
credMeta.last_used_at = new Date().toISOString();
|
|
106
136
|
yield this.passkeyRepository.updateMeta(cred, credMeta);
|
|
107
|
-
return { ok: true, passkeyConfirmed: true };
|
|
137
|
+
return { ok: true, passkeyConfirmed: true, userPk: credentialUserPk };
|
|
108
138
|
}
|
|
109
139
|
catch (e) {
|
|
110
140
|
return prefixedErrorResult('Error authenticating passkey: ', e);
|
|
@@ -210,6 +240,7 @@ export class PasskeyService {
|
|
|
210
240
|
[this.options.passkeys.credentialIdFieldName]: base64CredentialID,
|
|
211
241
|
[this.options.passkeys.credentialUserIdFieldName]: adminUser.pk,
|
|
212
242
|
[this.options.passkeys.credentialMetaFieldName]: {
|
|
243
|
+
user_handle: adminUser.pk,
|
|
213
244
|
public_key: base64PublicKey,
|
|
214
245
|
public_key_algorithm: response.response.publicKeyAlgorithm,
|
|
215
246
|
sign_count: 0,
|
|
@@ -358,18 +389,14 @@ export class PasskeyService {
|
|
|
358
389
|
if (!passkeyRecord) {
|
|
359
390
|
return { error: 'No such passkey found, most likely it was removed on this website but you still have it on your device' };
|
|
360
391
|
}
|
|
361
|
-
const
|
|
362
|
-
if (!
|
|
363
|
-
return { error: '
|
|
392
|
+
const verificationResult = yield this.verifyPasskeyResponse(passkeyResponse, null, cookiesValidationResult.decodedPasskeysCookies);
|
|
393
|
+
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
|
|
394
|
+
return { error: 'Passkey verification failed' };
|
|
364
395
|
}
|
|
365
|
-
const user = yield this.userRepository.getAuthUser(userPk);
|
|
396
|
+
const user = yield this.userRepository.getAuthUser(verificationResult.userPk);
|
|
366
397
|
if (!user) {
|
|
367
398
|
return { error: 'User not found' };
|
|
368
399
|
}
|
|
369
|
-
const verificationResult = yield this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies);
|
|
370
|
-
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
|
|
371
|
-
return { error: 'Passkey verification failed' };
|
|
372
|
-
}
|
|
373
400
|
return { ok: true, user };
|
|
374
401
|
});
|
|
375
402
|
}
|
package/index.ts
CHANGED
|
@@ -229,12 +229,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
229
229
|
throw new Error('Passkeys credentialMetaFieldName is required');
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
const metaResource = adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialMetaFieldName);
|
|
233
232
|
const metaField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialMetaFieldName);
|
|
234
233
|
if ( !metaField ) {
|
|
235
|
-
const similar = suggestIfTypo(
|
|
234
|
+
const similar = suggestIfTypo(credentialResource.columns.map(c => c.name), this.options.passkeys.credentialMetaFieldName);
|
|
236
235
|
throw new Error(
|
|
237
|
-
`Passkeys credentialMetaFieldName '${this.options.passkeys.credentialMetaFieldName}' not found in resource '${this.options.passkeys.
|
|
236
|
+
`Passkeys credentialMetaFieldName '${this.options.passkeys.credentialMetaFieldName}' not found in resource '${this.options.passkeys.credentialResourceID}'. ${
|
|
238
237
|
similar ? `Did you mean '${similar}'?` : ''
|
|
239
238
|
}`
|
|
240
239
|
);
|
|
@@ -245,6 +244,18 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
245
244
|
if (!this.options.passkeys.credentialUserIdFieldName) {
|
|
246
245
|
throw new Error('Passkeys credentialUserIdFieldName is required');
|
|
247
246
|
}
|
|
247
|
+
|
|
248
|
+
const credentialUserIdField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialUserIdFieldName);
|
|
249
|
+
if ( !credentialUserIdField ) {
|
|
250
|
+
const similar = suggestIfTypo(credentialResource.columns.map(c => c.name), this.options.passkeys.credentialUserIdFieldName);
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Passkeys credentialUserIdFieldName '${this.options.passkeys.credentialUserIdFieldName}' not found in resource '${this.options.passkeys.credentialResourceID}'. ${
|
|
253
|
+
similar ? `Did you mean '${similar}'?` : ''
|
|
254
|
+
}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
credentialUserIdField.backendOnly = true;
|
|
258
|
+
|
|
248
259
|
if (!this.options.passkeys.settings) {
|
|
249
260
|
throw new Error('Passkeys settings are required when passkeys option is enabled');
|
|
250
261
|
}
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ import { PasskeyRepository } from "../repositories/passkeyRepository.js";
|
|
|
15
15
|
import { UserRepository } from "../repositories/userRepository.js";
|
|
16
16
|
import { CookieService } from "./cookieService.js";
|
|
17
17
|
import { errorMessage, errorResult, prefixedErrorResult } from "../utils/errors.js";
|
|
18
|
-
import type { CookieList } from "../utils/types.js";
|
|
18
|
+
import type { AnyRecord, CookieList } from "../utils/types.js";
|
|
19
19
|
|
|
20
20
|
type AaguidEntry = {
|
|
21
21
|
name: string;
|
|
@@ -86,7 +86,28 @@ export class PasskeyService {
|
|
|
86
86
|
return { ok: true, decodedPasskeysCookies };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
private decodeUserHandle(userHandle: unknown): string | undefined {
|
|
90
|
+
if (typeof userHandle !== 'string' || !userHandle) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return isoUint8Array.toUTF8String(isoBase64URL.toBuffer(userHandle));
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private resolveCredentialUserPk(cred: AnyRecord): { userPk?: string, boundByUserHandle: boolean } {
|
|
101
|
+
const credMeta = cred[this.options.passkeys.credentialMetaFieldName];
|
|
102
|
+
const storedUserHandle = credMeta?.user_handle;
|
|
103
|
+
if (storedUserHandle) {
|
|
104
|
+
return { userPk: String(storedUserHandle), boundByUserHandle: true };
|
|
105
|
+
}
|
|
106
|
+
const columnUserPk = cred[this.options.passkeys.credentialUserIdFieldName];
|
|
107
|
+
return { userPk: columnUserPk ? String(columnUserPk) : undefined, boundByUserHandle: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public async verifyPasskeyResponse(body: PasskeyAuthenticationPayload, expectedUserPk: string | null, cookies: { challenge: string }) {
|
|
90
111
|
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
91
112
|
const expectedOrigin = body.origin;
|
|
92
113
|
const expectedChallenge = cookies.challenge;
|
|
@@ -104,9 +125,19 @@ export class PasskeyService {
|
|
|
104
125
|
if (!credMeta || !credMeta.public_key) {
|
|
105
126
|
throw new Error('Credential public key not found.');
|
|
106
127
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
128
|
+
const { userPk: credentialUserPk, boundByUserHandle } = this.resolveCredentialUserPk(cred);
|
|
129
|
+
if (!credentialUserPk) {
|
|
130
|
+
throw new Error('Credential is not bound to any user.');
|
|
131
|
+
}
|
|
132
|
+
const presentedUserHandle = this.decodeUserHandle(response.response?.userHandle);
|
|
133
|
+
if (boundByUserHandle && presentedUserHandle && presentedUserHandle !== credentialUserPk) {
|
|
134
|
+
throw new Error('Passkey user handle does not match the credential owner.');
|
|
135
|
+
}
|
|
136
|
+
if (expectedUserPk !== null && (!expectedUserPk || String(expectedUserPk) !== credentialUserPk)) {
|
|
137
|
+
throw new Error('Passkey does not belong to this user.');
|
|
138
|
+
}
|
|
139
|
+
const user = await this.userRepository.getAuthUser(credentialUserPk);
|
|
140
|
+
if (!user) {
|
|
110
141
|
throw new Error('User not found.');
|
|
111
142
|
}
|
|
112
143
|
const counter = credMeta.sign_count ?? 0;
|
|
@@ -130,7 +161,7 @@ export class PasskeyService {
|
|
|
130
161
|
credMeta.sign_count = authenticationInfo.newCounter;
|
|
131
162
|
credMeta.last_used_at = new Date().toISOString();
|
|
132
163
|
await this.passkeyRepository.updateMeta(cred, credMeta);
|
|
133
|
-
return { ok: true, passkeyConfirmed: true };
|
|
164
|
+
return { ok: true, passkeyConfirmed: true, userPk: credentialUserPk };
|
|
134
165
|
} catch (e) {
|
|
135
166
|
return prefixedErrorResult('Error authenticating passkey: ', e);
|
|
136
167
|
}
|
|
@@ -238,6 +269,7 @@ export class PasskeyService {
|
|
|
238
269
|
[this.options.passkeys.credentialIdFieldName] : base64CredentialID,
|
|
239
270
|
[this.options.passkeys.credentialUserIdFieldName] : adminUser.pk,
|
|
240
271
|
[this.options.passkeys.credentialMetaFieldName] : {
|
|
272
|
+
user_handle : adminUser.pk,
|
|
241
273
|
public_key : base64PublicKey,
|
|
242
274
|
public_key_algorithm : response.response.publicKeyAlgorithm,
|
|
243
275
|
sign_count : 0,
|
|
@@ -375,21 +407,16 @@ export class PasskeyService {
|
|
|
375
407
|
return { error: 'No such passkey found, most likely it was removed on this website but you still have it on your device' };
|
|
376
408
|
}
|
|
377
409
|
|
|
378
|
-
const
|
|
379
|
-
if (!
|
|
380
|
-
return { error: '
|
|
410
|
+
const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, null, cookiesValidationResult.decodedPasskeysCookies);
|
|
411
|
+
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
|
|
412
|
+
return { error: 'Passkey verification failed' };
|
|
381
413
|
}
|
|
382
414
|
|
|
383
|
-
const user = await this.userRepository.getAuthUser(userPk);
|
|
415
|
+
const user = await this.userRepository.getAuthUser(verificationResult.userPk);
|
|
384
416
|
if (!user) {
|
|
385
417
|
return { error: 'User not found' };
|
|
386
418
|
}
|
|
387
419
|
|
|
388
|
-
const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies);
|
|
389
|
-
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
|
|
390
|
-
return { error: 'Passkey verification failed' };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
420
|
return { ok: true, user };
|
|
394
421
|
}
|
|
395
422
|
}
|