@adminforth/two-factors-auth 4.4.9 → 4.6.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.
Files changed (34) hide show
  1. package/build.log +2 -2
  2. package/custom/TwoFAModal.vue +30 -19
  3. package/custom/TwoFactorsPasskeysSettings.vue +2 -4
  4. package/custom/use2faApi.ts +1 -0
  5. package/dist/custom/TwoFAModal.vue +30 -19
  6. package/dist/custom/TwoFactorsPasskeysSettings.vue +2 -4
  7. package/dist/custom/use2faApi.ts +1 -0
  8. package/dist/endpoints/registerPasskeyEndpoints.js +24 -0
  9. package/dist/endpoints/registerTwoFaEndpoints.js +18 -0
  10. package/dist/handlers/createPasskeyHandlers.d.ts +3 -6
  11. package/dist/handlers/createPasskeyHandlers.js +11 -15
  12. package/dist/index.d.ts +11 -7
  13. package/dist/index.js +23 -5
  14. package/dist/services/mfaRequestContext.d.ts +13 -0
  15. package/dist/services/mfaRequestContext.js +50 -0
  16. package/dist/services/totpService.d.ts +9 -10
  17. package/dist/services/totpService.js +27 -5
  18. package/dist/services/verifyMfaConfirmation.d.ts +7 -7
  19. package/dist/services/verifyMfaConfirmation.js +23 -20
  20. package/dist/utils/errors.d.ts +13 -0
  21. package/dist/utils/errors.js +14 -0
  22. package/dist/utils/types.d.ts +8 -0
  23. package/endpoints/registerPasskeyEndpoints.ts +24 -0
  24. package/endpoints/registerTwoFaEndpoints.ts +18 -0
  25. package/handlers/createPasskeyHandlers.ts +30 -16
  26. package/index.ts +28 -7
  27. package/package.json +3 -3
  28. package/services/mfaRequestContext.ts +51 -0
  29. package/services/totpService.ts +30 -7
  30. package/services/verifyMfaConfirmation.ts +30 -24
  31. package/tests/mfaRequestContext.test.mjs +79 -0
  32. package/tests/totpService.test.mjs +38 -0
  33. package/utils/errors.ts +14 -0
  34. package/utils/types.ts +4 -0
@@ -0,0 +1,50 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { AsyncLocalStorage } from 'node:async_hooks';
11
+ class MfaVerificationError extends Error {
12
+ constructor(result) {
13
+ super(result.error);
14
+ this.result = result;
15
+ }
16
+ }
17
+ /** Keeps verification within one external request, including every CRUD hook it invokes. */
18
+ export class MfaRequestContext {
19
+ constructor() {
20
+ this.storage = new AsyncLocalStorage();
21
+ }
22
+ run(confirmationResult, extra, callback) {
23
+ return this.storage.run({ confirmationResult, extra, results: new Map() }, () => __awaiter(this, void 0, void 0, function* () {
24
+ try {
25
+ return yield callback();
26
+ }
27
+ catch (error) {
28
+ if (error instanceof MfaVerificationError)
29
+ return error.result;
30
+ throw error;
31
+ }
32
+ }));
33
+ }
34
+ verify(adminUser, actionDescription, verifier) {
35
+ return __awaiter(this, void 0, void 0, function* () {
36
+ const context = this.storage.getStore();
37
+ if (!context)
38
+ return undefined;
39
+ let result = context.results.get(adminUser.pk);
40
+ if (!result) {
41
+ result = verifier(context.confirmationResult, context.extra);
42
+ context.results.set(adminUser.pk, result);
43
+ }
44
+ const verified = yield result;
45
+ if ('error' in verified)
46
+ throw new MfaVerificationError(Object.assign(Object.assign({}, verified), { actionDescription }));
47
+ return verified;
48
+ });
49
+ }
50
+ }
@@ -1,21 +1,20 @@
1
- import type { AdminUser } from "adminforth";
1
+ import { type AdminUser, type IAdminForthAuth } from "adminforth";
2
2
  import type { PluginOptions } from "../types.js";
3
3
  import { UserRepository } from "../repositories/userRepository.js";
