@openora/core 0.4.1-canary.140 → 0.4.1-canary.141

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 (82) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/contracts/schemas/events.d.ts +3 -1
  3. package/dist/contracts/schemas/events.d.ts.map +1 -1
  4. package/dist/contracts/schemas/events.js +3 -0
  5. package/dist/contracts/schemas/events.js.map +1 -1
  6. package/dist/contracts/schemas/identity.d.ts +31 -1
  7. package/dist/contracts/schemas/identity.d.ts.map +1 -1
  8. package/dist/contracts/schemas/identity.js +36 -7
  9. package/dist/contracts/schemas/identity.js.map +1 -1
  10. package/dist/contracts/schemas/mail.d.ts +9 -1
  11. package/dist/contracts/schemas/mail.d.ts.map +1 -1
  12. package/dist/contracts/schemas/mail.js +3 -0
  13. package/dist/contracts/schemas/mail.js.map +1 -1
  14. package/dist/mail/adapters/default-email-template-renderer.d.ts.map +1 -1
  15. package/dist/mail/adapters/default-email-template-renderer.js +6 -0
  16. package/dist/mail/adapters/default-email-template-renderer.js.map +1 -1
  17. package/dist/mail/contract/index.d.ts +5 -0
  18. package/dist/mail/contract/index.d.ts.map +1 -1
  19. package/dist/pam/identity/__tests__/identity.rate-limit.int.test.js +7 -1
  20. package/dist/pam/identity/__tests__/identity.rate-limit.int.test.js.map +1 -1
  21. package/dist/pam/identity/__tests__/identity.registration-gates.int.test.js +5 -0
  22. package/dist/pam/identity/__tests__/identity.registration-gates.int.test.js.map +1 -1
  23. package/dist/pam/identity/__tests__/identity.service.int.test.js +38 -4
  24. package/dist/pam/identity/__tests__/identity.service.int.test.js.map +1 -1
  25. package/dist/pam/identity/__tests__/two-factor-delivery.test.d.ts +2 -0
  26. package/dist/pam/identity/__tests__/two-factor-delivery.test.d.ts.map +1 -0
  27. package/dist/pam/identity/__tests__/two-factor-delivery.test.js +23 -0
  28. package/dist/pam/identity/__tests__/two-factor-delivery.test.js.map +1 -0
  29. package/dist/pam/identity/contract/index.d.ts +26 -1
  30. package/dist/pam/identity/contract/index.d.ts.map +1 -1
  31. package/dist/pam/identity/contract/index.js +14 -1
  32. package/dist/pam/identity/contract/index.js.map +1 -1
  33. package/dist/pam/identity/drizzle/migrations/0012_worried_nuke.sql +2 -0
  34. package/dist/pam/identity/drizzle/migrations/0013_backfill_two_factor_method.sql +6 -0
  35. package/dist/pam/identity/drizzle/migrations/meta/0012_snapshot.json +1090 -0
  36. package/dist/pam/identity/drizzle/migrations/meta/0013_snapshot.json +1090 -0
  37. package/dist/pam/identity/drizzle/migrations/meta/_journal.json +14 -0
  38. package/dist/pam/identity/plugin.d.ts.map +1 -1
  39. package/dist/pam/identity/plugin.js +6 -0
  40. package/dist/pam/identity/plugin.js.map +1 -1
  41. package/dist/pam/identity/react/account.d.ts +10 -4
  42. package/dist/pam/identity/react/account.d.ts.map +1 -1
  43. package/dist/pam/identity/react/account.js +14 -0
  44. package/dist/pam/identity/react/account.js.map +1 -1
  45. package/dist/pam/identity/react/auth.d.ts +1 -0
  46. package/dist/pam/identity/react/auth.d.ts.map +1 -1
  47. package/dist/pam/identity/router/index.d.ts +26 -1
  48. package/dist/pam/identity/router/index.d.ts.map +1 -1
  49. package/dist/pam/identity/router/index.js +2 -0
  50. package/dist/pam/identity/router/index.js.map +1 -1
  51. package/dist/pam/identity/schema/index.d.ts +18 -0
  52. package/dist/pam/identity/schema/index.d.ts.map +1 -1
  53. package/dist/pam/identity/schema/index.js +6 -1
  54. package/dist/pam/identity/schema/index.js.map +1 -1
  55. package/dist/pam/identity/service/admin-security.service.d.ts.map +1 -1
  56. package/dist/pam/identity/service/admin-security.service.js +3 -0
  57. package/dist/pam/identity/service/admin-security.service.js.map +1 -1
  58. package/dist/pam/identity/service/fresh-reauthentication.service.d.ts.map +1 -1
  59. package/dist/pam/identity/service/fresh-reauthentication.service.js +4 -9
  60. package/dist/pam/identity/service/fresh-reauthentication.service.js.map +1 -1
  61. package/dist/pam/identity/service/identity.service.d.ts +46 -6
  62. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  63. package/dist/pam/identity/service/identity.service.js +219 -22
  64. package/dist/pam/identity/service/identity.service.js.map +1 -1
  65. package/dist/pam/identity/service/two-factor-challenge.service.d.ts +36 -0
  66. package/dist/pam/identity/service/two-factor-challenge.service.d.ts.map +1 -0
  67. package/dist/pam/identity/service/two-factor-challenge.service.js +30 -0
  68. package/dist/pam/identity/service/two-factor-challenge.service.js.map +1 -0
  69. package/dist/pam/identity/service/two-factor-delivery.service.d.ts +67 -0
  70. package/dist/pam/identity/service/two-factor-delivery.service.d.ts.map +1 -0
  71. package/dist/pam/identity/service/two-factor-delivery.service.js +135 -0
  72. package/dist/pam/identity/service/two-factor-delivery.service.js.map +1 -0
  73. package/dist/pam/react.d.ts +1 -1
  74. package/dist/pam/react.d.ts.map +1 -1
  75. package/dist/pam/react.js +1 -1
  76. package/dist/pam/react.js.map +1 -1
  77. package/dist/server/auth/auth.d.ts.map +1 -1
  78. package/dist/server/auth/auth.js +3 -0
  79. package/dist/server/auth/auth.js.map +1 -1
  80. package/dist/testing/seed-player.d.ts +2 -0
  81. package/dist/testing/seed-player.d.ts.map +1 -1
  82. package/package.json +1 -1
