feat: allow optional card fallback for failed phone verifications (#3351)

This commit is contained in:
Daniel Salazar
2026-07-06 14:59:55 -07:00
committed by GitHub
parent 0109bf5b72
commit f68caa3bbd
3 changed files with 508 additions and 12 deletions
@@ -2534,6 +2534,312 @@ describe('AuthController.handleCardVerificationConfirm', () => {
});
});
describe('AuthController SMS → card fallback', () => {
const stubPrelude = (over: Record<string, unknown> = {}) => ({
isConfigured: () => true,
isCountrySupported: () => true,
defaultCountry: 'US',
createVerification: vi.fn(async () => ({ status: 'success' })),
...over,
});
const withPrelude = async (
prelude: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as { clients: { prelude: unknown } };
const real = ctrl.clients.prelude;
ctrl.clients.prelude = prelude;
try {
await fn();
} finally {
ctrl.clients.prelude = real;
}
};
const withFallbackConfig = async (
value: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.phone_verification_card_fallback;
cfg.phone_verification_card_fallback = value;
try {
await fn();
} finally {
cfg.phone_verification_card_fallback = prev;
}
};
// Drive the attempt counter directly so the threshold is deterministic
// (the handler keys it the same way: `phone-verify-attempts:<id>`).
const seedAttempts = (userId: number, attempts: number) =>
server.stores.kv.incr({
key: `phone-verify-attempts:${userId}`,
pathAndAmountMap: { attempts },
});
// Stamp the eligibility flag the card endpoints check, the same way a
// threshold-crossing send does (`card-fallback-open:<id>`).
const openFallback = (userId: number) =>
server.stores.kv.set({
key: `card-fallback-open:${userId}`,
value: true,
});
it('offers the fallback on send once the attempt threshold is reached', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// No after_attempts → exercises the default threshold of 2.
await withFallbackConfig({ enabled: true }, async () => {
await withPrelude(stubPrelude(), async () => {
const first = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
first,
);
// First attempt is below the threshold — no offer yet.
expect(first.body).toEqual({});
const second = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
second,
);
expect(second.body).toEqual({
card_fallback_available: true,
});
});
});
});
it('never offers the fallback on send when disabled', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: false, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({});
});
},
);
});
it('flags the fallback on a Prelude block once eligible', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'blocked',
})),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { card_fallback_available: true },
});
},
);
},
);
});
it('lets card setup proceed past the phone gate once eligible', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('still 409s card setup when no send has opened the fallback', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
// Counter above the threshold but no flag: eligibility is the flag a
// threshold-crossing send stamps, never the raw counter.
await seedAttempts(user.id, 5);
await withFallbackConfig(
{ enabled: true, after_attempts: 3 },
async () => {
await expect(
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
},
);
});
it('send crossing the threshold opens card setup end-to-end', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const sendRes = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
sendRes,
);
expect(sendRes.body).toEqual({
card_fallback_available: true,
});
});
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('clamps after_attempts to the send route rate limit', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// 9 prior attempts + this send = 10, the route limit. A threshold of
// 50 could never be crossed, so it clamps down and the offer opens.
await seedAttempts(user.id, 9);
await withFallbackConfig(
{ enabled: true, after_attempts: 50 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({
card_fallback_available: true,
});
});
},
);
});
it('clears BOTH gates when the fallback card verifies', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({
card_verified: true,
phone_verified: true,
});
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(false);
expect(after!.requires_phone_verification).toBe(false);
});
it('clears the phone gate via card even when card was not required', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({ phone_verified: true });
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_phone_verification).toBe(false);
});
});
describe('AuthController.handleConfirmEmail', () => {
it('throws 400 when code is missing', async () => {
const { actor } = await makeUserAndActor();
+178 -12
View File
@@ -61,6 +61,17 @@ const USERNAME_REGEX = /^\w{1,}$/;
const USERNAME_MAX_LENGTH = 45;
const FINGERPRINT_MAX_LENGTH = 128;
const DISPATCH_ID_MAX_LENGTH = 128;
// Default SMS send attempts before the card fallback opens.
const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
// /send-confirm-phone route rate limit. Also caps the fallback's
// `after_attempts`: requests past the route limit are rejected in middleware
// and never reach the attempt counter, so a higher threshold could never be
// crossed.
const SEND_PHONE_RATE_LIMIT = 10;
const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
// Once the threshold is crossed the fallback stays open this long, so the
// user can finish the card flow without racing the attempt counter's expiry.
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
@@ -1056,14 +1067,117 @@ export class AuthController extends PuterController {
});
}
// -- SMS-to-card fallback -----------------------------------------
//
// Once a user has made enough SMS send attempts in the rate-limit window
// without getting through, they can verify a card instead to clear the
// phone gate. Off unless config enables it.
//
// Two KV keys: a short-lived counter tied to the send rate-limit window
// triggers the fallback, and a longer-lived "open" flag holds eligibility
// once the threshold is crossed. The card endpoints check only the flag —
// deriving eligibility from the raw counter would let it expire while the
// user is mid-way through the card flow. Every KV failure fails closed
// (fallback unavailable), never open.
private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } {
const cfg = this.config.phone_verification_card_fallback;
const afterAttempts = Math.min(
typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0
? cfg.after_attempts
: DEFAULT_CARD_FALLBACK_ATTEMPTS,
SEND_PHONE_RATE_LIMIT,
);
return { enabled: Boolean(cfg?.enabled), afterAttempts };
}
private phoneAttemptsKey(userId: number): string {
return `phone-verify-attempts:${userId}`;
}
private cardFallbackFlagKey(userId: number): string {
return `card-fallback-open:${userId}`;
}
// TTL ties the counter to the send rate-limit window, so it resets with it.
private async bumpPhoneAttempts(userId: number): Promise<number> {
try {
const { res } = await this.stores.kv.incr({
key: this.phoneAttemptsKey(userId),
pathAndAmountMap: { attempts: 1 },
expireAt:
Math.floor(Date.now() / 1000) +
SEND_PHONE_RATE_WINDOW_MS / 1000,
});
const count = (res as { attempts?: number } | null)?.attempts;
return typeof count === 'number' ? count : 0;
} catch (e) {
console.warn('[send-confirm-phone] attempt-count bump failed:', e);
return 0;
}
}
/**
* Count a send attempt and, once the threshold is crossed, stamp the
* eligibility flag the card endpoints check. Returns whether the fallback
* is open so send responses (success or 429) can advertise it.
*/
private async recordPhoneAttemptForFallback(user: {
id: number;
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const attempts = await this.bumpPhoneAttempts(user.id);
const { enabled, afterAttempts } = this.cardFallbackConfig();
const open =
enabled &&
Boolean(user.requires_phone_verification) &&
attempts >= afterAttempts;
if (open) {
try {
// Plain set, so each eligible attempt refreshes the window.
await this.stores.kv.set({
key: this.cardFallbackFlagKey(user.id),
value: true,
expireAt:
Math.floor(Date.now() / 1000) +
CARD_FALLBACK_OPEN_TTL_SECONDS,
});
} catch (e) {
console.warn(
'[send-confirm-phone] fallback flag stamp failed:',
e,
);
return false;
}
}
return open;
}
private async isCardFallbackEligible(user: {
id: number;
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const { enabled } = this.cardFallbackConfig();
if (!enabled || !user.requires_phone_verification) return false;
try {
const { res } = await this.stores.kv.get({
key: this.cardFallbackFlagKey(user.id),
});
return res === true;
} catch (e) {
console.warn('[card-verification] fallback flag read failed:', e);
return false;
}
}
@Post('/send-confirm-phone', {
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
rateLimit: {
scope: 'send-confirm-phone',
limit: 10,
window: 60 * 60_000,
limit: SEND_PHONE_RATE_LIMIT,
window: SEND_PHONE_RATE_WINDOW_MS,
key: 'user',
},
})
@@ -1127,6 +1241,14 @@ export class AuthController extends PuterController {
{ legacyCode: 'phone_country_not_supported' as never },
);
// Counted before the abuse / Prelude checks so a blocked attempt still
// counts toward the fallback threshold.
const fallbackAvailable =
await this.recordPhoneAttemptForFallback(user);
const fallbackFields = fallbackAvailable
? { card_fallback_available: true }
: {};
// Abuse caps live ENTIRELY in a listening abuse extension, consulted
// via `puter.phone-verification.check`. The backend ships no thresholds
// or detection of its own (so none of it is readable in the open-source
@@ -1169,9 +1291,12 @@ export class AuthController extends PuterController {
},
{
legacyCode: 'phone_verification_unavailable' as never,
fields: abuseCheck.reason
? { reason: abuseCheck.reason }
: {},
fields: {
...fallbackFields,
...(abuseCheck.reason
? { reason: abuseCheck.reason }
: {}),
},
},
);
@@ -1232,7 +1357,10 @@ export class AuthController extends PuterController {
userUid: user.uuid,
country: parsed.country,
},
{ legacyCode: 'too_many_requests' as never },
{
legacyCode: 'too_many_requests' as never,
fields: fallbackFields,
},
);
}
} catch (e) {
@@ -1269,7 +1397,7 @@ export class AuthController extends PuterController {
} catch {
// ignore — best-effort velocity signal
}
res.json({});
res.json(fallbackAvailable ? { card_fallback_available: true } : {});
}
@Post('/confirm-phone', {
@@ -1420,11 +1548,14 @@ export class AuthController extends PuterController {
throw new HttpError(403, 'Account suspended.', {
legacyCode: 'account_suspended',
});
if (!user.requires_card_verification) {
// Phone normally comes first, but the fallback lets a phone-gated user
// in once they've exhausted SMS attempts.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (!user.requires_card_verification && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1472,6 +1603,16 @@ export class AuthController extends PuterController {
// Kill switch: the extension reports the feature disabled — unstick
// any user still carrying the flag instead of dead-ending them.
if (setupEvent.enabled === false) {
// A fallback user is here BECAUSE SMS isn't working for them, and
// now the card path is off too — they stay phone-gated with no
// way through. Surface it; don't clear a gate with nothing
// verified.
if (fallbackEligible)
console.warn(
'[card-verification/setup] card verification disabled;' +
` fallback-eligible user ${user.uuid} remains` +
' phone-gated with no working verification path',
);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
@@ -1528,11 +1669,13 @@ export class AuthController extends PuterController {
throw new HttpError(404, 'User not found.', {
legacyCode: 'not_found',
});
if (!user.requires_card_verification) {
// Same fallback exception as setup: card may come before phone.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (!user.requires_card_verification && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1562,6 +1705,12 @@ export class AuthController extends PuterController {
// Kill switch — same semantics as /card-verification/setup.
if (confirmEvent.enabled === false) {
if (fallbackEligible)
console.warn(
'[card-verification/confirm] card verification disabled;' +
` fallback-eligible user ${user.uuid} remains` +
' phone-gated with no working verification path',
);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
@@ -1579,8 +1728,15 @@ export class AuthController extends PuterController {
return;
}
// A fallback card clears the phone gate too — the point of the
// fallback. `fallbackEligible &&` makes the invariant local instead
// of leaning on the 409 guard above: only a fallback user's card can
// ever clear a phone gate.
const clearedPhoneGate =
fallbackEligible && Boolean(user.requires_phone_verification);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
try {
@@ -1605,11 +1761,21 @@ export class AuthController extends PuterController {
'user.card_verified',
{ original_client_socket_id },
);
// The fallback cleared the phone gate too — tell phone-gate UIs.
if (clearedPhoneGate)
await this.services.socket?.send(
{ room: user.id },
'user.phone_verified',
{ original_client_socket_id },
);
} catch {
// ignore — best-effort
}
res.json({ card_verified: true });
res.json({
card_verified: true,
...(clearedPhoneGate ? { phone_verified: true } : {}),
});
}
// -- Password recovery -------------------------------------------
+24
View File
@@ -584,6 +584,30 @@ interface IConfigOptional {
* signups). Requires a payments extension to actually run the $0 auth.
*/
always_require_card_verification: boolean;
/**
* Let a user who keeps getting blocked on SMS phone verification fall
* back to credit-card verification, which clears the phone gate (and the
* card gate too, when one is set). Off by default.
*
* The fallback opens after `after_attempts` SMS *send* attempts inside
* the send rate-limit window successful sends count too, so a user who
* receives codes fine can still choose the card path after that many
* requests. This trades the phone signal for a card signal; it does NOT
* guarantee SMS actually failed. Once open, the fallback stays open for
* 24 hours so the user can finish the card flow. Requires a payments
* extension to run the actual card check.
*/
phone_verification_card_fallback: {
enabled: boolean;
/**
* SMS send attempts (within the send rate-limit window) before the
* card fallback opens. Defaults to 2 when omitted. Values above the
* send route's rate limit (10/hour) are clamped down to it requests
* past the route limit never reach the attempt counter, so a higher
* threshold could never be crossed.
*/
after_attempts?: number;
};
/** Captcha configuration. */
captcha: { enabled: boolean; difficulty?: 'easy' | 'medium' | 'hard' };
/** OIDC / OAuth2 providers (google + custom). */