4
+ import type { VerificationFailure, VerificationResult } from "../utils/types.js";
4
5
  export declare class TotpService {
5
6
  private readonly options;
6
7
  private readonly userRepository;
7
- constructor(options: PluginOptions, userRepository: UserRepository);
8
+ private readonly auth;
9
+ private readonly codeAttemptLimiters;
10
+ constructor(options: PluginOptions, userRepository: UserRepository, auth: IAdminForthAuth);
8
11
  generateSecret(issuerName: string, userName: string): string;
9
12
  verifySecret(secret: string, code: string): boolean;
10
- verifyUserCode(userPk: string, code: string): Promise<{
11
- ok: true;
12
- } | {
13
- error: string;
14
- }>;
13
+ verifyUserCode(userPk: string, code: string): Promise<VerificationResult>;
15
14
  verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{
16
15
  ok: true;
17
- } | {
18
- error: string;
19
- }>;
16
+ verifiedCodeToken: string;
17
+ } | VerificationFailure>;
18
+ isVerifiedCodeToken(token: string, userPk: string): Promise<boolean>;
20
19
  saveSecret(userPk: string, secret: string): Promise<void>;
21
20
  }
@@ -7,11 +7,18 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
+ import { RateLimiter } from "adminforth";
10
11
  import twofactor from 'node-2fa';
