diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index ed24e5d43..a572fbb50 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -227,8 +227,40 @@ export type EventMap = { fingerprint?: string | null; /** True when the created account is a temp user (no email/password). */ is_temp?: boolean; + /** The bonus code `puter.signup-bonus.validate` accepted, if any. */ + bonus_code?: string; [key: string]: unknown; }; + // Signup bonus codes are pure mechanism here: an extension decides what a + // code is worth and fills these in (both emitted via `emitAndWait`). + 'puter.signup-bonus.check': { + code: string; + ip: string | null; + fingerprint: string | null; + valid: boolean; + /** Opaque to core; forwarded to the client when `valid` is false. */ + reason: string | null; + display: { title: string; description: string } | null; + /** Verification the code will require after signup. */ + requirements: { phone: boolean; card: boolean } | null; + }; + /** + * Emitted once a signup has passed `puter.signup.validate`, so listeners + * see the harness's verdict. A listener sets `accepted` to honor the code, + * and may raise (never lower) the verification requirements. + */ + 'puter.signup-bonus.validate': { + code: string; + email?: string; + clean_email?: string; + ip?: string | null; + fingerprint?: string | null; + reputation?: number | null; + source?: 'oidc'; + requires_phone_verification: boolean; + requires_card_verification: boolean; + accepted: boolean; + }; 'email.validate': { email: string; allow: boolean; diff --git a/src/backend/controllers/auth/AuthController.signupBonus.test.ts b/src/backend/controllers/auth/AuthController.signupBonus.test.ts new file mode 100644 index 000000000..4eec946c3 --- /dev/null +++ b/src/backend/controllers/auth/AuthController.signupBonus.test.ts @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Signup bonus codes through the real controller: the check route, the signup + * hand-off to `puter.signup-bonus.validate`, and the awaited + * `user.card-verified` a grant on card verification depends on. Tests stand in + * for the extension with shared listeners (EventClient has no `off()`). + */ + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import type { EventMap } from '../../clients/event/types.js'; +import type { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; + +let server: PuterServer; +let controller: any; + +type Handler = ((data: EventMap[K]) => void) | null; + +let onBonusCheck: Handler<'puter.signup-bonus.check'> = null; +let onBonusValidate: Handler<'puter.signup-bonus.validate'> = null; +let onSignupValidate: ((data: Record) => void) | null = null; +let onCardConfirm: ((data: Record) => void) | null = null; +let onCardVerified: (() => Promise) | null = null; +const heardSignupSuccess: Array> = []; + +beforeAll(async () => { + server = await setupTestServer(); + controller = server.controllers.auth; + const events = server.clients.event; + events.on('puter.signup-bonus.check', (_k, data) => onBonusCheck?.(data)); + events.on('puter.signup-bonus.validate', (_k, data) => + onBonusValidate?.(data), + ); + events.on('puter.signup.validate', (_k, data) => + onSignupValidate?.(data as Record), + ); + events.on('puter.signup.success', (_k, data) => { + heardSignupSuccess.push(data as Record); + }); + events.on('puter.card-verification.confirm', (_k, data) => + onCardConfirm?.(data as Record), + ); + events.on('user.card-verified' as never, async () => { + await onCardVerified?.(); + }); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +afterEach(() => { + onBonusCheck = null; + onBonusValidate = null; + onSignupValidate = null; + onCardConfirm = null; + onCardVerified = null; +}); + +const uniq = () => Math.random().toString(36).slice(2, 10); + +/** A check listener that finds the code open, as an extension would. */ +const openCheck: Handler<'puter.signup-bonus.check'> = (data) => { + data.valid = true; + data.display = { title: 'T', description: 'D' }; +}; + +const makeReq = ( + body: Record = {}, + extra: { actor?: Actor; ip?: string } = {}, +) => ({ + body, + headers: {}, + connection: { remoteAddress: extra.ip ?? '127.0.0.1' }, + socket: { remoteAddress: extra.ip ?? '127.0.0.1' }, + ip: extra.ip ?? '127.0.0.1', + params: {}, + actor: extra.actor, +}); + +const makeRes = () => { + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + json(b: unknown) { + this.body = b; + return this; + }, + cookie() { + return this; + }, + clearCookie() { + return this; + }, + send() { + return this; + }, + end() { + return this; + }, + }; + return res; +}; + +const signupBody = (extra: Record = {}) => { + const username = `b_${uniq()}`; + return { + username, + email: `${username}@test.local`, + password: 'correct-horse-battery', + ...extra, + }; +}; + +const signup = async (body: Record) => { + const res = makeRes(); + await controller.handleSignup(makeReq(body), res); + return res; +}; + +const findUser = (username: unknown) => + server.stores.user.getByUsername(username as string, { force: true }); + +/** `puter.signup.success` is fire-and-forget; wait for it to land. */ +const waitForSuccess = async (userUuid: string) => { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + const hit = heardSignupSuccess.find((e) => e.user_uuid === userUuid); + if (hit) return hit; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error('puter.signup.success never arrived'); +}; + +// -- POST /signup/bonus-code/check ----------------------------------- + +describe('AuthController.handleSignupBonusCheck', () => { + const check = async (body: Record) => { + const res = makeRes(); + await controller.handleSignupBonusCheck(makeReq(body), res); + return res.body; + }; + + it('answers invalid when no extension is listening', async () => { + expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({ + valid: false, + }); + }); + + it('returns the display and requirements a listener fills in', async () => { + let seen: EventMap['puter.signup-bonus.check'] | null = null; + onBonusCheck = (data) => { + seen = { ...data }; + data.valid = true; + data.display = { title: '3 months of Basic', description: 'Free' }; + data.requirements = { phone: true, card: false }; + }; + expect( + await check({ + bonusCode: 'Startup3M-ABCD1234', + fingerprint: 'fp1', + }), + ).toEqual({ + valid: true, + display: { title: '3 months of Basic', description: 'Free' }, + requirements: { phone: true, card: false }, + }); + // Listeners only ever see the canonical form. + expect(seen).toMatchObject({ + code: 'startup3mabcd1234', + fingerprint: 'fp1', + ip: '127.0.0.1', + }); + }); + + it('forwards an opaque reason for a refused code', async () => { + onBonusCheck = (data) => { + data.reason = 'ended'; + }; + expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({ + valid: false, + reason: 'ended', + }); + }); + + it('treats valid without display as invalid', async () => { + onBonusCheck = (data) => { + data.valid = true; + }; + expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({ + valid: false, + }); + }); + + it('answers a malformed code without consulting listeners', async () => { + let called = false; + onBonusCheck = () => { + called = true; + }; + expect(await check({ bonusCode: 'a:b' })).toEqual({ valid: false }); + expect(called).toBe(false); + }); + + it('400s a non-string bonusCode', async () => { + await expect( + controller.handleSignupBonusCheck( + makeReq({ bonusCode: 42 }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + +// -- POST /signup with bonusCode ------------------------------------- + +describe('AuthController.handleSignup with a bonus code', () => { + it('refuses the signup when nothing accepts the code', async () => { + const body = signupBody({ bonusCode: 'startup3m-abcd1234' }); + await expect(signup(body)).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bonus_code_invalid', + }); + expect(await findUser(body.username)).toBeFalsy(); + }); + + it('refuses a dead code before the abuse gate records the attempt', async () => { + let validated = false; + onSignupValidate = () => { + validated = true; + }; + await expect( + signup(signupBody({ bonusCode: 'startup3m-abcd1234' })), + ).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' }); + expect(validated).toBe(false); + }); + + it('still refuses a code that passed the check but is refused after the gate', async () => { + onBonusCheck = openCheck; + const body = signupBody({ bonusCode: 'startup3m-abcd1234' }); + await expect(signup(body)).rejects.toMatchObject({ + legacyCode: 'bonus_code_invalid', + }); + expect(await findUser(body.username)).toBeFalsy(); + }); + + it('refuses a malformed code before any listener runs', async () => { + let called = false; + onBonusValidate = () => { + called = true; + }; + await expect( + signup(signupBody({ bonusCode: 'nope' + '!'.repeat(3) })), + ).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' }); + expect(called).toBe(false); + }); + + it('400s a non-string bonusCode', async () => { + await expect( + signup(signupBody({ bonusCode: ['startup3m'] })), + ).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' }); + }); + + it('applies the requirements an accepted code raises and reports the code', async () => { + onBonusCheck = openCheck; + let seen: EventMap['puter.signup-bonus.validate'] | null = null; + onBonusValidate = (data) => { + seen = { ...data }; + data.accepted = true; + data.requires_card_verification = true; + }; + const body = signupBody({ + bonusCode: 'STARTUP6M-abcd1234', + fingerprint: 'fp-bonus', + }); + const res = await signup(body); + expect( + (res.body as { user: Record }).user, + ).toMatchObject({ requires_card_verification: true }); + + expect(seen).toMatchObject({ + code: 'startup6mabcd1234', + email: body.email, + fingerprint: 'fp-bonus', + requires_phone_verification: false, + requires_card_verification: false, + }); + const user = await findUser(body.username); + expect(Boolean(user!.requires_card_verification)).toBe(true); + expect(Boolean(user!.requires_phone_verification)).toBe(false); + const success = await waitForSuccess(user!.uuid); + expect(success.bonus_code).toBe('startup6mabcd1234'); + }); + + it('keeps a requirement the abuse harness set even if the listener clears it', async () => { + onBonusCheck = openCheck; + onSignupValidate = (data) => { + data.requires_phone_verification = true; + }; + onBonusValidate = (data) => { + expect(data.requires_phone_verification).toBe(true); + data.accepted = true; + data.requires_phone_verification = false; + }; + const body = signupBody({ bonusCode: 'startup3m-abcd1234' }); + await signup(body); + const user = await findUser(body.username); + expect(Boolean(user!.requires_phone_verification)).toBe(true); + }); + + it('never offers the code for a signup the abuse harness blocked', async () => { + onBonusCheck = openCheck; + let called = false; + onSignupValidate = (data) => { + data.allow = false; + data.message = 'Signup blocked'; + }; + onBonusValidate = () => { + called = true; + }; + await expect( + signup(signupBody({ bonusCode: 'startup3m-abcd1234' })), + ).rejects.toMatchObject({ statusCode: 403 }); + expect(called).toBe(false); + }); + + it('leaves the success event without a code when none was presented', async () => { + const body = signupBody(); + await signup(body); + const user = await findUser(body.username); + const success = await waitForSuccess(user!.uuid); + expect(success).not.toHaveProperty('bonus_code'); + }); + + it('ignores a bonus code on a temp signup', async () => { + let called = false; + onBonusValidate = () => { + called = true; + }; + const res = makeRes(); + await controller.handleSignup( + makeReq({ is_temp: true, bonusCode: 'startup3m-abcd1234' }), + res, + ); + expect((res.body as { user: { is_temp: boolean } }).user.is_temp).toBe( + true, + ); + expect(called).toBe(false); + }); +}); + +// -- /card-verification/confirm awaits user.card-verified ------------ + +describe('AuthController.handleCardVerificationConfirm', () => { + it('finishes user.card-verified listeners before responding', async () => { + const body = signupBody(); + await signup(body); + const created = await findUser(body.username); + await server.stores.user.update(created!.id, { + requires_card_verification: 1, + }); + const actor = { + user: { + id: created!.id, + uuid: created!.uuid, + username: created!.username, + email: created!.email ?? null, + email_confirmed: false, + }, + } as Actor; + + onCardConfirm = (data) => { + data.enabled = true; + data.verified = true; + data.fingerprint = 'fp_card_1'; + }; + let listenerDone = false; + onCardVerified = async () => { + await new Promise((r) => setTimeout(r, 30)); + listenerDone = true; + }; + + const res = makeRes(); + await controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + res, + ); + expect(res.body).toMatchObject({ card_verified: true }); + expect(listenerDone).toBe(true); + }); +}); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 32afce2b2..e3e11570d 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -76,6 +76,13 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js'; import { cleanEmail, isBlockedEmail } from '../../util/email.js'; import { generate_identifier } from '../../util/identifier.js'; import { parsePhone } from '../../util/phone.js'; +import { + bonusCodeInvalidError, + checkSignupBonus, + isAbsentBonusCode, + normalizeBonusCode, + validateSignupBonus, +} from '../../util/signupBonus.js'; import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js'; import { getTaskbarItems } from '../../util/taskbarItems.js'; import { @@ -815,6 +822,18 @@ export class AuthController extends PuterController { } } + // Only the shape is judged here; whether the code is honored is up to + // `puter.signup-bonus.validate`, after the abuse checks below. + let bonusCode: string | null = null; + if (!is_temp && !isAbsentBonusCode(body.bonusCode)) { + if (typeof body.bonusCode !== 'string') + throw new HttpError(400, 'bonusCode must be a string.', { + legacyCode: 'bad_request', + }); + bonusCode = normalizeBonusCode(body.bonusCode); + if (!bonusCode) throw bonusCodeInvalidError(); + } + // Signup-disabled gate. Runs before the duplicate checks so a // disabled endpoint doesn't reveal which usernames or emails // exist. Claiming a pre-existing placeholder row is still @@ -888,6 +907,20 @@ export class AuthController extends PuterController { ? null : await this.#resolveSignupEmailClaim(body.email); + // A dead code fails here rather than after the gate below, which records + // an allowed attempt against the address as if an account followed. + if ( + bonusCode && + !( + await checkSignupBonus(this.clients.event, bonusCode, { + ip: clientIp, + fingerprint, + }) + ).valid + ) { + throw bonusCodeInvalidError(); + } + // Extension-level validation gate. Abuse-prevention extensions // inspect the incoming signup and can: // - block it outright via `event.allow = false` @@ -974,18 +1007,37 @@ export class AuthController extends PuterController { const force_email_confirmation = Boolean( validateEvent.requires_email_confirmation, ); - const force_phone_verification = + let force_phone_verification = Boolean(validateEvent.requires_phone_verification) || // Test/QA switch: force the SMS gate on every signup regardless of // reputation (see config.always_require_phone_verification). Boolean(this.config.always_require_phone_verification); - const force_card_verification = Boolean( + let force_card_verification = Boolean( validateEvent.requires_card_verification || // Test/QA switch: force the card gate on every signup regardless of // reputation (see config.always_require_card_verification). this.config.always_require_card_verification, ); + if (bonusCode) { + const verdict = await validateSignupBonus( + this.clients.event, + bonusCode, + { + email: body.email, + clean_email: cleanEmail(body.email), + ip: clientIp, + fingerprint, + reputation: validateEvent.reputation, + requires_phone_verification: force_phone_verification, + requires_card_verification: force_card_verification, + }, + ); + if (!verdict.accepted) throw bonusCodeInvalidError(); + force_phone_verification = verdict.requiresPhoneVerification; + force_card_verification = verdict.requiresCardVerification; + } + // Prepare shared fields const user_uuid = uuidv4(); const email_confirm_code = String(crypto.randomInt(100000, 1000000)); @@ -1206,6 +1258,7 @@ export class AuthController extends PuterController { // have to agree or per-IP counters are written under one // key and read under another. ip: clientIp, + ...(bonusCode ? { bonus_code: bonusCode } : {}), } as never, {}, ); @@ -1227,6 +1280,64 @@ export class AuthController extends PuterController { await this.#completeLogin(req, res, user!); } + /** + * Preview a signup bonus code before the account exists, so the signup form + * can say what it grants. Answers only valid/invalid: an extension owns the + * codes, and with none installed every code is invalid. + */ + @Post('/signup/bonus-code/check', { + rateLimit: [ + { scope: 'signup-bonus-check', limit: 20, window: 15 * 60_000 }, + { + scope: 'signup-bonus-check-ip', + limit: 60, + window: 15 * 60_000, + key: 'ip', + }, + ], + }) + async handleSignupBonusCheck(req: Request, res: Response): Promise { + const raw = req.body?.bonusCode; + if (typeof raw !== 'string') + throw new HttpError(400, 'bonusCode must be a string.', { + legacyCode: 'bad_request', + }); + const code = normalizeBonusCode(raw); + if (!code) { + res.json({ valid: false }); + return; + } + + const fingerprint = + typeof req.body?.fingerprint === 'string' && + req.body.fingerprint.length <= FINGERPRINT_MAX_LENGTH + ? req.body.fingerprint + : null; + const checkEvent = await checkSignupBonus(this.clients.event, code, { + ip: (req.ip || req.socket?.remoteAddress || null) as string | null, + fingerprint, + }); + + if (checkEvent.valid !== true || !checkEvent.display) { + res.json({ + valid: false, + ...(checkEvent.reason ? { reason: checkEvent.reason } : {}), + }); + return; + } + res.json({ + valid: true, + display: { + title: String(checkEvent.display.title), + description: String(checkEvent.display.description), + }, + requirements: { + phone: checkEvent.requirements?.phone === true, + card: checkEvent.requirements?.card === true, + }, + }); + } + // -- Logout ------------------------------------------------------ @Post('/logout', { @@ -2220,8 +2331,11 @@ export class AuthController extends PuterController { ...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}), }); + // Awaited like `user.phone-verified`, so whatever a listener grants on + // verification is in place before the client refreshes. emitAndWait + // swallows listener errors, so confirm never fails on one. try { - this.clients.event?.emit( + await this.clients.event?.emitAndWait( 'user.card-verified' as never, { user_id: user.id, @@ -2234,7 +2348,7 @@ export class AuthController extends PuterController { {}, ); } catch { - // ignore — event is a side-channel signal, not load-bearing + // ignore — listeners are best-effort } // Notify other tabs/devices for this user so they refresh + drop the gate. try { diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index 71944e733..04d9b9e7c 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -58,6 +58,10 @@ let router: PuterRouter; // override in and out (same pattern as AuthController.test.ts). type SignupValidateOverride = (data: Record) => void; let signupValidateOverride: SignupValidateOverride | null = null; +// Stand-in for the extension that honors signup bonus codes. +let bonusValidateOverride: SignupValidateOverride | null = null; +let bonusCheckOverride: SignupValidateOverride | null = null; +const heardSignupSuccess: Array> = []; beforeAll(async () => { server = await setupTestServer({ @@ -90,6 +94,24 @@ beforeAll(async () => { } }, ); + server.clients.event.on( + 'puter.signup-bonus.check', + (_k: unknown, data: unknown) => { + bonusCheckOverride?.(data as Record); + }, + ); + server.clients.event.on( + 'puter.signup-bonus.validate', + (_k: unknown, data: unknown) => { + bonusValidateOverride?.(data as Record); + }, + ); + server.clients.event.on( + 'puter.signup.success', + (_k: unknown, data: unknown) => { + heardSignupSuccess.push(data as Record); + }, + ); }); afterAll(async () => { @@ -99,6 +121,8 @@ afterAll(async () => { afterEach(() => { vi.restoreAllMocks(); signupValidateOverride = null; + bonusValidateOverride = null; + bonusCheckOverride = null; }); interface CapturedResponse { @@ -1965,3 +1989,143 @@ describe('OIDCController rate limits', () => { expect(rateLimitOf('get', '/auth/oidc/providers').limit).toBe(1_200); }); }); + +// -- Signup bonus codes ---------------------------------------------- + +describe('OIDC signup bonus codes', () => { + const rand = () => Math.random().toString(36).slice(2, 8); + + const startState = async (query: Record) => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/:provider/start', + makeReq({ params: { provider: 'custom' }, query }), + res, + ); + const state = new URL(captured.redirectUrl ?? '').searchParams.get( + 'state', + ); + return oidc().verifyState(state!) as Record; + }; + + const stubIdp = (sub: string, email: string) => { + vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({ + access_token: 'access', + id_token: 'id', + } as never); + vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({ + sub, + email, + email_verified: true, + } as never); + }; + + const openCheck = (data: Record) => { + data.valid = true; + data.display = { title: 'T', description: 'D' }; + }; + + const signupCallback = async (bonusCode: string) => { + const state = oidc().signState({ + provider: 'custom', + redirect_uri: TEST_ORIGIN + '/', + bonus_code: bonusCode, + }); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/callback/signup', + makeReq({ query: { code: 'c', state } }), + res, + ); + return captured; + }; + + it('carries a canonical code in the signed state', async () => { + const decoded = await startState({ + flow: 'signup', + bonusCode: 'Startup3M-ABCD1234', + }); + expect(decoded.bonus_code).toBe('startup3mabcd1234'); + }); + + it('drops a malformed code at start', async () => { + const decoded = await startState({ flow: 'signup', bonusCode: 'a:b' }); + expect(decoded).not.toHaveProperty('bonus_code'); + }); + + it('creates the account with the requirements an accepted code raises', async () => { + const email = `bonus-${rand()}@test.local`; + stubIdp(`sub-${rand()}`, email); + bonusCheckOverride = openCheck; + let seen: Record | null = null; + bonusValidateOverride = (data) => { + seen = { ...data }; + data.accepted = true; + data.requires_phone_verification = true; + }; + + const captured = await signupCallback('startup3mabcd1234'); + + expect(captured.cookies).toHaveLength(1); + expect(seen).toMatchObject({ + code: 'startup3mabcd1234', + source: 'oidc', + email, + }); + const user = await server.stores.user.findEmailOwner(email, { + force: true, + }); + expect(Boolean(user!.requires_phone_verification)).toBe(true); + const deadline = Date.now() + 2000; + let success: Record | undefined; + while (!success && Date.now() < deadline) { + success = heardSignupSuccess.find((e) => e.email === email); + if (!success) await new Promise((r) => setTimeout(r, 10)); + } + expect(success?.bonus_code).toBe('startup3mabcd1234'); + }); + + it('refuses a dead code before the validate hook runs', async () => { + const email = `bonus-${rand()}@test.local`; + stubIdp(`sub-${rand()}`, email); + let validated = false; + signupValidateOverride = (data) => { + if (data.email === email) validated = true; + }; + + const captured = await signupCallback('startup3mabcd1234'); + + expect(captured.redirectUrl).toContain('message=bonus_code_invalid'); + expect(validated).toBe(false); + }); + + it('refuses the account when nothing accepts the code, without echoing it', async () => { + const email = `bonus-${rand()}@test.local`; + stubIdp(`sub-${rand()}`, email); + + const captured = await signupCallback('startup3mabcd1234'); + + expect(captured.redirectUrl).toContain('message=bonus_code_invalid'); + expect(captured.redirectUrl).not.toContain('bonusCode='); + expect(captured.cookies).toHaveLength(0); + expect( + await server.stores.user.findEmailOwner(email, { force: true }), + ).toBeFalsy(); + }); + + it('keeps the code on the retry redirect when something else failed', async () => { + const email = `bonus-${rand()}@test.local`; + stubIdp(`sub-${rand()}`, email); + bonusCheckOverride = openCheck; + signupValidateOverride = (data) => { + if (data.email === email) data.allow = false; + }; + + const captured = await signupCallback('startup3mabcd1234'); + + expect(captured.redirectUrl).toContain('message=signup_blocked'); + expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234'); + }); +}); diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index a73f9a3bf..9b1cbd1df 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -23,6 +23,7 @@ import { HttpError } from '../../core/http/HttpError.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { PuterController } from '../types.js'; import { sessionCookieFlags } from '../../util/cookieFlags.js'; +import { normalizeBonusCode } from '../../util/signupBonus.js'; import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js'; import { SHARE_DEEP_LINK_ITEMS_LIMIT, @@ -50,6 +51,7 @@ const ALLOWED_ERRORS = [ 'account_suspended', 'unauthorized', 'signup_blocked', + 'bonus_code_invalid', ] as const; /** @@ -69,6 +71,23 @@ function resolutionErrorCode(code: string | undefined): string { : 'signup_blocked'; } +/** What a new account created by an OIDC callback inherits from its flow. */ +interface OIDCSignupOptions { + referrer?: string | null; + bonusCode?: string | null; +} + +function signupOptionsFromState( + stateDecoded: Record, +): OIDCSignupOptions { + return { + referrer: (stateDecoded.referrer as string) ?? null, + // Normalized when the state was signed; re-checked since this is the + // value signup acts on. + bonusCode: normalizeBonusCode(stateDecoded.bonus_code), + }; +} + // GUI pages an OIDC flow may return to: the root (where a share email lands), // /desktop, /dashboard, and direct app landings (/app/ and its // desktop-booted twin /desktop/app/, mirroring APP_NAME_REGEX in @@ -222,6 +241,13 @@ function buildErrorRedirectUrl( if (requestCode) { params.set('request_code', requestCode); } + // A retry from the error page keeps the bonus code, unless it's what failed. + if ( + typeof stateDecoded?.bonus_code === 'string' && + clamped !== 'bonus_code_invalid' + ) { + params.set('bonusCode', stateDecoded.bonus_code); + } for (const path of sharedPaths) { params.append(SHARE_DEEP_LINK_PARAM, path); } @@ -423,6 +449,15 @@ export class OIDCController extends PuterController { redirect_uri: appRedirectUri, }; if (referrer) statePayload.referrer = referrer ?? openerOrigin; + // Login can create an account too, so both flows carry it. A + // malformed code is dropped here and never reaches signup. + const rawBonusCode = Array.isArray(req.query.bonusCode) + ? req.query.bonusCode[0] + : req.query.bonusCode; + const bonusCode = normalizeBonusCode(rawBonusCode); + if (bonusCode && (flow === 'login' || flow === 'signup')) { + statePayload.bonus_code = bonusCode; + } if (embeddedInPopup && msgId) { statePayload.embedded_in_popup = true; statePayload.msg_id = msgId; @@ -507,7 +542,7 @@ export class OIDCController extends PuterController { const resolved = await this.#resolveOrCreateOIDCUser( provider, userinfo, - (stateDecoded.referrer as string) ?? null, + signupOptionsFromState(stateDecoded), ); if ('error' in resolved) { console.warn( @@ -574,7 +609,7 @@ export class OIDCController extends PuterController { const resolved = await this.#resolveOrCreateOIDCUser( provider, userinfo, - (stateDecoded.referrer as string) ?? null, + signupOptionsFromState(stateDecoded), ); if ('error' in resolved) { console.warn( @@ -742,7 +777,7 @@ if (window.opener) { async #resolveOrCreateOIDCUser( provider: string, userinfo: { sub: string; email?: unknown; [k: string]: unknown }, - referrer?: string | null, + signup: OIDCSignupOptions = {}, attempt = 0, ): Promise< | { error: string; code?: string; requestCode?: string } @@ -792,7 +827,8 @@ if (window.opener) { const outcome = await this.services.oidc.createUserFromOIDC( provider, userinfo as { sub: string; email?: string }, - referrer, + signup.referrer ?? null, + { bonusCode: signup.bonusCode ?? null }, ); // A concurrent callback (a second tab, a provider retry) created the // account between step 2 and the insert. Nothing went wrong for the @@ -802,7 +838,7 @@ if (window.opener) { return this.#resolveOrCreateOIDCUser( provider, userinfo, - referrer, + signup, attempt + 1, ); } diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index 8fbe2d8c1..e7669f0b6 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -24,6 +24,10 @@ import { isOwnedEmailConflict } from '../../stores/user/UserStore.js'; import { PuterService } from '../types'; import { cleanEmail, isBlockedEmail } from '../../util/email.js'; import { generate_identifier } from '../../util/identifier.js'; +import { + checkSignupBonus, + validateSignupBonus, +} from '../../util/signupBonus.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import { Context } from '../../core'; import crypto from 'node:crypto'; @@ -456,11 +460,14 @@ export class OIDCService extends PuterService { * `raced` means a concurrent callback got there first and the caller should * re-resolve rather than surface an error — see * `#resolveOrCreateOIDCUser`. + * + * `bonusCode` must already be canonical (`normalizeBonusCode`). */ async createUserFromOIDC( providerId: string, claims: OIDCUserInfo, referrer?: string | null, + { bonusCode = null }: { bonusCode?: string | null } = {}, ): Promise<{ success: boolean; user?: UserRow; @@ -525,7 +532,7 @@ export class OIDCService extends PuterService { // IdP already authenticated the user, so captcha listeners // (e.g. Turnstile) should skip — abuse/IP/email checks still run. source: 'oidc' as const, - data: { username, email }, + data: { username, email, ...(bonusCode ? { bonusCode } : {}) }, // `req.ip` honors `trust proxy`; reading x-forwarded-for directly // would let a client pick its own per-IP abuse bucket. ip: clientIp, @@ -586,6 +593,24 @@ export class OIDCService extends PuterService { }; } + // Refuse a dead code before the validate hook records the attempt; see + // the same check in AuthController. + if ( + bonusCode && + !( + await checkSignupBonus(this.clients.event, bonusCode, { + ip: clientIp, + fingerprint: null, + }) + ).valid + ) { + return { + success: false, + error: 'This bonus code is invalid or no longer available.', + code: 'bonus_code_invalid', + }; + } + try { await this.clients.event?.emitAndWait( 'puter.signup.validate', @@ -608,13 +633,38 @@ export class OIDCService extends PuterService { always_require_phone_verification?: boolean; always_require_card_verification?: boolean; }; - const force_phone_verification = + let force_phone_verification = Boolean(validateEvent.requires_phone_verification) || Boolean(cfg.always_require_phone_verification); - const force_card_verification = + let force_card_verification = Boolean(validateEvent.requires_card_verification) || Boolean(cfg.always_require_card_verification); + if (bonusCode) { + const verdict = await validateSignupBonus( + this.clients.event, + bonusCode, + { + source: 'oidc', + email, + clean_email: cleanEmail(email), + ip: clientIp, + reputation: validateEvent.reputation, + requires_phone_verification: force_phone_verification, + requires_card_verification: force_card_verification, + }, + ); + if (!verdict.accepted) { + return { + success: false, + error: 'This bonus code is invalid or no longer available.', + code: 'bonus_code_invalid', + }; + } + force_phone_verification = verdict.requiresPhoneVerification; + force_card_verification = verdict.requiresCardVerification; + } + // The caller checked this email was free before we got here, but the // validate hook and the blocklist checks above sit in between — long // enough for a second callback (another tab, a provider retry) to have @@ -765,6 +815,7 @@ export class OIDCService extends PuterService { // have to agree or per-IP counters are written under one // key and read under another. ip: clientIp, + ...(bonusCode ? { bonus_code: bonusCode } : {}), }, {}, ); diff --git a/src/backend/util/signupBonus.test.ts b/src/backend/util/signupBonus.test.ts new file mode 100644 index 000000000..8fe8203bb --- /dev/null +++ b/src/backend/util/signupBonus.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { EventClient } from '../clients/event/EventClient.js'; +import type { EventMap } from '../clients/event/types.js'; +import { + bonusCodeInvalidError, + isAbsentBonusCode, + normalizeBonusCode, + validateSignupBonus, +} from './signupBonus.js'; + +const baseContext = { + email: 'a@test.local', + ip: '203.0.113.1', + requires_phone_verification: false, + requires_card_verification: false, +}; + +const clientWith = ( + listener: (data: EventMap['puter.signup-bonus.validate']) => void, +) => { + const client = new EventClient({} as never); + client.on('puter.signup-bonus.validate', (_k, data) => listener(data)); + return client; +}; + +describe('normalizeBonusCode', () => { + it('lowercases and drops separators', () => { + expect(normalizeBonusCode('Startup3M-7KQ2_9XPA')).toBe( + 'startup3m7kq29xpa', + ); + expect(normalizeBonusCode(' abcd 1234 ')).toBe('abcd1234'); + }); + + it('rejects anything that cannot be a code', () => { + for (const bad of [ + undefined, + null, + 42, + {}, + '', + 'abc', + 'a'.repeat(65), + 'x'.repeat(129), + 'abcd.1234', + 'abcd:1234', + 'ábcd1234', + ]) { + expect(normalizeBonusCode(bad)).toBeNull(); + } + }); +}); + +describe('isAbsentBonusCode', () => { + it('treats undefined, null and empty string as absent', () => { + expect(isAbsentBonusCode(undefined)).toBe(true); + expect(isAbsentBonusCode(null)).toBe(true); + expect(isAbsentBonusCode('')).toBe(true); + expect(isAbsentBonusCode('abcd')).toBe(false); + expect(isAbsentBonusCode(0)).toBe(false); + }); +}); + +describe('bonusCodeInvalidError', () => { + it('is a 400 with the stable code', () => { + const err = bonusCodeInvalidError(); + expect(err.statusCode).toBe(400); + expect(err.legacyCode).toBe('bonus_code_invalid'); + }); +}); + +describe('validateSignupBonus', () => { + it('refuses when nothing is listening', async () => { + expect( + await validateSignupBonus(undefined, 'abcd1234', baseContext), + ).toEqual({ accepted: false }); + }); + + it('refuses when the listener leaves the code unaccepted', async () => { + const client = clientWith(() => {}); + expect( + await validateSignupBonus(client, 'abcd1234', baseContext), + ).toEqual({ accepted: false }); + }); + + it('hands the listener the code and the current requirements', async () => { + let seen: EventMap['puter.signup-bonus.validate'] | null = null; + const client = clientWith((data) => { + seen = { ...data }; + }); + await validateSignupBonus(client, 'abcd1234', { + ...baseContext, + requires_card_verification: true, + }); + expect(seen).toMatchObject({ + code: 'abcd1234', + email: 'a@test.local', + requires_phone_verification: false, + requires_card_verification: true, + accepted: false, + }); + }); + + it('returns the requirements a listener raises', async () => { + const client = clientWith((data) => { + data.accepted = true; + data.requires_phone_verification = true; + }); + expect( + await validateSignupBonus(client, 'abcd1234', baseContext), + ).toEqual({ + accepted: true, + requiresPhoneVerification: true, + requiresCardVerification: false, + }); + }); + + it('never lets a listener lower a requirement', async () => { + const client = clientWith((data) => { + data.accepted = true; + data.requires_phone_verification = false; + data.requires_card_verification = false; + }); + expect( + await validateSignupBonus(client, 'abcd1234', { + ...baseContext, + requires_phone_verification: true, + requires_card_verification: true, + }), + ).toEqual({ + accepted: true, + requiresPhoneVerification: true, + requiresCardVerification: true, + }); + }); + + it('treats a throwing listener as not accepting', async () => { + const client = clientWith(() => { + throw new Error('boom'); + }); + expect( + await validateSignupBonus(client, 'abcd1234', baseContext), + ).toEqual({ accepted: false }); + }); +}); diff --git a/src/backend/util/signupBonus.ts b/src/backend/util/signupBonus.ts new file mode 100644 index 000000000..0ddb4863c --- /dev/null +++ b/src/backend/util/signupBonus.ts @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Signup bonus codes: the shape a code may take, and the hand-off to whatever + * extension decides what a code is worth. Core holds no codes and grants + * nothing. + * + * Codes have one canonical form (lower case, separators dropped) so a code + * typed as `ABC-123` and linked as `abc123` is one code to every counter keyed + * on it. + */ +import type { EventClient } from '../clients/event/EventClient'; +import type { EventMap } from '../clients/event/types'; +import { HttpError } from '../core/http/HttpError.js'; + +const CANONICAL_BONUS_CODE = /^[a-z0-9]{4,64}$/; +/** Raw input is capped before normalizing so a huge string is never scanned. */ +const RAW_BONUS_CODE_MAX_LENGTH = 128; + +/** The canonical form of `raw`, or null when it can't be a bonus code. */ +export function normalizeBonusCode(raw: unknown): string | null { + if (typeof raw !== 'string' || raw.length > RAW_BONUS_CODE_MAX_LENGTH) { + return null; + } + const canonical = raw.toLowerCase().replace(/[\s_-]/g, ''); + return CANONICAL_BONUS_CODE.test(canonical) ? canonical : null; +} + +/** Whether a request body field counts as "no bonus code presented". */ +export const isAbsentBonusCode = (raw: unknown): boolean => + raw === undefined || raw === null || raw === ''; + +/** + * One answer for every way a code can fail — unknown, expired, used up — so + * signup can't be used to tell them apart. + */ +export const bonusCodeInvalidError = (): HttpError => + new HttpError(400, 'This bonus code is invalid or no longer available.', { + legacyCode: 'bonus_code_invalid', + }); + +/** + * Ask listeners what a canonical code grants right now — the preview the signup + * form shows. Signup asks it too, before its abuse checks, so a dead code is + * refused before those checks record the attempt against the address. + */ +export async function checkSignupBonus( + events: EventClient | undefined, + code: string, + { ip, fingerprint }: { ip: string | null; fingerprint: string | null }, +): Promise { + const event: EventMap['puter.signup-bonus.check'] = { + code, + ip, + fingerprint, + valid: false, + reason: null, + display: null, + requirements: null, + }; + try { + await events?.emitAndWait('puter.signup-bonus.check', event, {}); + } catch (e) { + console.warn('[signup] bonus check hook failed:', e); + } + return event; +} + +export type SignupBonusContext = Omit< + EventMap['puter.signup-bonus.validate'], + 'code' | 'accepted' +>; + +export type SignupBonusVerdict = + | { accepted: false } + | { + accepted: true; + requiresPhoneVerification: boolean; + requiresCardVerification: boolean; + }; + +/** + * Offer a canonical bonus code to listeners for a signup that already passed + * `puter.signup.validate`. With no listener installed nothing accepts, so a + * code is refused rather than silently ignored. + */ +export async function validateSignupBonus( + events: EventClient | undefined, + code: string, + context: SignupBonusContext, +): Promise { + const event: EventMap['puter.signup-bonus.validate'] = { + ...context, + code, + accepted: false, + }; + try { + await events?.emitAndWait('puter.signup-bonus.validate', event, {}); + } catch (e) { + console.warn('[signup] bonus validate hook failed:', e); + } + if (event.accepted !== true) return { accepted: false }; + return { + accepted: true, + requiresPhoneVerification: + context.requires_phone_verification || + event.requires_phone_verification === true, + requiresCardVerification: + context.requires_card_verification || + event.requires_card_verification === true, + }; +} diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 5e912e262..3a3e0e95b 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -440,6 +440,10 @@ async function UIWindowLogin (options) { if ( referrer ) { url += `&referrer=${encodeURIComponent(referrer)}`; } + // A provider login can create the account, so a signup link's code rides along. + if ( window.signup_bonus_code ) { + url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`; + } if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) { url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`; if ( window.openerOrigin ) { diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 5ec4316dc..2ab4c0115 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -27,6 +27,30 @@ import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderI import { offersFederatedSignInInPopup } from '../util/popupAuth.js'; import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js'; import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js'; +import { bonusRequirementsKey, checkSignupBonusCode } from '../helpers/signupBonusCode.js'; + +// What a checked bonus code grants, above the form (and the provider buttons). +function renderBonusNotice(el_window, result) { + const $notice = $(el_window).find('.signup-bonus-notice'); + if (!result) { + $notice.hide().empty(); + return; + } + if (!result.valid) { + $notice + .addClass('signup-bonus-notice-invalid') + .html(`

${i18n('signup_bonus_code_invalid_retry')}

`) + .show(); + return; + } + const requirementsKey = bonusRequirementsKey(result.requirements); + let h = `${html_encode(result.display.title)}`; + h += `

${html_encode(result.display.description)}

`; + if (requirementsKey) { + h += ``; + } + $notice.removeClass('signup-bonus-notice-invalid').html(h).show(); +} function UIWindowSignup(options) { options = options ?? {}; @@ -70,6 +94,7 @@ function UIWindowSignup(options) { if (window.embedded_in_popup && window.openerOrigin) { h += `

${i18n('popup_opener_uses_puter', [new URL(window.openerOrigin).hostname])}

`; } + h += ''; // signup form h += '