diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..9f4eb27406 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "packages/private"] + path = packages/private + url = https://github.com/stack-auth/private.git + branch = main diff --git a/apps/backend/.env b/apps/backend/.env index 282d841923..1cb85405db 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -41,7 +41,7 @@ STACK_EMAIL_PORT=# for local inbucket: 8129 STACK_EMAIL_USERNAME=# for local inbucket: test STACK_EMAIL_PASSWORD=# for local inbucket: none STACK_EMAIL_SENDER=# for local inbucket: noreply@test.com -STACK_EMAILABLE_API_KEY=# for Emailable email validation, see https://emailable.com +STACK_EMAILABLE_API_KEY=# Emailable API key for email validation, see https://emailable.com. Use a test key (starting with "test_") for local dev — it does not consume credits. Set to "disable_email_validation" to disable. STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR=# the number of emails a new project can send. Defaults to 200 diff --git a/apps/backend/.env.development b/apps/backend/.env.development index aa7c0e9586..02fcc18611 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -17,6 +17,19 @@ STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=this-secret-server-key-is-for-loca STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only STACK_OAUTH_MOCK_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}14 +STACK_TURNSTILE_SITEVERIFY_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}14/turnstile/siteverify + +# Cloudflare Turnstile test keys — always-pass widgets, no real challenges +# See https://developers.cloudflare.com/turnstile/troubleshooting/testing/ +NEXT_PUBLIC_STACK_BOT_CHALLENGE_SITE_KEY=1x00000000000000000000AA +NEXT_PUBLIC_STACK_BOT_CHALLENGE_INVISIBLE_SITE_KEY=1x00000000000000000000BB +STACK_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA +# Set to true to disable Turnstile entirely in local development. +# This skips invisible/visible bot challenge flow and removes the Turnstile risk penalty. +STACK_DISABLE_BOT_CHALLENGE=false +# Default behavior is to block sign-up if the visible challenge cannot be completed. +# Flip this only when you intentionally want local sign-up to continue during Turnstile outages. +STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE=false STACK_GITHUB_CLIENT_ID=MOCK STACK_GITHUB_CLIENT_SECRET=MOCK @@ -47,6 +60,10 @@ STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR=10000 STACK_SVIX_SERVER_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}13 STACK_SVIX_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk +# Trusted reverse proxy for reading real client IP addresses. +# Set to "vercel", "cloudflare", or leave empty/unset for no proxy trust. +STACK_TRUSTED_PROXY= + STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=500 STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING=yes @@ -69,6 +86,8 @@ STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_P STACK_EMAIL_MONITOR_USE_INBUCKET=true STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only +STACK_EMAILABLE_API_KEY= + # S3 Configuration for local development using s3mock STACK_S3_ENDPOINT=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21 STACK_S3_REGION=us-east-1 diff --git a/apps/backend/package.json b/apps/backend/package.json index 581e12531a..1f47a3bf41 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -91,6 +91,7 @@ "chokidar-cli": "^3.0.0", "dotenv": "^16.4.5", "dotenv-cli": "^7.3.0", + "emailable": "^3.1.1", "freestyle-sandboxes": "^0.1.6", "jiti": "^2.6.1", "jose": "^6.1.3", diff --git a/apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql b/apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql new file mode 100644 index 0000000000..d3dc4524e6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql @@ -0,0 +1,19 @@ +ALTER TABLE "ProjectUser" ADD COLUMN "signUpRiskScoreBot" SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE "ProjectUser" ADD COLUMN "signUpRiskScoreFreeTrialAbuse" SMALLINT NOT NULL DEFAULT 0; + +ALTER TABLE "ProjectUser" + ADD CONSTRAINT "ProjectUser_risk_score_bot_range" + CHECK ("signUpRiskScoreBot" >= 0 AND "signUpRiskScoreBot" <= 100) NOT VALID; + +ALTER TABLE "ProjectUser" + ADD CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range" + CHECK ("signUpRiskScoreFreeTrialAbuse" >= 0 AND "signUpRiskScoreFreeTrialAbuse" <= 100) NOT VALID; + +ALTER TABLE "ProjectUser" ADD COLUMN "signUpCountryCode" TEXT; + +ALTER TABLE "ProjectUser" + ADD COLUMN "signedUpAt" TIMESTAMP(3), + ADD COLUMN "signUpIp" TEXT, + ADD COLUMN "signUpIpTrusted" BOOLEAN, + ADD COLUMN "signUpEmailNormalized" TEXT, + ADD COLUMN "signUpEmailBase" TEXT; diff --git a/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/migration.sql b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/migration.sql new file mode 100644 index 0000000000..a8f686913a --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/migration.sql @@ -0,0 +1,16 @@ +-- SINGLE_STATEMENT_SENTINEL +-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL +WITH to_update AS ( + SELECT "projectUserId", "tenancyId" + FROM "ProjectUser" + WHERE "signedUpAt" IS NULL + LIMIT 10000 +), +updated AS ( + UPDATE "ProjectUser" pu + SET "signedUpAt" = pu."createdAt" + FROM to_update tu + WHERE pu."tenancyId" = tu."tenancyId" AND pu."projectUserId" = tu."projectUserId" + RETURNING 1 +) +SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated; diff --git a/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/tests/backfill-and-defaults.ts b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/tests/backfill-and-defaults.ts new file mode 100644 index 0000000000..3375878a85 --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000001_backfill_signup_fraud_protection/tests/backfill-and-defaults.ts @@ -0,0 +1,32 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + const regularUserId = randomUUID(); + const anonUserId = randomUUID(); + + await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`; + await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`; + await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${regularUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`; + await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "isAnonymous") VALUES (${anonUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW(), true)`; + + return { regularUserId, anonUserId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + for (const userId of [ctx.regularUserId, ctx.anonUserId]) { + const rows = await sql` + SELECT "signedUpAt", "createdAt", "signUpRiskScoreBot", "signUpRiskScoreFreeTrialAbuse" + FROM "ProjectUser" + WHERE "projectUserId" = ${userId}::uuid + `; + + expect(rows).toHaveLength(1); + expect(rows[0].signedUpAt.toISOString()).toBe(rows[0].createdAt.toISOString()); + expect(rows[0].signUpRiskScoreBot).toBe(0); + expect(rows[0].signUpRiskScoreFreeTrialAbuse).toBe(0); + } +}; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql new file mode 100644 index 0000000000..e5103d26ab --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql @@ -0,0 +1,65 @@ +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_signedUpAt_asc" + ON "ProjectUser"("tenancyId", "isAnonymous", "signedUpAt" ASC); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_signUpIp_recent_idx" + ON "ProjectUser"("tenancyId", "isAnonymous", "signUpIp", "signedUpAt"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_signUpEmailBase_recent_idx" + ON "ProjectUser"("tenancyId", "isAnonymous", "signUpEmailBase", "signedUpAt"); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_bot_range"; + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range"; + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE OR REPLACE FUNCTION "set_project_user_signed_up_at_from_created_at"() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW."signedUpAt" IS NULL THEN + NEW."signedUpAt" := NEW."createdAt"; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE TRIGGER "ProjectUser_set_signedUpAt_from_createdAt" +BEFORE INSERT ON "ProjectUser" +FOR EACH ROW +EXECUTE FUNCTION "set_project_user_signed_up_at_from_created_at"(); + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +ALTER TABLE "ProjectUser" + ADD CONSTRAINT "ProjectUser_signedUpAt_not_null" + CHECK ("signedUpAt" IS NOT NULL) NOT VALID; + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_signedUpAt_not_null"; + +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +ALTER TABLE "ProjectUser" ALTER COLUMN "signedUpAt" SET NOT NULL; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/constraints-validated.ts b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/constraints-validated.ts new file mode 100644 index 0000000000..872233dc39 --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/constraints-validated.ts @@ -0,0 +1,39 @@ +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +export const postMigration = async (sql: Sql) => { + const triggers = await sql` + SELECT tgname + FROM pg_trigger + WHERE tgrelid = '"ProjectUser"'::regclass + AND tgname = 'ProjectUser_set_signedUpAt_from_createdAt' + AND NOT tgisinternal + `; + expect(triggers).toHaveLength(1); + + const constraints = await sql` + SELECT conname, convalidated + FROM pg_constraint + WHERE conrelid = '"ProjectUser"'::regclass + AND conname IN ( + 'ProjectUser_risk_score_bot_range', + 'ProjectUser_risk_score_free_trial_abuse_range', + 'ProjectUser_signedUpAt_not_null' + ) + ORDER BY conname + `; + + expect(constraints).toHaveLength(3); + for (const c of constraints) { + expect(c.convalidated, `${c.conname} should be validated`).toBe(true); + } + + const colInfo = await sql` + SELECT is_nullable, column_default + FROM information_schema.columns + WHERE table_name = 'ProjectUser' AND column_name = 'signedUpAt' + `; + expect(colInfo).toHaveLength(1); + expect(colInfo[0].is_nullable).toBe('NO'); + expect(colInfo[0].column_default).toBe(null); +}; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/default-on-insert.ts b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/default-on-insert.ts new file mode 100644 index 0000000000..799fef2642 --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/default-on-insert.ts @@ -0,0 +1,23 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +export const postMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + const userId = randomUUID(); + + await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`; + await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`; + await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${userId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`; + + const rows = await sql` + SELECT "signedUpAt", "createdAt" + FROM "ProjectUser" + WHERE "projectUserId" = ${userId}::uuid + `; + + expect(rows).toHaveLength(1); + expect(rows[0].signedUpAt).not.toBeNull(); + expect(rows[0].signedUpAt.toISOString()).toBe(rows[0].createdAt.toISOString()); +}; diff --git a/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/indexes-exist.ts b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/indexes-exist.ts new file mode 100644 index 0000000000..79ff1c003e --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/indexes-exist.ts @@ -0,0 +1,29 @@ +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +export const postMigration = async (sql: Sql) => { + const indexes = await sql` + SELECT indexname, indexdef + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'ProjectUser' + AND indexname IN ( + 'ProjectUser_signedUpAt_asc', + 'ProjectUser_signUpIp_recent_idx', + 'ProjectUser_signUpEmailBase_recent_idx' + ) + ORDER BY indexname + `; + + expect(indexes.map((row) => row.indexname)).toEqual([ + 'ProjectUser_signUpEmailBase_recent_idx', + 'ProjectUser_signUpIp_recent_idx', + 'ProjectUser_signedUpAt_asc', + ]); + + const indexDefByName = Object.fromEntries(indexes.map((row) => [row.indexname, row.indexdef])); + + expect(indexDefByName['ProjectUser_signedUpAt_asc']).toContain('"tenancyId", "isAnonymous", "signedUpAt"'); + expect(indexDefByName['ProjectUser_signUpIp_recent_idx']).toContain('"tenancyId", "isAnonymous", "signUpIp", "signedUpAt"'); + expect(indexDefByName['ProjectUser_signUpEmailBase_recent_idx']).toContain('"tenancyId", "isAnonymous", "signUpEmailBase", "signedUpAt"'); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0d408ff0ba..ef7b4b6ae9 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -268,12 +268,24 @@ model ProjectUser { requiresTotpMfa Boolean @default(false) totpSecret Bytes? isAnonymous Boolean @default(false) + signUpCountryCode String? // Admin restriction fields - can be set by signup rules or manually by admins restrictedByAdmin Boolean @default(false) restrictedByAdminReason String? // Publicly viewable reason (shown to user) restrictedByAdminPrivateDetails String? // Private details (server access only) + // Sign-up metadata + signedUpAt DateTime + signUpIp String? + signUpIpTrusted Boolean? + signUpEmailNormalized String? + signUpEmailBase String? + + // Sign-up risk scores (0-100, set at sign-up time) + signUpRiskScoreBot Int @default(0) @db.SmallInt + signUpRiskScoreFreeTrialAbuse Int @default(0) @db.SmallInt + projectUserOAuthAccounts ProjectUserOAuthAccount[] teamMembers TeamMember[] contactChannels ContactChannel[] @@ -298,6 +310,9 @@ model ProjectUser { @@index([tenancyId, displayName(sort: Desc)], name: "ProjectUser_displayName_desc") @@index([tenancyId, createdAt(sort: Asc)], name: "ProjectUser_createdAt_asc") @@index([tenancyId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc") + @@index([tenancyId, isAnonymous, signedUpAt(sort: Asc)], name: "ProjectUser_signedUpAt_asc") + @@index([tenancyId, isAnonymous, signUpIp, signedUpAt], name: "ProjectUser_signUpIp_recent_idx") + @@index([tenancyId, isAnonymous, signUpEmailBase, signedUpAt], name: "ProjectUser_signUpEmailBase_recent_idx") @@index([tenancyId, sequenceId], name: "ProjectUser_tenancyId_sequenceId_idx") @@index([shouldUpdateSequenceId, tenancyId], name: "ProjectUser_shouldUpdateSequenceId_idx") } diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index fd5573cc2f..8dd8a33b52 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -14,7 +14,7 @@ import { import { ensurePermissionDefinition, grantTeamPermission } from '@/lib/permissions'; import { createOrUpdateProjectWithLegacyConfig, getProject } from '@/lib/projects'; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from '@/lib/tenancies'; -import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction } from '@/prisma-client'; +import { PrismaClientTransaction, getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config'; import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails'; import { AdminUserProjectsCrud, ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects'; @@ -318,6 +318,9 @@ export async function seed() { tenancyId: internalTenancy.id, mirroredProjectId: 'internal', mirroredBranchId: DEFAULT_BRANCH_ID, + signedUpAt: new Date(), + signUpRiskScoreBot: 0, + signUpRiskScoreFreeTrialAbuse: 0, } }); @@ -447,6 +450,9 @@ export async function seed() { tenancyId: internalTenancy.id, mirroredProjectId: 'internal', mirroredBranchId: DEFAULT_BRANCH_ID, + signedUpAt: new Date(), + signUpRiskScoreBot: 0, + signUpRiskScoreFreeTrialAbuse: 0, } }); diff --git a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx index 460477d26f..ef2c23292e 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx @@ -1,17 +1,19 @@ import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys"; import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { decodeAccessToken, oauthCookieSchema } from "@/lib/tokens"; +import { getRequestContextAndBotChallengeAssessment, botChallengeFlowRequestSchemaFields } from "@/lib/turnstile"; import { getProjectBranchFromClientId, getProvider } from "@/oauth"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { urlSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { urlSchema, yupArray, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { generators } from "openid-client"; -import * as yup from "yup"; +import type { InferType, Schema } from "yup"; const outerOAuthFlowExpirationInMinutes = 10; @@ -36,6 +38,8 @@ export const GET = createSmartRouteHandler({ error_redirect_url: urlSchema.optional().meta({ openapiField: { hidden: true } }), error_redirect_uri: urlSchema.optional(), after_callback_redirect_url: yupString().optional(), + stack_response_mode: yupString().oneOf(["json", "redirect"]).default("redirect"), + ...botChallengeFlowRequestSchemaFields, // oauth parameters client_id: yupString().defined(), @@ -49,11 +53,25 @@ export const GET = createSmartRouteHandler({ response_type: yupString().defined(), }).noUnknown(/* Allow unknown query params such as ttclid, other stuff that's being injected by browsers */ false).defined(), }), - response: yupObject({ - // we never return as we always redirect - statusCode: yupNumber().oneOf([302]).defined(), - bodyType: yupString().oneOf(["empty"]).defined(), - }), + response: yupUnion( + yupObject({ + // The SDK uses stack_response_mode=json so it can intercept bot challenges before navigating. + // The redirect path (default) is the legacy browser-direct flow. + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + location: yupString().defined(), + }).defined(), + }).defined(), + yupObject({ + statusCode: yupNumber().oneOf([307]).defined(), + headers: yupObject({ + location: yupArray(yupString().defined()).defined(), + }).defined(), + bodyType: yupString().oneOf(["text"]).defined(), + body: yupString().defined(), + }).defined(), + ) as unknown as Schema, async handler({ params, query }, fullReq) { const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(query.client_id), true); if (!tenancy) { @@ -76,6 +94,8 @@ export const GET = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, "?token= query parameter is required for link type"); } + const { turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(query, "oauth_authenticate", tenancy); + // If a token is provided, store it in the outer info so we can use it to link another user to the account, or to upgrade an anonymous user let projectUserId: string | undefined; if (query.token) { @@ -126,13 +146,28 @@ export const GET = createSmartRouteHandler({ providerScope: query.provider_scope, errorRedirectUrl: query.error_redirect_uri || query.error_redirect_url, afterCallbackRedirectUrl: query.after_callback_redirect_url, - } satisfies yup.InferType, + turnstileResult: turnstileAssessment.status, + turnstileVisibleChallengeResult: turnstileAssessment.visibleChallengeResult, + responseMode: query.stack_response_mode, + } satisfies InferType, expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes), }, }); - // prevent CSRF by keeping track of the inner state in cookies - // the callback route must ensure that the inner state cookie is set + if (query.stack_response_mode === "json") { + // In JSON mode the client controls the flow programmatically and PKCE + // already prevents CSRF, so we skip the cookie (which would require + // credentials: "include" and a non-wildcard CORS origin). + return { + statusCode: 200, + bodyType: "json", + body: { + location: oauthUrl, + }, + }; + } + + // For browser-redirect mode, set a CSRF cookie that the callback route checks. (await cookies()).set( "stack-oauth-inner-" + innerState, "true", diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index fbac9c757e..baad5c019c 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -1,4 +1,6 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import { getBestEffortEndUserRequestContext } from "@/lib/end-users"; +import { buildSignUpRuleOptions, reconstructTurnstileAssessment } from "@/lib/sign-up-context"; import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys"; import { createOAuthUserAndAccount, findExistingOAuthAccount, handleOAuthEmailMergeStrategy, linkOAuthAccountToUser } from "@/lib/oauth"; import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; @@ -24,7 +26,7 @@ async function createProjectUserOAuthAccountForLink(prisma: PrismaClientTransact tenancyId: string, providerId: string, providerAccountId: string, - email?: string | null, + email: string | null, projectUserId: string, }) { return await prisma.projectUserOAuthAccount.create({ @@ -75,12 +77,6 @@ const handler = createSmartRouteHandler({ }), async handler({ params, query, body }, fullReq) { const innerState = query.state ?? (body as any)?.state ?? ""; - const cookieInfo = (await cookies()).get("stack-oauth-inner-" + innerState); - (await cookies()).delete("stack-oauth-inner-" + innerState); - - if (cookieInfo?.value !== 'true') { - throw new StatusError(StatusError.BadRequest, "Inner OAuth cookie not found. This is likely because you refreshed the page during the OAuth sign in process. Please try signing in again"); - } const outerInfoDB = await globalPrismaClient.oAuthOuterInfo.findUnique({ where: { @@ -89,7 +85,7 @@ const handler = createSmartRouteHandler({ }); if (!outerInfoDB) { - throw new StatusError(StatusError.BadRequest, "Invalid OAuth cookie. Please try signing in again."); + throw new StatusError(StatusError.BadRequest, "Invalid OAuth state. Please try signing in again."); } let outerInfo: Awaited>; @@ -99,6 +95,17 @@ const handler = createSmartRouteHandler({ throw new StackAssertionError("Invalid outer info"); } + // JSON-mode requests use PKCE for CSRF protection and don't set a cookie. + // Only check the CSRF cookie for browser-redirect mode requests. + if (outerInfo.responseMode !== 'json') { + const cookieInfo = (await cookies()).get("stack-oauth-inner-" + innerState); + (await cookies()).delete("stack-oauth-inner-" + innerState); + + if (cookieInfo?.value !== 'true') { + throw new StatusError(StatusError.BadRequest, "Inner OAuth cookie not found. This is likely because you refreshed the page during the OAuth sign in process. Please try signing in again"); + } + } + const { tenancyId, innerCodeVerifier, @@ -248,7 +255,7 @@ const handler = createSmartRouteHandler({ tenancyId: outerInfo.tenancyId, providerId: provider.id, providerAccountId: userInfo.accountId, - email: userInfo.email, + email: userInfo.email ?? null, projectUserId, }); @@ -288,7 +295,7 @@ const handler = createSmartRouteHandler({ tenancyId: outerInfo.tenancyId, providerId: provider.id, providerAccountId: userInfo.accountId, - email: userInfo.email ?? undefined, + email: userInfo.email ?? null, projectUserId: linkedUserId, }); @@ -321,24 +328,28 @@ const handler = createSmartRouteHandler({ } } + const requestContext = await getBestEffortEndUserRequestContext(); const { projectUserId: newUserId, oauthAccountId } = await createOAuthUserAndAccount( prisma, tenancy, { providerId: provider.id, providerAccountId: userInfo.accountId, - email: userInfo.email ?? undefined, + email: userInfo.email ?? null, emailVerified: userInfo.emailVerified, primaryEmailAuthEnabled, currentUser, - displayName: userInfo.displayName ?? undefined, - profileImageUrl: userInfo.profileImageUrl ?? undefined, - signUpRuleOptions: { + displayName: userInfo.displayName ?? null, + profileImageUrl: userInfo.profileImageUrl ?? null, + signUpRuleOptions: buildSignUpRuleOptions({ authMethod: 'oauth', oauthProvider: provider.id, - // Note: Request context not easily available in OAuth callback - // TODO: Pass IP and user agent from stored OAuth state if needed - }, + requestContext, + turnstileAssessment: reconstructTurnstileAssessment( + outerInfo.turnstileResult ?? "invalid", + outerInfo.turnstileVisibleChallengeResult, + ), + }), } ); diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index a4d1071349..b85a9378b8 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -1,4 +1,7 @@ import { createOAuthUserAndAccount, findExistingOAuthAccount, getProjectUserIdFromOAuthAccount, handleOAuthEmailMergeStrategy, linkOAuthAccountToUser } from "@/lib/oauth"; +import { getBestEffortEndUserRequestContext } from "@/lib/end-users"; +import { buildSignUpRuleOptions } from "@/lib/sign-up-context"; +import { getDisabledBotChallengeAssessment, isBotChallengeDisabled } from "@/lib/turnstile"; import { createAuthTokens } from "@/lib/tokens"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -18,7 +21,7 @@ const appleJWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/key */ async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): Promise<{ sub: string, - email?: string, + email: string | null, emailVerified: boolean, }> { try { @@ -29,7 +32,7 @@ async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): return { sub: payload.sub ?? throwErr("No sub claim in Apple ID token"), - email: typeof payload.email === "string" ? payload.email : undefined, + email: typeof payload.email === "string" ? payload.email : null, emailVerified: payload.email_verified === true || payload.email_verified === "true", }; } catch (error) { @@ -119,17 +122,26 @@ export const POST = createSmartRouteHandler({ projectUserId = linkedUserId; } else { // ========================== Create new user ========================== + const requestContext = await getBestEffortEndUserRequestContext(); const result = await createOAuthUserAndAccount(prisma, tenancy, { providerId: "apple", providerAccountId: appleUser.sub, email: appleUser.email, emailVerified: appleUser.emailVerified, primaryEmailAuthEnabled, - signUpRuleOptions: { + currentUser: null, + displayName: null, + profileImageUrl: null, + signUpRuleOptions: buildSignUpRuleOptions({ authMethod: 'oauth', oauthProvider: 'apple', - // Note: Request context not easily available in native OAuth callback - }, + requestContext, + turnstileAssessment: isBotChallengeDisabled() + ? getDisabledBotChallengeAssessment() + : { status: "invalid" }, + // Apple native OAuth doesn't pass a turnstile token because the + // authentication happens in the native Apple sign-in flow outside our control + }), }); projectUserId = result.projectUserId; isNewUser = true; diff --git a/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx b/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx index e43c905e87..2a705014ee 100644 --- a/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx +++ b/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx @@ -1,3 +1,5 @@ +import { getRequestContextAndBotChallengeAssessment, botChallengeFlowRequestSchemaFields } from "@/lib/turnstile"; +import { serializeStoredSignUpRequestContext } from "@/lib/sign-up-context"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, clientOrHigherAuthTypeSchema, emailOtpSignInCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -17,6 +19,7 @@ export const POST = createSmartRouteHandler({ body: yupObject({ email: signInEmailSchema.defined(), callback_url: emailOtpSignInCallbackUrlSchema.defined(), + ...botChallengeFlowRequestSchemaFields, }).defined(), clientVersion: yupObject({ version: yupString().optional(), @@ -30,19 +33,25 @@ export const POST = createSmartRouteHandler({ nonce: yupString().defined().meta({ openapiField: { description: "A token that must be stored temporarily and provided when verifying the 6-digit code", exampleValue: "u3h6gn4w24pqc8ya679inrhjwh1rybth6a7thurqhnpf2" } }), }).defined(), }), - async handler({ auth: { tenancy }, body: { email, callback_url: callbackUrl }, clientVersion }, fullReq) { + async handler({ auth: { tenancy }, body: { email, callback_url: callbackUrl, ...botChallenge }, clientVersion }) { if (!tenancy.config.auth.otp.allowSignIn) { throw new StatusError(StatusError.Forbidden, "OTP sign-in is not enabled for this project"); } await ensureUserForEmailAllowsOtp(tenancy, email); + const { requestContext, turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(botChallenge, "send_magic_link_email", tenancy); + const { nonce } = await signInVerificationCodeHandler.sendCode( { tenancy, callbackUrl, method: { email }, - data: {}, + data: { + turnstile_result: turnstileAssessment.status, + turnstile_visible_challenge_result: turnstileAssessment.visibleChallengeResult, + ...serializeStoredSignUpRequestContext(requestContext), + }, }, { email } ); diff --git a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx index f16ada136c..6aecd5a268 100644 --- a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx @@ -1,12 +1,14 @@ +import { VerificationCodeType } from "@/generated/prisma/client"; import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel"; import { sendEmailFromDefaultTemplate } from "@/lib/emails"; import { getSoleTenancyFromProjectBranch, Tenancy } from "@/lib/tenancies"; import { createAuthTokens } from "@/lib/tokens"; +import { buildSignUpRuleOptions, deserializeStoredSignUpRequestContext, deserializeStoredTurnstileAssessment, storedSignUpRequestContextSchemaFields } from "@/lib/sign-up-context"; import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; -import { VerificationCodeType } from "@/generated/prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; +import { turnstileResultValues } from "@stackframe/stack-shared/dist/utils/turnstile"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { emailSchema, signInResponseSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { usersCrudHandlers } from "../../../users/crud"; @@ -73,7 +75,11 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ codeDescription: `A 45-character verification code. For magic links, this is the code found in the "code" URL query parameter. For OTP, this is formed by concatenating the 6-digit code entered by the user with the nonce (received during code creation)`, }, type: VerificationCodeType.ONE_TIME_PASSWORD, - data: yupObject({}), + data: yupObject({ + turnstile_result: yupString().oneOf(turnstileResultValues).optional(), + turnstile_visible_challenge_result: yupString().oneOf(turnstileResultValues).optional(), + ...storedSignUpRequestContextSchemaFields, + }), method: yupObject({ email: emailSchema.defined(), }), @@ -117,10 +123,15 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ otp_auth_enabled: true, }, [], - { + buildSignUpRuleOptions({ authMethod: 'otp', - // TODO: Pass request context when available in verification code handler - } + oauthProvider: null, + requestContext: deserializeStoredSignUpRequestContext(data), + turnstileAssessment: deserializeStoredTurnstileAssessment( + data.turnstile_result, + data.turnstile_visible_challenge_result, + ), + }) ); isNewUser = true; } diff --git a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx index a97a128527..0b6f25053d 100644 --- a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx @@ -1,5 +1,7 @@ import { validateRedirectUrl } from "@/lib/redirect-urls"; +import { buildSignUpRuleOptions } from "@/lib/sign-up-context"; import { createAuthTokens } from "@/lib/tokens"; +import { getRequestContextAndBotChallengeAssessment, botChallengeFlowRequestSchemaFields } from "@/lib/turnstile"; import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; @@ -25,6 +27,7 @@ export const POST = createSmartRouteHandler({ email: signInEmailSchema.defined(), password: passwordSchema.defined(), verification_callback_url: emailVerificationCallbackUrlSchema.optional(), + ...botChallengeFlowRequestSchemaFields, }).defined(), }), response: yupObject({ @@ -36,7 +39,7 @@ export const POST = createSmartRouteHandler({ user_id: yupString().defined(), }).defined(), }), - async handler({ auth: { tenancy, user: currentUser }, body: { email, password, verification_callback_url: verificationCallbackUrl } }, fullReq) { + async handler({ auth: { tenancy, user: currentUser }, body: { email, password, verification_callback_url: verificationCallbackUrl, ...botChallenge } }) { if (!tenancy.config.auth.password.allowSignIn) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } @@ -54,6 +57,8 @@ export const POST = createSmartRouteHandler({ throw passwordError; } + const { requestContext, turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(botChallenge, "sign_up_with_credential", tenancy); + const createdUser = await createOrUpgradeAnonymousUserWithRules( tenancy, currentUser ?? null, @@ -64,9 +69,12 @@ export const POST = createSmartRouteHandler({ password, }, [KnownErrors.UserWithEmailAlreadyExists], - { + buildSignUpRuleOptions({ authMethod: 'password', - } + oauthProvider: null, + requestContext, + turnstileAssessment, + }) ); if (verificationCallbackUrl) { diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.tsx b/apps/backend/src/app/api/latest/internal/metrics/route.tsx index 85fb905140..d6f2ad6192 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -92,7 +92,7 @@ async function loadTotalUsers(tenancy: Tenancy, now: Date, includeAnonymous: boo SUM(COALESCE(COUNT(pu."projectUserId"), 0)) OVER (ORDER BY ds.registration_day) AS "cumUsers" FROM date_series ds LEFT JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu - ON DATE(pu."createdAt") = ds.registration_day + ON DATE(COALESCE(pu."signedUpAt", pu."createdAt")) = ds.registration_day AND pu."tenancyId" = ${tenancy.id}::UUID AND (${includeAnonymous} OR pu."isAnonymous" = false) GROUP BY ds.registration_day diff --git a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx index cc55fd772c..b51a0d6a0d 100644 --- a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx +++ b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx @@ -1,9 +1,15 @@ import { createSignUpRuleContext } from "@/lib/cel-evaluator"; +import { getBestEffortEndUserRequestContext } from "@/lib/end-users"; +import { calculateSignUpRiskScores } from "@/lib/risk-scores"; import { evaluateSignUpRulesWithTrace } from "@/lib/sign-up-rules"; +import { getDerivedSignUpCountryCode } from "@/lib/users"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { riskScoreFieldSchema } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { signUpAuthMethodValues } from "@stackframe/stack-shared/dist/utils/auth-methods"; +import type { TurnstileResult } from "@stackframe/stack-shared/dist/utils/turnstile"; +import { turnstileResultValues } from "@stackframe/stack-shared/dist/utils/turnstile"; +import { adaptSchema, adminAuthTypeSchema, countryCodeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -const AUTH_METHODS = ['password', 'otp', 'oauth', 'passkey'] as const; const ACTION_TYPES = ['allow', 'reject', 'restrict', 'log'] as const; const DECISION_TYPES = ['allow', 'reject', 'default-allow', 'default-reject'] as const; const STATUS_TYPES = ['matched', 'not_matched', 'disabled', 'missing_condition', 'error'] as const; @@ -18,9 +24,15 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }), body: yupObject({ - email: yupString().optional(), - auth_method: yupString().oneOf(AUTH_METHODS).defined(), - oauth_provider: yupString().optional(), + email: yupString().nullable().defined(), + auth_method: yupString().oneOf(signUpAuthMethodValues).defined(), + oauth_provider: yupString().nullable().defined(), + country_code: countryCodeSchema.nullable().defined(), + turnstile_result: yupString().oneOf(["ok", "invalid", "error"]).optional(), + risk_scores: yupObject({ + bot: riskScoreFieldSchema, + free_trial_abuse: riskScoreFieldSchema, + }).optional(), }).defined(), }), response: yupObject({ @@ -30,8 +42,14 @@ export const POST = createSmartRouteHandler({ context: yupObject({ email: yupString().defined(), email_domain: yupString().defined(), - auth_method: yupString().oneOf(AUTH_METHODS).defined(), + country_code: yupString().defined(), + auth_method: yupString().oneOf(signUpAuthMethodValues).defined(), oauth_provider: yupString().defined(), + turnstile_result: yupString().oneOf(turnstileResultValues).defined(), + risk_scores: yupObject({ + bot: riskScoreFieldSchema, + free_trial_abuse: riskScoreFieldSchema, + }).defined(), }).defined(), evaluations: yupArray(yupObject({ rule_id: yupString().defined(), @@ -54,10 +72,29 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async (req) => { + const endUserRequestContext = await getBestEffortEndUserRequestContext(); + const derivedCountryCode = getDerivedSignUpCountryCode(endUserRequestContext.location?.countryCode ?? null, req.body.email); + const normalizedTurnstileResult: TurnstileResult = req.body.turnstile_result ?? "invalid"; + const derivedRiskScores = await calculateSignUpRiskScores(req.auth.tenancy, { + primaryEmail: req.body.email, + primaryEmailVerified: req.body.auth_method === "otp", + authMethod: req.body.auth_method, + oauthProvider: req.body.oauth_provider, + ipAddress: endUserRequestContext.ipAddress, + ipTrusted: endUserRequestContext.ipTrusted, + turnstileAssessment: { + status: normalizedTurnstileResult, + }, + }); + const riskScores = req.body.risk_scores === undefined + ? derivedRiskScores + : req.body.risk_scores; const context = createSignUpRuleContext({ email: req.body.email, + countryCode: req.body.country_code ?? derivedCountryCode, authMethod: req.body.auth_method, oauthProvider: req.body.oauth_provider, + riskScores, }); const trace = evaluateSignUpRulesWithTrace(req.auth.tenancy, context); @@ -68,8 +105,14 @@ export const POST = createSmartRouteHandler({ context: { email: context.email, email_domain: context.emailDomain, + country_code: context.countryCode, auth_method: context.authMethod, oauth_provider: context.oauthProvider, + turnstile_result: normalizedTurnstileResult, + risk_scores: { + bot: context.riskScores.bot, + free_trial_abuse: context.riskScores.free_trial_abuse, + }, }, evaluations: trace.evaluations.map((evaluation) => ({ rule_id: evaluation.ruleId, diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 3a16996d3d..6d1670891e 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -16,8 +16,8 @@ import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { KnownErrors } from "@stackframe/stack-shared"; import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; +import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64"; import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -60,6 +60,10 @@ const getPersonalTeamDisplayName = (userDisplayName: string | null, userPrimaryE const personalTeamDefaultDisplayName = "Personal Team"; +function getSignedUpAtMillis(signedUpAt: Date): number { + return signedUpAt.getTime(); +} + async function createPersonalTeamIfEnabled(prisma: PrismaClientTransaction, tenancy: Tenancy, user: UsersCrud["Admin"]["Read"]) { if (tenancy.config.teams.createPersonalTeamOnSignUp) { const team = await teamsCrudHandlers.adminCreate({ @@ -161,7 +165,7 @@ export const userPrismaToCrud = ( primary_email_verified: primaryEmailVerified, primary_email_auth_enabled: !!primaryEmailContactChannel?.usedForAuth, profile_image_url: prisma.profileImageUrl, - signed_up_at_millis: prisma.createdAt.getTime(), + signed_up_at_millis: getSignedUpAtMillis(prisma.signedUpAt), client_metadata: prisma.clientMetadata, client_read_only_metadata: prisma.clientReadOnlyMetadata, server_metadata: prisma.serverMetadata, @@ -184,6 +188,13 @@ export const userPrismaToCrud = ( restricted_by_admin: prisma.restrictedByAdmin, restricted_by_admin_reason: prisma.restrictedByAdminReason, restricted_by_admin_private_details: prisma.restrictedByAdminPrivateDetails, + country_code: prisma.signUpCountryCode, + risk_scores: { + sign_up: { + bot: prisma.signUpRiskScoreBot, + free_trial_abuse: prisma.signUpRiskScoreFreeTrialAbuse, + }, + }, }; return result; }; @@ -371,7 +382,7 @@ export function getUserQuery(projectId: string, branchId: string, userId: string primary_email_verified: primaryEmailContactChannel?.isVerified || false, primary_email_auth_enabled: primaryEmailContactChannel?.usedForAuth === 'TRUE' ? true : false, profile_image_url: row.profileImageUrl, - signed_up_at_millis: new Date(row.createdAt + "Z").getTime(), + signed_up_at_millis: getSignedUpAtMillis(new Date((row.signedUpAt ?? throwErr("signedUpAt should never be null — anonymous users get createdAt, and the backfill migration ensures all existing rows are populated")) + "Z")), client_metadata: row.clientMetadata, client_read_only_metadata: row.clientReadOnlyMetadata, server_metadata: row.serverMetadata, @@ -402,6 +413,13 @@ export function getUserQuery(projectId: string, branchId: string, userId: string restricted_by_admin: row.restrictedByAdmin, restricted_by_admin_reason: row.restrictedByAdminReason, restricted_by_admin_private_details: row.restrictedByAdminPrivateDetails, + country_code: row.signUpCountryCode, + risk_scores: { + sign_up: { + bot: row.signUpRiskScoreBot, + free_trial_abuse: row.signUpRiskScoreFreeTrialAbuse, + }, + }, }; }, }; @@ -558,14 +576,18 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC } : {}, }; + const sortDirection = query.desc === 'true' ? 'desc' : 'asc'; const db = await prisma.projectUser.findMany({ where, include: userFullInclude, - orderBy: { - [({ - signed_up_at: 'createdAt', - } as const)[query.order_by ?? 'signed_up_at']]: query.desc === 'true' ? 'desc' : 'asc', - }, + orderBy: [ + { + [({ + signed_up_at: 'signedUpAt', + } as const)[query.order_by ?? 'signed_up_at']]: sortDirection, + }, + { projectUserId: sortDirection }, + ], // +1 because we need to know if there is a next page take: query.limit ? query.limit + 1 : undefined, ...query.cursor ? { @@ -642,6 +664,10 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC restrictedByAdmin, restrictedByAdminReason, restrictedByAdminPrivateDetails, + signUpCountryCode: data.country_code, + signedUpAt: new Date(), + signUpRiskScoreBot: data.risk_scores?.sign_up.bot ?? 0, + signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse ?? 0, }, include: userFullInclude, }); @@ -1140,10 +1166,18 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC requiresTotpMfa: data.totp_secret_base64 === undefined ? undefined : (data.totp_secret_base64 !== null), totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), isAnonymous: data.is_anonymous ?? undefined, + // Set signedUpAt when upgrading anonymous → non-anonymous (first real sign-up). + // We intentionally do NOT clear signedUpAt on non-anonymous → anonymous because: + // (a) that transition is admin-only and rare, and (b) preserving the original + // sign-up timestamp keeps risk/audit data intact. + signedUpAt: oldUser.isAnonymous && data.is_anonymous === false ? new Date() : undefined, profileImageUrl: await uploadAndGetUrl(data.profile_image_url, "user-profile-images"), restrictedByAdmin: data.restricted_by_admin ?? undefined, restrictedByAdminReason: restrictedByAdminReason, restrictedByAdminPrivateDetails: restrictedByAdminPrivateDetails, + signUpCountryCode: data.country_code, + signUpRiskScoreBot: data.risk_scores?.sign_up.bot, + signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse, }), include: userFullInclude, }); diff --git a/apps/backend/src/lib/cel-evaluator.ts b/apps/backend/src/lib/cel-evaluator.ts index 33900b1fb1..3fbc834162 100644 --- a/apps/backend/src/lib/cel-evaluator.ts +++ b/apps/backend/src/lib/cel-evaluator.ts @@ -1,253 +1,190 @@ +import type { SignUpRiskScoresCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; +import { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods"; +import { unescapeCelString } from "@stackframe/stack-shared/dist/utils/cel-fields"; import { evaluate } from "cel-js"; import { normalizeEmail } from "./emails"; +import { SignUpRiskScores } from "./risk-scores"; + + +// ── Error ────────────────────────────────────────────────────────────── -/** - * Custom error class for CEL evaluation failures. - * Used to distinguish CEL-specific errors from other unexpected errors. - */ export class CelEvaluationError extends Error { public readonly customCaptureExtraArgs: unknown[]; constructor( message: string, public readonly expression: string, - public readonly cause?: unknown + public readonly cause: unknown | null = null, ) { super(message); this.name = 'CelEvaluationError'; - // Extra context for structured logging via captureError this.customCaptureExtraArgs = [{ expression, cause }]; } } -/** - * Context variables available for sign-up rule CEL expressions. - */ + +// ── Context ──────────────────────────────────────────────────────────── + export type SignUpRuleContext = { - /** User's email address */ email: string, - /** Domain part of email (after @) */ emailDomain: string, - /** Authentication method: "password", "otp", "oauth", "passkey" */ - authMethod: 'password' | 'otp' | 'oauth' | 'passkey', - /** OAuth provider ID if authMethod is "oauth", empty string otherwise */ + /** Best-effort ISO 3166-1 alpha-2 country code. Empty string when unavailable (never null/undefined). */ + countryCode: string, + authMethod: SignUpAuthMethod, oauthProvider: string, + riskScores: { bot: number, free_trial_abuse: number }, }; -/** - * Pre-processes a CEL expression to transform method calls into function calls - * that cel-js can evaluate. - * - * Transforms: - * - `str.contains("x")` → `_method_0` (pre-computed in context) - * - `str.startsWith("x")` → `_method_1` (pre-computed in context) - * - etc. - * - * Since cel-js doesn't support method calls, we pre-compute these values - * and add them to the context with unique keys to avoid collisions. - */ -function unescapeCelString(escaped: string): string { - return escaped.replace(/\\\\/g, '\\').replace(/\\"/g, '"'); +export function createSignUpRuleContext(params: { + email: string | null, + countryCode: string | null, + authMethod: SignUpAuthMethod, + oauthProvider: string | null, + riskScores: SignUpRiskScoresCrud, +}): SignUpRuleContext { + let email = ''; + let emailDomain = ''; + + if (params.email) { + email = normalizeEmail(params.email); + emailDomain = email.includes('@') ? (email.split('@').pop() ?? '') : ''; + } + + return { + email, + emailDomain, + countryCode: params.countryCode === null ? '' : normalizeCountryCode(params.countryCode), + authMethod: params.authMethod, + oauthProvider: params.oauthProvider ?? '', + riskScores: { bot: params.riskScores.bot, free_trial_abuse: params.riskScores.free_trial_abuse }, + }; } + +// ── Preprocessing ────────────────────────────────────────────────────── + +// cel-js doesn't support method calls, so we pre-compute string methods +// and replace them with pre-evaluated boolean placeholders in the context. +const METHOD_PATTERN = /(\w+)\.(contains|startsWith|endsWith|matches)\s*\(\s*"((?:\\.|[^"\\])*)"\s*\)/g; + +const stringMethodEvaluators: Partial boolean>> = { + contains: (s, a) => s.includes(a), + startsWith: (s, a) => s.startsWith(a), + endsWith: (s, a) => s.endsWith(a), + matches: (s, a) => new RegExp(a).test(s), +}; + function preprocessExpression( expression: string, - context: SignUpRuleContext + context: SignUpRuleContext, ): { expression: string, context: Record } { const extendedContext: Record = { ...context }; - - // Pattern to match method calls: identifier.method("literal with optional escaped quotes") - // We handle: contains, startsWith, endsWith, matches - const methodPattern = /(\w+)\.(contains|startsWith|endsWith|matches)\s*\(\s*"((?:\\.|[^"\\])*)"\s*\)/g; - let counter = 0; - // Use replace with a callback to handle each match uniquely - // This ensures each occurrence gets a unique key, even if the same expression appears multiple times - const transformedExpr = expression.replace(methodPattern, (fullMatch, varName, method, arg) => { - // Get the variable value from context + const transformedExpr = expression.replace(METHOD_PATTERN, (fullMatch, varName, method, arg) => { const varValue = context[varName as keyof SignUpRuleContext]; - if (typeof varValue !== 'string') { - // Return unchanged if variable is not a string - return fullMatch; - } + if (typeof varValue !== 'string') return fullMatch; - const unescapedArg = unescapeCelString(arg); + const evaluator = stringMethodEvaluators[method]; + if (!evaluator) return fullMatch; - // Use a counter-based key to avoid collisions between different arguments - // that would otherwise sanitize to the same key (e.g., "test+1" and "test-1") + const unescapedArg = unescapeCelString(arg); const resultKey = `_method_${counter++}`; - let result: boolean; - - switch (method) { - case 'contains': { - result = varValue.includes(unescapedArg); - break; - } - case 'startsWith': { - result = varValue.startsWith(unescapedArg); - break; - } - case 'endsWith': { - result = varValue.endsWith(unescapedArg); - break; - } - case 'matches': { - try { - const regex = new RegExp(unescapedArg); - result = regex.test(varValue); - } catch { - // Invalid regex pattern - treat as non-match - result = false; - } - break; - } - default: { - return fullMatch; - } + + try { + extendedContext[resultKey] = evaluator(varValue, unescapedArg); + } catch (e) { + throw new CelEvaluationError(`Invalid regex pattern in matches(): "${unescapedArg}"`, expression, e); } - extendedContext[resultKey] = result; return resultKey; }); return { expression: transformedExpr, context: extendedContext }; } -/** - * Evaluates a CEL expression against a signup context. - * Returns true if the expression matches, false otherwise. - * - * Supports standard CEL operators plus string methods: - * - contains("substring") - * - startsWith("prefix") - * - endsWith("suffix") - * - matches("regex") - * - * @param expression - The CEL expression string to evaluate - * @param context - The signup context with variables like email, authMethod, etc. - * @returns boolean result of the expression evaluation - */ -export function evaluateCelExpression( - expression: string, - context: SignUpRuleContext -): boolean { + +// ── Evaluation ───────────────────────────────────────────────────────── + +export function evaluateCelExpression(expression: string, context: SignUpRuleContext): boolean { try { - // Pre-process to handle method calls const { expression: transformedExpr, context: extendedContext } = preprocessExpression(expression, context); - const result = evaluate(transformedExpr, extendedContext); - return Boolean(result); + + if (typeof result !== "boolean") { + throw new CelEvaluationError( + `CEL expression must evaluate to a boolean, got ${typeof result}: ${JSON.stringify(result)}`, + expression, + ); + } + return result; } catch (e) { - // Wrap CEL evaluation errors with context and rethrow - // Callers should catch CelEvaluationError specifically - throw new CelEvaluationError( - `Failed to evaluate CEL expression: ${expression}`, - expression, - e - ); + if (e instanceof CelEvaluationError) throw e; + throw new CelEvaluationError(`Failed to evaluate CEL expression: ${expression}`, expression, e); } } -/** - * Creates a SignUpRuleContext from raw request data. - * This helper extracts and derives the context variables needed for rule evaluation. - * - * @param params - Raw parameters from the signup request - * @returns SignUpRuleContext ready for CEL evaluation - */ -export function createSignUpRuleContext(params: { - email?: string, - authMethod: 'password' | 'otp' | 'oauth' | 'passkey', - oauthProvider?: string, -}): SignUpRuleContext { - // Handle missing email (e.g., OAuth providers that don't return email) - // Use empty string so email-based rules don't match - let email = ''; - let emailDomain = ''; - if (params.email) { - // Normalize email to match how it's stored in the database - email = normalizeEmail(params.email); - // Extract domain from normalized email - emailDomain = email.includes('@') ? (email.split('@').pop() ?? '') : ''; - } +// ── Tests ────────────────────────────────────────────────────────────── - return { - email, - emailDomain, - authMethod: params.authMethod, - oauthProvider: params.oauthProvider ?? '', - }; -} - -// Unit tests import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { - // Should normalize email - expect(createSignUpRuleContext({ - email: 'Test.User@Example.COM', - authMethod: 'password', - })).toEqual({ - email: 'test.user@example.com', - emailDomain: 'example.com', - authMethod: 'password', - oauthProvider: '', + const ctx = (email: string | null, countryCode: string | null, authMethod: SignUpAuthMethod, oauthProvider: string | null, bot = 0, fta = 0) => + createSignUpRuleContext({ email, countryCode, authMethod, oauthProvider, riskScores: { bot, free_trial_abuse: fta } }); + + expect(ctx('Test.User@Example.COM', null, 'password', null, 17, 23)).toEqual({ + email: 'test.user@example.com', emailDomain: 'example.com', countryCode: '', + authMethod: 'password', oauthProvider: '', riskScores: { bot: 17, free_trial_abuse: 23 }, }); - // Should handle missing email (OAuth providers without email) - expect(createSignUpRuleContext({ - email: undefined, - authMethod: 'oauth', - oauthProvider: 'discord', - })).toEqual({ - email: '', - emailDomain: '', - authMethod: 'oauth', - oauthProvider: 'discord', + expect(ctx(null, null, 'oauth', 'discord', 1, 2)).toEqual({ + email: '', emailDomain: '', countryCode: '', + authMethod: 'oauth', oauthProvider: 'discord', riskScores: { bot: 1, free_trial_abuse: 2 }, }); - // Should handle empty string email - expect(createSignUpRuleContext({ - email: '', - authMethod: 'oauth', - oauthProvider: 'twitter', - })).toEqual({ - email: '', - emailDomain: '', - authMethod: 'oauth', - oauthProvider: 'twitter', + expect(ctx('', null, 'oauth', 'twitter', 10, 20)).toEqual({ + email: '', emailDomain: '', countryCode: '', + authMethod: 'oauth', oauthProvider: 'twitter', riskScores: { bot: 10, free_trial_abuse: 20 }, }); - // Should handle OAuth with email - expect(createSignUpRuleContext({ - email: 'oauth.user@gmail.com', - authMethod: 'oauth', - oauthProvider: 'google', - })).toEqual({ - email: 'oauth.user@gmail.com', - emailDomain: 'gmail.com', - authMethod: 'oauth', - oauthProvider: 'google', + expect(ctx('oauth.user@gmail.com', null, 'oauth', 'google', 8, 9)).toEqual({ + email: 'oauth.user@gmail.com', emailDomain: 'gmail.com', countryCode: '', + authMethod: 'oauth', oauthProvider: 'google', riskScores: { bot: 8, free_trial_abuse: 9 }, + }); + + expect(ctx('user@example.com', 'us', 'password', null, 3, 4)).toEqual({ + email: 'user@example.com', emailDomain: 'example.com', countryCode: 'US', + authMethod: 'password', oauthProvider: '', riskScores: { bot: 3, free_trial_abuse: 4 }, }); }); import.meta.vitest?.test('evaluateCelExpression with missing email', async ({ expect }) => { - // When email is empty, email-based conditions should not match const context = createSignUpRuleContext({ - email: undefined, - authMethod: 'oauth', - oauthProvider: 'discord', + email: null, countryCode: null, authMethod: 'oauth', oauthProvider: 'discord', + riskScores: { bot: 33, free_trial_abuse: 44 }, }); - // Email-based conditions should fail when email is empty + // Email-based conditions should fail expect(evaluateCelExpression('email == "test@example.com"', context)).toBe(false); expect(evaluateCelExpression('email.contains("@")', context)).toBe(false); expect(evaluateCelExpression('emailDomain == "example.com"', context)).toBe(false); + expect(evaluateCelExpression('email == ""', context)).toBe(true); - // But authMethod-based conditions should still work + // Non-email conditions should work expect(evaluateCelExpression('authMethod == "oauth"', context)).toBe(true); expect(evaluateCelExpression('oauthProvider == "discord"', context)).toBe(true); + expect(evaluateCelExpression('riskScores.bot == 33', context)).toBe(true); + expect(evaluateCelExpression('riskScores.free_trial_abuse == 44', context)).toBe(true); + expect(evaluateCelExpression('riskScores.bot > 10 && riskScores.free_trial_abuse < 90', context)).toBe(true); +}); - // Empty email should match empty string - expect(evaluateCelExpression('email == ""', context)).toBe(true); +import.meta.vitest?.test('countryCode in_list vs equals', ({ expect }) => { + const context = createSignUpRuleContext({ + email: 'test@example.com', countryCode: 'US', authMethod: 'password', oauthProvider: null, + riskScores: { bot: 0, free_trial_abuse: 0 }, + }); + + expect(evaluateCelExpression('countryCode in ["US", "CA"]', context)).toBe(true); + expect(evaluateCelExpression('countryCode in ["CA"]', context)).toBe(false); + expect(evaluateCelExpression('countryCode == "US"', context)).toBe(true); }); diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index b98ebb812c..04f2347587 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -15,6 +15,7 @@ import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry"; import { randomUUID } from "node:crypto"; +import { checkEmailWithEmailable, type EmailableCheckResult } from "./emailable"; import { lowLevelSendEmailDirectWithoutRetries } from "./emails-low-level"; const MAX_RENDER_BATCH = 50; @@ -23,6 +24,12 @@ const MAX_SEND_ATTEMPTS = 5; const SEND_RETRY_BACKOFF_BASE_MS = 20000; +/** Warn if the time between consecutive email queue steps exceeds this many seconds. */ +const DELTA_WARNING_THRESHOLD_SECONDS = 30; + +/** Consider an email stuck in rendering/sending if it started more than this many ms ago. */ +const STUCK_EMAIL_TIMEOUT_MS = 20 * 60 * 1000; + const calculateRetryBackoffMs = (attemptCount: number): number => { return (Math.random() + 0.5) * SEND_RETRY_BACKOFF_BASE_MS * Math.pow(2, attemptCount); }; @@ -52,95 +59,22 @@ const appendSendAttemptError =( // Track if email queue has run at least once since server start (used to suppress first-run delta warnings in dev) const emailQueueFirstRunKey = Symbol.for("__stack_email_queue_first_run_completed"); -type EmailableVerificationResult = - | { status: "ok" } - | { status: "not-deliverable", emailableResponse: Record }; - -/** - * Verifies email deliverability using the Emailable API. - * - * If STACK_EMAILABLE_API_KEY is set, it calls the Emailable API to verify the email. - * If the API key is not set, it falls back to a default behavior where emails - * with the domain "emailable-not-deliverable.example.com" are rejected (for testing). - */ async function verifyEmailDeliverability( email: string, shouldSkipDeliverabilityCheck: boolean, emailConfigType: "shared" | "standard" -): Promise { +): Promise { // Skip deliverability check if requested or using non-shared email config if (shouldSkipDeliverabilityCheck || emailConfigType !== "shared") { - return { status: "ok" }; + return { status: "ok", emailableScore: null }; } - const emailableApiKey = getEnvVariable("STACK_EMAILABLE_API_KEY", ""); - - if (emailableApiKey) { - // Use Emailable API for verification - return await traceSpan("verifying email address with Emailable", async () => { - try { - const emailableResponseResult = await Result.retry(async () => { - const res = await fetch( - `https://api.emailable.com/v1/verify?email=${encodeURIComponent(email)}&api_key=${emailableApiKey}` - ); - if (res.status === 249) { - const text = await res.text(); - console.log("Emailable is taking longer than expected, retrying...", text, { email }); - return Result.error( - new Error( - `Emailable API returned a 249 error for ${email}. This means it takes some more time to verify the email address. Response body: ${text}` - ) - ); - } - return Result.ok(res); - }, 4, { exponentialDelayBase: 4000 }); - - if (emailableResponseResult.status === "error") { - throw new StackAssertionError("Timed out while verifying email address with Emailable", { - email, - emailableResponseResult, - }); - } - - const emailableResponse = emailableResponseResult.data; - if (!emailableResponse.ok) { - throw new StackAssertionError("Failed to verify email address with Emailable", { - email, - emailableResponse, - emailableResponseText: await emailableResponse.text(), - }); - } - - const json = await emailableResponse.json() as Record; - - if (json.state === "undeliverable" || json.disposable) { - console.log("email not deliverable", email, json); - return { status: "not-deliverable", emailableResponse: json }; - } - - return { status: "ok" }; - } catch (error) { - // If something goes wrong with the Emailable API (eg. 500, ran out of credits, etc.), we just send the email anyway - captureError("emailable-api-error", error); - return { status: "ok" }; - } - }); - } else { - // Fallback behavior when no API key is set: reject test domain for testing purposes, and accept everything else - const EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN = "emailable-not-deliverable.example.com"; - const emailDomain = email.split("@")[1]?.toLowerCase(); - if (emailDomain === EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN) { - return { - status: "not-deliverable", - emailableResponse: { - state: "undeliverable", - reason: "test_domain_rejection", - message: `Emails to ${EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN} are rejected in test mode when STACK_EMAILABLE_API_KEY is not set`, - }, - }; - } - return { status: "ok" }; + const result = await checkEmailWithEmailable(email); + // Email queue should not block on emailable failures — treat errors as deliverable + if (result.status === "error") { + return { status: "ok", emailableScore: null }; } + return result; } type TenancySendBatch = { @@ -186,7 +120,7 @@ async function retryEmailsStuckInRendering(): Promise { const res = await globalPrismaClient.emailOutbox.updateManyAndReturn({ where: { startedRenderingAt: { - lte: new Date(Date.now() - 1000 * 60 * 20), + lte: new Date(Date.now() - STUCK_EMAIL_TIMEOUT_MS), }, finishedRenderingAt: null, skippedReason: null, @@ -208,7 +142,7 @@ async function logEmailsStuckInSending(): Promise { const res = await globalPrismaClient.emailOutbox.findMany({ where: { startedSendingAt: { - lte: new Date(Date.now() - 1000 * 60 * 20), + lte: new Date(Date.now() - STUCK_EMAIL_TIMEOUT_MS), }, finishedSendingAt: null, skippedReason: null, @@ -284,7 +218,7 @@ async function updateLastExecutionTime(): Promise { return 0; } - if (delta > 30) { + if (delta > DELTA_WARNING_THRESHOLD_SECONDS) { const isFirstRun = !(globalThis as any)[emailQueueFirstRunKey]; if (isFirstRun && getNodeEnvironment() === "development") { // In development, the first run after server start often has a large delta because the server wasn't running diff --git a/apps/backend/src/lib/emailable.tsx b/apps/backend/src/lib/emailable.tsx new file mode 100644 index 0000000000..14fce3beb4 --- /dev/null +++ b/apps/backend/src/lib/emailable.tsx @@ -0,0 +1,173 @@ +import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry"; +import createEmailableClient from "emailable"; + +export const EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN = "emailable-not-deliverable.example.com"; + + +// ── Types ────────────────────────────────────────────────────────────── + +const VERIFY_STATES = ["deliverable", "undeliverable", "risky", "unknown"] as const; +type EmailableVerifyResponse = ReturnType; + +export type EmailableCheckResult = + | { status: "ok", emailableScore: number | null } + | { status: "not-deliverable", emailableResponse: EmailableVerifyResponse, emailableScore: number | null } + | { status: "error", error: unknown, emailableScore: null }; + + +// ── Helpers ──────────────────────────────────────────────────────────── + +const RETRY_BACKOFF_BASE_MS = 4000; + +function isReservedTestDomain(emailDomain: string): boolean { + if (!["development", "test"].includes(getNodeEnvironment())) return false; + return emailDomain === "example.com" || emailDomain.endsWith(".example.com"); +} + +function validateVerifyResponse(value: unknown) { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw new StackAssertionError("Emailable returned a non-object response body", { value }); + } + const response = Object.assign(Object.create(null), value) as Record; + const { state, disposable, score } = response; + if (typeof state !== "string" || !VERIFY_STATES.some(s => s === state)) { + throw new StackAssertionError("Emailable verify response has invalid or missing state", { response }); + } + const parsedScore = typeof score === "number" && score >= 0 && score <= 100 ? score : null; + return { ...response, state, disposable: disposable === true, score: parsedScore }; +} + +async function verifyWithRetries(verifyFn: () => Promise, maxAttempts: number, delayBaseMs: number) { + for (let i = 0; i < maxAttempts; i++) { + try { + return await verifyFn(); + } catch (error) { + const code = (error != null && typeof error === "object" && !Array.isArray(error)) + ? Reflect.get(error, "code") + : null; + if (code !== 249) throw error; // only retry rate-limit errors + if (i < maxAttempts - 1) { + await new Promise(r => setTimeout(r, (Math.random() + 0.5) * delayBaseMs * (2 ** i))); + } + } + } + throw new StackAssertionError("Timed out while verifying email address with Emailable"); +} + +function buildTestUndeliverableResponse(email: string) { + const match = email.match(/^([^@]+)@([^@]+)$/); + if (!match) { + throw new StackAssertionError("Expected a valid email before creating the Emailable test-mode response", { email }); + } + return { + accept_all: false, did_you_mean: null, disposable: false, domain: match[2], + duration: 0, email, first_name: null, free: false, full_name: null, gender: null, + last_name: null, mailbox_full: false, mx_record: null, no_reply: false, + reason: "test_domain_rejection", role: false, score: 0, smtp_provider: null, + state: "undeliverable" as const, tag: null, user: match[1], + }; +} + + +// ── Public API ───────────────────────────────────────────────────────── + +export async function checkEmailWithEmailable( + email: string, + options?: { + retryExponentialDelayBaseMs?: number, + /** @internal — used by tests to inject a fake client */ + _clientFactory?: (apiKey: string) => { verify: (email: string) => Promise }, + }, +): Promise { + const rawApiKey = getEnvVariable("STACK_EMAILABLE_API_KEY", ""); + const emailDomain = email.split("@")[1]?.toLowerCase() ?? ""; + + // Always reject the explicit test domain, regardless of API key + if (emailDomain === EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN) { + const testResponse = buildTestUndeliverableResponse(email); + return { status: "not-deliverable", emailableResponse: testResponse, emailableScore: testResponse.score }; + } + + if (!rawApiKey) { + if (["development", "test"].includes(getNodeEnvironment())) { + return { status: "ok", emailableScore: null }; + } + throw new StackAssertionError("STACK_EMAILABLE_API_KEY must not be empty; set it to 'disable_email_validation' to disable email validation"); + } + + const apiKey = rawApiKey === "disable_email_validation" ? "" : rawApiKey; + if (!apiKey || isReservedTestDomain(emailDomain)) { + return { status: "ok", emailableScore: null }; + } + + const clientFactory = options?._clientFactory ?? createEmailableClient; + const retryDelayBase = options?.retryExponentialDelayBaseMs ?? RETRY_BACKOFF_BASE_MS; + + return await traceSpan("checking email address with Emailable", async () => { + const client = clientFactory(apiKey); + let raw: unknown; + try { + raw = await verifyWithRetries(() => client.verify(email), 4, retryDelayBase); + } catch (error) { + captureError("emailable-api-error", error); + return { status: "error", error, emailableScore: null }; + } + const response = validateVerifyResponse(raw); + + if (response.state === "undeliverable" || response.disposable) { + return { status: "not-deliverable", emailableResponse: response, emailableScore: response.score }; + } + return { status: "ok", emailableScore: response.score }; + }); +} + + +// ── Tests ────────────────────────────────────────────────────────────── + +import.meta.vitest?.describe("checkEmailWithEmailable(...)", () => { + const { vi, test, beforeEach } = import.meta.vitest!; + + const fakeClient = (verifyFn: (email: string) => Promise) => (_apiKey: string) => ({ verify: verifyFn }); + + const deliverableClient = fakeClient(async () => ({ + state: "deliverable", disposable: false, score: 95, domain: "gmail.com", email: "test@gmail.com", user: "test", + })); + + const errorClient = fakeClient(async () => { + throw new Error("network error"); + }); + + beforeEach(() => { + vi.stubEnv("STACK_EMAILABLE_API_KEY", "test_api_key"); + return () => vi.unstubAllEnvs(); + }); + + test("returns test-domain rejection regardless of API key", async ({ expect }) => { + await expect(checkEmailWithEmailable(`user@${EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN}`)) + .resolves.toMatchObject({ status: "not-deliverable", emailableResponse: { state: "undeliverable", reason: "test_domain_rejection" } }); + }); + + test("returns test-domain rejection even when API key is unset", async ({ expect }) => { + vi.stubEnv("STACK_EMAILABLE_API_KEY", ""); + await expect(checkEmailWithEmailable(`user@${EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN}`)) + .resolves.toMatchObject({ status: "not-deliverable", emailableResponse: { state: "undeliverable", reason: "test_domain_rejection" } }); + }); + + test("returns ok for deliverable email", async ({ expect }) => { + const result = await checkEmailWithEmailable("test@gmail.com", { _clientFactory: deliverableClient }); + expect(result.status).toBe("ok"); + }); + + test("returns error on API error", async ({ expect }) => { + const result = await checkEmailWithEmailable("test@gmail.com", { _clientFactory: errorClient }); + expect(result.status).toBe("error"); + }); + + test("throws on malformed Emailable response bodies", async ({ expect }) => { + const malformedClient = fakeClient(async () => "definitely not an object"); + await expect(checkEmailWithEmailable("test@gmail.com", { _clientFactory: malformedClient })) + .rejects.toThrowError("Emailable returned a non-object response body"); + }); +}); diff --git a/apps/backend/src/lib/end-users.tsx b/apps/backend/src/lib/end-users.tsx index 5dc301e701..a0cc7ea284 100644 --- a/apps/backend/src/lib/end-users.tsx +++ b/apps/backend/src/lib/end-users.tsx @@ -1,3 +1,4 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { isIpAddress } from "@stackframe/stack-shared/dist/utils/ips"; import { pick } from "@stackframe/stack-shared/dist/utils/objects"; @@ -13,21 +14,9 @@ import { headers } from "next/headers"; /** - * Tries to guess the end user's IP address based on the current request's headers. Returns `undefined` if the end - * user IP can't be determined. - * - * This value can be spoofed by any user to any value; do not trust the value for security purposes (use the - * `getExactEndUserIp` function for that). It is useful for derived data like location analytics, which can be spoofed - * with VPNs anyways. However, for legitimate users, this function is guaranteed to either return the IP address - * (potentially of a VPN/proxy) or `undefined`. - * - * Note that the "end user" refers to the user sitting behind a computer screen; for example, if my-stack-app.com is - * using Stack Auth, and person A is on my-stack-app.com and sends a server action to server B of my-stack-app.com, - * then the end user IP address is the address of the computer of person A, not server B. - * - * If we can determine that the request is coming from a browser, we try to read the IP address from the proxy headers. - * Otherwise, we can read the `X-Stack-Requester` header to find information about the end user's IP address. (We don't - * do this currently, see the TODO in the implementation.) + * Returns the end user's IP address from the current request's headers, or `undefined` if it can't be determined. + * Falls back to spoofable headers (x-forwarded-for) if no trusted proxy header (cf-connecting-ip, + * x-vercel-forwarded-for) is available. */ export async function getSpoofableEndUserIp(): Promise { const endUserInfo = await getEndUserInfo(); @@ -36,7 +25,8 @@ export async function getSpoofableEndUserIp(): Promise { /** - * Tries to guess the end user's IP address based on the current request's headers. If + * Returns the end user's IP only if it came from a trusted proxy header (cf-connecting-ip or x-vercel-forwarded-for). + * Returns `undefined` if the IP could only be determined from spoofable headers. */ export async function getExactEndUserIp(): Promise { const endUserInfo = await getEndUserInfo(); @@ -52,14 +42,111 @@ type EndUserLocation = { tzIdentifier?: string, }; +type TrustedProxy = "" | "vercel" | "cloudflare"; + export async function getSpoofableEndUserLocation(): Promise { const endUserInfo = await getEndUserInfo(); - return endUserInfo?.maybeSpoofed === false ? pick(endUserInfo.exactInfo, ["countryCode", "regionCode", "cityName", "latitude", "longitude", "tzIdentifier"]) : null; + if (!endUserInfo) { + return null; + } + + const locationInfo = getLocationInfo(endUserInfo); + return pick(locationInfo, ["countryCode", "regionCode", "cityName", "latitude", "longitude", "tzIdentifier"]); +} + +export type BestEffortEndUserRequestContext = { + ipAddress: string | null, + ipTrusted: boolean | null, + location: EndUserLocation | null, +}; + +export async function getBestEffortEndUserRequestContext(): Promise { + const endUserInfo = await getEndUserInfo(); + if (!endUserInfo) { + return { + ipAddress: null, + ipTrusted: null, + location: null, + }; + } + + const locationInfo = getLocationInfo(endUserInfo); + return { + ipAddress: locationInfo.ip, + ipTrusted: !endUserInfo.maybeSpoofed, + location: pick(locationInfo, ["countryCode", "regionCode", "cityName", "latitude", "longitude", "tzIdentifier"]), + }; } type EndUserInfoInner = EndUserLocation & { ip: string } +function getLocationInfo(endUserInfo: { maybeSpoofed: true, spoofedInfo: EndUserInfoInner } | { maybeSpoofed: false, exactInfo: EndUserInfoInner }) { + return endUserInfo.maybeSpoofed ? endUserInfo.spoofedInfo : endUserInfo.exactInfo; +} + +function parseCoordinate(raw: string | null | undefined): number | undefined { + if (!raw) return undefined; + const parsed = parseFloat(raw); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy): + | { maybeSpoofed: true, spoofedInfo: EndUserInfoInner } + | { maybeSpoofed: false, exactInfo: EndUserInfoInner } + | null { + const isVercelTrusted = trustedProxy === "vercel"; + const isCloudflareTrusted = trustedProxy === "cloudflare"; + + // Only read proxy headers as trusted when the corresponding proxy is configured + const trustedIp = (isVercelTrusted ? allHeaders.get("x-vercel-forwarded-for") : undefined) + ?? (isCloudflareTrusted ? allHeaders.get("cf-connecting-ip") : undefined) + ?? undefined; + + // All other IP headers are always spoofable — including proxy headers when the proxy is not configured as trusted + const spoofableIp = allHeaders.get("x-real-ip") + ?? allHeaders.get("x-forwarded-for")?.split(",").at(0) + ?? (!isVercelTrusted ? allHeaders.get("x-vercel-forwarded-for") : undefined) + ?? (!isCloudflareTrusted ? allHeaders.get("cf-connecting-ip") : undefined) + ?? undefined; + + const ip = trustedIp ?? spoofableIp; + + if (!ip || !isIpAddress(ip)) { + console.warn("getEndUserIp() found IP address in headers, but is invalid. This is most likely a misconfigured client", { ip, headers: Object.fromEntries(allHeaders) }); + return null; + } + + // Geo headers are only trustworthy when they come from a verified proxy. + // If a trusted proxy is configured but it did not provide its trusted IP header, + // treat its geo headers as spoofed too. + const geoLocation: EndUserLocation = { + countryCode: (isVercelTrusted ? allHeaders.get("x-vercel-ip-country") : undefined) + ?? (isCloudflareTrusted ? allHeaders.get("cf-ipcountry") : undefined) + ?? undefined, + regionCode: (isVercelTrusted ? allHeaders.get("x-vercel-ip-country-region") : undefined) || undefined, + cityName: (isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined) || undefined, + latitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-latitude") : null), + longitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-longitude") : null), + tzIdentifier: (isVercelTrusted ? allHeaders.get("x-vercel-ip-timezone") : undefined) || undefined, + }; + + // When no proxy is trusted, geo headers are spoofable — still include them but under spoofedInfo + const spoofedGeoLocation: EndUserLocation = trustedProxy === "" ? { + countryCode: (allHeaders.get("x-vercel-ip-country") ?? allHeaders.get("cf-ipcountry")) || undefined, + regionCode: allHeaders.get("x-vercel-ip-country-region") || undefined, + cityName: allHeaders.get("x-vercel-ip-city") || undefined, + latitude: parseCoordinate(allHeaders.get("x-vercel-ip-latitude")), + longitude: parseCoordinate(allHeaders.get("x-vercel-ip-longitude")), + tzIdentifier: allHeaders.get("x-vercel-ip-timezone") || undefined, + } : {}; + + if (trustedIp) { + return { maybeSpoofed: false, exactInfo: { ip, ...geoLocation } }; + } + return { maybeSpoofed: true, spoofedInfo: { ip, ...(trustedProxy === "" ? spoofedGeoLocation : {}) } }; +} + export async function getEndUserInfo(): Promise< // discriminated union to make sure the user is really explicit about checking the maybeSpoofed field | { maybeSpoofed: true, spoofedInfo: EndUserInfoInner } @@ -78,37 +165,14 @@ export async function getEndUserInfo(): Promise< const isClaimingToBeBrowser = ["Mozilla", "Chrome", "Safari"].some(header => allHeaders.get("User-Agent")?.includes(header)); if (isClaimingToBeBrowser) { - // this case is easy, we just read the IP from the headers - const ip = - allHeaders.get("cf-connecting-ip") - ?? allHeaders.get("x-vercel-forwarded-for") - ?? allHeaders.get("x-real-ip") - ?? allHeaders.get("x-forwarded-for")?.split(",").at(0) - ?? undefined; - if (!ip || !isIpAddress(ip)) { - console.warn("getEndUserIp() found IP address in headers, but is invalid. This is most likely a misconfigured client", { ip, headers: Object.fromEntries(allHeaders) }); - return null; + // Determine which proxy we trust based on deployment configuration. + // These headers can only be trusted when the origin is exclusively reachable through the proxy; + // STACK_TRUSTED_PROXY should be set to "vercel", "cloudflare", or left empty/unset for no proxy trust. + const trustedProxy = getEnvVariable("STACK_TRUSTED_PROXY", "").toLowerCase().trim(); + if (trustedProxy !== "" && trustedProxy !== "vercel" && trustedProxy !== "cloudflare") { + throw new StackAssertionError(`STACK_TRUSTED_PROXY must be "vercel", "cloudflare", or empty/unset, but got: "${trustedProxy}"`); } - - return ip ? { - // currently we just trust all headers (including X-Forwarded-For), so this is easy to spoof - // hence, we set maybeSpoofed to true - // TODO be smarter about this (eg. use x-vercel-signature and CF request validation to make sure they pass through - // those proxies before trusting the values) - maybeSpoofed: true, - - spoofedInfo: { - ip, - - // TODO use our own geoip data so we can get better accuracy, and also support non-Vercel/Cloudflare setups - countryCode: (allHeaders.get("cf-ipcountry") ?? allHeaders.get("x-vercel-ip-country")) || undefined, - regionCode: allHeaders.get("x-vercel-ip-country-region") || undefined, - cityName: allHeaders.get("x-vercel-ip-city") || undefined, - latitude: allHeaders.get("x-vercel-ip-latitude") ? parseFloat(allHeaders.get("x-vercel-ip-latitude")!) : undefined, - longitude: allHeaders.get("x-vercel-ip-longitude") ? parseFloat(allHeaders.get("x-vercel-ip-longitude")!) : undefined, - tzIdentifier: allHeaders.get("x-vercel-ip-timezone") || undefined, - }, - } : null; + return getBrowserEndUserInfo(allHeaders, trustedProxy); } /** @@ -132,3 +196,53 @@ export async function getEndUserInfo(): Promise< // most likely it's a consumer of our REST API that doesn't use our SDKs return null; } + +import.meta.vitest?.describe("getBrowserEndUserInfo(...)", () => { + const { expect, test } = import.meta.vitest!; + + test("does not trust Vercel geo headers when the trusted Vercel IP header is absent", () => { + const result = getBrowserEndUserInfo(new Headers({ + "user-agent": "Mozilla/5.0", + "x-forwarded-for": "203.0.113.10", + "x-vercel-ip-country": "DE", + "x-vercel-ip-country-region": "BE", + "x-vercel-ip-city": "Berlin", + "x-vercel-ip-latitude": "52.52", + "x-vercel-ip-longitude": "13.40", + "x-vercel-ip-timezone": "Europe/Berlin", + }), "vercel"); + + expect(result).toEqual({ + maybeSpoofed: true, + spoofedInfo: { + ip: "203.0.113.10", + }, + }); + }); + + test("keeps trusted proxy geo headers when the trusted IP header is present", () => { + const result = getBrowserEndUserInfo(new Headers({ + "user-agent": "Mozilla/5.0", + "x-vercel-forwarded-for": "203.0.113.10", + "x-vercel-ip-country": "DE", + "x-vercel-ip-country-region": "BE", + "x-vercel-ip-city": "Berlin", + "x-vercel-ip-latitude": "52.52", + "x-vercel-ip-longitude": "13.40", + "x-vercel-ip-timezone": "Europe/Berlin", + }), "vercel"); + + expect(result).toEqual({ + maybeSpoofed: false, + exactInfo: { + ip: "203.0.113.10", + countryCode: "DE", + regionCode: "BE", + cityName: "Berlin", + latitude: 52.52, + longitude: 13.4, + tzIdentifier: "Europe/Berlin", + }, + }); + }); +}); diff --git a/apps/backend/src/lib/oauth.tsx b/apps/backend/src/lib/oauth.tsx index 98cf315af2..3b796771a6 100644 --- a/apps/backend/src/lib/oauth.tsx +++ b/apps/backend/src/lib/oauth.tsx @@ -131,7 +131,7 @@ export async function linkOAuthAccountToUser( tenancyId: string, providerId: string, providerAccountId: string, - email?: string, + email: string | null, projectUserId: string, } ): Promise<{ oauthAccountId: string }> { @@ -189,12 +189,12 @@ export async function createOAuthUserAndAccount( params: { providerId: string, providerAccountId: string, - email?: string, + email: string | null, emailVerified: boolean, primaryEmailAuthEnabled: boolean, - currentUser?: UsersCrud["Admin"]["Read"] | null, - displayName?: string, - profileImageUrl?: string, + currentUser: UsersCrud["Admin"]["Read"] | null, + displayName: string | null, + profileImageUrl: string | null, signUpRuleOptions: SignUpRuleOptions, } ): Promise<{ projectUserId: string, oauthAccountId: string }> { @@ -206,7 +206,7 @@ export async function createOAuthUserAndAccount( // Create new user (or upgrade anonymous user) with sign-up rule evaluation const newUser = await createOrUpgradeAnonymousUserWithRules( tenancy, - params.currentUser ?? null, + params.currentUser, { display_name: params.displayName, profile_image_url: params.profileImageUrl, diff --git a/apps/backend/src/lib/openapi.tsx b/apps/backend/src/lib/openapi.tsx index cfe266855a..467f329568 100644 --- a/apps/backend/src/lib/openapi.tsx +++ b/apps/backend/src/lib/openapi.tsx @@ -61,8 +61,10 @@ export function parseWebhookOpenAPI(options: { type: yupString().defined().meta({ openapiField: { description: webhook.type, exampleValue: webhook.type } }), data: webhook.schema.defined(), }).describe()) || yupObject().describe(), - responseTypeDesc: yupString().oneOf(['json']).describe(), - statusCodeDesc: yupNumber().oneOf([200]).describe(), + responseVariants: [{ + responseTypeDesc: yupString().oneOf(['json']).describe(), + statusCodeDesc: yupNumber().oneOf([200]).describe(), + }], }), operationId: webhook.type, summary: webhook.type, @@ -117,22 +119,19 @@ function isMaybeRequestSchemaForAudience(requestDescribe: yup.SchemaObjectDescri return true; } - function parseRouteHandler(options: { handler: SmartRouteHandler, path: string, method: HttpMethod, audience: 'client' | 'server' | 'admin', }) { - let result: any = undefined; + let result: ReturnType | undefined; for (const overload of options.handler.overloads.values()) { if (overload.metadata?.hidden) continue; const requestDescribe = overload.request.describe(); - const responseDescribe = overload.response.describe(); if (!isSchemaObjectDescription(requestDescribe)) throw new Error('Request schema must be a yup.ObjectSchema'); - if (!isSchemaObjectDescription(responseDescribe)) throw new Error('Response schema must be a yup.ObjectSchema'); // estimate whether this overload is the right one based on a heuristic if (!isMaybeRequestSchemaForAudience(requestDescribe, options.audience)) { @@ -150,6 +149,9 @@ function parseRouteHandler(options: { `); } + const responseSchemaInfo = overload.response.meta()?.stackSchemaInfo; + const responseSchemas: yup.AnySchema[] = responseSchemaInfo?.type === "union" ? responseSchemaInfo.items : [overload.response]; + result = parseOverload({ metadata: overload.metadata, method: options.method, @@ -158,9 +160,17 @@ function parseRouteHandler(options: { parameterDesc: undefinedIfMixed(requestDescribe.fields.query), headerDesc: undefinedIfMixed(requestDescribe.fields.headers), requestBodyDesc: undefinedIfMixed(requestDescribe.fields.body), - responseDesc: undefinedIfMixed(responseDescribe.fields.body), - responseTypeDesc: undefinedIfMixed(responseDescribe.fields.bodyType) ?? throwErr('Response type must be defined and not mixed', { options, bodyTypeField: responseDescribe.fields.bodyType }), - statusCodeDesc: undefinedIfMixed(responseDescribe.fields.statusCode) ?? throwErr('Status code must be defined and not mixed', { options, statusCodeField: responseDescribe.fields.statusCode }), + responseVariants: responseSchemas.map((schema) => { + const responseDescribe = schema.describe(); + if (!isSchemaObjectDescription(responseDescribe)) { + throw new Error('Response schema must be a yup.ObjectSchema'); + } + return { + responseDesc: undefinedIfMixed(responseDescribe.fields.body), + responseTypeDesc: undefinedIfMixed(responseDescribe.fields.bodyType) ?? throwErr('Response type must be defined and not mixed', { options, bodyTypeField: responseDescribe.fields.bodyType }), + statusCodeDesc: undefinedIfMixed(responseDescribe.fields.statusCode) ?? throwErr('Status code must be defined and not mixed', { options, statusCodeField: responseDescribe.fields.statusCode }), + }; + }), }); } @@ -327,9 +337,11 @@ export function parseOverload(options: { parameterDesc?: yup.SchemaFieldDescription, headerDesc?: yup.SchemaFieldDescription, requestBodyDesc?: yup.SchemaFieldDescription, - responseDesc?: yup.SchemaFieldDescription, - responseTypeDesc: yup.SchemaFieldDescription, - statusCodeDesc: yup.SchemaFieldDescription, + responseVariants: Array<{ + responseDesc?: yup.SchemaFieldDescription, + responseTypeDesc: yup.SchemaFieldDescription, + statusCodeDesc: yup.SchemaFieldDescription, + }>, }) { const endpointDocumentation = options.metadata ?? { summary: `${options.method} ${options.path}`, @@ -359,87 +371,61 @@ export function parseOverload(options: { }; } - const exRes = { - summary: endpointDocumentation.summary, - description: endpointDocumentation.description, - parameters: [...queryParameters, ...pathParameters, ...headerParameters], - requestBody, - tags: endpointDocumentation.tags ?? ["Others"], - 'x-full-url': `https://api.stack-auth.com/api/v1${options.path}`, - } as const; + const allResponses: Record = {}; - if (!isSchemaStringDescription(options.responseTypeDesc)) { - throw new StackAssertionError(`Expected response type to be a string`, { actual: options.responseTypeDesc, options }); - } - if (options.responseTypeDesc.oneOf.length !== 1) { - throw new StackAssertionError(`Expected response type to have exactly one value`, { actual: options.responseTypeDesc, options }); - } - const bodyType = options.responseTypeDesc.oneOf[0]; + for (const { responseDesc, responseTypeDesc, statusCodeDesc } of options.responseVariants) { + if (!isSchemaStringDescription(responseTypeDesc)) { + throw new StackAssertionError(`Expected response type to be a string`, { actual: responseTypeDesc, options }); + } + if (responseTypeDesc.oneOf.length !== 1) { + throw new StackAssertionError(`Expected response type to have exactly one value`, { actual: responseTypeDesc, options }); + } + const bodyType = responseTypeDesc.oneOf[0]; - if (!isSchemaNumberDescription(options.statusCodeDesc)) { - throw new StackAssertionError('Expected status code to be a number', { actual: options.statusCodeDesc, options }); - } + if (!isSchemaNumberDescription(statusCodeDesc)) { + throw new StackAssertionError('Expected status code to be a number', { actual: statusCodeDesc, options }); + } - // Get all status codes or use 200 as default if none specified - const statusCodes: number[] = options.statusCodeDesc.oneOf.length > 0 - ? options.statusCodeDesc.oneOf as number[] - : [200]; // TODO HACK hardcoded, used in case all status codes may be returned, should be configurable per endpoint + // Get all status codes or use 200 as default if none specified + const statusCodes: number[] = statusCodeDesc.oneOf.length > 0 + ? statusCodeDesc.oneOf as number[] + : [200]; // TODO HACK hardcoded, used in case all status codes may be returned, should be configurable per endpoint - switch (bodyType) { - case 'json': { - const responses = statusCodes.reduce((acc, status) => { - return { - ...acc, - [status]: { + for (const status of statusCodes) { + switch (bodyType) { + case 'json': { + allResponses[status] = { description: 'Successful response', content: { 'application/json': { schema: { - ...options.responseDesc ? toSchema(options.responseDesc, endpointDocumentation.crudOperation) : {}, - required: options.responseDesc ? toRequired(options.responseDesc, endpointDocumentation.crudOperation) : undefined, + ...responseDesc ? toSchema(responseDesc, endpointDocumentation.crudOperation) : {}, + required: responseDesc ? toRequired(responseDesc, endpointDocumentation.crudOperation) : undefined, }, }, }, - }, - }; - }, {}); - - return { - ...exRes, - responses, - }; - } - case 'text': { - if (!options.responseDesc || !isSchemaStringDescription(options.responseDesc)) { - throw new StackAssertionError('Expected response body of bodyType=="text" to be a string schema', { actual: options.responseDesc }); - } - const responses = statusCodes.reduce((acc, status) => { - return { - ...acc, - [status]: { + }; + break; + } + case 'text': { + if (!responseDesc || !isSchemaStringDescription(responseDesc)) { + throw new StackAssertionError('Expected response body of bodyType=="text" to be a string schema', { actual: responseDesc }); + } + allResponses[status] = { description: 'Successful response', content: { 'text/plain': { schema: { type: 'string', - example: options.responseDesc && isSchemaStringDescription(options.responseDesc) ? options.responseDesc.meta?.openapiField?.exampleValue : undefined, + example: isSchemaStringDescription(responseDesc) ? responseDesc.meta?.openapiField?.exampleValue : undefined, }, }, }, - }, - }; - }, {}); - - return { - ...exRes, - responses, - }; - } - case 'success': { - const responses = statusCodes.reduce((acc, status) => { - return { - ...acc, - [status]: { + }; + break; + } + case 'success': { + allResponses[status] = { description: 'Successful response', content: { "application/json": { @@ -456,32 +442,29 @@ export function parseOverload(options: { }, }, }, - }, - }; - }, {}); - - return { - ...exRes, - responses, - }; - } - case 'empty': { - const responses = statusCodes.reduce((acc, status) => { - return { - ...acc, - [status]: { + }; + break; + } + case 'empty': { + allResponses[status] = { description: 'No content', - }, - }; - }, {}); - - return { - ...exRes, - responses, - }; - } - default: { - throw new StackAssertionError(`Unsupported body type: ${bodyType}`); + }; + break; + } + default: { + throw new StackAssertionError(`Unsupported body type: ${bodyType}`); + } + } } } + + return { + summary: endpointDocumentation.summary, + description: endpointDocumentation.description, + parameters: [...queryParameters, ...pathParameters, ...headerParameters], + requestBody, + tags: endpointDocumentation.tags ?? ["Others"], + 'x-full-url': `https://api.stack-auth.com/api/v1${options.path}`, + responses: allResponses, + }; } diff --git a/apps/backend/src/lib/risk-scores.tsx b/apps/backend/src/lib/risk-scores.tsx new file mode 100644 index 0000000000..f661f46f49 --- /dev/null +++ b/apps/backend/src/lib/risk-scores.tsx @@ -0,0 +1,317 @@ +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; +import type { SignUpRiskScoresCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import type { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { isIpAddress } from "@stackframe/stack-shared/dist/utils/ips"; +import path from "node:path"; +import { checkEmailWithEmailable } from "./emailable"; +import { normalizeEmail } from "./emails"; +import { createNeutralSignUpHeuristicFacts, type DerivedSignUpHeuristicFacts } from "./sign-up-heuristics"; +import type { Tenancy } from "./tenancies"; +import type { SignUpTurnstileAssessment } from "./turnstile"; + + +// ── Types ────────────────────────────────────────────────────────────── + +export type SignUpRiskScores = SignUpRiskScoresCrud; + +export type SignUpRiskScoreContext = { + primaryEmail: string | null, + primaryEmailVerified: boolean, + authMethod: SignUpAuthMethod, + oauthProvider: string | null, + ipAddress: string | null, + ipTrusted: boolean | null, + turnstileAssessment: SignUpTurnstileAssessment, +}; + +export type SignUpRiskAssessment = { + scores: SignUpRiskScores, + heuristicFacts: DerivedSignUpHeuristicFacts, +}; + +export type SignUpRiskRecentStatsRequest = { + signedUpAt: Date, + signUpIp: string | null, + signUpEmailBase: string | null, + recentWindowHours: number, + sameIpLimit: number, + similarEmailLimit: number, +}; + +export type SignUpRiskRecentStats = { + sameIpCount: number, + similarEmailCount: number, +}; + +export type SignUpRiskEngineDependencies = { + now: () => Date, + normalizeEmail: (email: string) => string, + isIpAddress: (ipAddress: string) => boolean, + createAssertionError: (message: string, details: Record) => Error, + checkPrimaryEmailRisk: (email: string) => Promise<{ emailableScore: number | null }>, + loadRecentSignUpStats: (request: SignUpRiskRecentStatsRequest) => Promise, +}; + +export type SignUpRiskEngine = { + calculateRiskAssessment: ( + context: SignUpRiskScoreContext, + dependencies: SignUpRiskEngineDependencies, + ) => Promise, +}; + + +// ── Fallback engine (zero scores) ────────────────────────────────────── + +const ZERO_SCORES: SignUpRiskScores = { bot: 0, free_trial_abuse: 0 }; + +const fallbackEngine: SignUpRiskEngine = { + async calculateRiskAssessment(_context, deps) { + return { scores: ZERO_SCORES, heuristicFacts: createNeutralSignUpHeuristicFacts(deps.now()) }; + }, +}; + + +// ── Private engine loader ────────────────────────────────────────────── + +const PRIVATE_MODULE_PATH = "dist/sign-up-risk-engine.js"; +const PRIVATE_PACKAGE_IMPORT = "@stackframe/private/dist/sign-up-risk-engine.js"; + +const _testOverrides = { + rootPath: null as string | null, + importer: null as ((modulePath: string) => Promise) | null, +}; + +let cachedEngine: Promise | null = null; + +function isEngine(value: unknown): value is SignUpRiskEngine { + return typeof value === "object" && value !== null + && "calculateRiskAssessment" in value + && typeof (value as Record).calculateRiskAssessment === "function"; +} + +function extractEngine(mod: unknown): SignUpRiskEngine { + const nested = (obj: unknown, key: string): unknown => + typeof obj === "object" && obj !== null && key in obj ? (obj as Record)[key] : undefined; + + const defaultExport = nested(mod, "default"); + for (const candidate of [mod, nested(mod, "signUpRiskEngine"), defaultExport, nested(defaultExport, "signUpRiskEngine")]) { + if (isEngine(candidate)) return candidate; + } + + throw new StackAssertionError("Private sign-up risk module does not export a valid signUpRiskEngine"); +} + +function getFallbackPaths(): string[] { + if (_testOverrides.rootPath != null) { + return [path.join(_testOverrides.rootPath, PRIVATE_MODULE_PATH)]; + } + const cwd = process.cwd(); + return [ + path.join(cwd, "packages/private", PRIVATE_MODULE_PATH), // monorepo root + path.join(cwd, "../../packages/private", PRIVATE_MODULE_PATH), // workspace dir (e.g. apps/backend) + ]; +} + +function isModuleNotFound(e: unknown): boolean { + if (typeof e === "object" && e !== null && "code" in e) { + const code = (e as { code: unknown }).code; + return code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND"; + } + return false; +} + +// Native dynamic import — the webpackIgnore comment prevents bundler transformation. +function nativeImport(modulePath: string): Promise { + return import(/* webpackIgnore: true */ modulePath); +} + +async function loadEngine(): Promise { + const importer = _testOverrides.importer ?? nativeImport; + const fallbackPaths = getFallbackPaths(); + + // 1. Try package-name resolution (works when @stackframe/private is a proper dependency) + try { + const engine = extractEngine(await importer(PRIVATE_PACKAGE_IMPORT)); + console.info("[risk-scores] Loaded private sign-up risk engine via package import"); + return engine; + } catch (e: unknown) { + if (!isModuleNotFound(e)) { + captureError("sign-up-risk-engine-load", new StackAssertionError( + "Failed to load private sign-up risk engine via package import", + { importPath: PRIVATE_PACKAGE_IMPORT, cause: e }, + )); + } + } + + // 2. Fall back to path-based resolution for monorepo setups + for (const fullPath of fallbackPaths) { + try { + const engine = extractEngine(await importer(fullPath)); + console.info("[risk-scores] Loaded private sign-up risk engine from path:", fullPath); + return engine; + } catch (e: unknown) { + if (!isModuleNotFound(e)) { + captureError("sign-up-risk-engine-load", new StackAssertionError( + "Failed to load private sign-up risk engine from path", + { fullPath, cause: e }, + )); + } + } + } + + // 3. No engine found — fall back to zero scores + captureError("sign-up-risk-engine-not-found", new StackAssertionError( + "Private sign-up risk engine not found — using fallback (zero scores)", + { searchedPaths: [PRIVATE_PACKAGE_IMPORT, ...fallbackPaths] }, + )); + return fallbackEngine; +} + +function getEngine(): Promise { + if (cachedEngine == null) { + cachedEngine = loadEngine().catch((e) => { + cachedEngine = null; // clear so next call retries + throw e; + }); + } + return cachedEngine; +} + +function resetForTests() { + cachedEngine = null; + _testOverrides.rootPath = null; + _testOverrides.importer = null; +} + + +// ── DB queries ───────────────────────────────────────────────────────── + +async function loadRecentSignUpStats( + tenancy: Tenancy, + request: SignUpRiskRecentStatsRequest, +): Promise { + const prisma = await getPrismaClientForTenancy(tenancy); + const schema = await getPrismaSchemaForTenancy(tenancy); + const windowStart = new Date(request.signedUpAt.getTime() - request.recentWindowHours * 60 * 60 * 1000); + + const [sameIpRows, similarEmailRows] = await Promise.all([ + request.signUpIp == null || request.sameIpLimit === 0 + ? [] + : prisma.$replica().$queryRaw<{ matched: number }[]>` + SELECT 1 AS "matched" + FROM ${sqlQuoteIdent(schema)}."ProjectUser" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "isAnonymous" = false + AND "signedUpAt" >= ${windowStart} + AND "signUpIp" = ${request.signUpIp} + LIMIT ${request.sameIpLimit} + `, + + request.signUpEmailBase == null || request.similarEmailLimit === 0 + ? [] + : prisma.$replica().$queryRaw<{ matched: number }[]>` + SELECT 1 AS "matched" + FROM ${sqlQuoteIdent(schema)}."ProjectUser" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "isAnonymous" = false + AND "signedUpAt" >= ${windowStart} + AND "signUpEmailBase" = ${request.signUpEmailBase} + LIMIT ${request.similarEmailLimit} + `, + ]); + + return { + sameIpCount: sameIpRows.length, + similarEmailCount: similarEmailRows.length, + }; +} + +function createDependencies(tenancy: Tenancy): SignUpRiskEngineDependencies { + return { + now: () => new Date(), + normalizeEmail, + isIpAddress, + createAssertionError: (message, details) => new StackAssertionError(message, details), + checkPrimaryEmailRisk: async (email) => ({ + emailableScore: (await checkEmailWithEmailable(email)).emailableScore, + }), + loadRecentSignUpStats: (request) => loadRecentSignUpStats(tenancy, request), + }; +} + + +// ── Public API ───────────────────────────────────────────────────────── + +export async function calculateSignUpRiskAssessment( + tenancy: Tenancy, + context: SignUpRiskScoreContext, +): Promise { + const engine = await getEngine(); + try { + return await engine.calculateRiskAssessment(context, createDependencies(tenancy)); + } catch (error) { + captureError("sign-up-risk-engine-error", error); + return { scores: ZERO_SCORES, heuristicFacts: createNeutralSignUpHeuristicFacts(new Date()) }; + } +} + +export async function calculateSignUpRiskScores( + tenancy: Tenancy, + context: SignUpRiskScoreContext, +): Promise { + return (await calculateSignUpRiskAssessment(tenancy, context)).scores; +} + + +// ── Tests ────────────────────────────────────────────────────────────── + +import.meta.vitest?.test("fallback engine returns zero scores", async ({ expect }) => { + const now = new Date("2026-03-11T00:00:00.000Z"); + const assessment = await fallbackEngine.calculateRiskAssessment({ + primaryEmail: "user@example.com", + primaryEmailVerified: false, + authMethod: "password", + oauthProvider: null, + ipAddress: "127.0.0.1", + ipTrusted: true, + turnstileAssessment: { status: "invalid" }, + }, { + now: () => now, + normalizeEmail, + isIpAddress, + createAssertionError: (msg, details) => new StackAssertionError(msg, details), + checkPrimaryEmailRisk: async () => ({ emailableScore: 100 }), + loadRecentSignUpStats: async () => ({ sameIpCount: 10, similarEmailCount: 10 }), + }); + + expect(assessment).toEqual({ + scores: ZERO_SCORES, + heuristicFacts: createNeutralSignUpHeuristicFacts(now), + }); +}); + +import.meta.vitest?.test("loader falls back when private submodule is absent", async ({ expect }) => { + resetForTests(); + _testOverrides.rootPath = path.join(process.cwd(), "packages", `private-missing-${Date.now()}`); + + try { + expect(await getEngine()).toBe(fallbackEngine); + } finally { + resetForTests(); + } +}); + +import.meta.vitest?.test("loader falls back when private engine import fails", async ({ expect }) => { + resetForTests(); + _testOverrides.rootPath = path.join(process.cwd(), "packages", "private"); + _testOverrides.importer = async () => { + throw new Error("private engine exploded"); + }; + + try { + expect(await getEngine()).toBe(fallbackEngine); + } finally { + resetForTests(); + } +}); diff --git a/apps/backend/src/lib/sign-up-context.ts b/apps/backend/src/lib/sign-up-context.ts new file mode 100644 index 0000000000..928a6a49b0 --- /dev/null +++ b/apps/backend/src/lib/sign-up-context.ts @@ -0,0 +1,107 @@ +import { yupBoolean, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods"; +import { BestEffortEndUserRequestContext } from "./end-users"; +import { SignUpTurnstileAssessment } from "./turnstile"; +import { SignUpRuleOptions } from "./users"; + +export const storedSignUpRequestContextSchemaFields = { + sign_up_ip_address: yupString().nullable().optional(), + sign_up_ip_trusted: yupBoolean().nullable().optional(), + sign_up_country_code: yupString().nullable().optional(), +} as const; + +export type StoredSignUpRequestContext = { + sign_up_ip_address: string | null, + sign_up_ip_trusted: boolean | null, + sign_up_country_code: string | null, +}; + +export function serializeStoredSignUpRequestContext(requestContext: BestEffortEndUserRequestContext): StoredSignUpRequestContext { + return { + sign_up_ip_address: requestContext.ipAddress, + sign_up_ip_trusted: requestContext.ipTrusted, + sign_up_country_code: requestContext.location?.countryCode ?? null, + }; +} + +export function deserializeStoredSignUpRequestContext(data: Partial): BestEffortEndUserRequestContext | null { + const signUpIpAddress = data.sign_up_ip_address ?? null; + const signUpIpTrusted = data.sign_up_ip_trusted ?? null; + const signUpCountryCode = data.sign_up_country_code ?? null; + + if (signUpIpAddress == null && signUpIpTrusted == null && signUpCountryCode == null) { + return null; + } + + return { + ipAddress: signUpIpAddress, + ipTrusted: signUpIpTrusted, + location: signUpCountryCode == null ? null : { + countryCode: signUpCountryCode, + }, + }; +} + +/** + * Builds a `SignUpRuleOptions` from a request context and auth details. + * Centralises the boilerplate that every auth route previously duplicated. + */ +export function buildSignUpRuleOptions(params: { + authMethod: SignUpAuthMethod, + oauthProvider: string | null, + requestContext: BestEffortEndUserRequestContext | null, + turnstileAssessment: SignUpTurnstileAssessment, +}): SignUpRuleOptions { + return { + authMethod: params.authMethod, + oauthProvider: params.oauthProvider, + ipAddress: params.requestContext?.ipAddress ?? null, + ipTrusted: params.requestContext?.ipTrusted ?? null, + countryCode: params.requestContext?.location?.countryCode ?? null, + requestContext: params.requestContext, + turnstileAssessment: params.turnstileAssessment, + }; +} + +/** + * Reconstructs a `SignUpTurnstileAssessment` from stored status/result values. + * Used in OAuth callbacks and OTP verification where the assessment was serialized. + */ +export function reconstructTurnstileAssessment( + status: SignUpTurnstileAssessment["status"], + visibleChallengeResult?: SignUpTurnstileAssessment["visibleChallengeResult"], +): SignUpTurnstileAssessment { + if (visibleChallengeResult != null) { + return { status, visibleChallengeResult }; + } + return { status }; +} + +export function deserializeStoredTurnstileAssessment( + status: SignUpTurnstileAssessment["status"] | undefined, + visibleChallengeResult?: SignUpTurnstileAssessment["visibleChallengeResult"], +): SignUpTurnstileAssessment { + if (status == null) { + return { status: "error" }; + } + return reconstructTurnstileAssessment(status, visibleChallengeResult); +} + + +// ── Tests ────────────────────────────────────────────────────────────── + +import.meta.vitest?.describe("stored sign-up context helpers", () => { + const { expect, test } = import.meta.vitest!; + + test("backward-compatible schema accepts missing stored request context fields", async () => { + await expect(yupObject(storedSignUpRequestContextSchemaFields).defined().validate({})).resolves.toEqual({}); + }); + + test("missing stored request context deserializes to null", () => { + expect(deserializeStoredSignUpRequestContext({})).toBeNull(); + }); + + test("missing stored turnstile result falls back to a neutral assessment", () => { + expect(deserializeStoredTurnstileAssessment(undefined)).toEqual({ status: "error" }); + }); +}); diff --git a/apps/backend/src/lib/sign-up-heuristics.tsx b/apps/backend/src/lib/sign-up-heuristics.tsx new file mode 100644 index 0000000000..74aec6a51f --- /dev/null +++ b/apps/backend/src/lib/sign-up-heuristics.tsx @@ -0,0 +1,35 @@ +export type DerivedSignUpHeuristicFacts = { + signedUpAt: Date, + signUpIp: string | null, + signUpIpTrusted: boolean | null, + signUpEmailNormalized: string | null, + signUpEmailBase: string | null, + emailNormalized: string | null, + emailBase: string | null, +}; + +export function createNeutralSignUpHeuristicFacts(recordedAt: Date = new Date()): DerivedSignUpHeuristicFacts { + return { + signedUpAt: recordedAt, + signUpIp: null, + signUpIpTrusted: null, + signUpEmailNormalized: null, + signUpEmailBase: null, + emailNormalized: null, + emailBase: null, + }; +} + +import.meta.vitest?.test("createNeutralSignUpHeuristicFacts(...)", ({ expect }) => { + const recordedAt = new Date("2026-03-11T00:00:00.000Z"); + + expect(createNeutralSignUpHeuristicFacts(recordedAt)).toEqual({ + signedUpAt: recordedAt, + signUpIp: null, + signUpIpTrusted: null, + signUpEmailNormalized: null, + signUpEmailBase: null, + emailNormalized: null, + emailBase: null, + }); +}); diff --git a/apps/backend/src/lib/sign-up-rules.ts b/apps/backend/src/lib/sign-up-rules.ts index e3210d2f96..4a9b7ecde7 100644 --- a/apps/backend/src/lib/sign-up-rules.ts +++ b/apps/backend/src/lib/sign-up-rules.ts @@ -176,27 +176,29 @@ function evaluateSignUpRulesInternal( } } if (actionType === 'allow' || actionType === 'reject') { + const outcome = { + restrictedBecauseOfSignUpRuleId, + shouldAllow: actionType === 'allow', + decision: actionType, + decisionRuleId: ruleId, + }; return { evaluations, - outcome: { - restrictedBecauseOfSignUpRuleId, - shouldAllow: actionType === 'allow', - decision: actionType, - decisionRuleId: ruleId, - }, + outcome, }; } } } const shouldAllow = config.auth.signUpRulesDefaultAction !== 'reject'; + const outcome = { + restrictedBecauseOfSignUpRuleId, + shouldAllow, + decision: shouldAllow ? 'default-allow' as const : 'default-reject' as const, + decisionRuleId: null, + }; return { evaluations, - outcome: { - restrictedBecauseOfSignUpRuleId, - shouldAllow, - decision: shouldAllow ? 'default-allow' : 'default-reject', - decisionRuleId: null, - }, + outcome, }; } diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 4fc989d61c..82bb8f4afe 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -11,6 +11,7 @@ import { captureError, StackAssertionError, throwErr } from '@stackframe/stack-s import { getPrivateJwks, getPublicJwkSet, signJWT, verifyJWT } from '@stackframe/stack-shared/dist/utils/jwt'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry'; +import { turnstileResultValues } from '@stackframe/stack-shared/dist/utils/turnstile'; import * as jose from 'jose'; import { JOSEError, JWTExpired } from 'jose/errors'; import { getEndUserIpInfoForEvent, logEvent, SystemEventTypes } from './events'; @@ -45,6 +46,10 @@ export const oauthCookieSchema = yupObject({ providerScope: yupString().optional(), errorRedirectUrl: yupString().optional(), afterCallbackRedirectUrl: yupString().optional(), + // TODO next-release: make these .defined() once all deployments write these fields into the cookie + turnstileResult: yupString().oneOf(turnstileResultValues).optional(), + turnstileVisibleChallengeResult: yupString().oneOf(turnstileResultValues).optional(), + responseMode: yupString().oneOf(['json', 'redirect']).optional(), }); type UserType = 'normal' | 'restricted' | 'anonymous'; diff --git a/apps/backend/src/lib/turnstile.tsx b/apps/backend/src/lib/turnstile.tsx new file mode 100644 index 0000000000..8dbe37b3bd --- /dev/null +++ b/apps/backend/src/lib/turnstile.tsx @@ -0,0 +1,493 @@ +import { KnownErrors } from "@stackframe/stack-shared"; +import { yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvBoolean, getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { + TurnstileAction, + TurnstilePhase, + TurnstileResult, + turnstileDevelopmentKeys, + turnstilePhaseValues, +} from "@stackframe/stack-shared/dist/utils/turnstile"; +import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls"; +import { BestEffortEndUserRequestContext, getBestEffortEndUserRequestContext } from "./end-users"; +import { Tenancy } from "./tenancies"; + + +// ── Types ────────────────────────────────────────────────────────────── + +export type SignUpTurnstileAssessment = { + status: TurnstileResult, + visibleChallengeResult?: TurnstileResult, +}; + +export type BotChallengeFlowRequest = { + bot_challenge_token?: string, + bot_challenge_phase?: TurnstilePhase, + bot_challenge_unavailable?: "true", +}; + +export const botChallengeFlowRequestSchemaFields = { + bot_challenge_token: yupString().optional(), + bot_challenge_phase: yupString().oneOf(turnstilePhaseValues).optional(), + bot_challenge_unavailable: yupString().oneOf(["true"]).optional(), +} as const; + +type SiteverifyResponse = { + success: boolean, + action?: string, + hostname?: string, + "error-codes"?: string[], +}; + + +// ── Configuration ────────────────────────────────────────────────────── + +const FETCH_TIMEOUT_MS = 10_000; + +function isAllowedTurnstileHostname(hostname: string, tenancy: Tenancy): boolean { + if (tenancy.config.domains.allowLocalhost && isLocalhost(`http://${hostname}`)) { + return true; + } + return Object.values(tenancy.config.domains.trustedDomains).some(({ baseUrl }) => { + if (baseUrl == null) return false; + const pattern = createUrlIfValid(baseUrl)?.hostname + ?? baseUrl.match(/^[^:]+:\/\/([^/:]+)/)?.[1]; + return pattern != null && matchHostnamePattern(pattern, hostname); + }); +} + +function getSecretKey(override?: string): string { + if (override) return override; + const isDev = ["development", "test"].includes(getNodeEnvironment()); + return getEnvVariable("STACK_TURNSTILE_SECRET_KEY", isDev ? turnstileDevelopmentKeys.secretKey : ""); +} + +function getSiteverifyUrl(): string { + return getEnvVariable("STACK_TURNSTILE_SITEVERIFY_URL", "https://challenges.cloudflare.com/turnstile/v0/siteverify"); +} + +const visibleChallengeSignupBypassActions = new Set([ + "sign_up_with_credential", + "send_magic_link_email", + "oauth_authenticate", +]); + +export function isBotChallengeDisabled(): boolean { + return getEnvBoolean("STACK_DISABLE_BOT_CHALLENGE"); +} + +export function getDisabledBotChallengeAssessment(): SignUpTurnstileAssessment { + return { status: "ok" }; +} + +function shouldAllowInvalidVisibleChallengeBypass(expectedAction: TurnstileAction): boolean { + return getEnvBoolean("STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE") + && visibleChallengeSignupBypassActions.has(expectedAction); +} + + +// ── Siteverify ───────────────────────────────────────────────────────── + +function isSiteverifyResponse(value: unknown): value is SiteverifyResponse { + return value != null && typeof value === "object" && !Array.isArray(value) + && "success" in value && typeof value.success === "boolean"; +} + +async function fetchSiteverify(token: string, remoteIp: string | null, secretKey: string): Promise { + const body = new URLSearchParams({ secret: secretKey, response: token }); + if (remoteIp != null) { + body.set("remoteip", remoteIp); + } + + // No retry — a failed verification triggers a visible challenge on the client, + // which is preferable to silently accepting a potentially-replayed token. + const response = await fetch(getSiteverifyUrl(), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new StackAssertionError("Turnstile siteverify request failed", { + status: response.status, + statusText: response.statusText, + }); + } + + const json = await response.json(); + if (!isSiteverifyResponse(json)) { + throw new StackAssertionError("Turnstile siteverify response missing required fields", { json }); + } + + return json; +} + + +// ── Token verification ───────────────────────────────────────────────── + +export async function verifyTurnstileToken(params: { + token: string | undefined, + remoteIp: string | null, + expectedAction: TurnstileAction, + isAllowedHostname?: (hostname: string) => boolean, + secretKey?: string, + captureRejectedAsError?: boolean, +}): Promise { + const token = params.token?.trim() ?? ""; + if (!token) { + return { status: "invalid" }; + } + + const result = await Result.fromThrowingAsync( + () => fetchSiteverify(token, params.remoteIp, getSecretKey(params.secretKey)), + ); + + if (result.status === "error") { + captureError("turnstile-siteverify-error", new StackAssertionError("Turnstile siteverify request failed", { + cause: result.error, + expectedAction: params.expectedAction, + })); + return { status: "error" }; + } + + const data = result.data; + + if (!data.success) { + if (params.captureRejectedAsError ?? true) { + captureError("turnstile-siteverify-rejected", new StackAssertionError("Turnstile siteverify returned success=false", { + errorCodes: data["error-codes"], + expectedAction: params.expectedAction, + receivedAction: data.action, + hostname: data.hostname, + })); + } + return { status: "invalid" }; + } + + if (data.hostname != null && params.isAllowedHostname != null && !params.isAllowedHostname(data.hostname)) { + captureError("turnstile-hostname-mismatch", new StackAssertionError("Turnstile hostname does not match any allowed domain", { + receivedHostname: data.hostname, + })); + return { status: "invalid" }; + } + + if (data.action != null && data.action !== params.expectedAction) { + return { status: "invalid" }; + } + + return { status: "ok" }; +} + +export async function verifyTurnstileTokenWithOptionalVisibleChallenge(params: { + token: string | undefined, + remoteIp: string | null, + expectedAction: TurnstileAction, + isAllowedHostname?: (hostname: string) => boolean, + phase?: "invisible" | "visible", + challengeUnavailable?: boolean, + secretKey?: string, +}): Promise { + if (isBotChallengeDisabled()) { + return getDisabledBotChallengeAssessment(); + } + + const phase = params.phase; + if (params.challengeUnavailable) { + if (params.token != null || phase != null) { + throw new StackAssertionError("challengeUnavailable cannot be combined with a bot challenge token or phase"); + } + return { status: "error", visibleChallengeResult: "error" }; + } + + const assessment = await verifyTurnstileToken({ + ...params, + // Invisible rejection is often the normal escalation path into a visible challenge, + // so only capture rejections as errors once we're outside that first phase. + captureRejectedAsError: phase !== "invisible", + }); + + if (phase == null) { + // Legacy clients do not participate in the multi-phase challenge flow, so they + // still receive the raw assessment directly. + return assessment; + } + + if (phase === "invisible") { + if (assessment.status !== "ok") { + throw new KnownErrors.BotChallengeRequired(); + } + return assessment; + } + + if (assessment.status !== "ok") { + if (shouldAllowInvalidVisibleChallengeBypass(params.expectedAction)) { + return { status: "invalid", visibleChallengeResult: "invalid" }; + } + throw new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"); + } + + // Visible passed but invisible failed — always record "invalid" rather than + // trusting a client-supplied value (a malicious client could claim "error" + // to avoid the risk-score penalty that "invalid" carries). + return { status: "invalid", visibleChallengeResult: "ok" }; +} + + +// ── Convenience ──────────────────────────────────────────────────────── + +export async function getRequestContextAndBotChallengeAssessment( + botChallenge: BotChallengeFlowRequest, + expectedAction: TurnstileAction, + tenancy: Tenancy, +): Promise<{ + requestContext: BestEffortEndUserRequestContext, + turnstileAssessment: SignUpTurnstileAssessment, +}> { + const requestContext = await getBestEffortEndUserRequestContext(); + const turnstileAssessment = await verifyTurnstileTokenWithOptionalVisibleChallenge({ + token: botChallenge.bot_challenge_token, + remoteIp: requestContext.ipAddress, + expectedAction, + isAllowedHostname: (hostname) => isAllowedTurnstileHostname(hostname, tenancy), + phase: botChallenge.bot_challenge_phase, + challengeUnavailable: botChallenge.bot_challenge_unavailable === "true", + }); + return { requestContext, turnstileAssessment }; +} + + +// ── Tests ────────────────────────────────────────────────────────────── + +import.meta.vitest?.describe("verifyTurnstileToken(...)", () => { + const { vi, test, afterEach } = import.meta.vitest!; + + const stubFetch = (response: object, status = 200) => { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify(response), { + status, + headers: { "Content-Type": "application/json" }, + })); + }; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + const baseParams = { + remoteIp: null as string | null, + expectedAction: "sign_up_with_credential" as const, + secretKey: "secret-key", + }; + + test("returns invalid for empty or omitted token", async ({ expect }) => { + await expect(verifyTurnstileToken({ ...baseParams, token: "" })).resolves.toEqual({ status: "invalid" }); + await expect(verifyTurnstileToken({ ...baseParams, token: undefined })).resolves.toEqual({ status: "invalid" }); + }); + + test("maps siteverify success, rejection, and action mismatch", async ({ expect }) => { + const cases = [ + { response: { success: true, action: "sign_up_with_credential" }, expected: "ok" }, + { response: { success: false, action: "sign_up_with_credential" }, expected: "invalid" }, + { response: { success: true, action: "oauth_authenticate" }, expected: "invalid" }, + ] as const; + + for (const { response, expected } of cases) { + stubFetch(response); + await expect(verifyTurnstileToken({ ...baseParams, token: "real-token", remoteIp: "127.0.0.1" })) + .resolves.toEqual({ status: expected }); + } + }); + + test("returns error when siteverify network fails", async ({ expect }) => { + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + await expect(verifyTurnstileToken({ ...baseParams, token: "real-token", remoteIp: "127.0.0.1" })) + .resolves.toEqual({ status: "error" }); + }); + + test("can suppress captureError for expected siteverify rejections", async ({ expect }) => { + const errorsModule = await import("@stackframe/stack-shared/dist/utils/errors"); + const captureErrorSpy = vi.spyOn(errorsModule, "captureError").mockImplementation(() => {}); + stubFetch({ success: false, action: "sign_up_with_credential" }); + + await expect(verifyTurnstileToken({ + ...baseParams, + token: "real-token", + remoteIp: "127.0.0.1", + captureRejectedAsError: false, + })).resolves.toEqual({ status: "invalid" }); + + expect(captureErrorSpy).not.toHaveBeenCalled(); + }); + + const allowMyapp = (h: string) => h === "myapp.com" || matchHostnamePattern("*.myapp.com", h); + + test("returns invalid when hostname does not match allowed hostnames", async ({ expect }) => { + stubFetch({ success: true, action: "sign_up_with_credential", hostname: "evil.example.com" }); + await expect(verifyTurnstileToken({ + ...baseParams, token: "real-token", remoteIp: "127.0.0.1", + isAllowedHostname: allowMyapp, + })).resolves.toEqual({ status: "invalid" }); + }); + + test("returns ok when hostname matches an allowed hostname", async ({ expect }) => { + stubFetch({ success: true, action: "sign_up_with_credential", hostname: "app.myapp.com" }); + await expect(verifyTurnstileToken({ + ...baseParams, token: "real-token", remoteIp: "127.0.0.1", + isAllowedHostname: allowMyapp, + })).resolves.toEqual({ status: "ok" }); + }); + + test("returns ok when isAllowedHostname accepts the value", async ({ expect }) => { + stubFetch({ success: true, action: "sign_up_with_credential", hostname: "localhost" }); + await expect(verifyTurnstileToken({ + ...baseParams, token: "real-token", remoteIp: "127.0.0.1", + isAllowedHostname: () => true, + })).resolves.toEqual({ status: "ok" }); + }); + + test("skips hostname validation when response omits hostname", async ({ expect }) => { + stubFetch({ success: true, action: "sign_up_with_credential" }); + await expect(verifyTurnstileToken({ + ...baseParams, token: "real-token", remoteIp: "127.0.0.1", + isAllowedHostname: () => false, + })).resolves.toEqual({ status: "ok" }); + }); + + test("skips hostname validation when no isAllowedHostname provided", async ({ expect }) => { + stubFetch({ success: true, action: "sign_up_with_credential", hostname: "anything.com" }); + await expect(verifyTurnstileToken({ + ...baseParams, token: "real-token", remoteIp: "127.0.0.1", + })).resolves.toEqual({ status: "ok" }); + }); + + test("uses development secret key when none is configured", async ({ expect }) => { + const processEnv = Reflect.get(process, "env"); + const originalNodeEnv = Reflect.get(processEnv, "NODE_ENV"); + const originalKey = Reflect.get(processEnv, "STACK_TURNSTILE_SECRET_KEY"); + Reflect.set(processEnv, "NODE_ENV", "development"); + Reflect.set(processEnv, "STACK_TURNSTILE_SECRET_KEY", ""); + + let postedSecret = ""; + try { + vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body; + if (!(body instanceof URLSearchParams)) throw new Error("Expected URLSearchParams body"); + postedSecret = body.get("secret") ?? ""; + return new Response(JSON.stringify({ success: true, action: "sign_up_with_credential" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await expect(verifyTurnstileToken({ ...baseParams, token: "real-token", remoteIp: "127.0.0.1", secretKey: undefined })) + .resolves.toEqual({ status: "ok" }); + } finally { + Reflect.set(processEnv, "NODE_ENV", originalNodeEnv); + Reflect.set(processEnv, "STACK_TURNSTILE_SECRET_KEY", originalKey); + } + + expect(postedSecret).toBe(turnstileDevelopmentKeys.secretKey); + }); +}); + +import.meta.vitest?.describe("verifyTurnstileTokenWithOptionalVisibleChallenge(...)", () => { + const { vi, test, afterEach, beforeEach } = import.meta.vitest!; + const processEnv = Reflect.get(process, "env"); + const originalFlag = Reflect.get(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE"); + const originalDisableFlag = Reflect.get(processEnv, "STACK_DISABLE_BOT_CHALLENGE"); + + beforeEach(() => { + Reflect.deleteProperty(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE"); + Reflect.deleteProperty(processEnv, "STACK_DISABLE_BOT_CHALLENGE"); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (originalFlag === undefined) { + Reflect.deleteProperty(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE"); + } else { + Reflect.set(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE", originalFlag); + } + if (originalDisableFlag === undefined) { + Reflect.deleteProperty(processEnv, "STACK_DISABLE_BOT_CHALLENGE"); + } else { + Reflect.set(processEnv, "STACK_DISABLE_BOT_CHALLENGE", originalDisableFlag); + } + }); + + const baseParams = { + remoteIp: null as string | null, + expectedAction: "send_magic_link_email" as const, + secretKey: "secret-key", + }; + + test("preserves legacy behavior when no phase is provided", async ({ expect }) => { + await expect(verifyTurnstileTokenWithOptionalVisibleChallenge({ ...baseParams, token: undefined })) + .resolves.toEqual({ status: "invalid" }); + }); + + test("throws challenge-required for invisible failures", async ({ expect }) => { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ success: false }), { + status: 200, headers: { "Content-Type": "application/json" }, + })); + await expect(verifyTurnstileTokenWithOptionalVisibleChallenge({ ...baseParams, token: "bad", phase: "invisible" })) + .rejects.toThrowError("An additional bot challenge is required before sign-up can continue."); + }); + + test("returns recovered assessment after successful visible retry", async ({ expect }) => { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ success: true, action: "send_magic_link_email" }), { + status: 200, headers: { "Content-Type": "application/json" }, + })); + await expect(verifyTurnstileTokenWithOptionalVisibleChallenge({ ...baseParams, token: "visible-token", phase: "visible" })) + .resolves.toEqual({ status: "invalid", visibleChallengeResult: "ok" }); + }); + + test("returns a distinct visible-failure assessment when the challenge was unavailable", async ({ expect }) => { + await expect(verifyTurnstileTokenWithOptionalVisibleChallenge({ + ...baseParams, + token: undefined, + challengeUnavailable: true, + })).resolves.toEqual({ status: "error", visibleChallengeResult: "error" }); + }); + + test("skips all bot challenge verification when disabled", async ({ expect }) => { + Reflect.set(processEnv, "STACK_DISABLE_BOT_CHALLENGE", "true"); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + await expect(verifyTurnstileTokenWithOptionalVisibleChallenge({ + ...baseParams, + token: undefined, + phase: "invisible", + challengeUnavailable: true, + })).resolves.toEqual({ status: "ok" }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("can downgrade visible invalid responses into a scored assessment when bypass is enabled", async ({ expect }) => { + Reflect.set(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE", "true"); + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ success: false }), { + status: 200, headers: { "Content-Type": "application/json" }, + })); + await expect(verifyTurnstileTokenWithOptionalVisibleChallenge({ + ...baseParams, + token: "visible-token", + phase: "visible", + })).resolves.toEqual({ status: "invalid", visibleChallengeResult: "invalid" }); + }); + + test("rejects contradictory unavailable and token inputs", async ({ expect }) => { + await expect(verifyTurnstileTokenWithOptionalVisibleChallenge({ + ...baseParams, + token: "visible-token", + phase: "visible", + challengeUnavailable: true, + })).rejects.toThrowError("challengeUnavailable cannot be combined with a bot challenge token or phase"); + }); +}); diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index 4de1115a7a..e0631bdb4e 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -1,19 +1,155 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; import { KnownErrors } from "@stackframe/stack-shared"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { normalizeCountryCode, validCountryCodeSet } from "@stackframe/stack-shared/dist/schema-fields"; +import { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods"; import { KeyIntersect } from "@stackframe/stack-shared/dist/utils/types"; import { createSignUpRuleContext } from "./cel-evaluator"; +import { BestEffortEndUserRequestContext, getBestEffortEndUserRequestContext } from "./end-users"; +import { calculateSignUpRiskAssessment } from "./risk-scores"; import { evaluateSignUpRules } from "./sign-up-rules"; import { Tenancy } from "./tenancies"; +import { SignUpTurnstileAssessment } from "./turnstile"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getEnvBoolean, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; /** * Options for sign-up rule evaluation context. */ export type SignUpRuleOptions = { - authMethod: 'password' | 'otp' | 'oauth' | 'passkey', - oauthProvider?: string, + authMethod: SignUpAuthMethod, + oauthProvider: string | null, + ipAddress: string | null, + ipTrusted: boolean | null, + countryCode: string | null, + requestContext?: BestEffortEndUserRequestContext | null, + turnstileAssessment: SignUpTurnstileAssessment, }; +function shouldAllowSignUpAfterVisibleBotChallengeFailure(): boolean { + return getEnvBoolean("STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE"); +} + +function isVisibleBotChallengeFailure(assessment: SignUpTurnstileAssessment): boolean { + return assessment.visibleChallengeResult != null && assessment.visibleChallengeResult !== "ok"; +} + +function assertVisibleBotChallengePassedForSignUp(assessment: SignUpTurnstileAssessment) { + if (isVisibleBotChallengeFailure(assessment) && !shouldAllowSignUpAfterVisibleBotChallengeFailure()) { + throw new KnownErrors.BotChallengeFailed("Visible bot challenge could not be completed"); + } +} + +async function persistSignUpHeuristicFacts(params: { + tenancy: Tenancy, + userId: string, + signedUpAt: Date, + signUpIp: string | null, + signUpIpTrusted: boolean | null, + signUpEmailNormalized: string | null, + signUpEmailBase: string | null, +}) { + const prisma = await getPrismaClientForTenancy(params.tenancy); + const schema = await getPrismaSchemaForTenancy(params.tenancy); + await prisma.$executeRaw` + UPDATE ${sqlQuoteIdent(schema)}."ProjectUser" + SET + "signedUpAt" = ${params.signedUpAt}, + "signUpIp" = ${params.signUpIp}, + "signUpIpTrusted" = ${params.signUpIpTrusted}, + "signUpEmailNormalized" = ${params.signUpEmailNormalized}, + "signUpEmailBase" = ${params.signUpEmailBase}, + "shouldUpdateSequenceId" = true + WHERE "tenancyId" = ${params.tenancy.id}::UUID + AND "projectUserId" = ${params.userId}::UUID + `; +} + +export function getDerivedSignUpCountryCode(requestCountryCode: string | null, email: string | null): string | null { + // In testing/development, allow deriving country code from email tags + // e.g. "user+CA@example.com" → "CA". Only works for @example.com addresses. + // Guarded to non-production environments to prevent user-controlled country code spoofing. + if (email != null && ["development", "test"].includes(getNodeEnvironment())) { + const match = email.match(/^[^+]+\+([^@]+)@example\.com$/i); + if (match) { + const tag = match[1]; + const normalized = normalizeCountryCode(tag); + if (validCountryCodeSet.has(normalized)) { + return normalized; + } + } + } + + if (requestCountryCode !== null) { + const normalized = normalizeCountryCode(requestCountryCode); + if (validCountryCodeSet.has(normalized)) { + return normalized; + } + } + return null; +} + +import.meta.vitest?.test("getDerivedSignUpCountryCode", ({ expect }) => { + expect(getDerivedSignUpCountryCode(" us ", null)).toBe("US"); + expect(getDerivedSignUpCountryCode("usa", null)).toBeNull(); + expect(getDerivedSignUpCountryCode("1", null)).toBeNull(); + + expect(getDerivedSignUpCountryCode(null, "test+us@example.com")).toBe("US"); + expect(getDerivedSignUpCountryCode(null, "test+de@example.com")).toBe("DE"); + expect(getDerivedSignUpCountryCode(null, "test+US@example.com")).toBe("US"); + expect(getDerivedSignUpCountryCode(null, "test+invalid@example.com")).toBeNull(); + expect(getDerivedSignUpCountryCode(null, "test+us@other.com")).toBeNull(); + expect(getDerivedSignUpCountryCode(null, "test@example.com")).toBeNull(); + expect(getDerivedSignUpCountryCode(null, "noplustag@example.com")).toBeNull(); + + expect(getDerivedSignUpCountryCode("de", "test+us@example.com")).toBe("US"); + expect(getDerivedSignUpCountryCode("de", "test@example.com")).toBe("DE"); +}); + +import.meta.vitest?.describe("visible bot challenge sign-up policy", () => { + const { expect, test, beforeEach, afterEach } = import.meta.vitest!; + const processEnv = Reflect.get(process, "env"); + const originalFlag = Reflect.get(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE"); + + beforeEach(() => { + Reflect.deleteProperty(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE"); + }); + + afterEach(() => { + if (originalFlag === undefined) { + Reflect.deleteProperty(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE"); + } else { + Reflect.set(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE", originalFlag); + } + }); + + test("blocks sign-up by default after a visible challenge failure", () => { + expect(() => assertVisibleBotChallengePassedForSignUp({ + status: "error", + visibleChallengeResult: "error", + })).toThrowError("Visible bot challenge could not be completed"); + }); + + test("allows sign-up when visible challenge failure override is enabled", () => { + Reflect.set(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE", "true"); + + expect(() => assertVisibleBotChallengePassedForSignUp({ + status: "error", + visibleChallengeResult: "error", + })).not.toThrow(); + }); + + test("treats invalid visible challenges as bypassable failures when the override is enabled", () => { + Reflect.set(processEnv, "STACK_ALLOW_SIGN_UP_ON_VISIBLE_BOT_CHALLENGE_FAILURE", "true"); + + expect(() => assertVisibleBotChallengePassedForSignUp({ + status: "invalid", + visibleChallengeResult: "invalid", + })).not.toThrow(); + }); +}); + /** * Creates or upgrades an anonymous user with sign-up rule evaluation. * @@ -42,11 +178,43 @@ export async function createOrUpgradeAnonymousUserWithRules( allowedErrorTypes: (new (...args: any) => any)[], signUpRuleOptions: SignUpRuleOptions, ): Promise { - const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? undefined; + assertVisibleBotChallengePassedForSignUp(signUpRuleOptions.turnstileAssessment); + + const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? null; + const primaryEmailVerified = createOrUpdate.primary_email_verified ?? currentUser?.primary_email_verified ?? false; + const endUserRequestContext = signUpRuleOptions.requestContext !== undefined + ? signUpRuleOptions.requestContext + : signUpRuleOptions.ipAddress !== null && signUpRuleOptions.ipTrusted !== null + ? null + : await getBestEffortEndUserRequestContext(); + const requestIpAddress = signUpRuleOptions.ipAddress ?? endUserRequestContext?.ipAddress ?? null; + const requestIpTrusted = signUpRuleOptions.ipTrusted ?? endUserRequestContext?.ipTrusted ?? null; + // EndUserLocation.countryCode is string | undefined; coerce to string | null for downstream consumers + const requestCountryCode = signUpRuleOptions.countryCode ?? endUserRequestContext?.location?.countryCode ?? null; + const countryCode = signUpRuleOptions.countryCode !== null + ? signUpRuleOptions.countryCode + : getDerivedSignUpCountryCode(requestCountryCode, email); + const countryCodeToPersist = currentUser?.is_anonymous && currentUser.country_code != null + ? currentUser.country_code + : countryCode; + + const riskAssessment = await calculateSignUpRiskAssessment(tenancy, { + primaryEmail: email ?? null, + primaryEmailVerified, + authMethod: signUpRuleOptions.authMethod, + oauthProvider: signUpRuleOptions.oauthProvider, + ipAddress: requestIpAddress, + ipTrusted: requestIpTrusted, + turnstileAssessment: signUpRuleOptions.turnstileAssessment, + }); + const riskScores = riskAssessment.scores; + const ruleResult = await evaluateSignUpRules(tenancy, createSignUpRuleContext({ email, + countryCode, authMethod: signUpRuleOptions.authMethod, oauthProvider: signUpRuleOptions.oauthProvider, + riskScores, })); if (!ruleResult.shouldAllow) { @@ -60,17 +228,32 @@ export async function createOrUpgradeAnonymousUserWithRules( : ""; const restrictionPrivateDetails = restrictionRuleId ? `Restricted by sign-up rule: ${restrictionRuleId}${restrictionRuleDisplayName ? ` (${restrictionRuleDisplayName})` : ""}` - : undefined; + : null; const enrichedCreateOrUpdate = { ...createOrUpdate, - ...!!ruleResult.restrictedBecauseOfSignUpRuleId ? { + ...(ruleResult.restrictedBecauseOfSignUpRuleId != null ? { restricted_by_admin: true, - restricted_by_admin_private_details: existingRestrictionPrivateDetails ? `${existingRestrictionPrivateDetails}\n\n${restrictionPrivateDetails}` : restrictionPrivateDetails, - } : {}, + restricted_by_admin_private_details: existingRestrictionPrivateDetails != null ? `${existingRestrictionPrivateDetails}\n\n${restrictionPrivateDetails}` : restrictionPrivateDetails, + } : {}), + ...(countryCodeToPersist !== null ? { country_code: countryCodeToPersist } : {}), + risk_scores: { + sign_up: { + bot: riskScores.bot, + free_trial_abuse: riskScores.free_trial_abuse, + }, + }, }; - // Proceed with user creation/upgrade + const signUpHeuristicFactsToPersist = { + tenancy, + signedUpAt: riskAssessment.heuristicFacts.signedUpAt, + signUpIp: riskAssessment.heuristicFacts.signUpIp, + signUpIpTrusted: riskAssessment.heuristicFacts.signUpIpTrusted, + signUpEmailNormalized: riskAssessment.heuristicFacts.signUpEmailNormalized, + signUpEmailBase: riskAssessment.heuristicFacts.signUpEmailBase, + } as const; + const user = await createOrUpgradeAnonymousUserWithoutRules( tenancy, currentUser, @@ -78,6 +261,15 @@ export async function createOrUpgradeAnonymousUserWithRules( allowedErrorTypes, ); + try { + await persistSignUpHeuristicFacts({ + ...signUpHeuristicFactsToPersist, + userId: user.id, + }); + } catch (error) { + captureError("persist-sign-up-heuristic-facts", error); + } + return user; } @@ -109,9 +301,11 @@ export async function createOrUpgradeAnonymousUserWithoutRules( }); } else { // Create new user (normal flow) + // Cast needed: createOrUpdate may contain create-only fields (like risk scores) that + // KeyIntersect strips from the type since they're absent on Update return await usersCrudHandlers.adminCreate({ tenancy, - data: createOrUpdate, + data: createOrUpdate as UsersCrud["Admin"]["Create"], allowedErrorTypes, }); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 9bec6421c3..7d1b8e85e3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -30,7 +30,7 @@ type UserListItem = { display_name?: string | null, primary_email?: string | null, last_active_at_millis?: number | null, - signed_up_at_millis?: number | null, + signed_up_at_millis: number, } const CustomTooltip = ({ active, payload }: TooltipProps) => { @@ -391,9 +391,7 @@ export function TabbedMetricsCard({ ? user.last_active_at_millis ? `Active ${fromNow(new Date(user.last_active_at_millis))}` : 'Never active' - : user.signed_up_at_millis - ? `Signed up ${fromNow(new Date(user.signed_up_at_millis))}` - : 'Unknown' + : `Signed up ${fromNow(new Date(user.signed_up_at_millis))}` } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index ae32c451b2..57e012662d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { CountryCodeSelect } from "@/components/country-code-select"; import { ConditionBuilder, isConditionTreeValid } from "@/components/rule-builder"; import { ActionDialog, @@ -38,6 +39,7 @@ import { ArrowsDownUpIcon, CheckIcon, PencilSimpleIcon, PlusIcon, TrashIcon, XIc import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; import type { SignUpRule, SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules"; +import { isValidCountryCode, normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { standardProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; @@ -48,6 +50,7 @@ import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; +import { validateRiskScore } from "@/lib/risk-score-utils"; // Analytics types type RuleAnalytics = { @@ -85,8 +88,14 @@ type SignUpRulesTestResult = { context: { email: string, email_domain: string, + country_code: string, auth_method: 'password' | 'otp' | 'oauth' | 'passkey', oauth_provider: string, + turnstile_result: 'ok' | 'invalid' | 'error', + risk_scores: { + bot: number, + free_trial_abuse: number, + }, }, evaluations: SignUpRulesTestEvaluation[], outcome: { @@ -350,7 +359,7 @@ function SortableRuleRow({ 'restrict': 'Restrict', 'log': 'Log', }; - const actionLabel = actionLabels[actionType] ?? actionType; + const actionLabel = actionLabels[actionType]; const conditionSummary = entry.rule.condition || '(no condition)'; const isEnabled = entry.rule.enabled !== false; @@ -513,6 +522,8 @@ function DefaultActionCard({ ); } +const DEFAULT_TURNSTILE_OVERRIDE = "__default__"; + function TestRulesCard({ stackAdminApp, }: { @@ -521,17 +532,54 @@ function TestRulesCard({ const [email, setEmail] = useState(''); const [authMethod, setAuthMethod] = useState('password'); const [oauthProvider, setOauthProvider] = useState(''); + const [countryCodeOverride, setCountryCodeOverride] = useState(''); + const [turnstileResultOverride, setTurnstileResultOverride] = useState<'ok' | 'invalid' | 'error' | typeof DEFAULT_TURNSTILE_OVERRIDE>(DEFAULT_TURNSTILE_OVERRIDE); + const [botRiskScoreOverride, setBotRiskScoreOverride] = useState(''); + const [freeTrialAbuseRiskScoreOverride, setFreeTrialAbuseRiskScoreOverride] = useState(''); const [result, setResult] = useState(null); const [runTest, isRunning] = useAsyncCallback(async () => { + setResult(null); + const normalizedCountryCodeOverride = normalizeCountryCode(countryCodeOverride); + const normalizedBotRiskScoreOverride = botRiskScoreOverride.trim(); + const normalizedFreeTrialAbuseRiskScoreOverride = freeTrialAbuseRiskScoreOverride.trim(); + if (normalizedCountryCodeOverride !== '' && !isValidCountryCode(normalizedCountryCodeOverride)) { + throw new Error("Country code override must be a two-letter ISO code."); + } + if (!validateRiskScore(normalizedBotRiskScoreOverride)) { + throw new Error("Bot risk score override must be an integer between 0 and 100."); + } + if (!validateRiskScore(normalizedFreeTrialAbuseRiskScoreOverride)) { + throw new Error("Free trial abuse risk score override must be an integer between 0 and 100."); + } + if ((normalizedBotRiskScoreOverride === '') !== (normalizedFreeTrialAbuseRiskScoreOverride === '')) { + throw new Error("Bot risk score and free trial abuse risk score overrides must both be provided or both be left blank."); + } + const response = await (stackAdminApp as any)[stackAppInternalsSymbol].sendRequest( '/internal/sign-up-rules-test', { method: 'POST', body: JSON.stringify({ - email: email || undefined, + email: email === '' ? null : email, auth_method: authMethod, - oauth_provider: authMethod === 'oauth' ? (oauthProvider || undefined) : undefined, + oauth_provider: authMethod === 'oauth' + ? (oauthProvider === '' ? null : oauthProvider) + : null, + country_code: normalizedCountryCodeOverride === '' ? null : normalizedCountryCodeOverride, + ...(turnstileResultOverride === DEFAULT_TURNSTILE_OVERRIDE + ? {} + : { + turnstile_result: turnstileResultOverride, + }), + ...(normalizedBotRiskScoreOverride === '' + ? {} + : { + risk_scores: { + bot: Number(normalizedBotRiskScoreOverride), + free_trial_abuse: Number(normalizedFreeTrialAbuseRiskScoreOverride), + }, + }), }), headers: { 'Content-Type': 'application/json', @@ -546,7 +594,7 @@ function TestRulesCard({ const data = await response.json(); setResult(data); - }, [authMethod, email, oauthProvider, stackAdminApp]); + }, [authMethod, botRiskScoreOverride, countryCodeOverride, email, freeTrialAbuseRiskScoreOverride, oauthProvider, stackAdminApp, turnstileResultOverride]); const handleAuthMethodChange = (value: string) => { if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') { @@ -653,6 +701,60 @@ function TestRulesCard({ +
+
+ + Country code override + + setCountryCodeOverride(val ?? "")} + /> +
+
+ + Bot score override + + setBotRiskScoreOverride(e.target.value)} + placeholder="0-100" + inputMode="numeric" + /> +
+
+ + Free trial abuse override + + setFreeTrialAbuseRiskScoreOverride(e.target.value)} + placeholder="0-100" + inputMode="numeric" + /> +
+
+ + Turnstile override + + +
+
+
@@ -801,9 +906,21 @@ function TestRulesCard({ Email domain: {result.context.email_domain || "(empty)"} + + Country code: {result.context.country_code || "(empty)"} + OAuth provider: {result.context.oauth_provider || "(empty)"} + + Turnstile result: {result.context.turnstile_result} + + + Risk score (bot): {result.context.risk_scores.bot} + + + Risk score (free trial abuse): {result.context.risk_scores.free_trial_abuse} + )} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index d438a6083f..7455aa898d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -42,17 +42,21 @@ import { useToast } from "@/components/ui"; import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialogs"; -import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, HashIcon, ProhibitIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; +import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, GlobeIcon, HashIcon, ProhibitIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; import { ServerContactChannel, ServerOAuthProvider, ServerUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared"; +import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; +import { CountryCodeSelect } from "@/components/country-code-select"; import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; import { captureError, StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { useState } from "react"; import * as yup from "yup"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; +import { parseRiskScore } from "@/lib/risk-score-utils"; const userMetadataDocsUrl = "https://docs.stack-auth.com/docs/concepts/custom-user-data"; @@ -388,6 +392,8 @@ type UserDetailsProps = { function UserDetails({ user }: UserDetailsProps) { const [newPassword, setNewPassword] = useState(null); + + return (
} name="User ID"> @@ -417,6 +423,44 @@ function UserDetails({ user }: UserDetailsProps) { } name="Signed up at"> + } name="Risk score: bot"> + { + await user.update({ + riskScores: { + signUp: { + bot: parseRiskScore(newValue), + freeTrialAbuse: user.riskScores.signUp.freeTrialAbuse, + }, + }, + }); + }} /> + + } name="Sign-up country code"> + { + runAsynchronouslyWithAlert(async () => { + await user.update({ + countryCode: newValue ? normalizeCountryCode(newValue) : null, + }); + }); + }} + placeholder="-" + className="w-full h-8 text-sm" + /> + + } name="Risk score: free trial abuse"> + { + await user.update({ + riskScores: { + signUp: { + bot: user.riskScores.signUp.bot, + freeTrialAbuse: parseRiskScore(newValue), + }, + }, + }); + }} /> +
); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx index a1274ab152..381886b90e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx @@ -1,12 +1,12 @@ "use client"; -import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { UserTable } from "@/components/data-table/user-table"; import { ExportUsersDialog } from "@/components/export-users-dialog"; import { StyledLink } from "@/components/link"; -import { Alert, Button, Skeleton } from "@/components/ui"; +import { Alert, Button, SimpleTooltip, Skeleton } from "@/components/ui"; import { UserDialog } from "@/components/user-dialog"; -import { DownloadSimpleIcon } from "@phosphor-icons/react"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; +import { ArrowsClockwiseIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; import { Suspense, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; @@ -39,6 +39,12 @@ export default function PageClient() { includeRestricted: boolean, includeAnonymous: boolean, }>({ includeRestricted: false, includeAnonymous: false }); + const [refreshKey, setRefreshKey] = useState(0); + + const handleRefresh = async () => { + await (stackAdminApp as any)._refreshUsers(); + setRefreshKey((k) => k + 1); + }; return ( @@ -47,11 +53,16 @@ export default function PageClient() { description={<> Total:{" "} Calculating}> - + } actions={
+ + + @@ -74,7 +85,7 @@ export default function PageClient() { )} - + ); diff --git a/apps/dashboard/src/components/country-code-select.tsx b/apps/dashboard/src/components/country-code-select.tsx new file mode 100644 index 0000000000..f46c44569a --- /dev/null +++ b/apps/dashboard/src/components/country-code-select.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + FormControl, + FormField, + FormItem, + FormMessage, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui"; +import { FieldLabel } from "@/components/form-fields"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { ISO_3166_ALPHA_2_COUNTRY_CODES } from "@stackframe/stack-shared/dist/schema-fields"; +import { Control, FieldValues, Path } from "react-hook-form"; +import { useState } from "react"; + +const COUNTRY_CODE_OPTIONS = ISO_3166_ALPHA_2_COUNTRY_CODES.map((code) => ({ + value: code, + label: code, +})); + +type CountryCodeSelectProps = { + value: string | null, + onChange: (value: string | null) => void, + placeholder?: string, + disabled?: boolean, + className?: string, + allowClear?: boolean, +}; + +export function CountryCodeSelect({ + value, + onChange, + placeholder = "Select country code...", + disabled, + className, + allowClear = true, +}: CountryCodeSelectProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + No country found. + + {allowClear && value && ( + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear + + )} + {COUNTRY_CODE_OPTIONS.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + {option.label} + {value === option.value && ( + + )} + + ))} + + + + + + ); +} + +export function CountryCodeField(props: { + control: Control, + name: Path, + label?: React.ReactNode, + placeholder?: string, + required?: boolean, + disabled?: boolean, +}) { + return ( + ( + + + + )} + /> + ); +} diff --git a/apps/dashboard/src/components/rule-builder/condition-builder.test.ts b/apps/dashboard/src/components/rule-builder/condition-builder.test.ts new file mode 100644 index 0000000000..16dd534a54 --- /dev/null +++ b/apps/dashboard/src/components/rule-builder/condition-builder.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyCondition } from "@/lib/cel-visual-parser"; +import { isConditionTreeValid } from "./condition-builder"; + +describe("isConditionTreeValid", () => { + it("accepts valid country codes", () => { + expect(isConditionTreeValid({ + ...createEmptyCondition(), + field: "countryCode", + operator: "equals", + value: "us", + })).toBe(true); + }); + + it("rejects invalid single country codes", () => { + expect(isConditionTreeValid({ + ...createEmptyCondition(), + field: "countryCode", + operator: "equals", + value: "usa", + })).toBe(false); + }); + + it("rejects invalid country codes in lists", () => { + expect(isConditionTreeValid({ + ...createEmptyCondition(), + field: "countryCode", + operator: "in_list", + value: ["US", "USA"], + })).toBe(false); + }); + + it("rejects empty country code lists", () => { + expect(isConditionTreeValid({ + ...createEmptyCondition(), + field: "countryCode", + operator: "in_list", + value: [], + })).toBe(false); + }); +}); diff --git a/apps/dashboard/src/components/rule-builder/condition-builder.tsx b/apps/dashboard/src/components/rule-builder/condition-builder.tsx index e78d9616e2..fafbb59c25 100644 --- a/apps/dashboard/src/components/rule-builder/condition-builder.tsx +++ b/apps/dashboard/src/components/rule-builder/condition-builder.tsx @@ -2,16 +2,17 @@ import { Button, cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui"; import { - type ConditionField, type ConditionNode, - type ConditionOperator, createEmptyCondition, createEmptyGroup, type GroupNode, type RuleNode, } from "@/lib/cel-visual-parser"; -import { PlusIcon, TrashIcon, WarningCircleIcon } from "@phosphor-icons/react"; -import { standardProviders } from "@stackframe/stack-shared/dist/utils/oauth"; +import { MinusIcon, PlusIcon, TrashIcon, WarningCircleIcon } from "@phosphor-icons/react"; +import { CountryCodeSelect } from "@/components/country-code-select"; +import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; +import { validateCountryCode } from "@stackframe/stack-shared/dist/utils/country-codes"; +import { type ConditionField, type ConditionOperator, conditionFields, fieldMetadata, getOperatorsForField, isNumericField, validateNumericFieldValue } from "@stackframe/stack-shared/dist/utils/cel-fields"; import React from "react"; /** @@ -33,12 +34,24 @@ function validateRegex(pattern: string): string | null { } } +const validateCountryCodeValue = validateCountryCode; + /** * Recursively checks if a RuleNode tree has any validation errors. * Returns true if the tree is valid, false if there are errors. */ export function isConditionTreeValid(node: RuleNode): boolean { if (node.type === 'condition') { + if (node.field === 'countryCode') { + const countryCodeError = validateCountryCodeValue( + Array.isArray(node.value) + ? node.value + : String(node.value), + ); + if (countryCodeError !== null) { + return false; + } + } // Check regex validation if (node.operator === 'matches') { const error = validateRegex(String(node.value)); @@ -46,6 +59,12 @@ export function isConditionTreeValid(node: RuleNode): boolean { return false; } } + // Validate numeric fields are integers within [0, 100] + if (isNumericField(node.field)) { + if (validateNumericFieldValue(node.field, String(node.value)) !== null) { + return false; + } + } return true; } @@ -53,37 +72,25 @@ export function isConditionTreeValid(node: RuleNode): boolean { return node.children.every(child => isConditionTreeValid(child)); } -// Field options with labels -const FIELD_OPTIONS: { value: ConditionField, label: string }[] = [ - { value: 'email', label: 'Email' }, - { value: 'emailDomain', label: 'Email Domain' }, - { value: 'authMethod', label: 'Auth Method' }, - { value: 'oauthProvider', label: 'OAuth Provider' }, -]; - -// Operator options with labels -const OPERATOR_OPTIONS: { value: ConditionOperator, label: string }[] = [ - { value: 'equals', label: 'equals' }, - { value: 'not_equals', label: 'does not equal' }, - { value: 'contains', label: 'contains' }, - { value: 'starts_with', label: 'starts with' }, - { value: 'ends_with', label: 'ends with' }, - { value: 'matches', label: 'matches regex' }, - { value: 'in_list', label: 'is one of' }, -]; - -// Get available operators for a field -function getOperatorsForField(field: ConditionField): ConditionOperator[] { - if (field === 'authMethod' || field === 'oauthProvider') { - return ['equals', 'not_equals', 'in_list']; - } - return ['equals', 'not_equals', 'contains', 'starts_with', 'ends_with', 'matches', 'in_list']; -} +// Field options derived from shared metadata +const FIELD_OPTIONS: { value: ConditionField, label: string }[] = conditionFields.map(f => ({ + value: f, + label: fieldMetadata[f].label, +})); -// Predefined options for certain fields -const PREDEFINED_VALUES: Partial> = { - authMethod: ['password', 'otp', 'oauth', 'passkey'], - oauthProvider: Array.from(standardProviders), +// Operator labels for display +const OPERATOR_LABELS: Record = { + equals: 'equals', + not_equals: 'does not equal', + greater_than: 'is greater than', + greater_or_equal: 'is greater than or equal', + less_than: 'is less than', + less_or_equal: 'is less than or equal', + contains: 'contains', + starts_with: 'starts with', + ends_with: 'ends with', + matches: 'matches regex', + in_list: 'is one of', }; // Single condition row component @@ -99,7 +106,21 @@ function ConditionRow({ showRemove: boolean, }) { const availableOperators = getOperatorsForField(condition.field); - const predefinedValues = PREDEFINED_VALUES[condition.field]; + const predefinedValues = fieldMetadata[condition.field].predefinedValues; + const isCountryCodeField = condition.field === 'countryCode'; + const isCountryCodeListOperator = isCountryCodeField && condition.operator === 'in_list'; + const countryCodeListValues = isCountryCodeListOperator + ? Array.isArray(condition.value) + ? condition.value + : [] + : []; + const countryCodeError = isCountryCodeField + ? validateCountryCodeValue( + Array.isArray(condition.value) + ? condition.value + : String(condition.value), + ) + : null; // Validate regex when operator is 'matches' const regexError = condition.operator === 'matches' @@ -111,26 +132,56 @@ function ConditionRow({ const operator = newOperators.includes(condition.operator) ? condition.operator : newOperators[0]; // Reset value - use array for in_list, string otherwise - const value: string | string[] = operator === 'in_list' ? [] : ''; + const value: string | number | string[] = operator === 'in_list' + ? [] + : isNumericField(field) + ? 0 + : ''; onChange({ ...condition, field, operator, value }); }; const handleOperatorChange = (operator: ConditionOperator) => { let value = condition.value; - // Convert between single value and array for in_list if (operator === 'in_list' && !Array.isArray(value)) { - value = value ? [String(value)] : []; + value = value ? [String(value)] : ['']; } else if (operator !== 'in_list' && Array.isArray(value)) { value = value[0] ?? ''; } onChange({ ...condition, operator, value }); }; - const handleValueChange = (value: string | string[]) => { + const handleValueChange = (value: string | number | string[]) => { + if (!isCountryCodeField) { + onChange({ ...condition, value }); + return; + } + + if (Array.isArray(value)) { + onChange({ ...condition, value: value.map(normalizeCountryCode) }); + return; + } + + if (typeof value === 'string') { + onChange({ ...condition, value: normalizeCountryCode(value) }); + return; + } + onChange({ ...condition, value }); }; + const handleCountryCodeListItemChange = (index: number, value: string) => { + handleValueChange(countryCodeListValues.map((item, itemIndex) => itemIndex === index ? value : item)); + }; + + const handleAddCountryCodeListItem = () => { + handleValueChange([...countryCodeListValues, '']); + }; + + const handleRemoveCountryCodeListItem = (index: number) => { + handleValueChange(countryCodeListValues.filter((_, itemIndex) => itemIndex !== index)); + }; + return (
{/* Field selector */} @@ -150,64 +201,146 @@ function ConditionRow({ onChange={(e) => handleOperatorChange(e.target.value as ConditionOperator)} className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md min-w-[120px]" > - {OPERATOR_OPTIONS.filter(opt => availableOperators.includes(opt.value)).map((opt) => ( - + {availableOperators.map((op) => ( + ))} {/* Value input */} - {condition.operator === 'in_list' ? ( - { - const items = e.target.value.split(',').map(s => s.trim()).filter(Boolean); - handleValueChange(items); - }} - placeholder="value1, value2, ..." - className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md flex-1" - /> - ) : predefinedValues ? ( - - ) : ( -
+
+ {isCountryCodeListOperator ? ( +
+ {countryCodeListValues.map((countryCode, index) => ( +
+ handleCountryCodeListItemChange(index, val ?? "")} + allowClear={false} + className={cn( + "h-8 text-sm flex-1", + countryCodeError !== null && "border-destructive ring-1 ring-destructive/30", + )} + /> + +
+ ))} + +
+ ) : condition.operator === 'in_list' ? ( handleValueChange(e.target.value)} - placeholder={condition.operator === 'matches' ? "Enter regex pattern..." : "Enter value..."} + value={Array.isArray(condition.value) ? condition.value.join(', ') : condition.value} + onChange={(e) => { + const items = e.target.value.split(',').map(s => s.trim()).filter(Boolean); + handleValueChange(items); + }} + placeholder="value1, value2, ... (values cannot contain commas)" className={cn( - "h-8 px-2 text-sm bg-background/60 border rounded-md flex-1", - regexError + "h-8 px-2 text-sm bg-background/60 border rounded-md w-full", + countryCodeError !== null ? "border-destructive ring-1 ring-destructive/30" - : "border-border/50" + : "border-border/50", )} /> - {regexError && ( - - - -
- -
-
- -

{regexError}

-
-
-
- )} -
- )} + ) : isCountryCodeField ? ( + handleValueChange(val ?? "")} + allowClear={false} + className={cn( + "h-8 text-sm w-full", + countryCodeError !== null && "border-destructive ring-1 ring-destructive/30", + )} + /> + ) : predefinedValues ? ( + + ) : isNumericField(condition.field) ? ( + handleValueChange(e.target.value === '' ? 0 : Number(e.target.value))} + placeholder="0-100" + className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md w-full" + /> + ) : ( +
+ handleValueChange(e.target.value)} + placeholder={ + condition.operator === 'matches' + ? "Enter regex pattern..." + : "Enter value..." + } + className={cn( + "h-8 px-2 text-sm bg-background/60 border rounded-md flex-1", + regexError !== null || countryCodeError !== null + ? "border-destructive ring-1 ring-destructive/30" + : "border-border/50" + )} + /> + {(regexError !== null || countryCodeError !== null) && ( + + + +
+ +
+
+ +

{regexError ?? countryCodeError}

+
+
+
+ )} +
+ )} + {isCountryCodeField && ( + <> +

+ {isCountryCodeListOperator + ? "Add one or more ISO country codes" + : "Single ISO country code only, e.g. US"} +

+ {countryCodeError !== null && ( +

+ {countryCodeError} +

+ )} + + )} +
{/* Remove button */} {showRemove && ( diff --git a/apps/dashboard/src/components/user-dialog.tsx b/apps/dashboard/src/components/user-dialog.tsx index 1047e16923..f1d090d77d 100644 --- a/apps/dashboard/src/components/user-dialog.tsx +++ b/apps/dashboard/src/components/user-dialog.tsx @@ -1,12 +1,14 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { ServerUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared"; -import { emailSchema, jsonStringOrEmptySchema, passwordSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import { countryCodeSchema, emailSchema, jsonStringOrEmptySchema, passwordSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Typography, useToast } from "@/components/ui"; import * as yup from "yup"; import { FormDialog } from "./form-dialog"; +import { CountryCodeField } from "./country-code-select"; import { DateField, InputField, SwitchField, TextAreaField } from "./form-fields"; import { StyledLink } from "./link"; +import { validateRiskScore } from "@/lib/risk-score-utils"; const metadataDocsUrl = "https://docs.stack-auth.com/docs/concepts/custom-user-data"; @@ -41,6 +43,9 @@ export function UserDialog(props: { } else { defaultValues = { signedUpAt: new Date(), + countryCode: null as string | null, + botRiskScore: "", + freeTrialAbuseRiskScore: "", }; } @@ -71,15 +76,48 @@ export function UserDialog(props: { }).optional(), passwordEnabled: yup.boolean().optional(), updatePassword: yup.boolean().optional(), + countryCode: countryCodeSchema.nullable().transform((value) => value === "" || value == null ? undefined : value).optional(), + botRiskScore: yup.string().test({ + name: "bot-risk-score-format", + message: "Bot risk score must be an integer between 0 and 100", + test: (value) => validateRiskScore(value), + }).optional(), + freeTrialAbuseRiskScore: yup.string().test({ + name: "free-trial-risk-score-format", + message: "Free trial abuse score must be an integer between 0 and 100", + test: (value) => validateRiskScore(value), + }).optional(), + }).test({ + name: "risk-score-pair", + message: "Bot risk score and free trial abuse score must both be provided or both be empty", + test: (value) => { + const botRiskScore = value.botRiskScore?.trim() ?? ""; + const freeTrialAbuseRiskScore = value.freeTrialAbuseRiskScore?.trim() ?? ""; + return (botRiskScore === "") === (freeTrialAbuseRiskScore === ""); + }, }); async function handleSubmit(values: yup.InferType) { + const normalizedCountryCode = values.countryCode ?? ""; + const normalizedBotRiskScore = values.botRiskScore?.trim() ?? ""; + const normalizedFreeTrialAbuseRiskScore = values.freeTrialAbuseRiskScore?.trim() ?? ""; const userValues = { ...values, primaryEmailAuthEnabled: true, clientMetadata: values.clientMetadata ? JSON.parse(values.clientMetadata) : undefined, clientReadOnlyMetadata: values.clientReadOnlyMetadata ? JSON.parse(values.clientReadOnlyMetadata) : undefined, - serverMetadata: values.serverMetadata ? JSON.parse(values.serverMetadata) : undefined + serverMetadata: values.serverMetadata ? JSON.parse(values.serverMetadata) : undefined, + ...(props.type === "create" ? { + countryCode: normalizedCountryCode === "" ? undefined : normalizedCountryCode, + riskScores: normalizedBotRiskScore === "" && normalizedFreeTrialAbuseRiskScore === "" + ? undefined + : { + signUp: { + bot: Number(normalizedBotRiskScore), + freeTrialAbuse: Number(normalizedFreeTrialAbuseRiskScore), + }, + }, + } : {}), }; try { @@ -148,6 +186,24 @@ export function UserDialog(props: { )} {!form.watch("primaryEmailVerified") && form.watch("otpAuthEnabled") && Primary email must be verified if OTP/magic link sign-in is enabled} + {props.type === "create" && ( + + + Risk and Geo + + +
+ + +
+ + Optional admin-only values for imports or custom anti-abuse systems. Leave blank to use the defaults. + +
+
+
+ )} + Metadata diff --git a/apps/dashboard/src/lib/cel-visual-parser.test.ts b/apps/dashboard/src/lib/cel-visual-parser.test.ts index 7edfe71a9f..ef80787c76 100644 --- a/apps/dashboard/src/lib/cel-visual-parser.test.ts +++ b/apps/dashboard/src/lib/cel-visual-parser.test.ts @@ -126,6 +126,40 @@ describe('cel-visual-parser', () => { expect(cel).toContain('inject\\"attack.com'); expect(cel).toContain('also\\\\bad.com'); }); + + it('should serialize numeric risk score comparisons', () => { + const greaterThan = visualTreeToCel({ + ...createEmptyCondition(), + field: 'riskScores.bot' as const, + operator: 'greater_than' as const, + value: 80, + }); + const lessOrEqual = visualTreeToCel({ + ...createEmptyCondition(), + field: 'riskScores.free_trial_abuse' as const, + operator: 'less_or_equal' as const, + value: 40, + }); + + expect(greaterThan).toBe('riskScores.bot > 80'); + expect(lessOrEqual).toBe('riskScores.free_trial_abuse <= 40'); + }); + + it('should normalize country code values to uppercase', () => { + expect(visualTreeToCel({ + ...createEmptyCondition(), + field: 'countryCode' as const, + operator: 'equals' as const, + value: 'us', + })).toBe('countryCode == "US"'); + + expect(visualTreeToCel({ + ...createEmptyCondition(), + field: 'countryCode' as const, + operator: 'in_list' as const, + value: ['us', 'ca'], + })).toBe('countryCode in ["US", "CA"]'); + }); }); describe('CEL to visual tree parsing', () => { @@ -166,5 +200,36 @@ describe('cel-visual-parser', () => { expect(result.value).toBe('test\\value'); } }); + + it('should parse numeric risk score comparisons', () => { + const result = parseCelToVisualTree('riskScores.bot >= 75'); + expect(result).toBeDefined(); + if (result?.type === 'condition') { + expect(result.field).toBe('riskScores.bot'); + expect(result.operator).toBe('greater_or_equal'); + expect(result.value).toBe(75); + } + }); + + it('should parse country code equality condition', () => { + const result = parseCelToVisualTree('countryCode == "US"'); + expect(result).toBeDefined(); + if (result?.type === 'condition') { + expect(result.field).toBe('countryCode'); + expect(result.operator).toBe('equals'); + expect(result.value).toBe('US'); + } + }); + + it('should parse country code in_list condition', () => { + const result = parseCelToVisualTree('countryCode in ["US", "CA"]'); + expect(result).toBeDefined(); + if (result?.type === 'condition') { + expect(result.field).toBe('countryCode'); + expect(result.operator).toBe('in_list'); + expect(result.value).toEqual(['US', 'CA']); + } + }); }); + }); diff --git a/apps/dashboard/src/lib/cel-visual-parser.ts b/apps/dashboard/src/lib/cel-visual-parser.ts index fb128a79a9..3c140c7dad 100644 --- a/apps/dashboard/src/lib/cel-visual-parser.ts +++ b/apps/dashboard/src/lib/cel-visual-parser.ts @@ -8,32 +8,25 @@ * - email == "value" / email != "value" * - email.endsWith("@domain.com") * - email.matches("regex") + * - countryCode == "US" / countryCode in ["US", "CA"] * - emailDomain == "domain.com" / emailDomain in ["d1", "d2"] * - authMethod == "password" / authMethod in ["password", "otp"] * - oauthProvider == "google" / oauthProvider in ["google", "github"] + * - riskScores.bot > 80 / riskScores.free_trial_abuse >= 60 */ -export type ConditionOperator = - | 'equals' - | 'not_equals' - | 'matches' // regex - | 'ends_with' - | 'starts_with' - | 'contains' - | 'in_list'; - -export type ConditionField = - | 'email' - | 'emailDomain' - | 'authMethod' - | 'oauthProvider'; +import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields"; +import { type ConditionField, type ConditionOperator, conditionFields, escapeCelString, fieldMetadata, isNumericField, unescapeCelString, validateNumericFieldValue } from "@stackframe/stack-shared/dist/utils/cel-fields"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + +export type { ConditionField, ConditionOperator } from "@stackframe/stack-shared/dist/utils/cel-fields"; export type ConditionNode = { type: 'condition', id: string, field: ConditionField, operator: ConditionOperator, - value: string | string[], + value: string | number | string[], }; export type GroupNode = { @@ -45,110 +38,109 @@ export type GroupNode = { export type RuleNode = ConditionNode | GroupNode; -/** - * Generates a unique ID for nodes - */ + +// ── Node factories ───────────────────────────────────────────────────── + function generateNodeId(): string { return `node-${Math.random().toString(36).slice(2, 11)}`; } -/** - * Converts a visual tree back to a CEL expression string - */ -export function visualTreeToCel(node: RuleNode): string { - if (node.type === 'condition') { - return conditionToCel(node); - } else { - return groupToCel(node); - } +export function createEmptyCondition(): ConditionNode { + return { type: 'condition', id: generateNodeId(), field: 'email', operator: 'equals', value: '' }; } -/** - * Escapes special characters in string values for use in CEL expressions. - * Backslashes must be escaped first to avoid double-escaping. - */ -function escapeCelString(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +export function createEmptyGroup(operator: 'and' | 'or' = 'and'): GroupNode { + return { type: 'group', id: generateNodeId(), operator, children: [] }; } -function unescapeCelString(value: string): string { - return value.replace(/\\\\/g, '\\').replace(/\\"/g, '"'); + +// ── Tree → CEL ───────────────────────────────────────────────────────── + +const comparisonSymbols: Record = { + equals: '==', + not_equals: '!=', + greater_than: '>', + greater_or_equal: '>=', + less_than: '<', + less_or_equal: '<=', +}; + +const stringMethodNames: Record = { + matches: 'matches', + ends_with: 'endsWith', + starts_with: 'startsWith', + contains: 'contains', +}; + +export function visualTreeToCel(node: RuleNode): string { + return node.type === 'condition' ? conditionToCel(node) : groupToCel(node); +} + +function normalizeConditionValue(condition: ConditionNode): ConditionNode['value'] { + if (condition.field !== 'countryCode') return condition.value; + if (typeof condition.value === 'number') { + throw new StackAssertionError(`Invalid numeric value for countryCode: ${condition.value}. Country codes must be strings.`); + } + return Array.isArray(condition.value) + ? condition.value.map(normalizeCountryCode) + : normalizeCountryCode(condition.value); } function conditionToCel(condition: ConditionNode): string { - const { field, operator, value } = condition; + const { field, operator } = condition; + const value = normalizeConditionValue(condition); + + // Numeric comparisons: field >= 42 + if (operator in comparisonSymbols && isNumericField(field)) { + const err = validateNumericFieldValue(field, String(value)); + if (err) throw new StackAssertionError(err); + return `${field} ${comparisonSymbols[operator]} ${typeof value === 'number' ? value : Number(value)}`; + } - switch (operator) { - case 'equals': { - return `${field} == "${escapeCelString(String(value))}"`; - } - case 'not_equals': { - return `${field} != "${escapeCelString(String(value))}"`; - } - case 'matches': { - return `${field}.matches("${escapeCelString(String(value))}")`; - } - case 'ends_with': { - return `${field}.endsWith("${escapeCelString(String(value))}")`; - } - case 'starts_with': { - return `${field}.startsWith("${escapeCelString(String(value))}")`; - } - case 'contains': { - return `${field}.contains("${escapeCelString(String(value))}")`; - } - case 'in_list': { - if (Array.isArray(value)) { - const items = value.map(v => `"${escapeCelString(String(v))}"`).join(', '); - return `${field} in [${items}]`; - } - return `${field} == "${escapeCelString(String(value))}"`; - } - default: { - return `${field} == "${escapeCelString(String(value))}"`; - } + // String equality/inequality: field == "value" + if (operator === 'equals' || operator === 'not_equals') { + const symbol = comparisonSymbols[operator]; + return `${field} ${symbol} "${escapeCelString(String(value))}"`; } -} -function groupToCel(group: GroupNode): string { - if (group.children.length === 0) { - return 'true'; + // String methods: field.contains("value") + if (operator in stringMethodNames) { + return `${field}.${stringMethodNames[operator]}("${escapeCelString(String(value))}")`; } - if (group.children.length === 1) { - return visualTreeToCel(group.children[0]); + // In-list: field in ["a", "b"] + if (operator === 'in_list') { + if (Array.isArray(value)) { + const items = value.map(v => `"${escapeCelString(String(v))}"`).join(', '); + return `${field} in [${items}]`; + } + return `${field} == "${escapeCelString(String(value))}"`; } + // Fallback + return `${field} == "${escapeCelString(String(value))}"`; +} + +function groupToCel(group: GroupNode): string { + if (group.children.length === 0) return 'true'; + if (group.children.length === 1) return visualTreeToCel(group.children[0]); + const celOperator = group.operator === 'and' ? ' && ' : ' || '; - const childExpressions = group.children.map(child => { + return group.children.map(child => { const expr = visualTreeToCel(child); - // Wrap child groups in parentheses if they have a different operator - if (child.type === 'group' && child.operator !== group.operator) { - return `(${expr})`; - } - return expr; - }); - - return childExpressions.join(celOperator); + return child.type === 'group' && child.operator !== group.operator ? `(${expr})` : expr; + }).join(celOperator); } -/** - * Attempts to parse a CEL expression into a visual tree. - * Returns null if the expression is too complex to be represented visually. - */ + +// ── CEL → Tree ───────────────────────────────────────────────────────── + export function parseCelToVisualTree(cel: string): RuleNode | null { try { const trimmed = cel.trim(); if (!trimmed || trimmed === 'true') { - return { - type: 'group', - id: generateNodeId(), - operator: 'and', - children: [], - }; + return { type: 'group', id: generateNodeId(), operator: 'and', children: [] }; } - - // Try to parse as a group or single condition return parseExpression(trimmed); } catch (e) { console.warn('Failed to parse CEL expression:', e); @@ -159,9 +151,8 @@ export function parseCelToVisualTree(cel: string): RuleNode | null { function parseExpression(expr: string): RuleNode | null { const trimmed = expr.trim(); - // Check if it's a parenthesized expression + // Unwrap fully-parenthesized expressions if (trimmed.startsWith('(') && trimmed.endsWith(')')) { - // Check if the outer parentheses wrap the entire expression let depth = 0; let isWrapped = true; for (let i = 0; i < trimmed.length - 1; i++) { @@ -172,42 +163,19 @@ function parseExpression(expr: string): RuleNode | null { break; } } - if (isWrapped) { - return parseExpression(trimmed.slice(1, -1)); - } + if (isWrapped) return parseExpression(trimmed.slice(1, -1)); } - // Try to split by || (OR) at the top level - const orParts = splitByOperator(trimmed, '||'); - if (orParts.length > 1) { - const children = orParts.map(part => parseExpression(part)).filter((n): n is RuleNode => n !== null); - if (children.length !== orParts.length) { - return null; // Some parts couldn't be parsed + // Try OR then AND at top level + for (const [op, logicalOp] of [['||', 'or'], ['&&', 'and']] as const) { + const parts = splitByOperator(trimmed, op); + if (parts.length > 1) { + const children = parts.map(p => parseExpression(p)); + if (children.some(c => c === null)) return null; + return { type: 'group', id: generateNodeId(), operator: logicalOp, children: children as RuleNode[] }; } - return { - type: 'group', - id: generateNodeId(), - operator: 'or', - children, - }; } - // Try to split by && (AND) at the top level - const andParts = splitByOperator(trimmed, '&&'); - if (andParts.length > 1) { - const children = andParts.map(part => parseExpression(part)).filter((n): n is RuleNode => n !== null); - if (children.length !== andParts.length) { - return null; // Some parts couldn't be parsed - } - return { - type: 'group', - id: generateNodeId(), - operator: 'and', - children, - }; - } - - // Try to parse as a single condition return parseCondition(trimmed); } @@ -220,9 +188,7 @@ function splitByOperator(expr: string, operator: string): string[] { for (let i = 0; i < expr.length; i++) { const char = expr[i]; - const nextChars = expr.slice(i, i + operator.length); - // Handle string literals if ((char === '"' || char === "'") && (i === 0 || expr[i - 1] !== '\\')) { if (!inString) { inString = true; @@ -236,13 +202,10 @@ function splitByOperator(expr: string, operator: string): string[] { if (char === '(' || char === '[') depth++; if (char === ')' || char === ']') depth--; - // Split at operator when not inside parentheses/brackets/strings - if (depth === 0 && nextChars === operator) { - if (current.trim()) { - parts.push(current.trim()); - } + if (depth === 0 && expr.slice(i, i + operator.length) === operator) { + if (current.trim()) parts.push(current.trim()); current = ''; - i += operator.length - 1; // Skip the operator + i += operator.length - 1; continue; } } @@ -250,136 +213,65 @@ function splitByOperator(expr: string, operator: string): string[] { current += char; } - if (current.trim()) { - parts.push(current.trim()); - } - + if (current.trim()) parts.push(current.trim()); return parts; } -function parseCondition(expr: string): ConditionNode | null { - const trimmed = expr.trim(); - - // Match patterns like: field == "value" - const equalsMatch = trimmed.match(/^(\w+)\s*==\s*"((?:\\.|[^"\\])*)"$/); - if (equalsMatch) { - return { - type: 'condition', - id: generateNodeId(), - field: equalsMatch[1] as ConditionField, - operator: 'equals', - value: unescapeCelString(equalsMatch[2]), - }; - } +function isConditionField(field: string): field is ConditionField { + return (conditionFields as string[]).includes(field); +} - // Match patterns like: field != "value" - const notEqualsMatch = trimmed.match(/^(\w+)\s*!=\s*"((?:\\.|[^"\\])*)"$/); - if (notEqualsMatch) { - return { - type: 'condition', - id: generateNodeId(), - field: notEqualsMatch[1] as ConditionField, - operator: 'not_equals', - value: unescapeCelString(notEqualsMatch[2]), - }; - } +function isValidFieldOperator(field: string, operator: ConditionOperator): field is ConditionField { + return isConditionField(field) && fieldMetadata[field].operators.includes(operator); +} - // Match patterns like: field.matches("regex") - const matchesMatch = trimmed.match(/^(\w+)\.matches\("((?:\\.|[^"\\])*)"\)$/); - if (matchesMatch) { - return { - type: 'condition', - id: generateNodeId(), - field: matchesMatch[1] as ConditionField, - operator: 'matches', - value: unescapeCelString(matchesMatch[2]), - }; - } +// Regex patterns for parsing conditions. Order matters: >= before >, <= before < +const numericComparisonParsers = [ + { re: /^([\w.]+)\s*>=\s*(-?\d+(?:\.\d+)?)$/, op: 'greater_or_equal' }, + { re: /^([\w.]+)\s*<=\s*(-?\d+(?:\.\d+)?)$/, op: 'less_or_equal' }, + { re: /^([\w.]+)\s*>\s*(-?\d+(?:\.\d+)?)$/, op: 'greater_than' }, + { re: /^([\w.]+)\s*<\s*(-?\d+(?:\.\d+)?)$/, op: 'less_than' }, + { re: /^([\w.]+)\s*==\s*(-?\d+(?:\.\d+)?)$/, op: 'equals' }, + { re: /^([\w.]+)\s*!=\s*(-?\d+(?:\.\d+)?)$/, op: 'not_equals' }, +] as const; + +const stringConditionParsers = [ + { re: /^([\w.]+)\s*==\s*"((?:\\.|[^"\\])*)"$/, op: 'equals' }, + { re: /^([\w.]+)\s*!=\s*"((?:\\.|[^"\\])*)"$/, op: 'not_equals' }, + { re: /^([\w.]+)\.matches\("((?:\\.|[^"\\])*)"\)$/, op: 'matches' }, + { re: /^([\w.]+)\.endsWith\("((?:\\.|[^"\\])*)"\)$/, op: 'ends_with' }, + { re: /^([\w.]+)\.startsWith\("((?:\\.|[^"\\])*)"\)$/, op: 'starts_with' }, + { re: /^([\w.]+)\.contains\("((?:\\.|[^"\\])*)"\)$/, op: 'contains' }, +] as const; - // Match patterns like: field.endsWith("value") - const endsWithMatch = trimmed.match(/^(\w+)\.endsWith\("((?:\\.|[^"\\])*)"\)$/); - if (endsWithMatch) { - return { - type: 'condition', - id: generateNodeId(), - field: endsWithMatch[1] as ConditionField, - operator: 'ends_with', - value: unescapeCelString(endsWithMatch[2]), - }; - } +function parseCondition(expr: string): ConditionNode | null { + const trimmed = expr.trim(); - // Match patterns like: field.startsWith("value") - const startsWithMatch = trimmed.match(/^(\w+)\.startsWith\("((?:\\.|[^"\\])*)"\)$/); - if (startsWithMatch) { - return { - type: 'condition', - id: generateNodeId(), - field: startsWithMatch[1] as ConditionField, - operator: 'starts_with', - value: unescapeCelString(startsWithMatch[2]), - }; + // Numeric comparisons: field >= 42 + for (const { re, op } of numericComparisonParsers) { + const m = trimmed.match(re); + if (m && isConditionField(m[1]) && isNumericField(m[1]) && isValidFieldOperator(m[1], op)) { + return { type: 'condition', id: generateNodeId(), field: m[1], operator: op, value: Number(m[2]) }; + } } - // Match patterns like: field.contains("value") - const containsMatch = trimmed.match(/^(\w+)\.contains\("((?:\\.|[^"\\])*)"\)$/); - if (containsMatch) { - return { - type: 'condition', - id: generateNodeId(), - field: containsMatch[1] as ConditionField, - operator: 'contains', - value: unescapeCelString(containsMatch[2]), - }; + // String conditions: field == "value", field.contains("value"), etc. + for (const { re, op } of stringConditionParsers) { + const m = trimmed.match(re); + if (m && isValidFieldOperator(m[1], op)) { + return { type: 'condition', id: generateNodeId(), field: m[1], operator: op, value: unescapeCelString(m[2]) }; + } } - // Match patterns like: field in ["a", "b", "c"] - const inListMatch = trimmed.match(/^(\w+)\s+in\s+\[([^\]]*)\]$/); - if (inListMatch) { - const listStr = inListMatch[2]; - const items = listStr - .split(',') - .map(s => s.trim()) - .filter(s => s) - .map(s => { - // Remove surrounding quotes - const match = s.match(/^["']((?:\\.|[^"\\])*)["']$/); - return match ? unescapeCelString(match[1]) : s; - }); - return { - type: 'condition', - id: generateNodeId(), - field: inListMatch[1] as ConditionField, - operator: 'in_list', - value: items, - }; + // In-list: field in ["a", "b"] + const inListMatch = trimmed.match(/^([\w.]+)\s+in\s+\[([^\]]*)\]$/); + if (inListMatch && isValidFieldOperator(inListMatch[1], 'in_list')) { + const items = inListMatch[2].split(',').map(s => s.trim()).filter(Boolean).map(s => { + const m = s.match(/^["']((?:\\.|[^"\\])*)["']$/); + return m ? unescapeCelString(m[1]) : s; + }); + return { type: 'condition', id: generateNodeId(), field: inListMatch[1], operator: 'in_list', value: items }; } - // Could not parse as a simple condition return null; } - -/** - * Creates an empty condition node with default values - */ -export function createEmptyCondition(): ConditionNode { - return { - type: 'condition', - id: generateNodeId(), - field: 'email', - operator: 'equals', - value: '', - }; -} - -/** - * Creates an empty group node - */ -export function createEmptyGroup(operator: 'and' | 'or' = 'and'): GroupNode { - return { - type: 'group', - id: generateNodeId(), - operator, - children: [], - }; -} - diff --git a/apps/dashboard/src/lib/risk-score-utils.ts b/apps/dashboard/src/lib/risk-score-utils.ts new file mode 100644 index 0000000000..2b4dd330a3 --- /dev/null +++ b/apps/dashboard/src/lib/risk-score-utils.ts @@ -0,0 +1,14 @@ +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const RISK_SCORE_REGEX = /^(100|[1-9]?[0-9])$/; + +export function validateRiskScore(value: string | null | undefined): boolean { + return value == null || value === "" || RISK_SCORE_REGEX.test(value); +} + +export function parseRiskScore(value: string): number { + if (!RISK_SCORE_REGEX.test(value)) { + throw new StackAssertionError("Risk scores must be integers between 0 and 100"); + } + return Number(value); +} diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index f477cf3e04..4ac6a19506 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -88,6 +88,23 @@ export const InternalProjectClientKeys = Object.freeze({ publishableClientKey: STACK_INTERNAL_PROJECT_CLIENT_KEY, }); +// These prefixes must match getMockTurnstileVerificationResponse in apps/mock-oauth-server/src/index.ts +export const mockTurnstileTokens = Object.freeze({ + signUpOk: "mock-turnstile-ok:sign_up_with_credential", + magicLinkOk: "mock-turnstile-ok:send_magic_link_email", + oauthOk: "mock-turnstile-ok:oauth_authenticate", + invalid: "mock-turnstile-invalid", + error: "mock-turnstile-error", + visibleSignUpOk: "mock-turnstile-visible-ok:sign_up_with_credential", + visibleMagicLinkOk: "mock-turnstile-visible-ok:send_magic_link_email", + visibleOAuthOk: "mock-turnstile-visible-ok:oauth_authenticate", +}); + +type TurnstileTestOptions = { + turnstileToken?: string, + turnstilePhase?: "invisible" | "visible", +}; + function expectSnakeCase(obj: unknown, path: string): void { if (typeof obj !== "object" || obj === null) return; if (Array.isArray(obj)) { @@ -431,15 +448,17 @@ export namespace Auth { } export namespace Otp { - export async function sendSignInCode() { + export async function sendSignInCode(options: TurnstileTestOptions = {}) { const mailbox = backendContext.value.mailbox; const response = await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", { method: "POST", accessType: "client", - body: { + body: filterUndefined({ email: mailbox.emailAddress, callback_url: "http://localhost:12345/some-callback-url", - }, + bot_challenge_token: options.turnstileToken ?? mockTurnstileTokens.magicLinkOk, + bot_challenge_phase: options.turnstilePhase, + }), }); expect(response).toMatchInlineSnapshot(` NiceResponse { @@ -522,18 +541,19 @@ export namespace Auth { } export namespace Password { - export async function signUpWithEmail(options: { password?: string, noWaitForEmail?: boolean } = {}) { + export async function signUpWithEmail(options: { password?: string, noWaitForEmail?: boolean, turnstileToken?: string } = {}) { const mailbox = backendContext.value.mailbox; const email = mailbox.emailAddress; const password = options.password ?? generateSecureRandomString(); const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { method: "POST", accessType: "client", - body: { + body: filterUndefined({ email, password, verification_callback_url: "http://localhost:12345/some-callback-url", - }, + bot_challenge_token: options.turnstileToken ?? mockTurnstileTokens.signUpOk, + }), }); expect(response).toMatchObject({ status: 200, @@ -707,7 +727,10 @@ export namespace Auth { export namespace OAuth { - export async function getAuthorizeQuery(options: { forceBranchId?: string, includeClientSecret?: boolean } = {}) { + export async function getAuthorizeQuery(options: TurnstileTestOptions & { + forceBranchId?: string, + includeClientSecret?: boolean, + } = {}) { const projectKeys = backendContext.value.projectKeys; if (projectKeys === "no-project") throw new Error("No project keys found in the backend context"); const branchId = options.forceBranchId ?? backendContext.value.currentBranchId; @@ -728,10 +751,18 @@ export namespace Auth { code_challenge: "some-code-challenge", code_challenge_method: "plain", token: userAuth?.accessToken ?? undefined, + bot_challenge_token: options.turnstileToken ?? mockTurnstileTokens.oauthOk, + bot_challenge_phase: options.turnstilePhase, + }); } - export async function authorize(options: { redirectUrl?: string, errorRedirectUrl?: string, forceBranchId?: string, includeClientSecret?: boolean } = {}) { + export async function authorize(options: TurnstileTestOptions & { + redirectUrl?: string, + errorRedirectUrl?: string, + forceBranchId?: string, + includeClientSecret?: boolean, + } = {}) { const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", { redirect: "manual", query: { @@ -757,7 +788,11 @@ export namespace Auth { }; } - export async function getInnerCallbackUrl(options: { authorizeResponse?: NiceResponse, forceBranchId?: string, includeClientSecret?: boolean } = {}) { + export async function getInnerCallbackUrl(options: TurnstileTestOptions & { + authorizeResponse?: NiceResponse, + forceBranchId?: string, + includeClientSecret?: boolean, + } = {}) { const authorizeResponse = options.authorizeResponse ?? (await Auth.OAuth.authorize(options)).authorizeResponse; const providerPassword = generateSecureRandomString(); const authLocation = new URL(authorizeResponse.headers.get("location")!); @@ -838,7 +873,12 @@ export namespace Auth { }; } - export async function getMaybeFailingAuthorizationCode(options: { innerCallbackUrl?: URL, authorizeResponse?: NiceResponse, forceBranchId?: string, includeClientSecret?: boolean } = {}) { + export async function getMaybeFailingAuthorizationCode(options: TurnstileTestOptions & { + innerCallbackUrl?: URL, + authorizeResponse?: NiceResponse, + forceBranchId?: string, + includeClientSecret?: boolean, + } = {}) { let authorizeResponse, innerCallbackUrl; if (options.innerCallbackUrl && options.authorizeResponse) { innerCallbackUrl = options.innerCallbackUrl; @@ -862,7 +902,12 @@ export namespace Auth { }; } - export async function getAuthorizationCode(options: { innerCallbackUrl?: URL, authorizeResponse?: NiceResponse, forceBranchId?: string, includeClientSecret?: boolean } = {}) { + export async function getAuthorizationCode(options: TurnstileTestOptions & { + innerCallbackUrl?: URL, + authorizeResponse?: NiceResponse, + forceBranchId?: string, + includeClientSecret?: boolean, + } = {}) { const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(options); expect(response).toMatchObject({ status: 303, @@ -884,7 +929,10 @@ export namespace Auth { }; } - export async function signIn(options: { forceBranchId?: string, includeClientSecret?: boolean } = {}) { + export async function signIn(options: TurnstileTestOptions & { + forceBranchId?: string, + includeClientSecret?: boolean, + } = {}) { const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode(options); const projectKeys = backendContext.value.projectKeys; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap b/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap index db45766580..d15eeba3fc 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap +++ b/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap @@ -533,6 +533,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -551,6 +552,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -560,6 +567,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -578,6 +586,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -587,6 +601,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -605,6 +620,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -614,6 +635,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -632,6 +654,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -641,6 +669,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -659,6 +688,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -670,6 +705,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -688,6 +724,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -697,6 +739,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -715,6 +758,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -724,6 +773,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -742,6 +792,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -751,6 +807,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -769,6 +826,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -778,6 +841,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -796,6 +860,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts index 07c42b01bf..2a77ccfb0d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts @@ -189,6 +189,7 @@ it("list users includes anonymous users when requested", async ({ expect }) => { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": true, "id": "", @@ -207,6 +208,12 @@ it("list users includes anonymous users when requested", async ({ expect }) => { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -284,6 +291,7 @@ it("search users excludes anonymous users by default", async ({ expect }) => { "auth_with_email": false, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": "Unique Anonymous Name", "has_password": false, "id": "", @@ -302,6 +310,12 @@ it("search users excludes anonymous users by default", async ({ expect }) => { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": { "type": "anonymous" }, + "risk_scores": { + "sign_up": { + "bot": 0, + "free_trial_abuse": 0, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-upgrade.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-upgrade.test.ts index 28b07658e6..9fc88f26f9 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-upgrade.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-upgrade.test.ts @@ -49,7 +49,7 @@ it("anonymous user can upgrade to regular user via password sign-up", async ({ e `); // Upgrade the user via password sign-up while logged in as anonymous - const { signUpResponse: upgradeRes } = await Auth.Password.signUpWithEmail(); + const { signUpResponse: upgradeRes } = await Auth.Password.signUpWithEmail({ noWaitForEmail: true }); expect(upgradeRes).toMatchInlineSnapshot(` NiceResponse { @@ -100,6 +100,10 @@ it("anonymous user can upgrade to regular user via password sign-up", async ({ e "headers": Headers {
)} +
+
Restricted:
+
{user.isRestricted ? `Yes${user.restrictedReason ? ` (${user.restrictedReason.type})` : ''}` : 'No'}
+
diff --git a/examples/demo/src/app/turnstile-signup/page-client.tsx b/examples/demo/src/app/turnstile-signup/page-client.tsx new file mode 100644 index 0000000000..a448702f1a --- /dev/null +++ b/examples/demo/src/app/turnstile-signup/page-client.tsx @@ -0,0 +1,1065 @@ +'use client'; + +import { KnownErrors } from "@stackframe/stack-shared"; +import { stackAppInternalsSymbol, useStackApp, useUser } from "@stackframe/stack"; +import { turnstileDevelopmentKeys } from "@stackframe/stack-shared/dist/utils/turnstile"; +import { publishableClientKeyNotNecessarySentinel } from "@stackframe/stack-shared/dist/utils/oauth"; +import { executeTurnstileInvisible, showTurnstileVisibleChallenge, BotChallengeUserCancelledError, withBotChallengeFlow } from "@stackframe/stack-shared/dist/utils/turnstile-flow"; +import { Button, Card, CardContent, CardFooter, CardHeader, Input, Label, PasswordInput, Typography } from "@stackframe/stack-ui"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +function createSuggestedEmail() { + const suffix = crypto.randomUUID().slice(0, 8); + return `turnstile-demo+${suffix}@example.com`; +} + +const testKeys = { + invisiblePass: turnstileDevelopmentKeys.invisibleSiteKey, + visiblePass: turnstileDevelopmentKeys.visibleSiteKey, + forceChallenge: turnstileDevelopmentKeys.forcedChallengeSiteKey, +}; + +const authReturnStorageKey = "turnstile-auth-demo-last-redirect"; + +type FlowResult = { + status: "success" | "error" | "info", + message: string, +}; + +type SignupResult = + | { ok: true, accessToken: string, refreshToken: string } + | { ok: false, code: string, message: string }; + +type MagicLinkSendResult = + | { ok: true, nonce: string } + | { ok: false, code: string, message: string }; + +type OAuthAuthorizeResult = + | { ok: true, location: string } + | { ok: false, code: string, message: string }; + +function getDebugInternals(app: ReturnType): { + sendRequest: (path: string, init: RequestInit) => Promise, + signInWithTokens: (tokens: { accessToken: string, refreshToken: string }) => Promise, +} { + const candidate = app[stackAppInternalsSymbol]; + const sendRequest = Reflect.get(candidate, "sendRequest"); + const signInWithTokens = Reflect.get(candidate, "signInWithTokens"); + + if (typeof sendRequest !== "function") { + throw new Error("Expected demo app internals to expose sendRequest for Turnstile debug flows"); + } + if (typeof signInWithTokens !== "function") { + throw new Error("Expected demo app internals to expose signInWithTokens for Turnstile debug flows"); + } + + return { + sendRequest: async (path, init) => await sendRequest(path, init), + signInWithTokens: async (tokens) => await signInWithTokens(tokens), + }; +} + +function getDemoApiUrl(): string { + const baseUrl = process.env.NEXT_PUBLIC_STACK_API_URL + ?? process.env.NEXT_PUBLIC_STACK_URL; + + if (typeof baseUrl !== "string" || baseUrl.length === 0) { + throw new Error("Expected NEXT_PUBLIC_STACK_API_URL to be configured for Turnstile OAuth debug flows"); + } + + return `${baseUrl.replace(/\/$/, "")}/api/v1`; +} + +function getAppAbsoluteUrl(path: string): string { + return new URL(path, window.location.origin).toString(); +} + +function getCurrentRelativeUrl(): string { + const currentUrl = new URL(window.location.href); + currentUrl.hash = ""; + return `${currentUrl.pathname}${currentUrl.search}`; +} + +function toBase64Url(bytes: Uint8Array): string { + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function createPkcePair(): Promise<{ challenge: string }> { + const verifier = toBase64Url(crypto.getRandomValues(new Uint8Array(32))); + const challengeBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))); + return { + challenge: toBase64Url(challengeBytes), + }; +} + +async function createOAuthDebugState(): Promise<{ codeChallenge: string, state: string }> { + const codeVerifier = toBase64Url(crypto.getRandomValues(new Uint8Array(32))); + const challengeBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier))); + const codeChallenge = toBase64Url(challengeBytes); + const state = crypto.randomUUID(); + + document.cookie = [ + `stack-oauth-outer-${encodeURIComponent(state)}=${encodeURIComponent(codeVerifier)}`, + "Path=/", + "Max-Age=3600", + "SameSite=Lax", + ].join("; "); + + return { + codeChallenge, + state, + }; +} + +/** + * Sends a signup request through the SDK's internal request pipeline. + * Catches KnownErrors (which sendClientRequest throws) and returns structured results. + */ +async function debugSignup( + sendRequest: (path: string, init: RequestInit) => Promise, + options: { + email: string, + password: string, + turnstileToken?: string, + turnstilePhase?: "invisible" | "visible", + }, +): Promise { + const bodyObj: Record = { + email: options.email, + password: options.password, + }; + if (options.turnstileToken) { + bodyObj.bot_challenge_token = options.turnstileToken; + } + if (options.turnstilePhase) { + bodyObj.bot_challenge_phase = options.turnstilePhase; + } + + try { + const res = await sendRequest("/auth/password/sign-up", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bodyObj), + }); + if (res.ok) { + const resBody = await res.json().catch(() => ({})); + return { ok: true, accessToken: resBody.access_token, refreshToken: resBody.refresh_token }; + } + const resBody = await res.json().catch(() => ({})); + return { ok: false, code: resBody.code ?? `HTTP_${res.status}`, message: resBody.message ?? res.statusText }; + } catch (e: unknown) { + // sendClientRequest throws KnownErrors instead of returning error responses + if (e instanceof KnownErrors.BotChallengeRequired) { + return { ok: false, code: "BOT_CHALLENGE_REQUIRED", message: e.message }; + } + if (e instanceof KnownErrors.UserWithEmailAlreadyExists) { + return { ok: false, code: "USER_EMAIL_ALREADY_EXISTS", message: e.message }; + } + if (e instanceof KnownErrors.PasswordRequirementsNotMet) { + return { ok: false, code: "PASSWORD_REQUIREMENTS_NOT_MET", message: e.message }; + } + // Re-throw unknown errors + throw e; + } +} + +function isChallengeRequired(result: SignupResult): boolean { + return !result.ok && result.code === "BOT_CHALLENGE_REQUIRED"; +} + +async function debugMagicLinkSend( + sendRequest: (path: string, init: RequestInit) => Promise, + options: { + email: string, + callbackUrl: string, + turnstileToken?: string, + turnstilePhase?: "invisible" | "visible", + }, +): Promise { + const bodyObj: Record = { + email: options.email, + callback_url: options.callbackUrl, + }; + if (options.turnstileToken) { + bodyObj.bot_challenge_token = options.turnstileToken; + } + if (options.turnstilePhase) { + bodyObj.bot_challenge_phase = options.turnstilePhase; + } + + try { + const res = await sendRequest("/auth/otp/send-sign-in-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bodyObj), + }); + if (res.ok) { + const resBody = await res.json().catch(() => ({})); + return { ok: true, nonce: typeof resBody.nonce === "string" ? resBody.nonce : "" }; + } + const resBody = await res.json().catch(() => ({})); + return { ok: false, code: resBody.code ?? `HTTP_${res.status}`, message: resBody.message ?? res.statusText }; + } catch (e: unknown) { + if (e instanceof KnownErrors.BotChallengeRequired) { + return { ok: false, code: "BOT_CHALLENGE_REQUIRED", message: e.message }; + } + if (e instanceof KnownErrors.RedirectUrlNotWhitelisted) { + return { ok: false, code: "REDIRECT_URL_NOT_WHITELISTED", message: e.message }; + } + throw e; + } +} + +function isMagicLinkChallengeRequired(result: MagicLinkSendResult): boolean { + return !result.ok && result.code === "BOT_CHALLENGE_REQUIRED"; +} + +async function debugOAuthAuthorize( + options: { + apiUrl: string, + provider: "github" | "google", + projectId: string, + publishableClientKey: string, + codeChallenge: string, + state: string, + redirectUrl: string, + errorRedirectUrl: string, + turnstileToken?: string, + turnstilePhase?: "invisible" | "visible", + }, +): Promise { + const params = new URLSearchParams({ + client_id: options.projectId, + client_secret: options.publishableClientKey, + redirect_uri: options.redirectUrl, + scope: "legacy", + state: options.state, + grant_type: "authorization_code", + code_challenge: options.codeChallenge, + code_challenge_method: "S256", + response_type: "code", + type: "authenticate", + error_redirect_url: options.errorRedirectUrl, + stack_response_mode: "json", + }); + + if (options.turnstileToken) { + params.set("bot_challenge_token", options.turnstileToken); + } + if (options.turnstilePhase) { + params.set("bot_challenge_phase", options.turnstilePhase); + } + + try { + const res = await fetch(`${options.apiUrl}/auth/oauth/authorize/${options.provider}?${params.toString()}`, { + method: "GET", + }); + if (res.ok) { + const resBody = await res.json().catch(() => ({})); + if (typeof resBody.location !== "string") { + return { ok: false, code: "MISSING_LOCATION", message: "OAuth authorize response did not include a redirect location." }; + } + return { ok: true, location: resBody.location }; + } + const resBody = await res.json().catch(() => ({})); + return { ok: false, code: resBody.code ?? `HTTP_${res.status}`, message: resBody.message ?? res.statusText }; + } catch (e: unknown) { + if (e instanceof KnownErrors.BotChallengeRequired) { + return { ok: false, code: "BOT_CHALLENGE_REQUIRED", message: e.message }; + } + throw e; + } +} + +function isOAuthChallengeRequired(result: OAuthAuthorizeResult): boolean { + return !result.ok && result.code === "BOT_CHALLENGE_REQUIRED"; +} + +export default function TurnstileSignupPageClient() { + const app = useStackApp(); + const user = useUser({ includeRestricted: true }); + const [email, setEmail] = useState(() => createSuggestedEmail()); + const [password, setPassword] = useState("Demo-password-123!"); + + const [sdkActionResult, setSdkActionResult] = useState(null); + const [loadingSdkAction, setLoadingSdkAction] = useState(null); + const [returnMessage, setReturnMessage] = useState(null); + + // Debug card state + const [loadingFlow, setLoadingFlow] = useState(null); + const [lastResult, setLastResult] = useState(null); + + const internals = getDebugInternals(app); + const sendRequest = internals.sendRequest; + const signInWithTokens = internals.signInWithTokens; + const apiUrl = getDemoApiUrl(); + const oauthClientSecret = app[stackAppInternalsSymbol].toClientJson().publishableClientKey + ?? process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY + ?? process.env.STACK_PUBLISHABLE_CLIENT_KEY + ?? publishableClientKeyNotNecessarySentinel; + + useEffect(() => { + const redirectSource = window.sessionStorage.getItem(authReturnStorageKey); + if (redirectSource == null) { + return; + } + + window.sessionStorage.removeItem(authReturnStorageKey); + setReturnMessage(`Returned from the ${redirectSource} flow. If the provider account was new, this also exercised OAuth sign-up with Turnstile enabled.`); + }, []); + + function freshEmail() { + const e = createSuggestedEmail(); + setEmail(e); + return e; + } + + function getOAuthCallbackUrlForTurnstileLab() { + const callbackUrl = new URL(getAppAbsoluteUrl(app.urls.oauthCallback)); + callbackUrl.searchParams.set("after_auth_return_to", getCurrentRelativeUrl()); + return callbackUrl.toString(); + } + + async function runSdkAction(id: string, fn: () => Promise) { + setLoadingSdkAction(id); + setSdkActionResult(null); + try { + setSdkActionResult(await fn()); + } catch (e) { + if (e instanceof BotChallengeUserCancelledError) { + setSdkActionResult({ status: "error", message: "Turnstile challenge cancelled by user." }); + } else { + setSdkActionResult({ status: "error", message: e instanceof Error ? e.message : String(e) }); + } + } finally { + setLoadingSdkAction(null); + } + } + + if (user != null) { + return ( +
+ + + Turnstile Auth Lab + + + + Signed in as {user.primaryEmail ?? user.id}. + + {returnMessage && ( + + {returnMessage} + + )} + + Sign out to rerun password, magic-link, OAuth, and hosted flows from a clean state. + + + + + + Back to home + + + +
+ ); + } + + // ── SDK signup ── + async function handleSdkSignUp(): Promise { + const result = await app.signUpWithCredential({ + email, + password, + noRedirect: true, + noVerificationCallback: true, + }); + if (result.status === "error") { + return { status: "error", message: result.error.message }; + } + + return { status: "success", message: "Password sign-up succeeded. Turnstile was handled transparently by the SDK." }; + } + + async function handleMagicLinkSend(): Promise { + const result = await app.sendMagicLinkEmail(email); + if (result.status === "error") { + return { status: "error", message: result.error.message }; + } + + return { + status: "success", + message: `Magic link / OTP send succeeded for ${email}. Complete it from Inbucket or your inbox to continue the user flow.`, + }; + } + + async function handleOAuthStart(provider: "github" | "google"): Promise { + window.sessionStorage.setItem(authReturnStorageKey, `${provider} OAuth`); + await app.signInWithOAuth(provider, { returnTo: getOAuthCallbackUrlForTurnstileLab() }); + return { + status: "info", + message: `Redirecting to ${provider} OAuth...`, + }; + } + + async function handleMagicLinkVisibleDrill(): Promise { + const drillEmail = freshEmail(); + const callbackUrl = getAppAbsoluteUrl(app.urls.magicLinkCallback); + + const firstRes = await debugMagicLinkSend(sendRequest, { + email: drillEmail, + callbackUrl, + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "invisible", + }); + + if (!isMagicLinkChallengeRequired(firstRes)) { + return { status: "error", message: `Expected BOT_CHALLENGE_REQUIRED, got: ${firstRes.ok ? "ok" : firstRes.code}` }; + } + + const visibleToken = await showTurnstileVisibleChallenge(testKeys.forceChallenge, "send_magic_link_email"); + const secondRes = await debugMagicLinkSend(sendRequest, { + email: drillEmail, + callbackUrl, + turnstileToken: visibleToken, + turnstilePhase: "visible", + }); + + if (!secondRes.ok) { + return { status: "error", message: `Visible retry failed: ${secondRes.code} — ${secondRes.message}` }; + } + + return { + status: "success", + message: `Magic link / OTP send succeeded after a forced visible challenge for ${drillEmail}. Complete the link or code from Inbucket to continue the new-user flow.`, + }; + } + + async function handleOAuthVisibleDrill(provider: "github" | "google"): Promise { + const oauthDebugState = await createOAuthDebugState(); + + const firstRes = await debugOAuthAuthorize({ + apiUrl, + provider, + projectId: app.projectId, + publishableClientKey: oauthClientSecret, + codeChallenge: oauthDebugState.codeChallenge, + state: oauthDebugState.state, + redirectUrl: getOAuthCallbackUrlForTurnstileLab(), + errorRedirectUrl: getAppAbsoluteUrl(app.urls.error), + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "invisible", + }); + + if (!isOAuthChallengeRequired(firstRes)) { + return { status: "error", message: `Expected BOT_CHALLENGE_REQUIRED, got: ${firstRes.ok ? "ok" : firstRes.code}` }; + } + + const visibleToken = await showTurnstileVisibleChallenge(testKeys.forceChallenge, "oauth_authenticate"); + const secondRes = await debugOAuthAuthorize({ + apiUrl, + provider, + projectId: app.projectId, + publishableClientKey: oauthClientSecret, + codeChallenge: oauthDebugState.codeChallenge, + state: oauthDebugState.state, + redirectUrl: getOAuthCallbackUrlForTurnstileLab(), + errorRedirectUrl: getAppAbsoluteUrl(app.urls.error), + turnstileToken: visibleToken, + turnstilePhase: "visible", + }); + + if (!secondRes.ok) { + return { status: "error", message: `Visible retry failed: ${secondRes.code} — ${secondRes.message}` }; + } + + window.sessionStorage.setItem(authReturnStorageKey, `${provider} OAuth visible challenge`); + window.location.assign(secondRes.location); + return { + status: "info", + message: `Redirecting to ${provider} OAuth after the forced visible challenge...`, + }; + } + + // ── Debug flow runner ── + async function runFlow( + id: string, + fn: (signupEmail: string) => Promise, + ) { + setLoadingFlow(id); + setLastResult(null); + const signupEmail = freshEmail(); + try { + const result = await fn(signupEmail); + setLastResult(result); + } catch (e) { + if (e instanceof BotChallengeUserCancelledError) { + setLastResult({ status: "error", message: "User cancelled the visible challenge — signup blocked." }); + } else { + setLastResult({ status: "error", message: e instanceof Error ? e.message : String(e) }); + } + } finally { + setLoadingFlow(null); + } + } + + // Flow: invisible token succeeds → signup + async function flowInvisibleOk(signupEmail: string): Promise { + const token = await executeTurnstileInvisible(testKeys.invisiblePass, "sign_up_with_credential"); + const res = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: token, + turnstilePhase: "invisible", + }); + if (res.ok) { + await signInWithTokens({ accessToken: res.accessToken, refreshToken: res.refreshToken }); + return { status: "success", message: "Signup succeeded. Invisible token was accepted." }; + } + return { status: "error", message: `Signup failed: ${res.code} — ${res.message}` }; + } + + // Flow: invisible fails → visible challenge → signup + async function flowChallengeRequired(signupEmail: string): Promise { + const firstRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "invisible", + }); + + if (firstRes.ok) { + return { status: "success", message: "Signup unexpectedly succeeded on first attempt (no challenge required)." }; + } + + if (!isChallengeRequired(firstRes)) { + return { status: "error", message: `Expected BOT_CHALLENGE_REQUIRED, got: ${firstRes.code}` }; + } + + const visibleToken = await showTurnstileVisibleChallenge(testKeys.forceChallenge, "sign_up_with_credential"); + + const secondRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: visibleToken, + turnstilePhase: "visible", + }); + + if (secondRes.ok) { + await signInWithTokens({ accessToken: secondRes.accessToken, refreshToken: secondRes.refreshToken }); + return { status: "success", message: "Signup succeeded after visible challenge." }; + } + return { status: "error", message: `Retry failed: ${secondRes.code} — ${secondRes.message}` }; + } + + // Flow: both invisible and visible fail → blocked + async function flowBothFail(signupEmail: string): Promise { + const firstRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "invisible", + }); + + if (!isChallengeRequired(firstRes)) { + return { status: "error", message: `Expected BOT_CHALLENGE_REQUIRED, got: ${firstRes.ok ? "ok" : firstRes.code}` }; + } + + const secondRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "visible", + }); + + if (secondRes.ok) { + return { status: "error", message: "Signup unexpectedly succeeded even with invalid visible token." }; + } + return { status: "success", message: `Signup correctly blocked: ${secondRes.code}` }; + } + + // Flow: no token at all + async function flowNoToken(signupEmail: string): Promise { + const res = await debugSignup(sendRequest, { email: signupEmail, password }); + if (res.ok) { + await signInWithTokens({ accessToken: res.accessToken, refreshToken: res.refreshToken }); + return { status: "success", message: "Signup succeeded without any token. Backend accepted it." }; + } + return { status: "error", message: `Signup failed: ${res.code} — ${res.message}` }; + } + + // Flow: withBotChallengeFlow orchestrator + async function flowOrchestrator(signupEmail: string): Promise { + const result = await withBotChallengeFlow({ + invisibleSiteKey: testKeys.invisiblePass, + visibleSiteKey: testKeys.forceChallenge, + action: "sign_up_with_credential", + execute: async (turnstile) => { + return await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: turnstile.token, + turnstilePhase: turnstile.phase, + }); + }, + isChallengeRequired: (res) => { + return !res.ok && res.code === "BOT_CHALLENGE_REQUIRED"; + }, + }); + + if (result.ok) { + await signInWithTokens({ accessToken: result.accessToken, refreshToken: result.refreshToken }); + return { status: "success", message: "Signup succeeded via withBotChallengeFlow orchestrator." }; + } + return { status: "error", message: `Signup failed: ${result.code} — ${result.message}` }; + } + + // Flow: random outcome (simulates realistic behavior) + async function flowRandom(signupEmail: string): Promise { + const rand = Math.random(); + if (rand < 0.4) { + // 40%: invisible succeeds + const token = await executeTurnstileInvisible(testKeys.invisiblePass, "sign_up_with_credential"); + const res = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: token, + turnstilePhase: "invisible", + }); + if (res.ok) { + await signInWithTokens({ accessToken: res.accessToken, refreshToken: res.refreshToken }); + return { status: "success", message: "[Random: invisible pass] Signup succeeded." }; + } + return { status: "error", message: `[Random: invisible pass] Failed: ${res.code}` }; + } else if (rand < 0.7) { + // 30%: invisible fails → visible challenge → succeeds + const firstRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "invisible", + }); + if (!isChallengeRequired(firstRes)) { + return { status: "info", message: `[Random: challenge] Unexpected: ${firstRes.ok ? "ok" : firstRes.code}` }; + } + const visibleToken = await showTurnstileVisibleChallenge(testKeys.forceChallenge, "sign_up_with_credential"); + const secondRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: visibleToken, + turnstilePhase: "visible", + }); + if (secondRes.ok) { + await signInWithTokens({ accessToken: secondRes.accessToken, refreshToken: secondRes.refreshToken }); + return { status: "success", message: "[Random: challenge -> pass] Signup succeeded after challenge." }; + } + return { status: "error", message: `[Random: challenge -> pass] Retry failed: ${secondRes.code}` }; + } else if (rand < 0.9) { + // 20%: both fail → blocked + const firstRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "invisible", + }); + if (!isChallengeRequired(firstRes)) { + return { status: "info", message: `[Random: both fail] Unexpected: ${firstRes.ok ? "ok" : firstRes.code}` }; + } + const secondRes = await debugSignup(sendRequest, { + email: signupEmail, password, + turnstileToken: "mock-turnstile-invalid", + turnstilePhase: "visible", + }); + if (secondRes.ok) { + return { status: "error", message: "[Random: both fail] Signup unexpectedly succeeded." }; + } + return { status: "success", message: `[Random: both fail] Signup correctly blocked: ${secondRes.code}` }; + } else { + // 10%: no token + const res = await debugSignup(sendRequest, { email: signupEmail, password }); + if (res.ok) { + await signInWithTokens({ accessToken: res.accessToken, refreshToken: res.refreshToken }); + return { status: "success", message: "[Random: no token] Signup succeeded." }; + } + return { status: "error", message: `[Random: no token] Failed: ${res.code}` }; + } + } + + return ( +
+ {/* Header */} +
+
+ Turnstile Auth Lab + + Exercise password sign-up, magic links, OAuth sign-up, and hosted auth screens with Turnstile turned on. The upper grid uses real SDK entrypoints; the lower grid still exposes the raw password-debug scenarios. + +
+
+ + {/* Shared credentials */} + + + Shared credentials + + +
+
+ + { + setEmail(e.target.value); + }} + /> +
+
+ + { + setPassword(e.target.value); + }} + /> +
+
+
+ + + +
+ + {/* SDK coverage */} + + + SDK auth coverage + + These are the real consumer-facing auth entrypoints. Turnstile is handled by the SDK and hosted flows without any custom demo glue. + + + + {sdkActionResult && ( + + {sdkActionResult.message} + + )} +
+ + + Password sign-up + + + + Calls app.signUpWithCredential(). This is the cleanest example of transparent Turnstile handling for direct sign-up. + + + + + + + + + + Magic link / OTP send + + + + Calls app.sendMagicLinkEmail(). For a brand-new email, completing the link covers the new-user flow backed by the OTP send endpoint. + + + + + + + + + + OAuth sign-up / sign-in + + + + Starts app.signInWithOAuth(). If the provider account is new, the callback path creates the user and applies sign-up rules with stored Turnstile context. + + + + + + + + + + + Hosted flows + + + + Opens the hosted Stack screens so you can verify Turnstile behavior in the out-of-the-box UI too. + + + + + Hosted sign-up + + + Hosted sign-in + + + + + + + Forced visible: magic link / OTP + + + + Deterministically triggers BOT_CHALLENGE_REQUIRED on the send step, then opens the visible challenge before retrying. + + + + + + + + + + Forced visible: OAuth authorize + + + + Forces the visible Turnstile step during OAuth authorize, then redirects into the provider login once the challenge passes. + + + + + + + +
+
+
+ + {/* Debug flows */} +
+ Password flow debugger + + Each card sends controlled Turnstile params to the backend via the SDK's internal request pipeline. A fresh email is generated per attempt so you can reproduce individual password sign-up states. + + +
+ {/* Invisible pass */} + + + Invisible token succeeds + + + + Acquires a valid invisible token (always-pass test key) and signs up. + + + + + + + + {/* Challenge required → visible → signup */} + + + Invisible fails → visible challenge + + + + Sends an invalid invisible token, then shows the visible challenge overlay. Solve it to complete signup. + + + + + + + + {/* Both fail → blocked */} + + + Both fail → signup blocked + + + + Invalid invisible token, then invalid visible token. Signup should be rejected. + + + + + + + + {/* No token */} + + + No token (backwards compat) + + + + Sends signup with no Turnstile token at all. Tests backwards compatibility. + + + + + + + + {/* withBotChallengeFlow orchestrator */} + + + withBotChallengeFlow orchestrator + + + + Uses withBotChallengeFlow() to automatically handle invisible → visible fallback. + + + + + + + + {/* Random */} + + + Random scenario + + + + Randomly picks a scenario (40% invisible pass, 30% challenge, 20% both fail, 10% no token) for realistic testing. + + + + + + +
+
+ + {/* Last result */} + {lastResult && ( + + + Last result + + + + {lastResult.message} + + + + )} + + {/* Config info */} + + + Config + + + Project: {app.projectId} + Invisible key: {testKeys.invisiblePass} + Visible key: {testKeys.visiblePass} + Force challenge key: {testKeys.forceChallenge} + + + + Back to home + + + Open hosted sign-up + + + Open hosted sign-in + + + +
+ ); +} diff --git a/examples/demo/src/app/turnstile-signup/page.tsx b/examples/demo/src/app/turnstile-signup/page.tsx new file mode 100644 index 0000000000..f54cbcbf75 --- /dev/null +++ b/examples/demo/src/app/turnstile-signup/page.tsx @@ -0,0 +1,5 @@ +import TurnstileSignupPageClient from "./page-client"; + +export default function TurnstileSignupPage() { + return ; +} diff --git a/examples/demo/src/components/turnstile-visible-widget.tsx b/examples/demo/src/components/turnstile-visible-widget.tsx new file mode 100644 index 0000000000..e1e667662f --- /dev/null +++ b/examples/demo/src/components/turnstile-visible-widget.tsx @@ -0,0 +1,92 @@ +'use client'; + +import type { TurnstileAction } from "@stackframe/stack-shared/dist/utils/turnstile"; +import { getTurnstileApi, loadTurnstileScript } from "@stackframe/stack-shared/dist/utils/turnstile-browser"; +import type { TurnstileWidgetId } from "@stackframe/stack-shared/dist/utils/turnstile-browser"; +import { useEffect, useRef } from "react"; + +export function TurnstileVisibleWidget(props: { + siteKey: string, + action: TurnstileAction, + onTokenChange: (token: string | null) => void, + onError?: (message: string) => void, +}) { + const { action, siteKey } = props; + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const onTokenChangeRef = useRef(props.onTokenChange); + const onErrorRef = useRef(props.onError); + + useEffect(() => { + onTokenChangeRef.current = props.onTokenChange; + onErrorRef.current = props.onError; + }, [props.onTokenChange, props.onError]); + + useEffect(() => { + onTokenChangeRef.current(null); + + const container = containerRef.current; + if (container == null) { + return; + } + + const state = { + cancelled: false, + }; + + const loadPromise = (async () => { + await loadTurnstileScript(); + const turnstileApi = getTurnstileApi(); + if (state.cancelled) { + return; + } + if (!turnstileApi) { + onErrorRef.current?.("Failed to initialize Turnstile"); + onTokenChangeRef.current(null); + return; + } + + widgetIdRef.current = turnstileApi.render(container, { + sitekey: siteKey, + action, + appearance: "always", + execution: "render", + theme: "auto", + size: "flexible", + callback: (token) => { + onTokenChangeRef.current(token); + }, + "error-callback": (errorCode) => { + onTokenChangeRef.current(null); + onErrorRef.current?.(errorCode ? `Turnstile error: ${errorCode}` : "Turnstile verification failed"); + }, + "expired-callback": () => { + onTokenChangeRef.current(null); + onErrorRef.current?.("Turnstile token expired. Solve the challenge again."); + }, + "timeout-callback": () => { + onTokenChangeRef.current(null); + onErrorRef.current?.("Turnstile challenge timed out. Solve it again."); + }, + }); + })(); + + loadPromise.catch((error: unknown) => { + if (state.cancelled) return; + onTokenChangeRef.current(null); + onErrorRef.current?.(error instanceof Error ? error.message : "Failed to load Turnstile"); + }); + + return () => { + state.cancelled = true; + onTokenChangeRef.current(null); + const turnstileApi = getTurnstileApi(); + if (widgetIdRef.current != null && turnstileApi) { + turnstileApi.remove(widgetIdRef.current); + } + widgetIdRef.current = null; + }; + }, [action, siteKey]); + + return
; +} diff --git a/examples/demo/src/stack.tsx b/examples/demo/src/stack.tsx index ae2dcba404..aa80bb72cb 100644 --- a/examples/demo/src/stack.tsx +++ b/examples/demo/src/stack.tsx @@ -6,5 +6,5 @@ export const stackServerApp = new StackServerApp({ tokenStore: "nextjs-cookie", urls: { accountSettings: '/settings', - } + }, }); diff --git a/package.json b/package.json index 762f7c594c..9f93e85fe9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "restart-dev-environment": "pnpm pre && pnpm run build:packages && pnpm run codegen && pnpm run restart-deps && pnpm run restart-dev-in-background", "stop-dev-environment": "pnpm pre && pnpm run kill-dev:named && pnpm run stop-deps", "clean": "pnpm pre-no-codegen && turbo run clean && rimraf --glob **/.next && rimraf --glob **/.turbo && rimraf .turbo && rimraf --glob **/node_modules && rimraf node_modules", + "fml": "pnpm clean && pnpm i && (pnpm build || true) && pnpm codegen && pnpm restart-deps", "codegen": "pnpm pre && turbo run codegen && pnpm run generate-sdks", "codegen:backend": "pnpm pre && turbo run codegen --filter=@stackframe/backend...", "deps-compose": "docker compose -p stack-dependencies-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81} -f docker/dependencies/docker.compose.yaml", @@ -43,12 +44,14 @@ "fern": "pnpm pre && pnpm run --filter=@stackframe/docs fern", "dev:full": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999\"", "dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo \"", + "dev:tui": "pnpm pre && (trap 'kill 0' EXIT; pnpm run generate-sdks:watch & pnpm run generate-openapi-docs:watch & turbo run dev --ui tui --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo)", "dev:inspect": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev", "dev:profile": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev", "dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/backend --filter=@stackframe/dashboard --filter=@stackframe/mock-oauth-server\"", "dev:docs": "pnpm pre && concurrently -k \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/stack-docs\"", "dev:named": "pnpm pre && concurrently -k \"pnpm run dev\" \"node -e \\\"process.title='node (stack-named-dev-server)'; process.stdin.resume();\\\"\"", "kill-dev:named": "(pgrep -f 'stack-named-dev-server' | xargs -r -n1 pkill -P); echo 'Killed named dev server (if found). Sleeping to give some time for it to shut down...' && sleep 10", + "kms": "PREFIX=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}; for p in 00 01 02 03 04 06 14; do pids=$(lsof -i :$PREFIX$p 2>/dev/null | grep LISTEN | awk '$1 != \"OrbStack\" {print $2}' | sort -u); [ -n \"$pids\" ] && echo $pids | xargs kill -9 2>/dev/null; done; echo Done.", "start": "pnpm pre && turbo run start --concurrency 99999", "start:backend": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/backend", "start:dashboard": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/dashboard", @@ -78,6 +81,7 @@ "chokidar-cli": "^3.0.0", "codebuff": "^1.0.261", "concurrently": "^8.2.2", + "dotenv-cli": "^7.3.0", "esbuild": "^0.24.0", "eslint": "^8.57.0", "eslint-config-next": "^14.2.17", @@ -89,12 +93,11 @@ "only-allow": "^1.2.1", "rimraf": "^5.0.10", "tsdown": "^0.20.3", - "turbo": "^2.2.3", + "turbo": "^2.8.15", "typescript": "5.9.3", "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.6.0", - "wait-on": "^8.0.1", - "dotenv-cli": "^7.3.0" + "wait-on": "^8.0.1" }, "pnpm": { "overrides": { diff --git a/packages/private b/packages/private new file mode 160000 index 0000000000..6f708e4206 --- /dev/null +++ b/packages/private @@ -0,0 +1 @@ +Subproject commit 6f708e4206abc6ec0903dd93629c7bd137dbcb0b diff --git a/packages/react/package.json b/packages/react/package.json index 3d9d16aaa4..d0febd419d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -105,6 +105,7 @@ "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "react": "^19.0.0", + "@types/react-dom": "^19.0.0", "react-dom": "^19.0.0", "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", diff --git a/packages/stack-shared/src/config/db-sync-mappings.ts b/packages/stack-shared/src/config/db-sync-mappings.ts index 65e839446c..31039fd759 100644 --- a/packages/stack-shared/src/config/db-sync-mappings.ts +++ b/packages/stack-shared/src/config/db-sync-mappings.ts @@ -91,7 +91,7 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { ), false ) AS "primary_email_verified", - "ProjectUser"."createdAt" AS "signed_up_at", + COALESCE("ProjectUser"."signedUpAt", "ProjectUser"."createdAt") AS "signed_up_at", COALESCE("ProjectUser"."clientMetadata", '{}'::jsonb) AS "client_metadata", COALESCE("ProjectUser"."clientReadOnlyMetadata", '{}'::jsonb) AS "client_read_only_metadata", COALESCE("ProjectUser"."serverMetadata", '{}'::jsonb) AS "server_metadata", @@ -167,7 +167,7 @@ export const DEFAULT_DB_SYNC_MAPPINGS = { ), false ) AS "primary_email_verified", - "ProjectUser"."createdAt" AS "signed_up_at", + COALESCE("ProjectUser"."signedUpAt", "ProjectUser"."createdAt") AS "signed_up_at", COALESCE("ProjectUser"."clientMetadata", '{}'::jsonb) AS "client_metadata", COALESCE("ProjectUser"."clientReadOnlyMetadata", '{}'::jsonb) AS "client_read_only_metadata", COALESCE("ProjectUser"."serverMetadata", '{}'::jsonb) AS "server_metadata", diff --git a/packages/stack-shared/src/interface/client-interface.test.ts b/packages/stack-shared/src/interface/client-interface.test.ts new file mode 100644 index 0000000000..84e5be3ae8 --- /dev/null +++ b/packages/stack-shared/src/interface/client-interface.test.ts @@ -0,0 +1,341 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { KnownErrors } from "../known-errors"; +import { InternalSession } from "../sessions"; +import { Result } from "../utils/results"; +import { StackClientInterface } from "./client-interface"; + +function createClientInterface() { + return new StackClientInterface({ + clientVersion: "test", + getBaseUrl: () => "https://api.example.com", + extraRequestHeaders: {}, + projectId: "project-id", + publishableClientKey: "publishable-client-key", + }); +} + +function createSession() { + return new InternalSession({ + refreshAccessTokenCallback: async () => null, + refreshToken: null, + accessToken: null, + }); +} + +function createJsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); +} + +function createKnownErrorResponse(error: InstanceType): Response { + return new Response(JSON.stringify({ + code: error.errorCode, + message: error.message, + details: error.details, + }), { + status: error.statusCode, + headers: { + "Content-Type": "application/json", + "x-stack-known-error": error.errorCode, + }, + }); +} + +function getRequestBody(fetchMock: { mock: { calls: unknown[][] } }): Record { + const requestInit = fetchMock.mock.calls[0]?.[1]; + if (requestInit == null || typeof requestInit !== "object" || !("body" in requestInit)) { + throw new Error("Expected request init to include a body"); + } + + const requestBody = requestInit.body; + if (requestBody == null || typeof requestBody !== "string") { + throw new Error("Expected request body to be a JSON string"); + } + + const parsed = JSON.parse(requestBody); + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Expected parsed request body to be an object"); + } + + return parsed; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("StackClientInterface bot challenge compatibility", () => { + it("omits bot challenge from magic link requests when no token is provided", async () => { + const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" })); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback"); + + expect(getRequestBody(fetchMock)).toStrictEqual({ + email: "user@example.com", + callback_url: "https://app.example.com/callback", + }); + }); + + it("serializes visible bot challenge retry fields for magic link requests", async () => { + const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" })); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", { + token: " visible-token ", + phase: "visible", + }); + + expect(getRequestBody(fetchMock)).toStrictEqual({ + email: "user@example.com", + callback_url: "https://app.example.com/callback", + bot_challenge_token: "visible-token", + bot_challenge_phase: "visible", + }); + }); + + it("serializes bot challenge unavailability for magic link requests", async () => { + const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" })); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", { + phase: "visible", + }); + + expect(getRequestBody(fetchMock)).toStrictEqual({ + email: "user@example.com", + callback_url: "https://app.example.com/callback", + bot_challenge_unavailable: "true", + }); + }); + + it("serializes explicit bot challenge unavailability for magic link requests", async () => { + const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" })); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", { + unavailable: true, + }); + + expect(getRequestBody(fetchMock)).toStrictEqual({ + email: "user@example.com", + callback_url: "https://app.example.com/callback", + bot_challenge_unavailable: "true", + }); + }); + + it("returns BotChallengeFailed as a Result error for magic link requests", async () => { + const fetchMock = vi.fn(async () => createKnownErrorResponse( + new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"), + )); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + const result = await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", { + phase: "visible", + }); + + expect(result.status).toBe("error"); + if (result.status !== "error") { + throw new Error("Expected magic link request to fail with BotChallengeFailed"); + } + expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed); + }); + + it("omits bot challenge from credential signup requests when no token is provided", async () => { + const fetchMock = vi.fn(async () => createJsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + })); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + await iface.signUpWithCredential( + "user@example.com", + "password", + undefined, + createSession(), + undefined, + ); + + expect(getRequestBody(fetchMock)).toStrictEqual({ + email: "user@example.com", + password: "password", + }); + }); + + it("returns BotChallengeFailed as a Result error for credential signup requests", async () => { + const fetchMock = vi.fn(async () => createKnownErrorResponse( + new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"), + )); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + const result = await iface.signUpWithCredential( + "user@example.com", + "password", + undefined, + createSession(), + { + phase: "visible", + }, + ); + + expect(result.status).toBe("error"); + if (result.status !== "error") { + throw new Error("Expected credential signup to fail with BotChallengeFailed"); + } + expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed); + }); + + it("omits bot challenge from OAuth URLs when no token is provided", async () => { + const iface = createClientInterface(); + const oauthUrl = await iface.getOAuthUrl({ + provider: "github", + redirectUrl: "https://app.example.com/oauth/callback", + errorRedirectUrl: "https://app.example.com/error", + codeChallenge: "code-challenge", + state: "state", + type: "authenticate", + session: createSession(), + }); + + expect(new URL(oauthUrl).searchParams.has("bot_challenge_token")).toBe(false); + }); + + it("serializes visible bot challenge retry fields in OAuth URLs", async () => { + const iface = createClientInterface(); + const oauthUrl = await iface.getOAuthUrl({ + provider: "github", + redirectUrl: "https://app.example.com/oauth/callback", + errorRedirectUrl: "https://app.example.com/error", + codeChallenge: "code-challenge", + state: "state", + type: "authenticate", + botChallenge: { + token: "visible-token", + phase: "visible", + }, + session: createSession(), + }); + + expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({ + bot_challenge_token: "visible-token", + bot_challenge_phase: "visible", + }); + }); + + it("serializes bot challenge unavailability in OAuth URLs", async () => { + const iface = createClientInterface(); + const oauthUrl = await iface.getOAuthUrl({ + provider: "github", + redirectUrl: "https://app.example.com/oauth/callback", + errorRedirectUrl: "https://app.example.com/error", + codeChallenge: "code-challenge", + state: "state", + type: "authenticate", + botChallenge: { + phase: "visible", + }, + session: createSession(), + }); + + expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({ + bot_challenge_unavailable: "true", + }); + }); + + it("authorizes OAuth via a JSON response instead of relying on manual redirects", async () => { + const fetchCalls: [input: RequestInfo | URL, init?: RequestInit][] = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + fetchCalls.push([input, init]); + return createJsonResponse({ + location: "https://accounts.example.com/oauth/authorize", + }); + }); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", {} as Window & typeof globalThis); + + const iface = createClientInterface(); + const result = await iface.authorizeOAuth({ + provider: "github", + redirectUrl: "https://app.example.com/oauth/callback", + errorRedirectUrl: "https://app.example.com/error", + codeChallenge: "code-challenge", + state: "state", + type: "authenticate", + session: createSession(), + }); + + expect(Result.orThrow(result)).toBe("https://accounts.example.com/oauth/authorize"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchCalls[0] ?? []; + if (!(typeof requestUrl === "string" || requestUrl instanceof URL)) { + throw new Error("Expected authorizeOAuth to call fetch with a URL"); + } + expect(new URL(requestUrl.toString()).searchParams.get("stack_response_mode")).toBe("json"); + expect(requestInit).toMatchObject({ + method: "GET", + }); + expect(requestInit).not.toHaveProperty("credentials"); + }); + + it("returns BotChallengeFailed as a Result error for OAuth authorization", async () => { + const fetchMock = vi.fn(async () => createKnownErrorResponse( + new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"), + )); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", {} as Window & typeof globalThis); + + const iface = createClientInterface(); + const result = await iface.authorizeOAuth({ + provider: "github", + redirectUrl: "https://app.example.com/oauth/callback", + errorRedirectUrl: "https://app.example.com/error", + codeChallenge: "code-challenge", + state: "state", + type: "authenticate", + session: createSession(), + }); + + expect(result.status).toBe("error"); + if (result.status !== "error") { + throw new Error("Expected OAuth authorization to fail with BotChallengeFailed"); + } + expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed); + }); + + it("serializes bot challenge unavailability for credential signup requests", async () => { + const fetchMock = vi.fn(async () => createJsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + })); + vi.stubGlobal("fetch", fetchMock); + + const iface = createClientInterface(); + await iface.signUpWithCredential( + "user@example.com", + "password", + undefined, + createSession(), + { + phase: "visible", + }, + ); + + expect(getRequestBody(fetchMock)).toStrictEqual({ + email: "user@example.com", + password: "password", + bot_challenge_unavailable: "true", + }); + }); +}); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index eefd2f6f30..a0d69deb71 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -47,6 +47,67 @@ export type ClientInterfaceOptions = { projectOwnerSession: InternalSession | (() => Promise), }); +type BotChallengeInput = { + token?: string, + phase?: "invisible" | "visible", + unavailable?: true, +}; + +const botChallengeKnownErrors = [ + KnownErrors.BotChallengeRequired, + KnownErrors.BotChallengeFailed, +] as const; + +function isBotChallengeKnownError(error: unknown): error is KnownErrors["BotChallengeRequired"] | KnownErrors["BotChallengeFailed"] { + return KnownErrors.BotChallengeRequired.isInstance(error) || KnownErrors.BotChallengeFailed.isInstance(error); +} + +function getBotChallengeRequestFields(botChallenge: BotChallengeInput | undefined, context: string) { + if (botChallenge?.unavailable) { + if (botChallenge.token != null || botChallenge.phase != null) { + throw new StackAssertionError(`${context} bot challenge unavailability cannot be combined with a token or phase.`); + } + + return { + bot_challenge_unavailable: "true" as const, + }; + } + + const challengeToken = botChallenge?.token?.trim() || undefined; + if (botChallenge?.phase === "visible") { + if (challengeToken == null) { + // Backward-compatible fallback for older callers; prefer `unavailable: true`. + return { + bot_challenge_unavailable: "true", + }; + } + + return { + bot_challenge_token: challengeToken, + bot_challenge_phase: "visible" as const, + }; + } + + if (challengeToken == null) { + if (botChallenge?.phase != null) { + throw new StackAssertionError(`${context} bot challenge phase options require a token.`); + } + + return {}; + } + + if (botChallenge?.phase == null) { + return { + bot_challenge_token: challengeToken, + }; + } + + return { + bot_challenge_token: challengeToken, + bot_challenge_phase: "invisible" as const, + }; +} + export class StackClientInterface { private pendingNetworkDiagnostics?: ReturnType; @@ -590,7 +651,8 @@ export class StackClientInterface { async sendMagicLinkEmail( email: string, callbackUrl: string, - ): Promise> { + botChallenge?: BotChallengeInput, + ): Promise> { const res = await this.sendClientRequestAndCatchKnownError( "/auth/otp/send-sign-in-code", { @@ -601,10 +663,11 @@ export class StackClientInterface { body: JSON.stringify({ email, callback_url: callbackUrl, + ...getBotChallengeRequestFields(botChallenge, "Magic link sign-in"), }), }, null, - [KnownErrors.RedirectUrlNotWhitelisted] + [KnownErrors.RedirectUrlNotWhitelisted, ...botChallengeKnownErrors] ); if (res.status === "error") { @@ -906,7 +969,8 @@ export class StackClientInterface { password: string, emailVerificationRedirectUrl: string | undefined, session: InternalSession, - ): Promise> { + botChallenge?: BotChallengeInput, + ): Promise> { const res = await this.sendClientRequestAndCatchKnownError( "/auth/password/sign-up", { @@ -918,10 +982,11 @@ export class StackClientInterface { email, password, verification_callback_url: emailVerificationRedirectUrl, + ...getBotChallengeRequestFields(botChallenge, "Credential sign-up"), }), }, session, - [KnownErrors.UserWithEmailAlreadyExists, KnownErrors.PasswordRequirementsNotMet] + [KnownErrors.UserWithEmailAlreadyExists, KnownErrors.PasswordRequirementsNotMet, ...botChallengeKnownErrors] ); if (res.status === "error") { @@ -1049,6 +1114,7 @@ export class StackClientInterface { state: string, type: "authenticate" | "link", providerScope?: string, + botChallenge?: BotChallengeInput, session: InternalSession, } ): Promise { @@ -1091,10 +1157,71 @@ export class StackClientInterface { if (options.providerScope) { url.searchParams.set("provider_scope", options.providerScope); } + for (const [key, value] of Object.entries(getBotChallengeRequestFields(options.botChallenge, `OAuth ${options.type}`))) { + url.searchParams.set(key, value); + } return url.toString(); } + async authorizeOAuth(options: { + provider: string, + redirectUrl: string, + errorRedirectUrl: string, + afterCallbackRedirectUrl?: string, + codeChallenge: string, + state: string, + type: "authenticate" | "link", + providerScope?: string, + botChallenge?: BotChallengeInput, + session: InternalSession, + }): Promise> { + if (typeof window === "undefined") { + throw new StackAssertionError("authorizeOAuth can currently only be called in a browser environment"); + } + + await this.options.prepareRequest?.(); + + const url = new URL(await this.getOAuthUrl(options)); + url.searchParams.set("stack_response_mode", "json"); + + let rawRes; + try { + rawRes = await fetch(url, { + method: "GET", + }); + } catch (error) { + if (error instanceof TypeError) { + throw await this._createNetworkError(error, options.session, "client"); + } + throw error; + } + + const processedResponse = await this._processResponse(rawRes); + if (processedResponse.status === "error") { + if (isBotChallengeKnownError(processedResponse.error)) { + return Result.error(processedResponse.error); + } + throw processedResponse.error; + } + + if (processedResponse.data.status !== 200) { + throw new StackAssertionError(`OAuth authorize returned an unexpected status: ${processedResponse.data.status}`); + } + + const body = await processedResponse.data.json(); + if (body == null || typeof body !== "object" || Array.isArray(body)) { + throw new StackAssertionError("OAuth authorize response body must be an object", { body }); + } + + const location = body.location; + if (typeof location !== "string") { + throw new StackAssertionError("OAuth authorize response is missing a redirect location", { body }); + } + + return Result.ok(location); + } + async callOAuthCallback(options: { oauthParams: URLSearchParams, redirectUri: string, diff --git a/packages/stack-shared/src/interface/crud/users.ts b/packages/stack-shared/src/interface/crud/users.ts index cbeb164370..a495bbb154 100644 --- a/packages/stack-shared/src/interface/crud/users.ts +++ b/packages/stack-shared/src/interface/crud/users.ts @@ -1,9 +1,46 @@ +import type { InferType } from "yup"; import * as yup from "yup"; import { CrudTypeOf, createCrud } from "../../crud"; import * as fieldSchema from "../../schema-fields"; import { WebhookEvent } from "../webhooks"; import { teamsCrudServerReadSchema } from "./teams"; +const restrictedByAdminMeta = { + restricted_by_admin: { openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }, + restricted_by_admin_reason: { openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }, + restricted_by_admin_private_details: { openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }, +} as const; + +const countryCodeMeta = { openapiField: { description: 'Best-effort ISO country code captured at sign-up time from request geo headers.', exampleValue: "US" } } as const; + +export const riskScoreFieldSchema = fieldSchema.yupNumber().integer().min(0).max(100).defined(); +export const signUpRiskScoresSchema = fieldSchema.yupObject({ + sign_up: fieldSchema.yupObject({ + bot: riskScoreFieldSchema, + free_trial_abuse: riskScoreFieldSchema, + }).defined(), +}); +export type SignUpRiskScoresCrud = InferType["sign_up"]; + +const oauthProviderBaseFields = { + id: fieldSchema.yupString().defined(), + account_id: fieldSchema.yupString().defined(), +}; +const hiddenFieldMeta = { openapiField: { hidden: true } } as const; + +function restrictedByAdminConsistencyTest(this: yup.TestContext, value: any) { + if (value == null) return true; + if (value.restricted_by_admin !== true) { + if (value.restricted_by_admin_reason != null) { + return this.createError({ message: "restricted_by_admin_reason must be null when restricted_by_admin is not true" }); + } + if (value.restricted_by_admin_private_details != null) { + return this.createError({ message: "restricted_by_admin_private_details must be null when restricted_by_admin is not true" }); + } + } + return true; +} + export const usersCrudServerUpdateSchema = fieldSchema.yupObject({ display_name: fieldSchema.userDisplayNameSchema.optional(), profile_image_url: fieldSchema.profileImageUrlSchema.nullable().optional(), @@ -20,25 +57,15 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({ totp_secret_base64: fieldSchema.userTotpSecretMutationSchema.optional(), selected_team_id: fieldSchema.selectedTeamIdSchema.nullable().optional(), is_anonymous: fieldSchema.yupBoolean().oneOf([false]).optional(), - restricted_by_admin: fieldSchema.yupBoolean().optional().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), - restricted_by_admin_reason: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), - restricted_by_admin_private_details: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + restricted_by_admin: fieldSchema.yupBoolean().optional().meta(restrictedByAdminMeta.restricted_by_admin), + restricted_by_admin_reason: fieldSchema.yupString().nullable().optional().meta(restrictedByAdminMeta.restricted_by_admin_reason), + restricted_by_admin_private_details: fieldSchema.yupString().nullable().optional().meta(restrictedByAdminMeta.restricted_by_admin_private_details), + country_code: fieldSchema.countryCodeSchema.nullable().optional().meta(countryCodeMeta), + risk_scores: signUpRiskScoresSchema.optional(), }).defined().test( "restricted_by_admin_consistency", "When restricted_by_admin is not true, reason and private_details must be null", - function(this: yup.TestContext, value: any) { - if (value == null) return true; - // If restricted_by_admin is false or missing, both reason and private_details must be null - if (value.restricted_by_admin !== true) { - if (value.restricted_by_admin_reason != null) { - return this.createError({ message: "restricted_by_admin_reason must be null when restricted_by_admin is not true" }); - } - if (value.restricted_by_admin_private_details != null) { - return this.createError({ message: "restricted_by_admin_private_details must be null when restricted_by_admin is not true" }); - } - } - return true; - } + restrictedByAdminConsistencyTest, ); export const usersCrudServerReadSchema = fieldSchema.yupObject({ @@ -61,15 +88,16 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({ is_anonymous: fieldSchema.yupBoolean().defined(), is_restricted: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is in restricted state (has signed up but not completed onboarding requirements)', exampleValue: false } }), restricted_reason: fieldSchema.restrictedReasonSchema.nullable().defined().meta({ openapiField: { description: 'The reason why the user is restricted (e.g., type: "email_not_verified", "anonymous", or "restricted_by_administrator"), null if not restricted', exampleValue: null } }), - restricted_by_admin: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), - restricted_by_admin_reason: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), - restricted_by_admin_private_details: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + restricted_by_admin: fieldSchema.yupBoolean().defined().meta(restrictedByAdminMeta.restricted_by_admin), + restricted_by_admin_reason: fieldSchema.yupString().nullable().defined().meta(restrictedByAdminMeta.restricted_by_admin_reason), + restricted_by_admin_private_details: fieldSchema.yupString().nullable().defined().meta(restrictedByAdminMeta.restricted_by_admin_private_details), + country_code: fieldSchema.countryCodeSchema.nullable().defined().meta(countryCodeMeta), + risk_scores: signUpRiskScoresSchema.defined().meta({ openapiField: { description: 'User risk scores used for sign-up risk evaluation.', exampleValue: { sign_up: { bot: 0, free_trial_abuse: 0 } } } }), oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({ - id: fieldSchema.yupString().defined(), - account_id: fieldSchema.yupString().defined(), + ...oauthProviderBaseFields, email: fieldSchema.yupString().nullable(), - }).defined()).defined().meta({ openapiField: { hidden: true } }), + }).defined()).defined().meta(hiddenFieldMeta), /** * @deprecated @@ -85,27 +113,14 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({ }).test( "restricted_by_admin_consistency", "When restricted_by_admin is not true, reason and private_details must be null", - function(this: yup.TestContext, value: any) { - if (value == null) return true; - // If restricted_by_admin is false or missing, both reason and private_details must be null - if (value.restricted_by_admin !== true) { - if (value.restricted_by_admin_reason != null) { - return this.createError({ message: "restricted_by_admin_reason must be null when restricted_by_admin is not true" }); - } - if (value.restricted_by_admin_private_details != null) { - return this.createError({ message: "restricted_by_admin_private_details must be null when restricted_by_admin is not true" }); - } - } - return true; - } + restrictedByAdminConsistencyTest, ); export const usersCrudServerCreateSchema = usersCrudServerUpdateSchema.omit(['selected_team_id']).concat(fieldSchema.yupObject({ oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({ - id: fieldSchema.yupString().defined(), - account_id: fieldSchema.yupString().defined(), + ...oauthProviderBaseFields, email: fieldSchema.yupString().nullable().defined().default(null), - }).defined()).optional().meta({ openapiField: { hidden: true } }), + }).defined()).optional().meta(hiddenFieldMeta), is_anonymous: fieldSchema.yupBoolean().optional(), }).defined()); @@ -146,25 +161,20 @@ export const usersCrud = createCrud({ }); export type UsersCrud = CrudTypeOf; -export const userCreatedWebhookEvent = { - type: "user.created", - schema: usersCrud.server.readSchema, - metadata: { - summary: "User Created", - description: "This event is triggered when a user is created.", - tags: ["Users"], - }, -} satisfies WebhookEvent; +function userWebhookEvent(action: string, schema: S): WebhookEvent { + return { + type: `user.${action}`, + schema, + metadata: { + summary: `User ${action[0].toUpperCase()}${action.slice(1)}`, + description: `This event is triggered when a user is ${action}.`, + tags: ["Users"], + }, + }; +} -export const userUpdatedWebhookEvent = { - type: "user.updated", - schema: usersCrud.server.readSchema, - metadata: { - summary: "User Updated", - description: "This event is triggered when a user is updated.", - tags: ["Users"], - }, -} satisfies WebhookEvent; +export const userCreatedWebhookEvent = userWebhookEvent("created", usersCrud.server.readSchema); +export const userUpdatedWebhookEvent = userWebhookEvent("updated", usersCrud.server.readSchema); const webhookUserDeletedSchema = fieldSchema.yupObject({ id: fieldSchema.userIdSchema.defined(), @@ -172,13 +182,4 @@ const webhookUserDeletedSchema = fieldSchema.yupObject({ id: fieldSchema.yupString().defined(), })).defined(), }).defined(); - -export const userDeletedWebhookEvent = { - type: "user.deleted", - schema: webhookUserDeletedSchema, - metadata: { - summary: "User Deleted", - description: "This event is triggered when a user is deleted.", - tags: ["Users"], - }, -} satisfies WebhookEvent; +export const userDeletedWebhookEvent = userWebhookEvent("deleted", webhookUserDeletedSchema); diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index ed4c8ef414..a1d5d0a4d6 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -752,6 +752,29 @@ const SignUpRejected = createKnownErrorConstructor( (json: any) => [json.message] as const, ); +const BotChallengeRequired = createKnownErrorConstructor( + KnownError, + "BOT_CHALLENGE_REQUIRED", + () => [ + 409, + "An additional bot challenge is required before sign-up can continue.", + ] as const, + () => [] as const, +); + +const BotChallengeFailed = createKnownErrorConstructor( + KnownError, + "BOT_CHALLENGE_FAILED", + (message: string) => [ + 400, + message, + { + message, + }, + ] as const, + (json: any) => [json.message] as const, +); + const PasswordAuthenticationNotEnabled = createKnownErrorConstructor( KnownError, "PASSWORD_AUTHENTICATION_NOT_ENABLED", @@ -1879,6 +1902,8 @@ export const KnownErrors = { BranchDoesNotExist, SignUpNotEnabled, SignUpRejected, + BotChallengeRequired, + BotChallengeFailed, PasswordAuthenticationNotEnabled, PasskeyAuthenticationNotEnabled, AnonymousAccountsNotEnabled, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 1a0abc064c..48c3145c01 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -8,9 +8,12 @@ import { decodeBasicAuthorizationHeader } from "./utils/http"; import { allProviders } from "./utils/oauth"; import { deepPlainClone, omit, typedFromEntries } from "./utils/objects"; import { deindent } from "./utils/strings"; +import { ISO_3166_ALPHA_2_COUNTRY_CODES, isValidCountryCode, normalizeCountryCode } from "./utils/country-codes"; import { isValidHostnameWithWildcards, isValidUrl } from "./utils/urls"; import { isUuid } from "./utils/uuids"; +export { ISO_3166_ALPHA_2_COUNTRY_CODES, isValidCountryCode, normalizeCountryCode, validateCountryCode, validCountryCodeSet } from "./utils/country-codes"; + const MAX_IMAGE_SIZE_BASE64_BYTES = 1_000_000; // 1MB declare module "yup" { @@ -431,6 +434,15 @@ export const base64Schema = yupString().test("is-base64", (params) => `${params. return isBase64(value); }); export const passwordSchema = yupString().max(70); +export const countryCodeSchema = yupString().transform((value) => typeof value === "string" ? normalizeCountryCode(value) : value).test({ + name: "country-code", + message: (params) => `${params.path} must be a valid ISO 3166-1 alpha-2 country code`, + test: (value) => value == null || isValidCountryCode(value), +}); +import.meta.vitest?.test("countryCodeSchema", async ({ expect }) => { + await expect(countryCodeSchema.validate(" us ")).resolves.toBe("US"); + await expect(countryCodeSchema.validate("usa")).rejects.toThrow("must be a valid ISO 3166-1 alpha-2 country code"); +}); export const intervalSchema = yupTuple([yupNumber().min(0).integer().defined(), yupString().oneOf(['millisecond', 'second', 'minute', 'hour', 'day', 'week', 'month', 'year']).defined()]); export const dayIntervalSchema = yupTuple([yupNumber().min(0).integer().defined(), yupString().oneOf(['day', 'week', 'month', 'year']).defined()]); export const intervalOrNeverSchema = yupUnion(intervalSchema.defined(), yupString().oneOf(['never']).defined()); diff --git a/packages/stack-shared/src/utils/auth-methods.ts b/packages/stack-shared/src/utils/auth-methods.ts new file mode 100644 index 0000000000..613a21f48b --- /dev/null +++ b/packages/stack-shared/src/utils/auth-methods.ts @@ -0,0 +1,8 @@ +export const signUpAuthMethodValues = [ + "password", + "otp", + "oauth", + "passkey", +] as const; + +export type SignUpAuthMethod = typeof signUpAuthMethodValues[number]; diff --git a/packages/stack-shared/src/utils/cel-fields.ts b/packages/stack-shared/src/utils/cel-fields.ts new file mode 100644 index 0000000000..f95a572397 --- /dev/null +++ b/packages/stack-shared/src/utils/cel-fields.ts @@ -0,0 +1,92 @@ +import { signUpAuthMethodValues } from "./auth-methods"; +import { standardProviders } from "./oauth"; + +// ── Types ────────────────────────────────────────────────────────────── + +export type ConditionField = + | 'email' + | 'countryCode' + | 'emailDomain' + | 'authMethod' + | 'oauthProvider' + | 'riskScores.bot' + | 'riskScores.free_trial_abuse'; + +export type ConditionOperator = + | 'equals' + | 'not_equals' + | 'greater_than' + | 'greater_or_equal' + | 'less_than' + | 'less_or_equal' + | 'matches' + | 'ends_with' + | 'starts_with' + | 'contains' + | 'in_list'; + +// ── Helpers ──────────────────────────────────────────────────────────── + +export function isNumericField(field: ConditionField): boolean { + return field === 'riskScores.bot' || field === 'riskScores.free_trial_abuse'; +} + +/** + * Validates a numeric field value is a finite integer within [0, 100]. + * Returns null if valid, or an error message string if invalid. + */ +export function validateNumericFieldValue(field: string, value: string | number): string | null { + const num = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(num)) { + return `Expected a finite number for field "${field}", got "${String(value)}"`; + } + if (!Number.isInteger(num)) { + return `Expected an integer for field "${field}", got "${String(value)}"`; + } + if (num < 0 || num > 100) { + return `Value for field "${field}" must be between 0 and 100, got ${num}`; + } + return null; +} + +export function escapeCelString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +export function unescapeCelString(value: string): string { + return value.replace(/\\\\/g, '\\').replace(/\\"/g, '"'); +} + +// ── Field metadata ───────────────────────────────────────────────────── + +const numericOperators: ConditionOperator[] = ['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal']; +const enumOperators: ConditionOperator[] = ['equals', 'not_equals', 'in_list']; +const stringOperators: ConditionOperator[] = ['equals', 'not_equals', 'contains', 'starts_with', 'ends_with', 'matches', 'in_list']; + +export type FieldMetadataEntry = { + label: string, + operators: ConditionOperator[], + predefinedValues?: string[], +}; + +export const fieldMetadata: Record = { + email: { label: 'Email', operators: stringOperators }, + countryCode: { label: 'Country Code', operators: enumOperators }, + emailDomain: { label: 'Email Domain', operators: stringOperators }, + authMethod: { label: 'Auth Method', operators: enumOperators, predefinedValues: [...signUpAuthMethodValues] }, + oauthProvider: { label: 'OAuth Provider', operators: enumOperators, predefinedValues: [...standardProviders] }, + 'riskScores.bot': { label: 'Risk Score: Bot', operators: numericOperators }, + 'riskScores.free_trial_abuse': { label: 'Risk Score: Free Trial Abuse', operators: numericOperators }, +}; + +export const conditionFields = Object.keys(fieldMetadata) as ConditionField[]; + +export const conditionOperators: ConditionOperator[] = [ + 'equals', 'not_equals', 'greater_than', 'greater_or_equal', + 'less_than', 'less_or_equal', 'matches', 'ends_with', + 'starts_with', 'contains', 'in_list', +]; + +export function getOperatorsForField(field: ConditionField): ConditionOperator[] { + return fieldMetadata[field].operators; +} diff --git a/packages/stack-shared/src/utils/country-codes.ts b/packages/stack-shared/src/utils/country-codes.ts new file mode 100644 index 0000000000..cf58523504 --- /dev/null +++ b/packages/stack-shared/src/utils/country-codes.ts @@ -0,0 +1,67 @@ +export const ISO_3166_ALPHA_2_COUNTRY_CODES = [ + "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", + "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", + "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", + "DE", "DJ", "DK", "DM", "DO", "DZ", + "EC", "EE", "EG", "EH", "ER", "ES", "ET", + "FI", "FJ", "FK", "FM", "FO", "FR", + "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", + "HK", "HM", "HN", "HR", "HT", "HU", + "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", + "JE", "JM", "JO", "JP", + "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", + "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", + "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", + "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", + "OM", + "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", + "QA", + "RE", "RO", "RS", "RU", "RW", + "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", + "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", + "UA", "UG", "UM", "US", "UY", "UZ", + "VA", "VC", "VE", "VG", "VI", "VN", "VU", + "WF", "WS", + "YE", "YT", + "ZA", "ZM", "ZW", +] as const; + +export type Iso3166Alpha2CountryCode = typeof ISO_3166_ALPHA_2_COUNTRY_CODES[number]; + +export const validCountryCodeSet = new Set(ISO_3166_ALPHA_2_COUNTRY_CODES); + +export function normalizeCountryCode(countryCode: string): string { + return countryCode.trim().toUpperCase(); +} + +export function isValidCountryCode(countryCode: string): boolean { + return validCountryCodeSet.has(normalizeCountryCode(countryCode)); +} + +/** + * Validates and normalizes a country code value (single string or array). + * Returns null if valid, or an error message string if invalid. + */ +export function validateCountryCode(value: string | string[]): string | null { + const values = Array.isArray(value) ? value : [value]; + if (values.length === 0) { + return "At least one country code is required"; + } + return values.every(v => isValidCountryCode(v)) + ? null + : "Country code must be a valid ISO 3166-1 alpha-2 code"; +} + +import.meta.vitest?.test("country codes", ({ expect }) => { + expect(ISO_3166_ALPHA_2_COUNTRY_CODES).toHaveLength(249); + expect(normalizeCountryCode(" us ")).toBe("US"); + expect(isValidCountryCode("us")).toBe(true); + expect(isValidCountryCode("zz")).toBe(false); + expect(isValidCountryCode("usa")).toBe(false); + + expect(validateCountryCode("US")).toBeNull(); + expect(validateCountryCode("zz")).toBe("Country code must be a valid ISO 3166-1 alpha-2 code"); + expect(validateCountryCode(["US", "CA"])).toBeNull(); + expect(validateCountryCode([])).toBe("At least one country code is required"); + expect(validateCountryCode(["US", "ZZ"])).toBe("Country code must be a valid ISO 3166-1 alpha-2 code"); +}); diff --git a/packages/stack-shared/src/utils/turnstile-browser.ts b/packages/stack-shared/src/utils/turnstile-browser.ts new file mode 100644 index 0000000000..77ea083c97 --- /dev/null +++ b/packages/stack-shared/src/utils/turnstile-browser.ts @@ -0,0 +1,106 @@ +import { StackAssertionError } from "./errors"; +import { TurnstileAction } from "./turnstile"; + +export type TurnstileWidgetId = string; +export type TurnstileTheme = "auto" | "light" | "dark"; +export type TurnstileAppearance = "always" | "execute" | "interaction-only"; +export type TurnstileExecution = "render" | "execute"; +export type TurnstileSize = "invisible" | "flexible" | "normal" | "compact"; + +export type TurnstileConfig = { + sitekey: string, + action: TurnstileAction, + theme?: TurnstileTheme, + appearance?: TurnstileAppearance, + execution?: TurnstileExecution, + size?: TurnstileSize, + callback: (token: string) => void, + "error-callback": (errorCode?: string) => void, + "expired-callback": () => void, + "timeout-callback"?: () => void, +}; + +export type TurnstileApi = { + render: (container: HTMLElement, config: TurnstileConfig) => TurnstileWidgetId, + execute?: (widgetId: TurnstileWidgetId) => void, + remove: (widgetId: TurnstileWidgetId) => void, + reset?: (widgetId: TurnstileWidgetId) => void, +}; + +const TURNSTILE_SCRIPT_BASE_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js"; +const TURNSTILE_SCRIPT_LOAD_TIMEOUT_MS = 30_000; + +export function isTurnstileApi(value: unknown): value is TurnstileApi { + return typeof value === "object" + && value !== null + && "render" in value + && "remove" in value; +} + +export function getTurnstileApi(): TurnstileApi | undefined { + if (typeof window === "undefined") { + return undefined; + } + + const maybeTurnstile = Reflect.get(window, "turnstile"); + return isTurnstileApi(maybeTurnstile) ? maybeTurnstile : undefined; +} + +let turnstileScriptPromise: Promise | null = null; + +export function loadTurnstileScript(): Promise { + if (typeof window === "undefined") { + return Promise.reject(new StackAssertionError("Turnstile can only be loaded in the browser")); + } + + if (getTurnstileApi()) { + return Promise.resolve(); + } + + turnstileScriptPromise ??= new Promise((resolve, reject) => { + const rejectAndReset = (err: Error) => { + turnstileScriptPromise = null; + reject(err); + }; + + const timeout = setTimeout(() => { + rejectAndReset(new Error("Turnstile script load timed out")); + }, TURNSTILE_SCRIPT_LOAD_TIMEOUT_MS); + + const resolveAndClearTimeout = () => { + clearTimeout(timeout); + resolve(); + }; + + const existingScript = document.querySelector(`script[src^="${TURNSTILE_SCRIPT_BASE_URL}"]`); + if (existingScript) { + // If the Turnstile API is already available (script loaded before our loader ran), + // resolve immediately — the load event may have already fired. + if (getTurnstileApi()) { + resolveAndClearTimeout(); + return; + } + existingScript.addEventListener("load", () => resolveAndClearTimeout(), { once: true }); + existingScript.addEventListener("error", () => { + existingScript.remove(); + clearTimeout(timeout); + rejectAndReset(new Error("Failed to load Turnstile")); + }, { once: true }); + return; + } + + const script = document.createElement("script"); + script.src = `${TURNSTILE_SCRIPT_BASE_URL}?render=explicit`; + script.async = true; + script.defer = true; + script.onload = () => resolveAndClearTimeout(); + script.onerror = () => { + script.remove(); + clearTimeout(timeout); + rejectAndReset(new Error("Failed to load Turnstile")); + }; + document.head.append(script); + }); + + return turnstileScriptPromise; +} diff --git a/packages/stack-shared/src/utils/turnstile-flow.test.ts b/packages/stack-shared/src/utils/turnstile-flow.test.ts new file mode 100644 index 0000000000..f99e7b8f49 --- /dev/null +++ b/packages/stack-shared/src/utils/turnstile-flow.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadTurnstileScriptMock = vi.fn(() => Promise.resolve()); +const renderMock = vi.fn(); +const executeMock = vi.fn(); +const removeMock = vi.fn(); +const captureErrorMock = vi.fn(); + +vi.mock("./turnstile-browser", () => ({ + loadTurnstileScript: loadTurnstileScriptMock, + getTurnstileApi: () => ({ + render: renderMock, + execute: executeMock, + remove: removeMock, + }), +})); + +vi.mock("./errors", async () => { + const actual = await vi.importActual("./errors"); + return { + ...actual, + captureError: captureErrorMock, + }; +}); + +describe("withBotChallengeFlow", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadTurnstileScriptMock.mockResolvedValue(undefined); + renderMock.mockImplementation((_container, config: { + callback: (token: string) => void, + }) => { + config.callback("invisible-token"); + return "widget-id"; + }); + executeMock.mockImplementation(() => {}); + removeMock.mockImplementation(() => {}); + }); + + it("throws a bot challenge execution error when the phase-2 visible challenge fails", async () => { + const { BotChallengeExecutionFailedError, withBotChallengeFlow } = await import("./turnstile-flow"); + + loadTurnstileScriptMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("cloudflare unavailable")); + + const execute = vi.fn(async ({ token, phase }: { token?: string, phase?: "invisible" | "visible" }) => { + if (token === "invisible-token" && phase === "invisible") { + return { requiresChallenge: true }; + } + return { requiresChallenge: false }; + }); + + await expect(withBotChallengeFlow({ + visibleSiteKey: "visible-site-key", + invisibleSiteKey: "invisible-site-key", + action: "sign_up_with_credential", + execute, + isChallengeRequired: (result) => result.requiresChallenge, + })).rejects.toBeInstanceOf(BotChallengeExecutionFailedError); + + expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith({ + token: "invisible-token", + phase: "invisible", + }); + expect(captureErrorMock).toHaveBeenCalledWith( + "turnstile-flow-visible-challenge-failed", + expect.any(Error), + ); + }); + + it("marks the challenge as unavailable when both phase-1 challenge attempts fail", async () => { + const { withBotChallengeFlow } = await import("./turnstile-flow"); + + loadTurnstileScriptMock + .mockRejectedValueOnce(new Error("invisible unavailable")) + .mockRejectedValueOnce(new Error("visible unavailable")); + + const execute = vi.fn(async ({ unavailable }: { unavailable?: true }) => ({ + unavailable, + })); + + await expect(withBotChallengeFlow({ + visibleSiteKey: "visible-site-key", + invisibleSiteKey: "invisible-site-key", + action: "sign_up_with_credential", + execute, + isChallengeRequired: () => false, + })).resolves.toEqual({ unavailable: true }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith({ unavailable: true }); + expect(captureErrorMock).toHaveBeenCalledWith( + "turnstile-flow-all-challenges-failed", + expect.any(Error), + ); + }); +}); diff --git a/packages/stack-shared/src/utils/turnstile-flow.ts b/packages/stack-shared/src/utils/turnstile-flow.ts new file mode 100644 index 0000000000..86c94fc86a --- /dev/null +++ b/packages/stack-shared/src/utils/turnstile-flow.ts @@ -0,0 +1,266 @@ +import { StackAssertionError, captureError } from "./errors"; +import { loadTurnstileScript, getTurnstileApi } from "./turnstile-browser"; +import type { TurnstileAction } from "./turnstile"; + +export class BotChallengeUserCancelledError extends Error { + constructor() { + super("User cancelled the bot challenge"); + this.name = "BotChallengeUserCancelledError"; + } +} + +export class BotChallengeExecutionFailedError extends Error { + constructor(message = "Bot challenge could not be completed", options?: { cause?: unknown }) { + super(message, options); + this.name = "BotChallengeExecutionFailedError"; + } +} + + +// ── Invisible challenge ──────────────────────────────────────────────── + +const INVISIBLE_TIMEOUT_MS = 30_000; + +export async function executeTurnstileInvisible(siteKey: string, action: TurnstileAction): Promise { + await loadTurnstileScript(); + const api = getTurnstileApi(); + if (!api) throw new StackAssertionError("Turnstile API not available after loadTurnstileScript() resolved"); + + const container = document.createElement("div"); + Object.assign(container.style, { position: "fixed", left: "-9999px", top: "-9999px" }); + document.body.appendChild(container); + + let widgetId: string | undefined; + try { + return await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Turnstile invisible challenge timed out")), + INVISIBLE_TIMEOUT_MS, + ); + const settle = (fn: () => void) => { + clearTimeout(timeout); + fn(); + }; + + widgetId = api.render(container, { + sitekey: siteKey, + action, + size: "invisible", + execution: "execute", + appearance: "execute", + callback: (t) => settle(() => resolve(t)), + "error-callback": () => settle(() => reject(new Error("Turnstile invisible verification failed"))), + "expired-callback": () => settle(() => reject(new Error("Turnstile token expired"))), + "timeout-callback": () => settle(() => reject(new Error("Turnstile challenge timed out"))), + }); + + api.execute?.(widgetId); + }); + } finally { + if (widgetId != null) { + try { + api.remove(widgetId); + } catch (e) { + captureError("turnstile-widget-remove", e instanceof Error ? e : new StackAssertionError("Non-Error thrown during Turnstile widget removal", { cause: e })); + } + } + container.remove(); + } +} + + +// ── Visible challenge overlay ────────────────────────────────────────── + +const VISIBLE_TIMEOUT_MS = 120_000; +const OVERLAY_Z_INDEX = "999999"; + +// Module-level singleton: only one visible overlay can be active at a time. +// If a second challenge is requested while one is showing (e.g. user clicks another +// auth flow), the previous overlay is cancelled with BotChallengeUserCancelledError +// and cleaned up before the new one renders. +let activeOverlay: { cleanup: () => void, reject: (err: Error) => void } | null = null; + +function el( + tag: K, + style: Partial, + props?: Record, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + Object.assign(element.style, style); + if (props) { + for (const [k, v] of Object.entries(props)) { + element.setAttribute(k, v); + } + } + return element; +} + +export function showTurnstileVisibleChallenge(siteKey: string, action: TurnstileAction): Promise { + if (activeOverlay) { + activeOverlay.reject(new BotChallengeUserCancelledError()); + activeOverlay.cleanup(); + activeOverlay = null; + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Visible Turnstile challenge timed out")); + }, VISIBLE_TIMEOUT_MS); + + const overlay = el("div", { + position: "fixed", inset: "0", zIndex: OVERLAY_Z_INDEX, + display: "flex", alignItems: "center", justifyContent: "center", + background: "rgba(0,0,0,0.5)", backdropFilter: "blur(2px)", + }, { "data-stack-turnstile-overlay": "true" }); + + const card = el("div", { + background: "white", borderRadius: "12px", padding: "24px", + maxWidth: "400px", width: "90%", textAlign: "center", + boxShadow: "0 4px 24px rgba(0,0,0,0.18)", + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + }); + + const title = el("p", { margin: "0 0 16px", fontSize: "16px", fontWeight: "600", color: "#333" }); + title.textContent = "Please complete the security check"; + + const widgetContainer = el("div", { display: "flex", justifyContent: "center", minHeight: "65px" }); + + const errorText = el("p", { margin: "8px 0 0", fontSize: "14px", color: "#dc2626", display: "none" }); + + const cancelBtn = el("button", { + marginTop: "16px", padding: "8px 20px", border: "1px solid #ddd", + borderRadius: "6px", background: "transparent", cursor: "pointer", + fontSize: "14px", color: "#666", + }); + cancelBtn.textContent = "Cancel"; + cancelBtn.onmouseover = () => { + cancelBtn.style.background = "#f5f5f5"; + }; + cancelBtn.onmouseout = () => { + cancelBtn.style.background = "transparent"; + }; + + card.append(title, widgetContainer, errorText, cancelBtn); + overlay.appendChild(card); + document.body.appendChild(overlay); + + function cleanup() { + clearTimeout(timeout); + overlay.remove(); + if (activeOverlay?.cleanup === cleanup) { + activeOverlay = null; + } + } + + activeOverlay = { cleanup, reject }; + cancelBtn.onclick = () => { + cleanup(); + reject(new BotChallengeUserCancelledError()); + }; + + loadTurnstileScript().then(() => { + const api = getTurnstileApi(); + if (!api) { + cleanup(); + reject(new StackAssertionError("Turnstile API not available after loadTurnstileScript() resolved")); + return; + } + + api.render(widgetContainer, { + sitekey: siteKey, + action, + appearance: "always", + execution: "render", + size: "flexible", + callback: (token) => { + cleanup(); + resolve(token); + }, + "error-callback": (errorCode) => { + errorText.textContent = errorCode ? `Verification error: ${errorCode}` : "Verification failed. Please try again."; + errorText.style.display = "block"; + }, + "expired-callback": () => { + errorText.textContent = "Challenge expired. Please solve it again."; + errorText.style.display = "block"; + }, + }); + }).catch((err) => { + cleanup(); + reject(err); + }); + }); +} + + +// ── Flow orchestrator ────────────────────────────────────────────────── + +export type BotChallengeExecuteParams = { + token?: string, + phase?: "invisible" | "visible", + unavailable?: true, +}; + +export type WithBotChallengeFlowOptions = { + visibleSiteKey: string, + invisibleSiteKey: string, + action: TurnstileAction, + execute: (challenge: BotChallengeExecuteParams) => Promise, + isChallengeRequired: (result: T) => boolean, +}; + +// We use separate invisible + visible flows (rather than Turnstile's "managed" mode) because: +// 1. Managed mode auto-decides visibility, but we need deterministic server-side logic: +// invisible-fail → require visible challenge → fail = block. +// 2. Invisible + visible use different site keys so the server can tell which phase passed. +// 3. Managed mode doesn't expose an API to programmatically trigger a retry with a +// different widget type, which our two-phase challenge escalation requires. +export async function withBotChallengeFlow(options: WithBotChallengeFlowOptions): Promise { + // Server safe: no Turnstile in SSR — just call execute with no turnstile params + if (typeof window === "undefined") { + return await options.execute({}); + } + + // Phase 1: invisible token + let invisibleToken: string | undefined; + let usedVisibleFallback = false; + try { + invisibleToken = await executeTurnstileInvisible(options.invisibleSiteKey, options.action); + } catch { + try { + invisibleToken = await showTurnstileVisibleChallenge(options.visibleSiteKey, options.action); + usedVisibleFallback = true; + } catch (e) { + if (e instanceof BotChallengeUserCancelledError) throw e; + // Both challenges failed (for example Cloudflare is unreachable) — tell the + // server explicitly so it can distinguish challenge infra outages from a + // user submitting an invalid visible challenge. + captureError("turnstile-flow-all-challenges-failed", e instanceof Error ? e : new StackAssertionError("Non-Error thrown during Turnstile challenge", { cause: e })); + return await options.execute({ unavailable: true }); + } + } + + const firstResult = await options.execute({ + token: invisibleToken, + phase: invisibleToken ? (usedVisibleFallback ? "visible" : "invisible") : undefined, + }); + + if (!options.isChallengeRequired(firstResult)) { + return firstResult; + } + + // Phase 2: visible challenge (single retry) + let visibleToken: string | undefined; + try { + visibleToken = await showTurnstileVisibleChallenge(options.visibleSiteKey, options.action); + } catch (e) { + if (e instanceof BotChallengeUserCancelledError) throw e; + captureError("turnstile-flow-visible-challenge-failed", e instanceof Error ? e : new StackAssertionError("Non-Error thrown during visible Turnstile challenge", { cause: e })); + throw new BotChallengeExecutionFailedError("Visible bot challenge could not be completed", { + cause: e, + }); + } + + return await options.execute({ token: visibleToken, phase: "visible" }); +} diff --git a/packages/stack-shared/src/utils/turnstile.ts b/packages/stack-shared/src/utils/turnstile.ts new file mode 100644 index 0000000000..0b3d515012 --- /dev/null +++ b/packages/stack-shared/src/utils/turnstile.ts @@ -0,0 +1,34 @@ +export const turnstileActionValues = [ + "sign_up_with_credential", + "send_magic_link_email", + "oauth_authenticate", +] as const; + +export type TurnstileAction = typeof turnstileActionValues[number]; + +export const turnstilePhaseValues = [ + "invisible", + "visible", +] as const; + +export type TurnstilePhase = typeof turnstilePhaseValues[number]; + +export const turnstileResultValues = [ + "ok", + "invalid", + "error", +] as const; + +export type TurnstileResult = typeof turnstileResultValues[number]; + +export const turnstileDevelopmentKeys = { + visibleSiteKey: "1x00000000000000000000AA", + invisibleSiteKey: "1x00000000000000000000BB", + secretKey: "1x0000000000000000000000000000000AA", + forcedChallengeSiteKey: "3x00000000000000000000FF", +} as const; + + +export function isTurnstileResult(value: unknown): value is TurnstileResult { + return typeof value === "string" && turnstileResultValues.some((status) => status === value); +} diff --git a/packages/stack-shared/src/utils/unicode.tsx b/packages/stack-shared/src/utils/unicode.tsx index 90470a173c..a957ec2293 100644 --- a/packages/stack-shared/src/utils/unicode.tsx +++ b/packages/stack-shared/src/utils/unicode.tsx @@ -1,9 +1,9 @@ +import { isValidCountryCode, normalizeCountryCode } from "./country-codes"; import { StackAssertionError } from "./errors"; export function getFlagEmoji(twoLetterCountryCode: string) { - if (!/^[a-zA-Z][a-zA-Z]$/.test(twoLetterCountryCode)) throw new StackAssertionError("Country code must be two alphabetical letters"); - const codePoints = twoLetterCountryCode - .toUpperCase() + if (!isValidCountryCode(twoLetterCountryCode)) throw new StackAssertionError("Country code must be two alphabetical letters"); + const codePoints = normalizeCountryCode(twoLetterCountryCode) .split('') .map(char => 127397 + char.charCodeAt(0)); return String.fromCodePoint(...codePoints); diff --git a/packages/stack/package.json b/packages/stack/package.json index 1299fbee1b..9a86bdb905 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -113,6 +113,7 @@ "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "react": "^19.0.0", + "@types/react-dom": "^19.0.0", "react-dom": "^19.0.0", "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", diff --git a/packages/template/package-template.json b/packages/template/package-template.json index 938a8ab11f..04f2fcf955 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -160,6 +160,8 @@ "postcss-nested": "^6.0.1", "react": "^19.0.0", "//": "NEXT_LINE_PLATFORM react-like", + "@types/react-dom": "^19.0.0", + "//": "NEXT_LINE_PLATFORM react-like", "react-dom": "^19.0.0", "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", diff --git a/packages/template/package.json b/packages/template/package.json index ece30c685a..1db4db5c07 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -118,6 +118,7 @@ "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "react": "^19.0.0", + "@types/react-dom": "^19.0.0", "react-dom": "^19.0.0", "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", diff --git a/packages/template/src/components/credential-sign-up.tsx b/packages/template/src/components/credential-sign-up.tsx index 247e81033a..d63e2e8302 100644 --- a/packages/template/src/components/credential-sign-up.tsx +++ b/packages/template/src/components/credential-sign-up.tsx @@ -8,7 +8,7 @@ import { Button, Input, Label, PasswordInput } from "@stackframe/stack-ui"; import React, { useState } from "react"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { useStackApp } from ".."; +import { useStackApp } from "../lib/hooks"; import { useTranslation } from "../lib/translations"; import { FormWarningText } from "./elements/form-warning"; diff --git a/packages/template/src/components/magic-link-sign-in.tsx b/packages/template/src/components/magic-link-sign-in.tsx index edaebe4bcc..b24c38d797 100644 --- a/packages/template/src/components/magic-link-sign-in.tsx +++ b/packages/template/src/components/magic-link-sign-in.tsx @@ -8,7 +8,7 @@ import { Button, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Typography import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { useStackApp } from ".."; +import { useStackApp } from "../lib/hooks"; import { useTranslation } from "../lib/translations"; import { FormWarningText } from "./elements/form-warning"; diff --git a/packages/template/src/components/oauth-button.tsx b/packages/template/src/components/oauth-button.tsx index 5e0eea5f92..f7d53b1f78 100644 --- a/packages/template/src/components/oauth-button.tsx +++ b/packages/template/src/components/oauth-button.tsx @@ -3,7 +3,7 @@ import { BrandIcons, Button, SimpleTooltip } from '@stackframe/stack-ui'; import Color, { ColorInstance } from 'color'; import { useEffect, useId, useState } from 'react'; -import { useStackApp } from '..'; +import { useStackApp } from '../lib/hooks'; import { useTranslation } from '../lib/translations'; import { useInIframe } from './use-in-iframe'; @@ -20,10 +20,12 @@ export function OAuthButton({ provider, type, isMock = false, + onAuthenticate, }: { provider: string, type: 'sign-in' | 'sign-up', isMock?: boolean, + onAuthenticate?: () => Promise, }) { const { t } = useTranslation(); const stackApp = useStackApp(); @@ -186,7 +188,7 @@ export function OAuthButton({