12
+ import { TwoFactorErrorCode } from "../utils/errors.js";
13
+ const CODE_ATTEMPT_RATE_LIMITS = ['30/1m', '300/1h', '1000/1d'];
14
+ const VERIFIED_CODE_JWT_TYPE = 'verifiedTotpCode';
15
+ const VERIFIED_CODE_TOKEN_TTL = '1m';
11
16
  export class TotpService {
12
- constructor(options, userRepository) {
17
+ constructor(options, userRepository, auth) {
13
18
  this.options = options;
14
19
  this.userRepository = userRepository;
20
+ this.auth = auth;
21
+ this.codeAttemptLimiters = CODE_ATTEMPT_RATE_LIMITS.map((rate) => new RateLimiter(rate));
15
22
  }
16
23
  generateSecret(issuerName, userName) {
17
24
  return twofactor.generateSecret({ name: issuerName, account: userName }).secret;
@@ -21,18 +28,33 @@ export class TotpService {
21
28
  }
22
29
  verifyUserCode(userPk, code) {
23
30
  return __awaiter(this, void 0, void 0, function* () {
31
+ const attemptsAllowed = yield Promise.all(this.codeAttemptLimiters.map((limiter) => limiter.consume(userPk)));
32
+ if (!attemptsAllowed.every(Boolean)) {
33
+ return { error: "Too many attempts to enter the code. Try again later.", code: TwoFactorErrorCode.RATE_LIMITED };
34
+ }
24
35
  const user = yield this.userRepository.getAuthUser(userPk);
25
36
  if (!user)
26
- return { error: "User not found" };
37
+ return { error: "User not found", code: TwoFactorErrorCode.USER_NOT_FOUND };
27
38
  const secret = this.userRepository.getSecret(user);
28
39
  if (!secret)
29
- return { error: "2FA is not set up for this user" };
30
- return this.verifySecret(secret, code) ? { ok: true } : { error: "Wrong or expired OTP code" };
40
+ return { error: "2FA is not set up for this user", code: TwoFactorErrorCode.NOT_SET_UP };
41
+ return this.verifySecret(secret, code)
42
+ ? { ok: true }
43
+ : { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
31
44
  });
32
45
  }
33
46
  verifyAdminUserCode(adminUser, code) {
34
47
  return __awaiter(this, void 0, void 0, function* () {
35
- return this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
48
+ const result = yield this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
49
+ if ('error' in result)
50
+ return result;
51
+ return { ok: true, verifiedCodeToken: this.auth.issueJWT({ pk: adminUser.pk }, VERIFIED_CODE_JWT_TYPE, VERIFIED_CODE_TOKEN_TTL) };
52
+ });
53
+ }
54
+ isVerifiedCodeToken(token, userPk) {
55
+ return __awaiter(this, void 0, void 0, function* () {
56
+ const payload = yield this.auth.verify(token, VERIFIED_CODE_JWT_TYPE, false);
57
+ return (payload === null || payload === void 0 ? void 0 : payload.pk) === userPk;
36
58
  });
37
59
  }
38
60
  saveSecret(userPk, secret) {
@@ -1,9 +1,13 @@
1
1
  import type { AdminUser, HttpExtra, IAdminForthHttpResponse } from "adminforth";
2
- import type { CookieList } from "../utils/types.js";
2
+ import type { CookieList, VerificationResult } from "../utils/types.js";
3
3
  type TotpConfirmationResult = {
4
4
  mode: "totp";
5
5
  result: string;
6
6
  };
7
+ type VerifiedTotpConfirmationResult = {
8
+ mode: "verifiedTotp";
9
+ result: string;
10
+ };
7
11
  type PasskeyConfirmationResult = {
8
12
  mode: "passkey";
9
13
  result: {
@@ -14,7 +18,7 @@ type PasskeyConfirmationResult = {
14
18
  type GraceConfirmationResult = {
15
19
  mode?: undefined;
16
20
  };
17
- export type MfaConfirmationResult = TotpConfirmationResult | PasskeyConfirmationResult | GraceConfirmationResult;
21
+ export type MfaConfirmationResult = TotpConfirmationResult | VerifiedTotpConfirmationResult | PasskeyConfirmationResult | GraceConfirmationResult;
18
22
  export type VerifyOptions = {
19
23
  adminUser: AdminUser;
20
24
  userPk: string;
@@ -22,9 +26,5 @@ export type VerifyOptions = {
22
26
  response?: IAdminForthHttpResponse;
23
27
  extra?: HttpExtra;
24
28
  };
25
- export declare function verifyMfaConfirmation(ctx: any, confirmationResult: MfaConfirmationResult, opts: VerifyOptions): Promise<{
26
- ok: true;
27
- } | {
28
- error: string;
29
- }>;
29
+ export declare function verifyMfaConfirmation(ctx: any, confirmationResult: MfaConfirmationResult | undefined, opts: VerifyOptions): Promise<VerificationResult>;
30
30
  export {};
@@ -7,11 +7,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
+ import { TwoFactorErrorCode } from "../utils/errors.js";
10
11
  export function verifyMfaConfirmation(ctx, confirmationResult, opts) {
11
12
  return __awaiter(this, void 0, void 0, function* () {
12
- var _a, _b, _c, _d, _e;
13
- if (!confirmationResult)
14
- return { error: "Confirmation result is required" };
13
+ var _a, _b, _c;
14
+ const mode = confirmationResult === null || confirmationResult === void 0 ? void 0 : confirmationResult.mode;
15
15
  const cookies = ((_a = opts.extra) === null || _a === void 0 ? void 0 : _a.cookies) || opts.cookies;
16
16
  const response = ((_b = opts.extra) === null || _b === void 0 ? void 0 : _b.response) || opts.response;
17
17
  if (ctx.options.usersFilterToApply) {
@@ -26,42 +26,45 @@ export function verifyMfaConfirmation(ctx, confirmationResult, opts) {
26
26
  return { ok: true };
27
27
  }
28
28
  }
29
- if (ctx.options.stepUpMfaGracePeriodSeconds && ((_c = opts.extra) === null || _c === void 0 ? void 0 : _c.headers) && !confirmationResult.mode) {
29
+ if (ctx.options.stepUpMfaGracePeriodSeconds && ((_c = opts.extra) === null || _c === void 0 ? void 0 : _c.headers) && !mode) {
30
30
  const verificationResult = yield ctx.cookieService.isMfaGraceValid(opts.extra.headers, cookies);
31
31
  if (verificationResult === true) {
32
32
  return { ok: true };
33
33
  }
34
34
  }
35
- const mode = confirmationResult.mode;
36
35
  if (!mode) {
37
- return { error: "Unsupported confirmation mode" };
36
+ return { error: "Confirmation result is required", code: TwoFactorErrorCode.REQUIRED };
38
37
  }
38
+ const confirmed = () => {
39
+ var _a;
40
+ if (ctx.options.stepUpMfaGracePeriodSeconds) {
41
+ ctx.cookieService.issueMfaGrace({ headers: (_a = opts.extra) === null || _a === void 0 ? void 0 : _a.headers, response }, cookies);
42
+ }
43
+ return { ok: true };
44
+ };
39
45
  if (mode === "totp") {
40
46
  const { result: code } = confirmationResult;
41
47
  const verificationResult = yield ctx.totpService.verifyUserCode(opts.userPk, code);
42
- if ('ok' in verificationResult && verificationResult.ok) {
43
- if (ctx.options.stepUpMfaGracePeriodSeconds) {
44
- ctx.cookieService.issueMfaGrace({ headers: (_d = opts.extra) === null || _d === void 0 ? void 0 : _d.headers, response }, cookies);
45
- }
46
- return { ok: true };
47
- }
48
- return verificationResult;
48
+ return 'error' in verificationResult ? verificationResult : confirmed();
49
+ }
50
+ else if (mode === "verifiedTotp") {
51
+ const { result: token } = confirmationResult;
52
+ return (yield ctx.totpService.isVerifiedCodeToken(token, opts.userPk))
53
+ ? confirmed()
54
+ : { error: "The code confirmation expired. Enter the code again.", code: TwoFactorErrorCode.INVALID };
49
55
  }
50
56
  else if (mode === "passkey") {
51
57
  const { result } = confirmationResult;
52
58
  const cookiesValidationResult = yield ctx.passkeyService.validateLoginChallengeCookie(cookies);
53
59
  if (!cookiesValidationResult.ok) {
54
- return { error: cookiesValidationResult.error };
60
+ return { error: cookiesValidationResult.error, code: TwoFactorErrorCode.INVALID };
55
61
  }
56
62
  const verificationResult = yield ctx.passkeyService.verifyPasskeyResponse(result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies);
57
63
  if (verificationResult.ok && verificationResult.passkeyConfirmed) {
58
- if (ctx.options.stepUpMfaGracePeriodSeconds) {
59
- ctx.cookieService.issueMfaGrace({ headers: (_e = opts.extra) === null || _e === void 0 ? void 0 : _e.headers, response }, cookies);
60
- }
61
- return { ok: true };
64
+ return confirmed();
62
65
  }
63
- return { error: "Invalid passkey" };
66
+ return { error: "Invalid passkey", code: TwoFactorErrorCode.INVALID };
64
67
  }
65
- return { error: "Unsupported confirmation mode" };
68
+ return { error: "Unsupported confirmation mode", code: TwoFactorErrorCode.UNSUPPORTED_MODE };
66
69
  });
67
70
  }
@@ -7,6 +7,19 @@ export declare enum HttpStatus {
7
7
  TOO_MANY_REQUESTS = 429,
8
8
  INTERNAL_SERVER_ERROR = 500
9
9
  }
10
+ export declare enum TwoFactorErrorCode {
11
+ REQUIRED = "TWO_FACTOR_REQUIRED",
12
+ INVALID = "TWO_FACTOR_INVALID",
13
+ RATE_LIMITED = "TWO_FACTOR_RATE_LIMITED",
14
+ NOT_SET_UP = "TWO_FACTOR_NOT_SET_UP",
15
+ USER_NOT_FOUND = "USER_NOT_FOUND",
16
+ UNSUPPORTED_MODE = "UNSUPPORTED_CONFIRMATION_MODE",
17
+ BROWSER_CONTEXT_REQUIRED = "BROWSER_CONTEXT_REQUIRED",
18
+ CANCELLED = "VERIFICATION_CANCELLED",
19
+ TIMEOUT = "VERIFICATION_TIMEOUT",
20
+ INVALID_SESSION = "INVALID_VERIFICATION_SESSION",
21
+ INTERNAL_ERROR = "INTERNAL_ERROR"
22
+ }
10
23
  export declare function respondWithStatus<T>(body: T, response: IAdminForthHttpResponse, status: HttpStatus): T;
11
24
  export declare function errorMessage(error: unknown): string;
12
25
  export declare function errorResult(message: string): ErrorResponse;
@@ -6,6 +6,20 @@ export var HttpStatus;
6
6
  HttpStatus[HttpStatus["TOO_MANY_REQUESTS"] = 429] = "TOO_MANY_REQUESTS";
7
7
  HttpStatus[HttpStatus["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR";
8
8
  })(HttpStatus || (HttpStatus = {}));
9
+ export var TwoFactorErrorCode;
10
+ (function (TwoFactorErrorCode) {
11
+ TwoFactorErrorCode["REQUIRED"] = "TWO_FACTOR_REQUIRED";
12
+ TwoFactorErrorCode["INVALID"] = "TWO_FACTOR_INVALID";
13
+ TwoFactorErrorCode["RATE_LIMITED"] = "TWO_FACTOR_RATE_LIMITED";
14
+ TwoFactorErrorCode["NOT_SET_UP"] = "TWO_FACTOR_NOT_SET_UP";
15
+ TwoFactorErrorCode["USER_NOT_FOUND"] = "USER_NOT_FOUND";
16
+ TwoFactorErrorCode["UNSUPPORTED_MODE"] = "UNSUPPORTED_CONFIRMATION_MODE";
17
+ TwoFactorErrorCode["BROWSER_CONTEXT_REQUIRED"] = "BROWSER_CONTEXT_REQUIRED";
18
+ TwoFactorErrorCode["CANCELLED"] = "VERIFICATION_CANCELLED";
19
+ TwoFactorErrorCode["TIMEOUT"] = "VERIFICATION_TIMEOUT";
20
+ TwoFactorErrorCode["INVALID_SESSION"] = "INVALID_VERIFICATION_SESSION";
21
+ TwoFactorErrorCode["INTERNAL_ERROR"] = "INTERNAL_ERROR";
22
+ })(TwoFactorErrorCode || (TwoFactorErrorCode = {}));
9
23
  export function respondWithStatus(body, response, status) {
10
24
  response.setStatus(status);
11
25
  return body;
@@ -1,4 +1,5 @@
1
1
  import type { HttpExtra } from "adminforth";
2
+ import type { TwoFactorErrorCode } from "./errors.js";
2
3
  export type AnyRecord = Record<string, any>;
3
4
  export type CookieList = HttpExtra["cookies"];
4
5
  export type HttpHeaders = HttpExtra["headers"];
@@ -11,3 +12,10 @@ export type OkResponse<T extends AnyRecord = AnyRecord> = {
11
12
  ok: true;
12
13
  } & T;
13
14
  export type PluginResponse<T extends AnyRecord = AnyRecord> = OkResponse<T> | ErrorResponse;
15
+ export type VerificationFailure = {
16
+ error: string;
17
+ code: TwoFactorErrorCode;
18
+ };
19
+ export type VerificationResult = {
20
+ ok: true;
21
+ } | VerificationFailure;
@@ -11,6 +11,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
11
11
  server.endpoint({
12
12
  method: 'POST',
13
13
  path: `/plugin/passkeys/registrationOptions`,
14
+ agent: {
15
+ hiddenFromAgents: true,
16
+ },
14
17
  noAuth: false,
15
18
  request_schema: registrationOptionsBodySchema,
16
19
  handler: async ({ body, adminUser, response, cookies, headers }) => {
@@ -21,6 +24,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
21
24
  server.endpoint({
22
25
  method: 'POST',
23
26
  path: `/plugin/passkeys/finishRegistration`,
27
+ agent: {
28
+ hiddenFromAgents: true,
29
+ },
24
30
  noAuth: false,
25
31
  request_schema: finishRegistrationBodySchema,
26
32
  handler: async ({ body, adminUser, cookies, response }) => {
@@ -31,6 +37,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
31
37
  server.endpoint({
32
38
  method: 'POST',
33
39
  path: `/plugin/passkeys/loginOptions`,
40
+ agent: {
41
+ hiddenFromAgents: true,
42
+ },
34
43
  noAuth: true,
35
44
  handler: async ({ response, headers }) => handlers.createLoginOptions({ response, headers }),
36
45
  });
@@ -38,6 +47,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
38
47
  server.endpoint({
39
48
  method: 'GET',
40
49
  path: `/plugin/passkeys/getPasskeys`,
50
+ agent: {
51
+ hiddenFromAgents: true,
52
+ },
41
53
  noAuth: false,
42
54
  handler: async ({ adminUser, response }) => handlers.getPasskeys({ adminUser, response }),
43
55
  });
@@ -45,6 +57,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
45
57
  server.endpoint({
46
58
  method: 'DELETE',
47
59
  path: `/plugin/passkeys/deletePasskey`,
60
+ agent: {
61
+ hiddenFromAgents: true,
62
+ },
48
63
  noAuth: false,
49
64
  request_schema: deletePasskeyBodySchema,
50
65
  handler: async ({ body, adminUser, response }) => {
@@ -55,6 +70,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
55
70
  server.endpoint({
56
71
  method: 'POST',
57
72
  path: `/plugin/passkeys/renamePasskey`,
73
+ agent: {
74
+ hiddenFromAgents: true,
75
+ },
58
76
  noAuth: false,
59
77
  request_schema: renamePasskeyBodySchema,
60
78
  handler: async ({ body, adminUser, response }) => {
@@ -65,6 +83,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
65
83
  server.endpoint({
66
84
  method: 'POST',
67
85
  path: `/plugin/passkeys/checkIfUserHasPasskeys`,
86
+ agent: {
87
+ hiddenFromAgents: true,
88
+ },
68
89
  noAuth: true,
69
90
  handler: async ({ cookies, response }) => handlers.checkIfUserHasPasskeys({ cookies, response }),
70
91
  });
@@ -72,6 +93,9 @@ export function registerPasskeyEndpoints(server: IHttpServer, handlers: any): vo
72
93
  server.endpoint({
73
94
  method: 'POST',
74
95
  path: `/plugin/passkeys/resolveVerifyAuto`,
96
+ agent: {
97
+ hiddenFromAgents: true,
98
+ },
75
99
  noAuth: false,
76
100
  request_schema: resolveVerifyAutoBodySchema,
77
101
  handler: async ({ body, adminUser, response, cookies, headers }) => {
@@ -9,6 +9,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
9
9
  server.endpoint({
10
10
  method: 'POST',
11
11
  path: `/plugin/twofa/initSetup`,
12
+ agent: {
13
+ hiddenFromAgents: true,
14
+ },
12
15
  noAuth: true,
13
16
  handler: async ({ cookies, response }) => handlers.initSetup({ cookies, response }),
14
17
  });
@@ -16,6 +19,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
16
19
  server.endpoint({
17
20
  method: 'POST',
18
21
  path: `/plugin/twofa/confirmLogin`,
22
+ agent: {
23
+ hiddenFromAgents: true,
24
+ },
19
25
  noAuth: true,
20
26
  request_schema: confirmLoginBodySchema,
21
27
  handler: async ({ body, response, cookies, headers, requestUrl, query }) => {
@@ -26,6 +32,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
26
32
  server.endpoint({
27
33
  method: 'POST',
28
34
  path: `/plugin/twofa/confirmLoginWithPasskey`,
35
+ agent: {
36
+ hiddenFromAgents: true,
37
+ },
29
38
  noAuth: true,
30
39
  request_schema: confirmLoginWithPasskeyBodySchema,
31
40
  handler: async ({ body, response, cookies, headers, requestUrl, query }) => {
@@ -36,6 +45,9 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
36
45
  server.endpoint({
37
46
  method: "GET",
38
47
  path: "/plugin/twofa/skip-allow",
48
+ agent: {
49
+ hiddenFromAgents: true,
50
+ },
39
51
  noAuth: true,
40
52
  handler: async ({ cookies, response }) => handlers.skipAllow({ cookies, response }),
41
53
  });
@@ -43,12 +55,18 @@ export function registerTwoFaEndpoints(server: IHttpServer, handlers: any): void
43
55
  server.endpoint({
44
56
  method: "GET",
45
57
  path: "/plugin/twofa/skip-allow-modal",
58
+ agent: {
59
+ hiddenFromAgents: true,
60
+ },
46
61
  handler: async ({ adminUser, headers, cookies, response }) => handlers.skipAllowModal({ adminUser, headers, cookies, response }),
47
62
  });
48
63
 
49
64
  server.endpoint({
50
65
  method: 'POST',
51
66
  path: `/plugin/twofa/verify`,
67
+ agent: {
68
+ hiddenFromAgents: true,
69
+ },
52
70
  noAuth: false,
53
71
  request_schema: verifyTotpBodySchema,
54
72
  handler: async ({ adminUser, body, response }) => {
@@ -1,5 +1,6 @@
1
1
  import type { HttpExtra } from "adminforth";
2
- import { HttpStatus, errorMessage, respondWithStatus } from "../utils/errors.js";
2
+ import { HttpStatus, TwoFactorErrorCode, errorMessage, respondWithStatus } from "../utils/errors.js";
3
+ import type { VerificationResult } from "../utils/types.js";
3
4
 
4
5
  export function createPasskeyHandlers(ctx: any) {
5
6
  return {
@@ -83,8 +84,8 @@ export function createPasskeyHandlers(ctx: any) {
83
84
  const confirmationResult = body?.confirmationResult;
84
85
  const idsToResolve = Array.isArray(sessionsIds) ? sessionsIds : [];
85
86
 
86
- const resolveAllIdsAsFailed = (message, code?: string) => {
87
- const payload = code ? { ok: false, code, error: message } : { ok: false, error: message };
87
+ const resolveAllIdsAsFailed = (message: string, code: TwoFactorErrorCode) => {
88
+ const payload = { ok: false, code, error: message };
88
89
  for (const id of idsToResolve) {
89
90
  ctx.autoVerify.resolveResponse(id, payload);
90
91
  }
@@ -94,7 +95,7 @@ export function createPasskeyHandlers(ctx: any) {
94
95
  try {
95
96
  if (!idsToResolve.length || !confirmationResult) {
96
97
  return respondWithStatus(
97
- resolveAllIdsAsFailed('Confirmation window was closed', 'VERIFICATION_CANCELLED'),
98
+ resolveAllIdsAsFailed('Confirmation window was closed', TwoFactorErrorCode.CANCELLED),
98
99
  response,
99
100
  HttpStatus.BAD_REQUEST,
100
101
  );
@@ -103,14 +104,22 @@ export function createPasskeyHandlers(ctx: any) {
103
104
  for (const id of idsToResolve) {
104
105
  const validationResult = await ctx.adminforth.auth.verify(id, 'auto2FA', false);
105
106
  if (!validationResult) {
106
- return respondWithStatus(resolveAllIdsAsFailed('Invalid session ID or confirmation result'), response, HttpStatus.FORBIDDEN);
107
+ return respondWithStatus(
108
+ resolveAllIdsAsFailed('Invalid session ID or confirmation result', TwoFactorErrorCode.INVALID_SESSION),
109
+ response,
110
+ HttpStatus.FORBIDDEN,
111
+ );
107
112
  }
108
113
  if (validationResult.adminUserPk !== adminUser.pk) {
109
- return respondWithStatus(resolveAllIdsAsFailed('Session does not belong to the authenticated user'), response, HttpStatus.FORBIDDEN);
114
+ return respondWithStatus(
115
+ resolveAllIdsAsFailed('Session does not belong to the authenticated user', TwoFactorErrorCode.INVALID_SESSION),
116
+ response,
117
+ HttpStatus.FORBIDDEN,
118
+ );
110
119
  }
111
120
  }
112
121
 
113
- const verificationResult = await ctx.verifyMfaConfirmation(confirmationResult, {
122
+ const verificationResult: VerificationResult = await ctx.verifyMfaConfirmation(confirmationResult, {
114
123
  adminUser: adminUser,
115
124
  userPk: adminUser.pk,
116
125
  cookies: cookies,
@@ -119,19 +128,24 @@ export function createPasskeyHandlers(ctx: any) {
119
128
  headers: headers,
120
129
  } as HttpExtra
121
130
  });
122
- if ( !verificationResult || !('ok' in verificationResult) ) {
123
- return respondWithStatus(resolveAllIdsAsFailed(verificationResult?.error ?? 'Verification failed'), response, HttpStatus.FORBIDDEN);
131
+ if ('error' in verificationResult) {
132
+ return respondWithStatus(
133
+ resolveAllIdsAsFailed(verificationResult.error, verificationResult.code),
134
+ response,
135
+ HttpStatus.FORBIDDEN,
136
+ );
124
137
  }
125
- if ('ok' in verificationResult && verificationResult.ok){
126
- for (const id of idsToResolve) {
127
- ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
128
- }
129
- return { ok: true };
138
+ for (const id of idsToResolve) {
139
+ ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
130
140
  }
131
- return respondWithStatus(resolveAllIdsAsFailed('Verification failed'), response, HttpStatus.FORBIDDEN);
141
+ return { ok: true };
132
142
  } catch (error) {
133
143
  console.error('[AdminForth 2FA] Error resolving automatic 2FA verification', error);
134
- return respondWithStatus(resolveAllIdsAsFailed(errorMessage(error)), response, HttpStatus.INTERNAL_SERVER_ERROR);
144
+ return respondWithStatus(
145
+ resolveAllIdsAsFailed(errorMessage(error), TwoFactorErrorCode.INTERNAL_ERROR),
146
+ response,
147
+ HttpStatus.INTERNAL_SERVER_ERROR,
148
+ );
135
149
  }
136
150
  },
137
151
  };
package/index.ts CHANGED
@@ -5,6 +5,7 @@ import { PluginOptions } from "./types.js"
5
5
  import { PasskeyService } from "./services/passkeyService.js";
6
6
  import { TotpService } from "./services/totpService.js";
7
7
  import { verifyMfaConfirmation, type MfaConfirmationResult, type VerifyOptions } from "./services/verifyMfaConfirmation.js";
8
+ import { MfaRequestContext } from './services/mfaRequestContext.js';
8
9
  import { CookieService } from "./services/cookieService.js";
9
10
  import { UserRepository } from "./repositories/userRepository.js";
10
11
  import { registerPasskeyEndpoints } from "./endpoints/registerPasskeyEndpoints.js";
@@ -12,8 +13,13 @@ import { registerTwoFaEndpoints } from "./endpoints/registerTwoFaEndpoints.js";
12
13
  import { createPasskeyHandlers } from "./handlers/createPasskeyHandlers.js";
13
14
  import { createTwoFaHandlers } from "./handlers/createTwoFaHandlers.js";
14
15
  import { parsePeriod } from "./utils/parsePeriod.js";
16
+ import { TwoFactorErrorCode } from "./utils/errors.js";
17
+ import type { VerificationResult } from "./utils/types.js";
15
18
  import crypto from 'crypto';
16
19
 
20
+ export { TwoFactorErrorCode };
21
+ export type { VerificationResult };
22
+
17
23
  const getRequestWebsocketClientId = (AdminForthRuntime as typeof AdminForthRuntime & {
18
24
  getRequestWebsocketClientId?: () => string | undefined;
19
25
  }).getRequestWebsocketClientId;
@@ -29,6 +35,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
29
35
  userRepository: UserRepository;
30
36
  passkeyLoginRateLimiters: RateLimiter[] = [];
31
37
  private ctx: any;
38
+ private readonly mfaRequestContext = new MfaRequestContext();
32
39
 
33
40
  constructor(options: PluginOptions) {
34
41
  super(options, import.meta.url);
@@ -78,7 +85,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
78
85
  };
79
86
  timeout = setTimeout(() => {
80
87
  cleanup();
81
- resolve({ ok: false, error: 'Verification timed out' });
88
+ resolve({ ok: false, error: 'Verification timed out', code: TwoFactorErrorCode.TIMEOUT });
82
89
  }, timeoutMs);
83
90
  this.pending.set(id, (value: any) => {
84
91
  cleanup();
@@ -94,16 +101,30 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
94
101
  }
95
102
  }
96
103
 
97
- public async verifyAuto(adminUser: AdminUser) {
104
+ public withVerificationContext<T>(confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra, callback: () => Promise<T>) {
105
+ return this.mfaRequestContext.run(confirmationResult, extra, callback);
106
+ }
107
+
108
+ public async verifyAuto(adminUser: AdminUser, actionDescription?: string) {
109
+ const contextualResult = await this.mfaRequestContext.verify(adminUser, actionDescription, (confirmationResult, extra) => this.verify(confirmationResult, {
110
+ adminUser,
111
+ userPk: adminUser.pk!,
112
+ extra,
113
+ }));
114
+ if (contextualResult) return contextualResult;
98
115
  const websocketClientId = getRequestWebsocketClientId?.();
99
116
  if (!websocketClientId) {
100
- return { ok: false, error: '2FA auto verification requires an active browser request context' };
117
+ return {
118
+ ok: false,
119
+ error: '2FA auto verification requires an active browser request context',
120
+ code: TwoFactorErrorCode.BROWSER_CONTEXT_REQUIRED,
121
+ };
101
122
  }
102
123
  const sessionId = crypto.randomUUID();
103
124
  const jwt = this.adminforth.auth.issueJWT({sessionId, adminUserPk: adminUser.pk}, 'auto2FA', '5m');
104
125
  const resultPromise = this.waitForResponse(jwt, 5 * 60 * 1000);
105
126
 
106
- this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt });
127
+ this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt, actionDescription });
107
128
 
108
129
  const result = await resultPromise;
109
130
 
@@ -113,9 +134,9 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
113
134
  }
114
135
 
115
136
  public async verify(
116
- confirmationResult: MfaConfirmationResult,
137
+ confirmationResult: MfaConfirmationResult | undefined,
117
138
  opts: VerifyOptions
118
- ): Promise<{ ok: true } | { error: string }> {
139
+ ): Promise<VerificationResult> {
119
140
  return verifyMfaConfirmation(this.ctx, confirmationResult, opts);
120
141
  }
121
142
 
@@ -125,7 +146,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
125
146
  this.adminForthAuth = adminforth.auth;
126
147
  this.userRepository = new UserRepository(this.adminforth, this.options);
127
148
  this.cookieService = new CookieService(this.adminforth, this.options);
128
- this.totpService = new TotpService(this.options, this.userRepository);
149
+ this.totpService = new TotpService(this.options, this.userRepository, this.adminforth.auth);
129
150
  this.passkeyService = new PasskeyService(this.options, this.adminforth, this.userRepository, undefined, this.cookieService);
130
151
  this.passkeyLoginRateLimiters = (adminforth.config.auth as any).rateLimit.map((rate) => new RateLimiter(rate));
131
152
  this.ctx = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "4.4.9",
3
+ "version": "4.6.0",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -29,11 +29,11 @@
29
29
  "zod": "^4.3.6"
30
30
  },
31
31
  "peerDependencies": {
32
- "adminforth": "^3.31.5"
32
+ "adminforth": "^3.32.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.10.7",
36
- "adminforth": "^3.31.5",
36
+ "adminforth": "^3.32.0",
37
37
  "semantic-release": "^24.2.1",
38
38
  "semantic-release-slack-bot": "^4.0.2",
39
39
  "typescript": "^5.7.3"