@adminforth/two-factors-auth 4.5.0 → 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.
@@ -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
  };
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.5.0",
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",
@@ -0,0 +1,51 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { AdminUser, HttpExtra } from 'adminforth';
3
+ import type { MfaConfirmationResult } from './verifyMfaConfirmation.js';
4
+ import type { VerificationFailure, VerificationResult } from '../utils/types.js';
5
+
6
+ type Context = {
7
+ confirmationResult: MfaConfirmationResult | undefined;
8
+ extra: HttpExtra;
9
+ results: Map<string, Promise<VerificationResult>>;
10
+ };
11
+
12
+ type ContextualVerificationFailure = VerificationFailure & { actionDescription?: string };
13
+
14
+ class MfaVerificationError extends Error {
15
+ constructor(readonly result: ContextualVerificationFailure) {
16
+ super(result.error);
17
+ }
18
+ }
19
+
20
+ /** Keeps verification within one external request, including every CRUD hook it invokes. */
21
+ export class MfaRequestContext {
22
+ private readonly storage = new AsyncLocalStorage<Context>();
23
+
24
+ run<T>(confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra, callback: () => Promise<T>): Promise<T | ContextualVerificationFailure> {
25
+ return this.storage.run({ confirmationResult, extra, results: new Map() }, async () => {
26
+ try {
27
+ return await callback();
28
+ } catch (error) {
29
+ if (error instanceof MfaVerificationError) return error.result;
30
+ throw error;
31
+ }
32
+ });
33
+ }
34
+
35
+ async verify(
36
+ adminUser: AdminUser,
37
+ actionDescription: string | undefined,
38
+ verifier: (confirmationResult: MfaConfirmationResult | undefined, extra: HttpExtra) => Promise<VerificationResult>,
39
+ ): Promise<VerificationResult | undefined> {
40
+ const context = this.storage.getStore();
41
+ if (!context) return undefined;
42
+ let result = context.results.get(adminUser.pk!);
43
+ if (!result) {
44
+ result = verifier(context.confirmationResult, context.extra);
45
+ context.results.set(adminUser.pk!, result);
46
+ }
47
+ const verified = await result;
48
+ if ('error' in verified) throw new MfaVerificationError({ ...verified, actionDescription });
49
+ return verified;
50
+ }
51
+ }
@@ -1,12 +1,21 @@
1
- import type { AdminUser } from "adminforth";
1
+ import { RateLimiter, type AdminUser, type IAdminForthAuth } from "adminforth";
2
2
  import twofactor from 'node-2fa';
3
3
  import type { PluginOptions } from "../types.js";
4
4
  import { UserRepository } from "../repositories/userRepository.js";
5
+ import { TwoFactorErrorCode } from "../utils/errors.js";
6
+ import type { VerificationFailure, VerificationResult } from "../utils/types.js";
7
+
8
+ const CODE_ATTEMPT_RATE_LIMITS = ['30/1m', '300/1h', '1000/1d'];
9
+ const VERIFIED_CODE_JWT_TYPE = 'verifiedTotpCode';
10
+ const VERIFIED_CODE_TOKEN_TTL = '1m';
5
11
 
