@adminforth/two-factors-auth 4.5.0 → 4.7.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.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AdminForthPlugin, RateLimiter } from "adminforth";
1
+ import { AdminForthPlugin, HttpExtra, RateLimiter } from "adminforth";
2
2
  import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth } from "adminforth";
3
3
  import { PluginOptions } from "./types.js";
4
4
  import { PasskeyService } from "./services/passkeyService.js";
@@ -6,6 +6,10 @@ import { TotpService } from "./services/totpService.js";
6
6
  import { type MfaConfirmationResult, type VerifyOptions } from "./services/verifyMfaConfirmation.js";
7
7
  import { CookieService } from "./services/cookieService.js";
8
8
  import { UserRepository } from "./repositories/userRepository.js";
9
+ import { TwoFactorErrorCode } from "./utils/errors.js";
10
+ import type { VerificationResult } from "./utils/types.js";
11
+ export { TwoFactorErrorCode };
12
+ export type { VerificationResult };
9
13
  export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
10
14
  options: PluginOptions;
11
15
  adminforth: IAdminForth;
@@ -17,6 +21,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
17
21
  userRepository: UserRepository;
18
22
  passkeyLoginRateLimiters: RateLimiter[];
19
23
  private ctx;
24
+ private readonly mfaRequestContext;
20
25
  constructor(options: PluginOptions);
21
26
  instanceUniqueRepresentation(pluginOptions: any): string;
22
27
  checkIfSkipSetupAllowSkipVerify(adminUser: AdminUser): Promise<{
@@ -25,12 +30,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
25
30
  private pending;
26
31
  private waitForResponse;
27
32
  private resolveResponse;
28
- verifyAuto(adminUser: AdminUser): Promise<any>;
29
- verify(confirmationResult: MfaConfirmationResult, opts: VerifyOptions): Promise<{
30
- ok: true;
31
- } | {
32
- error: string;
33
- }>;
33
+ withVerificationContext<T>(confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra, callback: () => Promise<T>): Promise<(import("./utils/types.js").VerificationFailure & {
34
+ actionDescription?: string;
35
+ }) | T>;
36
+ verifyAuto(adminUser: AdminUser, actionDescription?: string): Promise<any>;
37
+ verify(confirmationResult: MfaConfirmationResult | undefined, opts: VerifyOptions): Promise<VerificationResult>;
34
38
  modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource): void;
35
39
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource): void;
36
40
  activate(resourceConfig: AdminForthResource, adminforth: IAdminForth): void;
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import * as AdminForthRuntime from "adminforth";
12
12
  import { PasskeyService } from "./services/passkeyService.js";
13
13
  import { TotpService } from "./services/totpService.js";
14
14
  import { verifyMfaConfirmation } from "./services/verifyMfaConfirmation.js";
15
+ import { MfaRequestContext } from './services/mfaRequestContext.js';
15
16
  import { CookieService } from "./services/cookieService.js";
16
17
  import { UserRepository } from "./repositories/userRepository.js";
17
18
  import { registerPasskeyEndpoints } from "./endpoints/registerPasskeyEndpoints.js";
@@ -19,12 +20,15 @@ import { registerTwoFaEndpoints } from "./endpoints/registerTwoFaEndpoints.js";
19
20
  import { createPasskeyHandlers } from "./handlers/createPasskeyHandlers.js";
20
21
  import { createTwoFaHandlers } from "./handlers/createTwoFaHandlers.js";
21
22
  import { parsePeriod } from "./utils/parsePeriod.js";
23
+ import { TwoFactorErrorCode } from "./utils/errors.js";
22
24
  import crypto from 'crypto';
25
+ export { TwoFactorErrorCode };
23
26
  const getRequestWebsocketClientId = AdminForthRuntime.getRequestWebsocketClientId;
24
27
  export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
25
28
  constructor(options) {
26
29
  super(options, import.meta.url);
27
30
  this.passkeyLoginRateLimiters = [];
31
+ this.mfaRequestContext = new MfaRequestContext();
28
32
  this.pending = new Map();
29
33
  this.options = options;
30
34
  this.shouldHaveSingleInstancePerWholeApp = () => true;
@@ -66,7 +70,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
66
70
  };
67
71
  timeout = setTimeout(() => {
68
72
  cleanup();
69
- resolve({ ok: false, error: 'Verification timed out' });
73
+ resolve({ ok: false, error: 'Verification timed out', code: TwoFactorErrorCode.TIMEOUT });
70
74
  }, timeoutMs);
71
75
  this.pending.set(id, (value) => {
72
76
  cleanup();
@@ -80,16 +84,30 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
80
84
  resolve(data);
81
85
  }
82
86
  }