@@ -2,7 +2,8 @@ import { type EventBus, type NodeHeaders, DrizzleService } from '@openora/core/s
2
2
  import type { SessionService } from './session.service.js';
3
3
  import type { TrustedDeviceService } from './trusted-device.service.js';
4
4
  import type { TwoFactorLockoutService } from './two-factor-lockout.service.js';
5
- import type { CacheAdapter, RateLimiterAdapter, RateLimitKey, MailDispatchPort, LoginInput, RegisterInput, Enable2faInput, Verify2faInput, Disable2faInput, RegenerateBackupCodesInput, TrustCurrentDeviceInput, RequestPasswordResetInput, VerifyPasswordResetOtpInput, ResetPasswordInput, ResendEmailVerificationInput, VerifyEmailInput, UpdateProfileInput, User, IdentityReader, ChangePasswordInput, ChangeEmailInput, IdentityServiceOptions, PlatformConfig, ClientMeta, GeoCheckCommands, PlayerProvisioning, SecurityControls, SetLoginWithdrawalAlertsInput } from '@openora/core/contracts';
5
+ import { type TwoFactorDeliveryService } from './two-factor-delivery.service.js';
6
+ import type { CacheAdapter, RateLimiterAdapter, RateLimitKey, MailDispatchPort, LoginInput, RegisterInput, Enable2faInput, Enable2faResult, Verify2faInput, TwoFactorStatus, Disable2faInput, RegenerateBackupCodesInput, TrustCurrentDeviceInput, RequestPasswordResetInput, VerifyPasswordResetOtpInput, ResetPasswordInput, ResendEmailVerificationInput, VerifyEmailInput, UpdateProfileInput, User, IdentityReader, ChangePasswordInput, ChangeEmailInput, IdentityServiceOptions, PlatformConfig, ClientMeta, GeoCheckCommands, PlayerProvisioning, SecurityControls, SetLoginWithdrawalAlertsInput } from '@openora/core/contracts';
6
7
  export declare const UserNotFoundError: new (id: string) => Error & {
7
8
  data?: Record<string, string>;
8
9
  };
@@ -23,6 +24,7 @@ export type IdentityServiceDeps = {
23
24
  cache?: CacheAdapter;
24
25
  trustedDevices?: TrustedDeviceService;
25
26
  twoFactorLockout?: TwoFactorLockoutService;
27
+ twoFactorDelivery: TwoFactorDeliveryService;
26
28
  sessions?: SessionService;
27
29
  };