6
12
  export class TotpService {
13
+ private readonly codeAttemptLimiters = CODE_ATTEMPT_RATE_LIMITS.map((rate) => new RateLimiter(rate));
14
+
7
15
  constructor(
8
16
  private readonly options: PluginOptions,
9
17
  private readonly userRepository: UserRepository,
18
+ private readonly auth: IAdminForthAuth,
10
19
  ) {}
11
20
 
12
21
  public generateSecret(issuerName: string, userName: string): string {
@@ -17,18 +26,32 @@ export class TotpService {
17
26
  return Boolean(twofactor.verifyToken(secret, code, this.options.timeStepWindow));
18
27
  }
19
28
 
20
- public async verifyUserCode(userPk: string, code: string): Promise<{ ok: true } | { error: string }> {
29
+ public async verifyUserCode(userPk: string, code: string): Promise<VerificationResult> {
30
+ const attemptsAllowed = await Promise.all(this.codeAttemptLimiters.map((limiter) => limiter.consume(userPk)));
31
+ if (!attemptsAllowed.every(Boolean)) {
32
+ return { error: "Too many attempts to enter the code. Try again later.", code: TwoFactorErrorCode.RATE_LIMITED };
33
+ }
34
+
21
35
  const user = await this.userRepository.getAuthUser(userPk);
22
- if (!user) return { error: "User not found" };
36
+ if (!user) return { error: "User not found", code: TwoFactorErrorCode.USER_NOT_FOUND };
23
37
 
24
38
  const secret = this.userRepository.getSecret(user);
25
- if (!secret) return { error: "2FA is not set up for this user" };
39
+ if (!secret) return { error: "2FA is not set up for this user", code: TwoFactorErrorCode.NOT_SET_UP };
40
+
41
+ return this.verifySecret(secret, code)
42
+ ? { ok: true }
43
+ : { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
44
+ }
26
45
 
27
- return this.verifySecret(secret, code) ? { ok: true } : { error: "Wrong or expired OTP code" };
46
+ public async verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{ ok: true; verifiedCodeToken: string } | VerificationFailure> {
47
+ const result = await this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
48
+ if ('error' in result) return result;
49
+ return { ok: true, verifiedCodeToken: this.auth.issueJWT({ pk: adminUser.pk }, VERIFIED_CODE_JWT_TYPE, VERIFIED_CODE_TOKEN_TTL) };
28
50
  }
29
51
 
30
- public async verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{ ok: true } | { error: string }> {
31
- return this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
52
+ public async isVerifiedCodeToken(token: string, userPk: string): Promise<boolean> {
53
+ const payload = await this.auth.verify(token, VERIFIED_CODE_JWT_TYPE, false);
54
+ return payload?.pk === userPk;
32
55
  }
33
56
 
34
57
  public async saveSecret(userPk: string, secret: string): Promise<void> {
@@ -1,11 +1,17 @@
1
1
  import type { AdminUser, HttpExtra, IAdminForthHttpResponse } from "adminforth";
2
- import type { CookieList } from "../utils/types.js";
2
+ import { TwoFactorErrorCode } from "../utils/errors.js";
3
+ import type { CookieList, VerificationResult } from "../utils/types.js";
3
4
 
4
5
  type TotpConfirmationResult = {
5
6
  mode: "totp";
6
7
  result: string;
7
8
  };
8
9
 
10
+ type VerifiedTotpConfirmationResult = {
11
+ mode: "verifiedTotp";
12
+ result: string;
13
+ };
14
+
9
15
  type PasskeyConfirmationResult = {
10
16
  mode: "passkey";
11
17
  result: {
@@ -18,7 +24,7 @@ type GraceConfirmationResult = {
18
24
  mode?: undefined;
19
25
  };
20
26
 
21
- export type MfaConfirmationResult = TotpConfirmationResult | PasskeyConfirmationResult | GraceConfirmationResult;
27
+ export type MfaConfirmationResult = TotpConfirmationResult | VerifiedTotpConfirmationResult | PasskeyConfirmationResult | GraceConfirmationResult;
22
28
 
23
29
  export type VerifyOptions = {
24
30
  adminUser: AdminUser;
@@ -30,11 +36,10 @@ export type VerifyOptions = {
30
36
 
31
37
  export async function verifyMfaConfirmation(
32
38
  ctx: any,
33
- confirmationResult: MfaConfirmationResult,
39
+ confirmationResult: MfaConfirmationResult | undefined,
34
40
  opts: VerifyOptions,
35
- ): Promise<{ ok: true } | { error: string }> {
36
- if (!confirmationResult) return { error: "Confirmation result is required" };
37
-
41
+ ): Promise<VerificationResult> {
42
+ const mode = confirmationResult?.mode;
38
43
  const cookies = opts.extra?.cookies || opts.cookies;
39
44
  const response = opts.extra?.response || opts.response;
40
45
 
@@ -50,43 +55,44 @@ export async function verifyMfaConfirmation(
50
55
  return { ok: true };
51
56
  }
52
57
  }
53
- if ( ctx.options.stepUpMfaGracePeriodSeconds && opts.extra?.headers && !confirmationResult.mode) {
58
+ if ( ctx.options.stepUpMfaGracePeriodSeconds && opts.extra?.headers && !mode) {
54
59
  const verificationResult = await ctx.cookieService.isMfaGraceValid(opts.extra.headers, cookies);
55
60
  if ( verificationResult === true ) {
56
61
  return { ok: true };
57
62
  }
58
63
  }
59
- const mode = confirmationResult.mode;
60
64
  if (!mode) {
61
- return { error: "Unsupported confirmation mode" };
65
+ return { error: "Confirmation result is required", code: TwoFactorErrorCode.REQUIRED };
62
66
  }
63
67
 
68
+ const confirmed = (): VerificationResult => {
69
+ if (ctx.options.stepUpMfaGracePeriodSeconds) {
70
+ ctx.cookieService.issueMfaGrace({ headers: opts.extra?.headers, response }, cookies);
71
+ }
72
+ return { ok: true };
73
+ };
74
+
64
75
  if (mode === "totp") {
65
76
  const { result: code } = confirmationResult as TotpConfirmationResult;
66
77
  const verificationResult = await ctx.totpService.verifyUserCode(opts.userPk, code);
67
-
68
- if ( 'ok' in verificationResult && verificationResult.ok ) {
69
- if (ctx.options.stepUpMfaGracePeriodSeconds) {
70
- ctx.cookieService.issueMfaGrace({ headers: opts.extra?.headers, response }, cookies);
71
- }
72
- return { ok: true }
73
- }
74
- return verificationResult;
78
+ return 'error' in verificationResult ? verificationResult : confirmed();
79
+ } else if (mode === "verifiedTotp") {
80
+ const { result: token } = confirmationResult as VerifiedTotpConfirmationResult;
81
+ return await ctx.totpService.isVerifiedCodeToken(token, opts.userPk)
82
+ ? confirmed()
83
+ : { error: "The code confirmation expired. Enter the code again.", code: TwoFactorErrorCode.INVALID };
75
84
  } else if (mode === "passkey") {
76
85
  const { result } = confirmationResult as PasskeyConfirmationResult;
77
86
  const cookiesValidationResult = await ctx.passkeyService.validateLoginChallengeCookie(cookies);
78
87
  if (!cookiesValidationResult.ok) {
79
- return { error: cookiesValidationResult.error };
88
+ return { error: cookiesValidationResult.error, code: TwoFactorErrorCode.INVALID };
80
89
  }
81
90
  const verificationResult = await ctx.passkeyService.verifyPasskeyResponse(result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies );
82
91
 
83
92
  if (verificationResult.ok && verificationResult.passkeyConfirmed) {
84
- if (ctx.options.stepUpMfaGracePeriodSeconds) {
85
- ctx.cookieService.issueMfaGrace({ headers: opts.extra?.headers, response }, cookies);
86
- }
87
- return { ok: true }
93
+ return confirmed();
88
94
  }
89
- return { error: "Invalid passkey" }
95
+ return { error: "Invalid passkey", code: TwoFactorErrorCode.INVALID }
90
96
  }
91
- return { error: "Unsupported confirmation mode" };
97
+ return { error: "Unsupported confirmation mode", code: TwoFactorErrorCode.UNSUPPORTED_MODE };
92
98
  }