@adminforth/two-factors-auth 4.4.4 → 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/build.log CHANGED
@@ -17,5 +17,5 @@ custom/tsconfig.json
17
17
  custom/use2faApi.ts
18
18
  custom/utils.js
19
19
 
20
- sent 312,263 bytes received 248 bytes 625,022.00 bytes/sec
21
- total size is 311,290 speedup is 1.00
20
+ sent 312,284 bytes received 248 bytes 625,064.00 bytes/sec
21
+ total size is 311,311 speedup is 1.00
@@ -137,7 +137,7 @@
137
137
  }
138
138
  });
139
139
 
140
- watch( props, () => {
140
+ watch(props, () => {
141
141
  if (props.adminUser) {
142
142
  websocket.unsubscribeByPrefix(`/user2fa/`);
143
143
  const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
@@ -186,7 +186,7 @@
186
186
  }
187
187
  });
188
188
  }
189
- })
189
+ }, { immediate: true });
190
190
 
191
191
 
192
192
  const emit = defineEmits<{
@@ -137,7 +137,7 @@
137
137
  }
138
138
  });
139
139
 
140
- watch( props, () => {
140
+ watch(props, () => {
141
141
  if (props.adminUser) {
142
142
  websocket.unsubscribeByPrefix(`/user2fa/`);
143
143
  const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
@@ -186,7 +186,7 @@
186
186
  }
187
187
  });
188
188
  }
189
- })
189
+ }, { immediate: true });
190
190
 
191
191
 
192
192
  const emit = defineEmits<{
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(metaResource.columns.map(c => c.name), this.options.passkeys.credentialMetaFieldName);
205
- throw new Error(`Passkeys credentialMetaFieldName '${this.options.passkeys.credentialMetaFieldName}' not found in resource '${this.options.passkeys.credentialMetaFieldName}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
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
- verifyPasskeyResponse(body: PasskeyAuthenticationPayload, user_id: string, cookies: {
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: import("../utils/types.js").AnyRecord;
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
- verifyPasskeyResponse(body, user_id, cookies) {
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 usersPrimaryKeyFieldName = this.userRepository.getUserPkField();
83
- const user = yield this.userRepository.getAuthUser(cred[this.options.passkeys.credentialUserIdFieldName]);
84
- if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) {
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 = (_e = credMeta.sign_count) !== null && _e !== void 0 ? _e : 0;
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: ((_f = this.options.passkeys) === null || _f === void 0 ? void 0 : _f.settings.authenticatorSelection.userVerification) === 'discouraged' ? false : true,
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 userPk = passkeyRecord[this.options.passkeys.credentialUserIdFieldName];
362
- if (!userPk) {
363
- return { error: 'User ID not found in passkey record' };
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(metaResource.columns.map(c => c.name), this.options.passkeys.credentialMetaFieldName);
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.credentialMetaFieldName}'. ${
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "4.4.4",
3
+ "version": "4.4.6",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- public async verifyPasskeyResponse(body: PasskeyAuthenticationPayload, user_id: string, cookies: { challenge: string }) {
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 usersPrimaryKeyFieldName = this.userRepository.getUserPkField();
108
- const user = await this.userRepository.getAuthUser(cred[this.options.passkeys.credentialUserIdFieldName]);
109
- if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) {
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 userPk = passkeyRecord[this.options.passkeys.credentialUserIdFieldName];
379
- if (!userPk) {
380
- return { error: 'User ID not found in passkey record' };
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
  }