28
30
  /**
@@ -49,8 +51,9 @@ export declare class IdentityService {
49
51
  private readonly existingAccountSignUps;
50
52
  private readonly trustedDevices?;
51
53
  private readonly twoFactorLockout?;
54
+ private readonly twoFactorDelivery;
52
55
  private readonly sessions?;
53
- constructor({ drizzle, events, identityReader, mailDispatch, options, limiter, platformConfig, geoCheck, playerProvisioning, cache, trustedDevices, twoFactorLockout, sessions, }: IdentityServiceDeps);
56
+ constructor({ drizzle, events, identityReader, mailDispatch, options, limiter, platformConfig, geoCheck, playerProvisioning, cache, trustedDevices, twoFactorLockout, twoFactorDelivery, sessions, }: IdentityServiceDeps);
54
57
  private findUserByEmail;
55
58
  private get api();
56
59
  private forwardCookies;
@@ -90,6 +93,7 @@ export declare class IdentityService {
90
93
  * read a stale count and slip past the lockout threshold.
91
94
  */
92
95
  login(input: LoginInput, reqHeaders: NodeHeaders, resHeaders: Headers): Promise<{
96
+ twoFactorMethod?: "email" | "app" | "sms" | undefined;
93
97
  twoFactorRedirect: boolean;
94
98
  user?: undefined;
95
99
  session?: undefined;
@@ -127,7 +131,6 @@ export declare class IdentityService {
127
131
  attemptsRemaining: number;
128
132
  lockoutUntil: string | null;
129
133
  };
130
- twoFactorRedirect?: undefined;
131
134
  }>;
132
135
  private clearLockout;
133
136
  private resetLockoutTier;
@@ -156,10 +159,47 @@ export declare class IdentityService {
156
159
  createdAt: string;
157
160
  updatedAt: string;
158
161
  } | null>;