83
- verifyAuto(adminUser) {
87
+ withVerificationContext(confirmationResult, extra, callback) {
88
+ return this.mfaRequestContext.run(confirmationResult, extra, callback);
89
+ }
90
+ verifyAuto(adminUser, actionDescription) {
84
91
  return __awaiter(this, void 0, void 0, function* () {
92
+ const contextualResult = yield this.mfaRequestContext.verify(adminUser, actionDescription, (confirmationResult, extra) => this.verify(confirmationResult, {
93
+ adminUser,
94
+ userPk: adminUser.pk,
95
+ extra,
96
+ }));
97
+ if (contextualResult)
98
+ return contextualResult;
85
99
  const websocketClientId = getRequestWebsocketClientId === null || getRequestWebsocketClientId === void 0 ? void 0 : getRequestWebsocketClientId();
86
100
  if (!websocketClientId) {
87
- return { ok: false, error: '2FA auto verification requires an active browser request context' };
101
+ return {
102
+ ok: false,
103
+ error: '2FA auto verification requires an active browser request context',
104
+ code: TwoFactorErrorCode.BROWSER_CONTEXT_REQUIRED,
105
+ };
88
106
  }
89
107
  const sessionId = crypto.randomUUID();
90
108
  const jwt = this.adminforth.auth.issueJWT({ sessionId, adminUserPk: adminUser.pk }, 'auto2FA', '5m');
91
109
  const resultPromise = this.waitForResponse(jwt, 5 * 60 * 1000);
92
- this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt });
110
+ this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}`, { sessionId: jwt, actionDescription });
93
111
  const result = yield resultPromise;
94
112
  this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}/${websocketClientId}/resolve`, { sessionId: jwt });
95
113
  return result;
@@ -107,7 +125,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
107
125
  this.adminForthAuth = adminforth.auth;
108
126
  this.userRepository = new UserRepository(this.adminforth, this.options);
109
127
  this.cookieService = new CookieService(this.adminforth, this.options);
110
- this.totpService = new TotpService(this.options, this.userRepository);
128
+ this.totpService = new TotpService(this.options, this.userRepository, this.adminforth);
111
129
  this.passkeyService = new PasskeyService(this.options, this.adminforth, this.userRepository, undefined, this.cookieService);
112
130
  this.passkeyLoginRateLimiters = adminforth.config.auth.rateLimit.map((rate) => new RateLimiter(rate));
113
131
  this.ctx = {
@@ -0,0 +1,13 @@
1
+ import type { AdminUser, HttpExtra } from 'adminforth';
2
+ import type { MfaConfirmationResult } from './verifyMfaConfirmation.js';
3
+ import type { VerificationFailure, VerificationResult } from '../utils/types.js';
4
+ type ContextualVerificationFailure = VerificationFailure & {
5
+ actionDescription?: string;
6
+ };
7
+ /** Keeps verification within one external request, including every CRUD hook it invokes. */
8
+ export declare class MfaRequestContext {
9
+ private readonly storage;
10
+ run<T>(confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra, callback: () => Promise<T>): Promise<T | ContextualVerificationFailure>;
11
+ verify(adminUser: AdminUser, actionDescription: string | undefined, verifier: (confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra) => Promise<VerificationResult>): Promise<VerificationResult | undefined>;
12
+ }
13
+ export {};
@@ -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 IAdminForth } 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 adminforth;
9
+ private readonly codeAttemptLimiters;
10
+ constructor(options: PluginOptions, userRepository: UserRepository, adminforth: IAdminForth);
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, adminforth) {
13
18
  this.options = options;
14
19
  this.userRepository = userRepository;
20
+ this.adminforth = adminforth;
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.adminforth.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.adminforth.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;
@@ -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
  };
@@ -167,14 +167,10 @@ export function createTwoFaHandlers(ctx: any) {
167
167
  }
168
168
  }
169
169
 
170
- if ( ctx.options.stepUpMfaGracePeriodSeconds ) {
171
- const verificationResult = await ctx.cookieService.isMfaGraceValid(headers, cookies, true);
172
- if ( verificationResult === true ) {
173
- return { skipAllowed: true };
174
- }
175
- }
170
+ const gracePeriod = Boolean(ctx.options.stepUpMfaGracePeriodSeconds)
171
+ && await ctx.cookieService.isMfaGraceValid(headers, cookies, true);
176
172
 
177
- return { skipAllowed: false };
173
+ return { skipAllowed: false, gracePeriod };
178
174
  },
179
175
 
180
176
  verifyTotp: async ({ adminUser, body, response }) => {
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);
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 = {