159
- enableTwoFactor(input: Enable2faInput, reqHeaders: NodeHeaders, resHeaders: Headers): Promise<{
160
- totpUri: string;
161
- backupCodes: string[];
162
+ /**
163
+ * Starts enrolment. All three methods share the one secret better-auth mints here;
164
+ * `method` only decides how the code that activates it arrives, and is written
165
+ * before the send so the delivery hook knows where to route. better-auth flips
166
+ * `twoFactorEnabled` only once the first code clears, so abandoning a first
167
+ * enrolment leaves the account unprotected exactly as it was.
168
+ *
169
+ * A transport failure on the first send throws (see `sendOtpForCurrentMethod`)
170
+ * after `two_factor_method` is already written, but that write is inert on its
171
+ * own: `twoFactorStatus` reports it only once `enabled` is true, and the CONFLICT
172
+ * guard below only fires once it is. The caller recovers by retrying either
173
+ * `sendTwoFactorOtp` (resend) or this route again - both read the method that was
174
+ * already persisted, so neither loses the player's choice.
175
+ */
176
+ enableTwoFactor(input: Enable2faInput, reqHeaders: NodeHeaders, resHeaders: Headers): Promise<Enable2faResult>;
177
+ /**
178
+ * Re-sends the one-time code for an account on the `email` or `sms` method - the
179
+ * "Resend code" action during enrolment, and the send leg of a login challenge.
180
+ * Resolves its subject from a live session first and the pending-challenge cookie
181
+ * second, because at login there is no session yet.
182
+ */
183
+ sendTwoFactorOtp(reqHeaders: NodeHeaders, resHeaders: Headers): Promise<{
184
+ maskedDestination: string;
162
185
  }>;
186
+ /** Whether a second factor is active, which method it uses, and where a code can go. */
187
+ twoFactorStatus(reqHeaders: NodeHeaders): Promise<TwoFactorStatus>;
188
+ /**
189
+ * Rejects a method the account cannot actually receive a code on. Enrolling into
190
+ * `sms` without a verified phone would mint a secret the player can never clear,
191
+ * locking them out of their own account at the next login.
192
+ */
193
+ private assertMethodAvailable;
194
+ /**
195
+ * The account's delivery method, defaulting to `app`: an enrolment that predates the
196
+ * column reads as null, and every one of those was an authenticator.
197
+ */
198
+ private resolveTwoFactorMethod;
199
+ private isTwoFactorEnabled;
200
+ private setTwoFactorMethod;
201
+ private verifyChallengeCode;
202
+ private sendOtpForCurrentMethod;
163
203
  verifyTwoFactor(input: Verify2faInput, reqHeaders: NodeHeaders, resHeaders: Headers): Promise<{
164
204
  success: true;
165
205
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"identity.service.d.ts","sourceRoot":"","sources":["../../../../src/pam/identity/service/identity.service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"identity.service.d.ts","sourceRoot":"","sources":["../../../../src/pam/identity/service/identity.service.ts"],"names":[],"mappings":"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"}
@@ -6,11 +6,13 @@ import { and, eq, isNull, sql } from 'drizzle-orm';
6
6
  import * as z from 'zod';
7
7
  import { user, session, account, verification, twoFactor } from '../schema/index.js';
8
8
  import { captureTimezone } from './capture-timezone.service.js';
9
+ import { maskEmail, maskPhone, } from './two-factor-delivery.service.js';
9
10
  import { RATE_LIMIT_KEYS, makeRateLimitKey } from '@openora/core/contracts';
10
11
  import { assertSupportedLanguage } from '../../shared/language.js';
11
12
  import { assertAccountNotBlocked } from './rg-guard.service.js';
12
13
  import { DEFAULT_LOCKOUT_DURATION_MS, DEFAULT_MAX_LOGIN_ATTEMPTS, computeLockoutTier, createAccountLockedError, hasFailedLoginWindowExpired, makeLoginSecurityState, } from './lockout-policy.service.js';
13
14
  import { getSecurityControls } from './security-controls.service.js';
15
+ import { resolveChallengeMethod, verifyChallengeCode } from './two-factor-challenge.service.js';
14
16
  function nodeHeadersToHeaders(nodeHeaders) {
15
17
  const headers = new Headers();
16
18
  for (const [key, value] of Object.entries(nodeHeaders)) {
@@ -188,6 +190,14 @@ const VERIFY_EMAIL_RATE_LIMIT = {
188
190
  };
189
191
  const CHANGE_PASSWORD_RATE_LIMIT = { limit: 5, windowMs: 15 * MINUTE_MS };
190
192
  const TWO_FACTOR_PASSWORD_RATE_LIMIT = { limit: 5, windowMs: 5 * MINUTE_MS };
193
+ // Fails closed: every call spends real money on an `sms` enrolment and fills a mailbox
194
+ // on an `email` one, and better-auth issues a fresh code per send, so an unbounded
195
+ // resend loop is both a toll-fraud channel and an unbounded guess budget.
196
+ const SEND_2FA_OTP_RATE_LIMIT = {
197
+ limit: 3,
198
+ windowMs: 5 * MINUTE_MS,
199
+ onUnavailable: 'deny',
200
+ };
191
201
  const FAKE_LOGIN_SHADOW_TTL_MS = 7 * 24 * 60 * 60 * 1000;
192
202
  function loginShadowKey(email) {
193
203
  return `login-shadow:${email}`;
@@ -234,8 +244,9 @@ export class IdentityService {
234
244
  existingAccountSignUps = new Set();
235
245
  trustedDevices;
236
246
  twoFactorLockout;
247
+ twoFactorDelivery;
237
248
  sessions;
238
- constructor({ drizzle, events, identityReader, mailDispatch, options, limiter, platformConfig, geoCheck, playerProvisioning, cache, trustedDevices, twoFactorLockout, sessions, }) {
249
+ constructor({ drizzle, events, identityReader, mailDispatch, options, limiter, platformConfig, geoCheck, playerProvisioning, cache, trustedDevices, twoFactorLockout, twoFactorDelivery, sessions, }) {
239
250
  this.drizzle = drizzle;
240
251
  this.events = events;
241
252
  this.identityReader = identityReader;
@@ -248,6 +259,7 @@ export class IdentityService {
248
259
  this.cache = cache;
249
260
  this.trustedDevices = trustedDevices;
250
261
  this.twoFactorLockout = twoFactorLockout;
262
+ this.twoFactorDelivery = twoFactorDelivery;
251
263
  this.sessions = sessions;
252
264
  this.auth = createAuth({
253
265
  db: drizzle.db,
@@ -265,6 +277,9 @@ export class IdentityService {
265
277
  },
266
278
  }
267
279
  : {}),
280
+ // Binds the plugin's OTP hook to the transport fan-out. Without it better-auth
281
+ // still mints codes for the `email`/`sms` methods and drops them on the floor.
282
+ twoFactor: { sendOtp: (args) => twoFactorDelivery.deliver(args) },
268
283
  requireEmailVerification: this.platformConfig?.registration?.requireEmailVerification ?? false,
269
284
  isExistingAccountSignUp: (lookupEmail) => this.existingAccountSignUps.has(lookupEmail.toLowerCase()),
270
285
  isAdminPasswordReset: (lookupEmail) => this.isAdminPasswordReset(lookupEmail),
@@ -613,7 +628,15 @@ export class IdentityService {
613
628
  this.forwardCookies(authResponse, resHeaders);
614
629
  const body = (await authResponse.json());
615
630
  if (body.twoFactorRedirect || !body.user || !body.token) {
616
- return { twoFactorRedirect: true };
631
+ // The challenge screen has to know whether to ask for an authenticator code or
632
+ // to push one, and there is no session yet to read the account's method from.
633
+ // Naming it here reveals nothing the challenge would not show anyway.
634
+ return {
635
+ twoFactorRedirect: true,
636
+ ...(existingUser
637
+ ? { twoFactorMethod: await this.resolveTwoFactorMethod(existingUser.id) }
638
+ : {}),
639
+ };
617
640
  }
618
641
  if (lockoutEnabled && existingUser) {
619
642
  await this.clearLockout(existingUser.id);
@@ -945,10 +968,39 @@ export class IdentityService {
945
968
  }
946
969
  return toUser(session.user);
947
970
  }
971
+ /**
972
+ * Starts enrolment. All three methods share the one secret better-auth mints here;
973
+ * `method` only decides how the code that activates it arrives, and is written
974
+ * before the send so the delivery hook knows where to route. better-auth flips
975
+ * `twoFactorEnabled` only once the first code clears, so abandoning a first
976
+ * enrolment leaves the account unprotected exactly as it was.
977
+ *
978
+ * A transport failure on the first send throws (see `sendOtpForCurrentMethod`)
979
+ * after `two_factor_method` is already written, but that write is inert on its
980
+ * own: `twoFactorStatus` reports it only once `enabled` is true, and the CONFLICT
981
+ * guard below only fires once it is. The caller recovers by retrying either
982
+ * `sendTwoFactorOtp` (resend) or this route again - both read the method that was
983
+ * already persisted, so neither loses the player's choice.
984
+ */
948
985
  async enableTwoFactor(input, reqHeaders, resHeaders) {
949
986
  const headers = nodeHeadersToHeaders(reqHeaders);
950
987
  const userId = await this.currentUserId(headers);
951
988
  await assertRateLimit(this.limiter, `enable2fa:${userId ?? 'anonymous'}`, TWO_FACTOR_PASSWORD_RATE_LIMIT);
989
+ if (!userId) {
990
+ throw new ORPCError('UNAUTHORIZED', { message: 'Not signed in' });
991
+ }
992
+ await this.assertMethodAvailable(userId, input.method);
993
+ // There is no switch-in-place: a player changes method by disabling and enrolling
994
+ // again. better-auth's enable leg deletes and recreates the `twoFactor` row with a
995
+ // fresh secret and fresh backup codes, and marks it verified straight away when one
996
+ // was already verified - so re-running it on a live account destroys the working
997
+ // authenticator the moment the call lands, and abandoning the step strands the
998
+ // player on a secret they never scanned with `two_factor_enabled` still true.
999
+ if (await this.isTwoFactorEnabled(userId)) {
1000
+ throw new ORPCError('CONFLICT', {
1001
+ message: 'Two-factor authentication is already enabled. Disable it first to change method.',
1002
+ });
1003
+ }
952
1004
  const res = await this.api.enableTwoFactor({
953
1005
  body: { password: input.password },
954
1006
  headers,
@@ -957,7 +1009,148 @@ export class IdentityService {
957
1009
  await ensureOk(res);
958
1010
  this.forwardCookies(res, resHeaders);
959
1011
  const body = (await res.json());
960
- return { totpUri: body.totpURI, backupCodes: body.backupCodes };
1012
+ await this.setTwoFactorMethod(userId, input.method);
1013
+ if (input.method === 'app') {
1014
+ return { totpUri: body.totpURI, backupCodes: body.backupCodes };
1015
+ }
1016
+ // The first code has to reach the player before they can activate the method, so
1017
+ // the send belongs to enrolment rather than a separate call the client must know
1018
+ // to make. "Resend code" then replays it through sendTwoFactorOtp.
1019
+ const masked = await this.sendOtpForCurrentMethod(userId, headers, resHeaders);
1020
+ return { backupCodes: body.backupCodes, maskedDestination: masked };
1021
+ }
1022
+ /**
1023
+ * Re-sends the one-time code for an account on the `email` or `sms` method - the
1024
+ * "Resend code" action during enrolment, and the send leg of a login challenge.
1025
+ * Resolves its subject from a live session first and the pending-challenge cookie
1026
+ * second, because at login there is no session yet.
1027
+ */
1028
+ async sendTwoFactorOtp(reqHeaders, resHeaders) {
1029
+ const headers = nodeHeadersToHeaders(reqHeaders);
1030
+ const pendingCookie = twoFactorPendingCookieValue(headers);
1031
+ const userId = (await this.currentUserId(headers)) ??
1032
+ (await this.twoFactorLockout?.resolvePendingUserId(pendingCookie));
1033
+ if (!userId) {
1034
+ throw new ORPCError('UNAUTHORIZED', { message: 'Not signed in' });
1035
+ }
1036
+ await assertRateLimit(this.limiter, `send2faOtp:${userId}`, SEND_2FA_OTP_RATE_LIMIT);
1037
+ // An account inside a lockout window cannot spend a code, so minting one only
1038
+ // hands out fresh guesses - and, on `sms`, bills the operator for them.
1039
+ await this.twoFactorLockout?.assertNotLocked(userId);
1040
+ return { maskedDestination: await this.sendOtpForCurrentMethod(userId, headers, resHeaders) };
1041
+ }
1042
+ /** Whether a second factor is active, which method it uses, and where a code can go. */
1043
+ async twoFactorStatus(reqHeaders) {
1044
+ const headers = nodeHeadersToHeaders(reqHeaders);
1045
+ const userId = await this.currentUserId(headers);
1046
+ if (!userId) {
1047
+ throw new ORPCError('UNAUTHORIZED', { message: 'Not signed in' });
1048
+ }
1049
+ const [row] = await this.drizzle.db
1050
+ .select({
1051
+ email: user.email,
1052
+ phoneNumber: user.phoneNumber,
1053
+ phoneVerified: user.phoneVerified,
1054
+ twoFactorEnabled: user.twoFactorEnabled,
1055
+ twoFactorMethod: user.twoFactorMethod,
1056
+ })
1057
+ .from(user)
1058
+ .where(eq(user.id, userId))
1059
+ .limit(1);
1060
+ if (!row) {
1061
+ throw new UserNotFoundError(userId);
1062
+ }
1063
+ const enabled = row.twoFactorEnabled ?? false;
1064
+ return {
1065
+ enabled,
1066
+ // The column outlives an abandoned enrolment, so an account that never cleared
1067
+ // its first code must still read as having no active method.
1068
+ method: enabled ? (row.twoFactorMethod ?? null) : null,
1069
+ maskedEmail: maskEmail(row.email),
1070
+ maskedPhone: row.phoneVerified && row.phoneNumber ? maskPhone(row.phoneNumber) : null,
1071
+ };
1072
+ }
1073
+ /**
1074
+ * Rejects a method the account cannot actually receive a code on. Enrolling into
1075
+ * `sms` without a verified phone would mint a secret the player can never clear,
1076
+ * locking them out of their own account at the next login.
1077
+ */
1078
+ async assertMethodAvailable(userId, method) {
1079
+ if (method === 'app') {
1080
+ return;
1081
+ }
1082
+ const [row] = await this.drizzle.db
1083
+ .select({
1084
+ emailVerified: user.emailVerified,
1085
+ phoneNumber: user.phoneNumber,
1086
+ phoneVerified: user.phoneVerified,
1087
+ })
1088
+ .from(user)
1089
+ .where(eq(user.id, userId))
1090
+ .limit(1);
1091
+ // A code can only guard the account if the address it goes to is one the account
1092
+ // has proven it holds. `registration.requireEmailVerification` is off by default,
1093
+ // so an unverified or mistyped address would otherwise enrol and lock the player
1094
+ // out at the next login - the same failure the phone check already prevents.
1095
+ if (method === 'email' && !row?.emailVerified) {
1096
+ throw new ORPCError('BAD_REQUEST', {
1097
+ message: 'Verify your email address before using email for two-factor authentication',
1098
+ });
1099
+ }
1100
+ if (method === 'sms' && (!row?.phoneNumber || !row.phoneVerified)) {
1101
+ throw new ORPCError('BAD_REQUEST', {
1102
+ message: 'Add and verify a phone number before using SMS for two-factor authentication',
1103
+ });
1104
+ }
1105
+ }
1106
+ /**
1107
+ * The account's delivery method, defaulting to `app`: an enrolment that predates the
1108
+ * column reads as null, and every one of those was an authenticator.
1109
+ */
1110
+ async resolveTwoFactorMethod(userId) {
1111
+ const [row] = await this.drizzle.db
1112
+ .select({ twoFactorMethod: user.twoFactorMethod })
1113
+ .from(user)
1114
+ .where(eq(user.id, userId))
1115
+ .limit(1);
1116
+ return row?.twoFactorMethod ?? 'app';
1117
+ }
1118
+ async isTwoFactorEnabled(userId) {
1119
+ const [row] = await this.drizzle.db
1120
+ .select({ twoFactorEnabled: user.twoFactorEnabled })
1121
+ .from(user)
1122
+ .where(eq(user.id, userId))
1123
+ .limit(1);
1124
+ return row?.twoFactorEnabled ?? false;
1125
+ }
1126
+ async setTwoFactorMethod(userId, method) {
1127
+ await this.drizzle.db.update(user).set({ twoFactorMethod: method }).where(eq(user.id, userId));
1128
+ }
1129
+ verifyChallengeCode(method, body, headers) {
1130
+ return verifyChallengeCode(this.api, method, body, headers);
1131
+ }
1132
+ async sendOtpForCurrentMethod(userId, headers, resHeaders) {
1133
+ const destination = await this.twoFactorDelivery.describeDestination(userId);
1134
+ if (!destination) {
1135
+ throw new ORPCError('BAD_REQUEST', {
1136
+ message: 'This account does not receive two-factor codes by email or SMS',
1137
+ });
1138
+ }
1139
+ const res = await this.api.sendTwoFactorOTP({ body: {}, headers, asResponse: true });
1140
+ await ensureOk(res);
1141
+ // A 200 here means the code was minted, not that it left the building: the send
1142
+ // hook is wrapped in `.catch(logger.error)` upstream. Telling the player a code is
1143
+ // on its way when the transport just failed strands them on a screen waiting for
1144
+ // something that is never coming.
1145
+ const failure = this.twoFactorDelivery.takeFailure(userId);
1146
+ if (failure) {
1147
+ throw new ORPCError('SERVICE_UNAVAILABLE', {
1148
+ message: 'Could not send your verification code. Please try again.',
1149
+ cause: failure,
1150
+ });
1151
+ }
1152
+ this.forwardCookies(res, resHeaders);
1153
+ return destination.masked;
961
1154
  }
962
1155
  async verifyTwoFactor(input, reqHeaders, resHeaders) {
963
1156
  const { ip, userAgent } = extractClientMeta(reqHeaders);
@@ -971,12 +1164,11 @@ export class IdentityService {
971
1164
  await this.twoFactorLockout?.assertNotLocked(challengedUserId);
972
1165
  }
973
1166
  // A backup code is a single-use recovery credential, not a second factor to bind a
974
- // browser to: it clears the challenge but never buys the trust window.
975
- const trustDevice = input.trustDevice && input.method === 'totp' && this.trustedDevices !== undefined;
1167
+ // browser to: it clears the challenge but never buys the trust window. A pushed
1168
+ // `otp` is a real factor and does, the same as a live authenticator code.
1169
+ const trustDevice = input.trustDevice && input.method !== 'backup_code' && this.trustedDevices !== undefined;
976
1170
  const body = { code: input.code, trustDevice };
977
- const res = input.method === 'backup_code'
978
- ? await this.api.verifyBackupCode({ body, headers, asResponse: true })
979
- : await this.api.verifyTOTP({ body, headers, asResponse: true });
1171
+ const res = await this.verifyChallengeCode(input.method, body, headers);
980
1172
  if (!res.ok && challengedUserId) {
981
1173
  await this.twoFactorLockout?.recordFailure(challengedUserId, { ip, userAgent });
982
1174
  }
@@ -1065,13 +1257,18 @@ export class IdentityService {
1065
1257
  const challengeHeaders = new Headers(headers);
1066
1258
  challengeHeaders.set('cookie', requestCookieHeader(signIn));
1067
1259
  await this.twoFactorLockout?.assertNotLocked(userId);
1068
- // Only a live authenticator earns the trust window - a backup code is a recovery
1069
- // credential and the schema never lets one reach this route.
1070
- const verified = await this.api.verifyTOTP({
1071
- body: { code: input.code, trustDevice: true },
1072
- headers: challengeHeaders,
1073
- asResponse: true,
1074
- });
1260
+ // Authenticator accounts only. This route replays a fresh sign-in leg, and
1261
+ // better-auth keys a pushed code by the pending-challenge cookie that leg mints -
1262
+ // so an `email`/`sms` account has no code that can answer this challenge, and
1263
+ // letting the call through would only spend its lockout budget on a credential it
1264
+ // is impossible to present. Refusing says so instead of failing as a wrong code.
1265
+ const challengeMethod = await resolveChallengeMethod(this.drizzle, userId);
1266
+ if (challengeMethod !== 'totp') {
1267
+ throw new ORPCError('CONFLICT', {
1268
+ message: 'Trusting this device requires an authenticator app.',
1269
+ });
1270
+ }
1271
+ const verified = await this.verifyChallengeCode(challengeMethod, { code: input.code, trustDevice: true }, challengeHeaders);
1075
1272
  if (!verified.ok) {
1076
1273
  await this.twoFactorLockout?.recordFailure(userId, { ip, userAgent });
1077
1274
  }
@@ -1083,7 +1280,7 @@ export class IdentityService {
1083
1280
  this.events.emit('identity.2fa.verified', {
1084
1281
  userId,
1085
1282
  playerId,
1086
- method: 'totp',
1283
+ method: challengeMethod,
1087
1284
  trustedDevice: true,
1088
1285
  ip,
1089
1286
  userAgent,
@@ -1108,11 +1305,7 @@ export class IdentityService {
1108
1305
  */
1109
1306
  async assertFreshSecondFactor(userId, headers, meta, code) {
1110
1307
  await this.twoFactorLockout?.assertNotLocked(userId);
1111
- const res = await this.api.verifyTOTP({
1112
- body: { code, trustDevice: false },
1113
- headers,
1114
- asResponse: true,
1115
- });
1308
+ const res = await this.verifyChallengeCode(await resolveChallengeMethod(this.drizzle, userId), { code, trustDevice: false }, headers);
1116
1309
  if (!res.ok) {
1117
1310
  await this.twoFactorLockout?.recordFailure(userId, meta);
1118
1311
  }
@@ -1156,6 +1349,9 @@ export class IdentityService {
1156
1349
  throw new ORPCError('UNAUTHORIZED', { message: 'Not signed in' });
1157
1350
  }
1158
1351
  await this.assertFreshSecondFactor(userId, headers, { ip, userAgent }, input.code);
1352
+ // Read before the teardown clears it, so the audit trail records which method the
1353
+ // account was actually protected by rather than a blank.
1354
+ const disabledMethod = await resolveChallengeMethod(this.drizzle, userId);
1159
1355
  const res = await this.api.disableTwoFactor({
1160
1356
  body: { password: input.password },
1161
1357
  headers,
@@ -1163,6 +1359,7 @@ export class IdentityService {
1163
1359
  });
1164
1360
  await ensureOk(res);
1165
1361
  this.forwardCookies(res, resHeaders);
1362
+ await this.setTwoFactorMethod(userId, null);
1166
1363
  // Dropping the second factor takes every standing bypass with it, the same teardown
1167
1364
  // a Super Admin reset performs - a browser must not keep the access 2FA was guarding.
1168
1365
  await this.trustedDevices?.revokeAllForUser(userId, userId);
@@ -1170,7 +1367,7 @@ export class IdentityService {
1170
1367
  this.events.emit('identity.2fa.disabled', {
1171
1368
  userId,
1172
1369
  playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId),
1173
- method: 'totp',
1370
+ method: disabledMethod,
1174
1371
  ip,
1175
1372
  userAgent,
1176
1373
  });