From dd48f4fddcca5d418adf5f4909b180d9e8fd7b5e Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 5 Aug 2025 17:31:04 -0700 Subject: [PATCH 01/20] Wildcard domains --- .claude/settings.json | 30 ++ CLAUDE-KNOWLEDGE.md | 125 +++++ CLAUDE.md | 27 +- .../oauth/callback/[provider_id]/route.tsx | 3 +- .../register/verification-code-handler.tsx | 30 +- .../sign-in/verification-code-handler.tsx | 29 +- apps/backend/src/lib/redirect-urls.test.tsx | 462 +++++++++++++++++ apps/backend/src/lib/redirect-urls.tsx | 99 +++- apps/backend/src/oauth/model.tsx | 10 +- .../[projectId]/domains/page-client.tsx | 20 +- .../auth/oauth/exact-domain-matching.test.ts | 284 ++++++++++ .../v1/auth/oauth/wildcard-domains.test.ts | 313 +++++++++++ .../v1/auth/passkey/wildcard-domains.test.ts | 484 ++++++++++++++++++ .../src/helpers/production-mode.ts | 4 +- packages/stack-shared/src/utils/urls.tsx | 142 +++++ 15 files changed, 1983 insertions(+), 79 deletions(-) create mode 100644 .claude/settings.json create mode 100644 CLAUDE-KNOWLEDGE.md create mode 100644 apps/backend/src/lib/redirect-urls.test.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..ee52ce43b2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(pnpm typecheck:*)", + "Bash(pnpm test:*)", + "Bash(pnpm lint:*)", + "Bash(find:*)", + "Bash(ls:*)", + "Bash(pnpm codegen)", + "Bash(pnpm vitest run:*)", + "Bash(pnpm eslint:*)" + ], + "deny": [] + }, + "includeCoAuthoredBy": false, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "pnpm run lint --fix" + } + ] + } + ] + } +} diff --git a/CLAUDE-KNOWLEDGE.md b/CLAUDE-KNOWLEDGE.md new file mode 100644 index 0000000000..9857ba1196 --- /dev/null +++ b/CLAUDE-KNOWLEDGE.md @@ -0,0 +1,125 @@ +# CLAUDE-KNOWLEDGE.md + +This file documents key learnings from implementing wildcard domain support in Stack Auth, organized in Q&A format. + +## OAuth Flow and Validation + +### Q: Where does OAuth redirect URL validation happen in the flow? +A: The validation happens in the callback endpoint (`/api/v1/auth/oauth/callback/[provider_id]/route.tsx`), not in the authorize endpoint. The authorize endpoint just stores the redirect URL and redirects to the OAuth provider. The actual validation occurs when the OAuth provider calls back, and the oauth2-server library validates the redirect URL. + +### Q: How do you test OAuth flows that should fail? +A: Use `Auth.OAuth.getMaybeFailingAuthorizationCode()` instead of `Auth.OAuth.getAuthorizationCode()`. The latter expects success (status 303), while the former allows you to test failure cases. The failure happens at the callback stage with a 400 status and specific error message. + +### Q: What error is thrown for invalid redirect URLs in OAuth? +A: The callback endpoint returns a 400 status with the message: "Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard." + +## Wildcard Pattern Implementation + +### Q: How do you handle ** vs * precedence in regex patterns? +A: Use a placeholder approach to prevent ** from being corrupted when replacing *: +```typescript +const doubleWildcardPlaceholder = '\x00DOUBLE_WILDCARD\x00'; +regexPattern = regexPattern.replace(/\*\*/g, doubleWildcardPlaceholder); +regexPattern = regexPattern.replace(/\*/g, '[^.]*'); +regexPattern = regexPattern.replace(new RegExp(doubleWildcardPlaceholder, 'g'), '.*'); +``` + +### Q: Why can't you use `new URL()` with wildcard domains? +A: Wildcard characters (* and **) are not valid in URLs and will cause parsing errors. For wildcard domains, you need to manually parse the URL components instead of using the URL constructor. + +### Q: How do you validate URLs with wildcards? +A: Extract the hostname pattern manually and use `matchHostnamePattern()`: +```typescript +const protocolEnd = domain.baseUrl.indexOf('://'); +const protocol = domain.baseUrl.substring(0, protocolEnd + 3); +const afterProtocol = domain.baseUrl.substring(protocolEnd + 3); +const pathStart = afterProtocol.indexOf('/'); +const hostnamePattern = pathStart === -1 ? afterProtocol : afterProtocol.substring(0, pathStart); +``` + +## Testing Best Practices + +### Q: How should you run multiple independent test commands? +A: Use parallel execution by batching tool calls together: +```typescript +// Good - runs in parallel +const [result1, result2] = await Promise.all([ + niceBackendFetch("/endpoint1"), + niceBackendFetch("/endpoint2") +]); + +// In E2E tests, the framework handles this automatically when you +// batch multiple tool calls in a single response +``` + +### Q: What's the correct way to update project configuration in E2E tests? +A: Use the `/api/v1/internal/config/override` endpoint with PATCH method and admin access token: +```typescript +await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.name': { baseUrl: '...', handlerPath: '...' } + }), + }, +}); +``` + +## Code Organization + +### Q: Where does domain validation logic belong? +A: Core validation functions (`isValidHostnameWithWildcards`, `matchHostnamePattern`) belong in the shared utils package (`packages/stack-shared/src/utils/urls.tsx`) so they can be used by both frontend and backend. + +### Q: How do you simplify validation logic with wildcards? +A: Replace wildcards with valid placeholders before validation: +```typescript +const normalizedDomain = domain.replace(/\*+/g, 'wildcard-placeholder'); +url = new URL(normalizedDomain); // Now this won't throw +``` + +## Debugging E2E Tests + +### Q: What does "ECONNREFUSED" mean in E2E tests? +A: The backend server isn't running. Make sure to start the backend with `pnpm dev` before running E2E tests. + +### Q: How do you debug which stage of OAuth flow is failing? +A: Check the error location: +- Authorize endpoint (307 redirect) - Initial request succeeded +- Callback endpoint (400 error) - Validation failed during callback +- Token endpoint (400 error) - Validation failed during token exchange + +## Git and Development Workflow + +### Q: How should you format git commit messages in this project? +A: Use a HEREDOC to ensure proper formatting: +```bash +git commit -m "$(cat <<'EOF' +Commit message here. + +🤖 Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude +EOF +)" +``` + +### Q: What commands should you run before considering a task complete? +A: Always run: +1. `pnpm test run ` - Run tests +2. `pnpm lint` - Check for linting errors +3. `pnpm typecheck` - Check for TypeScript errors + +## Common Pitfalls + +### Q: Why might imports get removed after running lint --fix? +A: ESLint may remove "unused" imports. Always verify your changes after auto-fixing, especially if you're using imports in a way ESLint doesn't recognize (like in test expectations). + +### Q: What's a common linting error in test files? +A: Missing newline at end of file. ESLint requires files to end with a newline character. + +### Q: How do you handle TypeScript errors about missing exports? +A: Double-check that you're only importing what's actually exported from a module. The error "Module declares 'X' locally, but it is not exported" means you're trying to import something that isn't exported. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 18b87fbb14..cac840b9a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,18 +6,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Essential Commands - **Install dependencies**: `pnpm install` +- **Run tests**: `pnpm test run` (uses Vitest). You can filter with `pnpm test run `. The `run` is important to not trigger watch mode +- **Lint code**: `pnpm lint`. `pnpm lint --fix` will fix some of the linting errors, prefer that over fixing them manually. +- **Type check**: `pnpm typecheck` + +#### Extra commands +These commands are usually already called by the user, but you can remind them to run it for you if they forgot to. - **Build packages**: `pnpm build:packages` - **Generate code**: `pnpm codegen` - **Start dependencies**: `pnpm restart-deps` (resets & restarts Docker containers for DB, Inbucket, etc. Usually already called by the user) - **Run development**: `pnpm dev` (starts all services on different ports. Usually already started by the user in the background) - **Run minimal dev**: `pnpm dev:basic` (only backend and dashboard for resource-limited systems) -- **Run tests**: `pnpm test --no-watch` (uses Vitest). You can filter with `pnpm test --no-watch ` -- **Lint code**: `pnpm lint` -- **Type check**: `pnpm typecheck` ### Testing -- **Run all tests**: `pnpm test --no-watch` -- **Run some tests**: `pnpm test --no-watch ` +You should ALWAYS add new E2E tests when you change the API or SDK interface. Generally, err on the side of creating too many tests; it is super important that our codebase is well-tested, due to the nature of the industry we're building in. +- **Run all tests**: `pnpm test run` +- **Run some tests**: `pnpm test run ` ### Database Commands - **Generate migration**: `pnpm db:migration-gen` @@ -62,15 +66,12 @@ The API follows a RESTful design with routes organized by resource type: - OAuth providers: `/api/latest/oauth-providers/*` ### Development Ports -- 8100: Dev launchpad -- 8101: Dashboard -- 8102: Backend API -- 8103: Demo app -- 8104: Documentation -- 8105: Inbucket (email testing) -- 8106: Prisma Studio +To see all development ports, refer to the index.html of `apps/dev-launchpad/public/index.html`. ## Important Notes - Environment variables are pre-configured in `.env.development` files -- Code generation (`pnpm codegen`) must be run after schema changes +- Always run typecheck, lint, and test to make sure your changes are working as expected. You can save time by only linting and testing the files you've changed (and/or related E2E tests). - The project uses a custom route handler system in the backend for consistent API responses +- Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass. +- When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled. +- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). 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 62bafd0c4d..c675751b69 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 @@ -403,7 +403,8 @@ const handler = createSmartRouteHandler({ } catch (error) { if (error instanceof InvalidClientError) { if (error.message.includes("redirect_uri") || error.message.includes("redirectUri")) { - throw new StatusError(400, "Invalid redirect URI. You might have set the wrong redirect URI in the OAuth provider settings. (Please copy the redirect URI from the Stack Auth dashboard and paste it into the OAuth provider's dashboard)"); + console.log("User is trying to authorize OAuth with an invalid redirect URI", error, oauthRequest); + throw new StatusError(400, "Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); } } else if (error instanceof InvalidScopeError) { // which scopes are being requested, and by whom? diff --git a/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx index a2de637c49..44ac869a17 100644 --- a/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx @@ -1,3 +1,4 @@ +import { validateRedirectUrl } from "@/lib/redirect-urls"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; import { VerificationCodeType } from "@prisma/client"; @@ -50,35 +51,16 @@ export const registerVerificationCodeHandler = createVerificationCodeHandler({ } // HACK: we validate origin and rpid outside of simpleauth, this should be replaced once we have a primary authentication domain - - let expectedRPID = ""; - let expectedOrigin = ""; const clientDataJSON = decodeClientDataJSON(credential.response.clientDataJSON); const { origin } = clientDataJSON; - const localhostAllowed = tenancy.config.domains.allowLocalhost; - const parsedOrigin = new URL(origin); - const isLocalhost = parsedOrigin.hostname === "localhost"; - - if (!localhostAllowed && isLocalhost) { - throw new KnownErrors.PasskeyAuthenticationFailed("Passkey registration failed because localhost is not allowed"); - } - if (localhostAllowed && isLocalhost) { - expectedRPID = parsedOrigin.hostname; - expectedOrigin = origin; + if (!validateRedirectUrl(origin, tenancy)) { + throw new KnownErrors.PasskeyRegistrationFailed("Passkey registration failed because the origin is not allowed"); } - if (!isLocalhost) { - if (!Object.values(tenancy.config.domains.trustedDomains) - .filter(e => e.baseUrl) - .map(e => e.baseUrl) - .includes(parsedOrigin.origin)) { - throw new KnownErrors.PasskeyAuthenticationFailed("Passkey registration failed because the origin is not allowed"); - } else { - expectedRPID = parsedOrigin.hostname; - expectedOrigin = origin; - } - } + const parsedOrigin = new URL(origin); + const expectedRPID = parsedOrigin.hostname; + const expectedOrigin = origin; let verification; diff --git a/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx index 690c0753a5..842bb3d4f7 100644 --- a/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx @@ -1,3 +1,4 @@ +import { validateRedirectUrl } from "@/lib/redirect-urls"; import { createAuthTokens } from "@/lib/tokens"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; @@ -63,34 +64,16 @@ export const passkeySignInVerificationCodeHandler = createVerificationCodeHandle } // HACK: we validate origin and rpid outside of simpleauth, this should be replaced once we have a primary authentication domain - let expectedRPID = ""; - let expectedOrigin = ""; const clientDataJSON = decodeClientDataJSON(authentication_response.response.clientDataJSON); const { origin } = clientDataJSON; - const localhostAllowed = tenancy.config.domains.allowLocalhost; - const parsedOrigin = new URL(origin); - const isLocalhost = parsedOrigin.hostname === "localhost"; - - if (!localhostAllowed && isLocalhost) { - throw new KnownErrors.PasskeyAuthenticationFailed("Passkey authentication failed because localhost is not allowed"); - } - if (localhostAllowed && isLocalhost) { - expectedRPID = parsedOrigin.hostname; - expectedOrigin = origin; + if (!validateRedirectUrl(origin, tenancy)) { + throw new KnownErrors.PasskeyAuthenticationFailed("Passkey authentication failed because the origin is not allowed"); } - if (!isLocalhost) { - if (!Object.values(tenancy.config.domains.trustedDomains) - .filter(e => e.baseUrl) - .map(e => e.baseUrl) - .includes(parsedOrigin.origin)) { - throw new KnownErrors.PasskeyAuthenticationFailed("Passkey authentication failed because the origin is not allowed"); - } else { - expectedRPID = parsedOrigin.hostname; - expectedOrigin = origin; - } - } + const parsedOrigin = new URL(origin); + const expectedRPID = parsedOrigin.hostname; + const expectedOrigin = origin; let authVerify; authVerify = await verifyAuthenticationResponse({ diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx new file mode 100644 index 0000000000..efa71819eb --- /dev/null +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -0,0 +1,462 @@ +import { describe, it, expect } from 'vitest'; +import { validateRedirectUrl } from './redirect-urls'; +import { Tenancy } from './tenancies'; + +describe('validateRedirectUrl', () => { + const createMockTenancy = (config: Partial): Tenancy => { + return { + config: { + domains: { + allowLocalhost: false, + trustedDomains: {}, + ...config.domains, + }, + ...config, + }, + } as Tenancy; + }; + + describe('exact domain matching', () => { + it('should validate exact domain matches', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/handler/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/other', tenancy)).toBe(false); + expect(validateRedirectUrl('https://other.com/handler', tenancy)).toBe(false); + }); + + it('should validate protocol matching', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('http://example.com/handler', tenancy)).toBe(false); + }); + }); + + describe('wildcard domain matching', () => { + it('should validate single wildcard subdomain patterns', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://*.example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://api.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://www.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://staging.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api.v2.example.com/handler', tenancy)).toBe(false); + }); + + it('should validate double wildcard patterns', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://**.example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://api.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.v2.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://a.b.c.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(false); + }); + + it('should validate wildcard patterns with prefixes', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://api-*.example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://api-v1.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api-v2.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api-prod.example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.example.com/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://v1-api.example.com/handler', tenancy)).toBe(false); + }); + + it('should validate multiple wildcard patterns', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://*.*.org', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://mail.example.org/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.company.org/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.org/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://a.b.c.org/handler', tenancy)).toBe(false); + }); + }); + + describe('localhost handling', () => { + it('should allow localhost when configured', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: true, + trustedDomains: {}, + }, + }); + + expect(validateRedirectUrl('http://localhost/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('http://localhost:3000/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('http://127.0.0.1/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('http://sub.localhost/callback', tenancy)).toBe(true); + }); + + it('should reject localhost when not configured', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: {}, + }, + }); + + expect(validateRedirectUrl('http://localhost/callback', tenancy)).toBe(false); + expect(validateRedirectUrl('http://127.0.0.1/callback', tenancy)).toBe(false); + }); + }); + + describe('path validation', () => { + it('should validate handler path matching', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://example.com', handlerPath: '/auth/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://example.com/auth/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/auth/handler/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/auth', tenancy)).toBe(false); + expect(validateRedirectUrl('https://example.com/other/handler', tenancy)).toBe(false); + }); + + it('should work with wildcard domains and path validation', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://*.example.com', handlerPath: '/api/auth' }, + }, + }, + }); + + expect(validateRedirectUrl('https://api.example.com/api/auth', tenancy)).toBe(true); + expect(validateRedirectUrl('https://app.example.com/api/auth/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.example.com/api', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api.example.com/other/auth', tenancy)).toBe(false); + }); + }); + + describe('port number handling with wildcards', () => { + it('should handle exact domain without port (defaults to standard ports)', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://localhost', handlerPath: '/' }, + }, + }, + }); + + // https://localhost should match https://localhost:443 (default HTTPS port) + expect(validateRedirectUrl('https://localhost/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost:443/', tenancy)).toBe(true); + + // Should NOT match other ports + expect(validateRedirectUrl('https://localhost:3000/', tenancy)).toBe(false); + expect(validateRedirectUrl('https://localhost:8080/', tenancy)).toBe(false); + }); + + it('should handle http domain without port (defaults to port 80)', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'http://localhost', handlerPath: '/' }, + }, + }, + }); + + // http://localhost should match http://localhost:80 (default HTTP port) + expect(validateRedirectUrl('http://localhost/', tenancy)).toBe(true); + expect(validateRedirectUrl('http://localhost:80/', tenancy)).toBe(true); + + // Should NOT match other ports + expect(validateRedirectUrl('http://localhost:3000/', tenancy)).toBe(false); + expect(validateRedirectUrl('http://localhost:8080/', tenancy)).toBe(false); + }); + + it('should handle wildcard with port pattern to match any port', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://localhost:*', handlerPath: '/' }, + }, + }, + }); + + // Should match localhost on any port + expect(validateRedirectUrl('https://localhost/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost:443/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost:3000/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost:8080/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost:12345/', tenancy)).toBe(true); + + // Should NOT match different hostnames + expect(validateRedirectUrl('https://example.com:3000/', tenancy)).toBe(false); + }); + + it('should handle subdomain wildcard without affecting port matching', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://*.localhost', handlerPath: '/' }, + }, + }, + }); + + // Should match subdomains on default port only + expect(validateRedirectUrl('https://api.localhost/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.localhost:443/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://app.localhost/', tenancy)).toBe(true); + + // Should NOT match subdomains on other ports + expect(validateRedirectUrl('https://api.localhost:3000/', tenancy)).toBe(false); + expect(validateRedirectUrl('https://app.localhost:8080/', tenancy)).toBe(false); + + // Should NOT match the base domain (no subdomain) + expect(validateRedirectUrl('https://localhost/', tenancy)).toBe(false); + }); + + it('should handle subdomain wildcard WITH port wildcard', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://*.localhost:*', handlerPath: '/' }, + }, + }, + }); + + // Should match subdomains on any port + expect(validateRedirectUrl('https://api.localhost/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.localhost:3000/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://app.localhost:8080/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://staging.localhost:12345/', tenancy)).toBe(true); + + // Should NOT match the base domain (no subdomain) + expect(validateRedirectUrl('https://localhost:3000/', tenancy)).toBe(false); + }); + + it('should handle TLD wildcard without affecting port', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://localhost.*', handlerPath: '/' }, + }, + }, + }); + + // Should match different TLDs on default port + expect(validateRedirectUrl('https://localhost.de/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost.com/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost.org/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://localhost.de:443/', tenancy)).toBe(true); + + // Should NOT match different TLDs on other ports + expect(validateRedirectUrl('https://localhost.de:3000/', tenancy)).toBe(false); + expect(validateRedirectUrl('https://localhost.com:8080/', tenancy)).toBe(false); + + // Should NOT match without TLD + expect(validateRedirectUrl('https://localhost/', tenancy)).toBe(false); + }); + + it('should handle specific port in wildcard pattern', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://*.example.com:8080', handlerPath: '/' }, + }, + }, + }); + + // Should match subdomains only on port 8080 + expect(validateRedirectUrl('https://api.example.com:8080/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://app.example.com:8080/', tenancy)).toBe(true); + + // Should NOT match on other ports + expect(validateRedirectUrl('https://api.example.com/', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api.example.com:443/', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api.example.com:3000/', tenancy)).toBe(false); + }); + + it('should handle double wildcard with port patterns', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://**.example.com:*', handlerPath: '/' }, + }, + }, + }); + + // Should match any subdomain depth on any port + expect(validateRedirectUrl('https://api.example.com:3000/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.v2.example.com:8080/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://staging.api.v2.example.com:12345/', tenancy)).toBe(true); + + // Should NOT match base domain + expect(validateRedirectUrl('https://example.com:3000/', tenancy)).toBe(false); + }); + + it('should handle single wildcard (*:*) pattern correctly', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'http://*:*', handlerPath: '/' }, + }, + }, + }); + + // * matches single level (no dots), so should match simple hostnames on any port + expect(validateRedirectUrl('http://localhost:3000/', tenancy)).toBe(true); + expect(validateRedirectUrl('http://localhost:8080/', tenancy)).toBe(true); + expect(validateRedirectUrl('http://app:12345/', tenancy)).toBe(true); + + // Should NOT match hostnames with dots (need ** for that) + expect(validateRedirectUrl('http://example.com:8080/', tenancy)).toBe(false); + expect(validateRedirectUrl('http://api.test.com:12345/', tenancy)).toBe(false); + + // Should NOT match https (different protocol) + expect(validateRedirectUrl('https://localhost:3000/', tenancy)).toBe(false); + }); + + it('should handle double wildcard (**:*) pattern to match any hostname on any port', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'http://**:*', handlerPath: '/' }, + }, + }, + }); + + // ** matches any characters including dots, so should match any hostname on any port + expect(validateRedirectUrl('http://localhost:3000/', tenancy)).toBe(true); + expect(validateRedirectUrl('http://example.com:8080/', tenancy)).toBe(true); + expect(validateRedirectUrl('http://api.test.com:12345/', tenancy)).toBe(true); + expect(validateRedirectUrl('http://192.168.1.1:80/', tenancy)).toBe(true); + expect(validateRedirectUrl('http://deeply.nested.subdomain.example.com:9999/', tenancy)).toBe(true); + + // Should NOT match https (different protocol) + expect(validateRedirectUrl('https://localhost:3000/', tenancy)).toBe(false); + }); + + it('should correctly distinguish between port wildcard and subdomain wildcard', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://app-*.example.com', handlerPath: '/' }, + '2': { baseUrl: 'https://api.example.com:*', handlerPath: '/' }, + }, + }, + }); + + // First pattern should match app-* subdomains on default port + expect(validateRedirectUrl('https://app-v1.example.com/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://app-staging.example.com/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://app-v1.example.com:3000/', tenancy)).toBe(false); + + // Second pattern should match api.example.com on any port + expect(validateRedirectUrl('https://api.example.com/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.example.com:3000/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.example.com:8080/', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api-v1.example.com:3000/', tenancy)).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle invalid URLs', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('not-a-url', tenancy)).toBe(false); + expect(validateRedirectUrl('', tenancy)).toBe(false); + expect(validateRedirectUrl('javascript:alert(1)', tenancy)).toBe(false); + }); + + it('should handle missing baseUrl in domain config', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: undefined as any, handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(false); + }); + + it('should handle multiple trusted domains with wildcards', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://example.com', handlerPath: '/handler' }, + '2': { baseUrl: 'https://*.staging.com', handlerPath: '/auth' }, + '3': { baseUrl: 'https://**.production.com', handlerPath: '/callback' }, + }, + }, + }); + + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.staging.com/auth', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.v2.production.com/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('https://other.com/handler', tenancy)).toBe(false); + }); + }); +}); diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index 33acfa8135..23c6d0c164 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -1,5 +1,5 @@ import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; -import { createUrlIfValid, isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; +import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls"; import { Tenancy } from "./tenancies"; export function validateRedirectUrl( @@ -17,17 +17,94 @@ export function validateRedirectUrl( } const testUrl = url; - const baseUrl = createUrlIfValid(domain.baseUrl); - if (!baseUrl) { - captureError("invalid-redirect-domain", new StackAssertionError("Invalid redirect domain; maybe this should be fixed in the database", { - domain: domain.baseUrl, - })); - return false; - } - const sameOrigin = baseUrl.protocol === testUrl.protocol && baseUrl.hostname === testUrl.hostname; - const isSubPath = testUrl.pathname.startsWith(baseUrl.pathname); + // Check if the domain uses wildcards + const hasWildcard = domain.baseUrl.includes('*'); + + if (hasWildcard) { + // For wildcard domains, we need to parse the pattern manually + // Extract protocol, hostname pattern, and path + const protocolEnd = domain.baseUrl.indexOf('://'); + if (protocolEnd === -1) { + captureError("invalid-redirect-domain", new StackAssertionError("Invalid domain format; missing protocol", { + domain: domain.baseUrl, + })); + return false; + } + + const protocol = domain.baseUrl.substring(0, protocolEnd + 3); + const afterProtocol = domain.baseUrl.substring(protocolEnd + 3); + const pathStart = afterProtocol.indexOf('/'); + const hostPattern = pathStart === -1 ? afterProtocol : afterProtocol.substring(0, pathStart); + const basePath = pathStart === -1 ? '/' : afterProtocol.substring(pathStart); + + // Check protocol + if (testUrl.protocol + '//' !== protocol) { + return false; + } + + // Check host (including port) with wildcard pattern + // We need to handle port matching correctly + const hasPortInPattern = hostPattern.includes(':'); + + if (hasPortInPattern) { + // Pattern includes port - match against full host (hostname:port) + // Need to normalize for default ports + let normalizedTestHost = testUrl.host; + if (testUrl.port === '' || + (testUrl.protocol === 'https:' && testUrl.port === '443') || + (testUrl.protocol === 'http:' && testUrl.port === '80')) { + // Add default port explicitly for matching when pattern has a port + const defaultPort = testUrl.protocol === 'https:' ? '443' : '80'; + normalizedTestHost = testUrl.hostname + ':' + (testUrl.port || defaultPort); + } + + if (!matchHostnamePattern(hostPattern, normalizedTestHost)) { + return false; + } + } else { + // Pattern doesn't include port - match hostname only and check port separately + if (!matchHostnamePattern(hostPattern, testUrl.hostname)) { + return false; + } - return sameOrigin && isSubPath; + // When no port is specified in pattern, only allow default ports + const isDefaultPort = + (testUrl.protocol === 'https:' && (testUrl.port === '' || testUrl.port === '443')) || + (testUrl.protocol === 'http:' && (testUrl.port === '' || testUrl.port === '80')); + + if (!isDefaultPort) { + return false; + } + } + + // Check path + const handlerPath = domain.handlerPath || '/'; + const fullBasePath = basePath === '/' ? handlerPath : basePath + handlerPath; + return testUrl.pathname.startsWith(fullBasePath); + } else { + // For non-wildcard domains, use the original logic + const baseUrl = createUrlIfValid(domain.baseUrl); + if (!baseUrl) { + captureError("invalid-redirect-domain", new StackAssertionError("Invalid redirect domain; maybe this should be fixed in the database", { + domain: domain.baseUrl, + })); + return false; + } + + const protocolMatches = baseUrl.protocol === testUrl.protocol; + const hostnameMatches = baseUrl.hostname === testUrl.hostname; + + // Check port matching for non-wildcard domains + const portMatches = baseUrl.port === testUrl.port || + (baseUrl.port === '' && testUrl.protocol === 'https:' && testUrl.port === '443') || + (baseUrl.port === '' && testUrl.protocol === 'http:' && testUrl.port === '80') || + (testUrl.port === '' && baseUrl.protocol === 'https:' && baseUrl.port === '443') || + (testUrl.port === '' && baseUrl.protocol === 'http:' && baseUrl.port === '80'); + + const pathMatches = testUrl.pathname.startsWith(domain.handlerPath || '/'); + + return protocolMatches && hostnameMatches && portMatches && pathMatches; + } }); } diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index fd049393fc..eff9bd497b 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -52,9 +52,13 @@ export class OAuthModel implements AuthorizationCodeModel { let redirectUris: string[] = []; try { - redirectUris = Object.entries(tenancy.config.domains.trustedDomains).map( - ([_, domain]) => new URL(domain.handlerPath, domain.baseUrl).toString() - ); + redirectUris = Object.entries(tenancy.config.domains.trustedDomains) + // note that this may include wildcard domains, which is fine because we correctly account for them in + // model.validateRedirectUri(...) + .filter(([_, domain]) => { + return domain.baseUrl; + }) + .map(([_, domain]) => new URL(domain.handlerPath, domain.baseUrl).toString()); } catch (e) { captureError("get-oauth-redirect-urls", { error: e, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx index 7cd55b2e82..99fdee72b4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx @@ -5,7 +5,7 @@ import { SettingCard, SettingSwitch } from "@/components/settings"; import { AdminDomainConfig, AdminProject } from "@stackframe/stack"; import { yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import { isValidHostname, isValidUrl } from "@stackframe/stack-shared/dist/utils/urls"; +import { isValidHostnameWithWildcards, isValidUrl } from "@stackframe/stack-shared/dist/utils/urls"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, ActionDialog, Alert, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; import React from "react"; import * as yup from "yup"; @@ -35,7 +35,7 @@ function EditDialog(props: { .test({ name: 'domain', message: (params) => `Invalid domain`, - test: (value) => value == null || isValidHostname(value) + test: (value) => value == null || isValidHostnameWithWildcards(value) }) .test({ name: 'unique-domain', @@ -77,6 +77,11 @@ function EditDialog(props: { return false; } + // Don't allow adding www. to wildcard domains + if (domain.includes('*')) { + return false; + } + const httpsUrl = 'https://' + domain; if (!isValidUrl(httpsUrl)) { return false; @@ -153,7 +158,16 @@ function EditDialog(props: { render={(form) => ( <> - Please ensure you own or have control over this domain. Also note that each subdomain (e.g. blog.example.com, app.example.com) is treated as a distinct domain. +
+

Please ensure you own or have control over this domain. Also note that each subdomain (e.g. blog.example.com, app.example.com) is treated as a distinct domain.

+

Wildcard domains: You can use wildcards to match multiple domains:

+
    +
  • *.example.com - matches any single subdomain (e.g., api.example.com, www.example.com)
  • +
  • **.example.com - matches any subdomain level (e.g., api.v2.example.com)
  • +
  • api-*.example.com - matches api-v1.example.com, api-prod.example.com, etc.
  • +
  • *.*.org - matches mail.example.org, but not example.org
  • +
+
{ + it("should allow OAuth with exact matching domain", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add exact domain that matches our redirect URL + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.exact': { + baseUrl: 'http://localhost:8107', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': true, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // OAuth flow should succeed + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + await Auth.expectToBeSignedIn(); + }); + + it("should reject OAuth with non-matching exact domain", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add exact domain that does NOT match + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.production': { + baseUrl: 'https://app.production.com', + handlerPath: '/auth/handler', + }, + 'domains.allowLocalhost': false, // Ensure we only check exact domains + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should match exact subdomain but not other subdomains", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add exact subdomain + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.subdomain': { + baseUrl: 'https://app.example.com', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should require exact port matching", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add domain with specific port + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.withport': { + baseUrl: 'http://localhost:3000', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should require exact protocol matching", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add HTTPS domain + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.https': { + baseUrl: 'https://localhost:8107', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should match path prefix correctly", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add domain with specific handler path + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.withpath': { + baseUrl: 'http://localhost:8107', + handlerPath: '/auth/oauth/callback', // Different path than default /handler + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should work with multiple exact domains where one matches", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add multiple domains + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.prod': { + baseUrl: 'https://app.production.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.staging': { + baseUrl: 'https://app.staging.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.local': { + baseUrl: 'http://localhost:8107', // This one matches! + handlerPath: '/handler', + }, + 'domains.allowLocalhost': true, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // OAuth should succeed with the matching domain + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + }); + + it("should fail when no exact domains match", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add multiple domains, none match localhost:8107 + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.prod': { + baseUrl: 'https://app.production.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.staging': { + baseUrl: 'https://app.staging.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.differentPort': { + baseUrl: 'http://localhost:3000', // Different port + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts new file mode 100644 index 0000000000..a3bc418778 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -0,0 +1,313 @@ +import { describe } from "vitest"; +import { it, localRedirectUrl } from "../../../../../../helpers"; +import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; + +describe("OAuth with wildcard domains", () => { + it("should work with exact domain configuration", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add exact domain matching our test redirect URL + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.exact': { + baseUrl: 'http://localhost:8107', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': true, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // OAuth flow should work + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + }); + + it("should FAIL with exact domain that doesn't match", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add exact domain that DOESN'T match our test redirect URL + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.exact': { + baseUrl: 'https://app.example.com', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, // Disable localhost to ensure exact matching + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should work with single wildcard domain", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add wildcard domain + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.wildcard': { + baseUrl: 'http://*.localhost:8107', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': true, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // OAuth flow should work with localhost + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + }); + + it("should FAIL with single wildcard that doesn't match", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add wildcard domain that doesn't match localhost pattern + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.wildcard': { + baseUrl: 'https://*.example.com', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should work with double wildcard domain", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add double wildcard domain + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.double': { + baseUrl: 'http://**.localhost:8107', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': true, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // OAuth flow should work + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + }); + + it("should FAIL with double wildcard that doesn't match", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add double wildcard for different TLD + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.double': { + baseUrl: 'https://**.example.org', // Different TLD - won't match localhost + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should match prefix wildcard patterns correctly", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add prefix wildcard that should match "localhost" + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.prefix': { + baseUrl: 'http://local*:8107', // Should match localhost + handlerPath: '/handler', + }, + 'domains.allowLocalhost': true, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // OAuth flow should work + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + }); + + it("should FAIL with prefix wildcard that doesn't match", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Add prefix wildcard that won't match localhost + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.prefix': { + baseUrl: 'http://api-*:8107', // Won't match localhost + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Try to complete the OAuth flow - it should fail at the callback stage + const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); + expect(response.status).toBe(400); + expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + }); + + it("should properly validate multiple domains with wildcards", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + } + }); + + // Configure multiple domains, only one matches + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.prod': { + baseUrl: 'https://app.production.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.staging': { + baseUrl: 'https://*.staging.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.test': { + baseUrl: 'http://localhost:8107', // This one matches! + handlerPath: '/handler', + }, + 'domains.allowLocalhost': true, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Get the config to verify all domains are stored + const getResponse = await niceBackendFetch("/api/v1/internal/config", { + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + method: "GET", + }); + expect(getResponse.status).toBe(200); + + const config = JSON.parse(getResponse.body.config_string); + expect(Object.keys(config.domains.trustedDomains).length).toBe(3); + }); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts new file mode 100644 index 0000000000..75932f2c79 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts @@ -0,0 +1,484 @@ +import { describe } from "vitest"; +import { it } from "../../../../../../helpers"; +import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; + +describe("Passkey with wildcard domains", () => { + it("should store wildcard domains in config correctly", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + passkey_enabled: true, + } + }); + + // Configure various wildcard domains + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.exact': { + baseUrl: 'https://app.example.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.single-wildcard': { + baseUrl: 'https://*.example.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.prefix-wildcard': { + baseUrl: 'https://api-*.example.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.double-wildcard': { + baseUrl: 'https://**.example.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.multi-level': { + baseUrl: 'https://*.*.test.com', + handlerPath: '/handler', + }, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Get the config to verify wildcards are stored + const getResponse = await niceBackendFetch("/api/v1/internal/config", { + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + method: "GET", + }); + expect(getResponse.status).toBe(200); + + const config = JSON.parse(getResponse.body.config_string); + expect(config.domains.trustedDomains).toMatchObject({ + 'exact': { + baseUrl: 'https://app.example.com', + handlerPath: '/handler', + }, + 'single-wildcard': { + baseUrl: 'https://*.example.com', + handlerPath: '/handler', + }, + 'prefix-wildcard': { + baseUrl: 'https://api-*.example.com', + handlerPath: '/handler', + }, + 'double-wildcard': { + baseUrl: 'https://**.example.com', + handlerPath: '/handler', + }, + 'multi-level': { + baseUrl: 'https://*.*.test.com', + handlerPath: '/handler', + }, + }); + }); + + it("should successfully register passkey with matching wildcard domain", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + passkey_enabled: true, + magic_link_enabled: true + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Sign up a user first + const res = await Auth.Password.signUpWithEmail(); + + // Configure wildcard domain that matches our test origin + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.wildcard': { + baseUrl: 'http://*:8103', // Will match http://localhost:8103 and any host on port 8103 + handlerPath: '/', + }, + 'domains.allowLocalhost': false, // Disable default localhost to test wildcard + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Initiate passkey registration + const initiateResponse = await niceBackendFetch("/api/v1/auth/passkey/initiate-passkey-registration", { + method: "POST", + accessType: "client", + body: {}, + }); + expect(initiateResponse.status).toBe(200); + const { code } = initiateResponse.body; + + // Register passkey with origin matching wildcard + const registerResponse = await niceBackendFetch("/api/v1/auth/passkey/register", { + method: "POST", + accessType: "client", + body: { + "credential": { + "id": "WILDCARD_TEST_ID", + "rawId": "WILDCARD_TEST_ID", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAQWGAfwysz2R5taOiCxqOkpP3AXpQECAyYgASFYIO7JJihe93CDhZOPFp9pVefZyBvy62JMjSs47id1q0vpIlggNMjLAQG7ESYqRZsBQbX07WWIImEzYFDsJgBOSYiQZL8", + "clientDataJSON": btoa(JSON.stringify({ + type: "webauthn.create", + challenge: "TU9DSw", + origin: "http://localhost:8103", // Matches wildcard *:8103 + crossOrigin: false + })), + "transports": ["hybrid", "internal"], + "publicKeyAlgorithm": -7, + "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7skmKF73cIOFk48Wn2lV59nIG_LrYkyNKzjuJ3WrS-k0yMsBAbsRJipFmwFBtfTtZYgiYTNgUOwmAE5JiJBkvw", + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAQWGAfwysz2R5taOiCxqOkpP3AXpQECAyYgASFYIO7JJihe93CDhZOPFp9pVefZyBvy62JMjSs47id1q0vpIlggNMjLAQG7ESYqRZsBQbX07WWIImEzYFDsJgBOSYiQZL8" + }, + "type": "public-key", + "clientExtensionResults": { + "credProps": { + "rk": true + } + }, + "authenticatorAttachment": "platform" + }, + "code": code, + }, + }); + + if (registerResponse.status !== 200) { + console.log("Register failed with:", registerResponse.body); + } + expect(registerResponse.status).toBe(200); + expect(registerResponse.body).toHaveProperty("user_handle"); + }); + + it("should successfully sign in with passkey using matching double wildcard domain", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + passkey_enabled: true, + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Sign up and register passkey with default localhost allowed + const res = await Auth.Password.signUpWithEmail(); + const expectedUserId = res.userId; + await Auth.Passkey.register(); // This uses http://localhost:8103 + await Auth.signOut(); + + // Configure double wildcard domain that matches localhost:8103 + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.double': { + baseUrl: 'http://**host:8103', // Will match localhost:8103 + handlerPath: '/', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Initiate authentication + const initiateResponse = await niceBackendFetch("/api/v1/auth/passkey/initiate-passkey-authentication", { + method: "POST", + accessType: "client", + body: {}, + }); + expect(initiateResponse.status).toBe(200); + const { code } = initiateResponse.body; + + // Sign in with passkey using deeply nested subdomain + const signinResponse = await niceBackendFetch("/api/v1/auth/passkey/sign-in", { + method: "POST", + accessType: "client", + body: { + "authentication_response": { + "id": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc", + "rawId": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc", + "response": { + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVFU5RFN3Iiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MTAzIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", // Matches **host:8103 + "signature": "MEUCIQDPFYXxm-ALPZVuP4YdXBr1INrfObXR6hukxTttYNnegAIgEfy5MlnIi10VwmilOmuT1TuuDBLw9GDSv9DQuIRZXRE", + "userHandle": "YzE3YzJjNjMtMTkxZi00MWZmLTlkNjEtYzBjOGVlMmVlMGQ0" + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + }, + "code": code, + }, + }); + + expect(signinResponse.status).toBe(200); + expect(signinResponse.body.user_id).toBe(expectedUserId); + }); + + it("should FAIL passkey registration with non-matching exact domain", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + passkey_enabled: true, + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Sign up a user first + await Auth.Password.signUpWithEmail(); + + // Configure exact domain that doesn't match + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.exact': { + baseUrl: 'https://app.production.com', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Initiate passkey registration + const initiateResponse = await niceBackendFetch("/api/v1/auth/passkey/initiate-passkey-registration", { + method: "POST", + accessType: "client", + body: {}, + }); + expect(initiateResponse.status).toBe(200); + const { code } = initiateResponse.body; + + // Try to register passkey with non-matching origin + const registerResponse = await niceBackendFetch("/api/v1/auth/passkey/register", { + method: "POST", + accessType: "client", + body: { + "credential": { + "id": "FAIL_TEST_ID", + "rawId": "FAIL_TEST_ID", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAQWGAfwysz2R5taOiCxqOkpP3AXpQECAyYgASFYIO7JJihe93CDhZOPFp9pVefZyBvy62JMjSs47id1q0vpIlggNMjLAQG7ESYqRZsBQbX07WWIImEzYFDsJgBOSYiQZL8", + "clientDataJSON": btoa(JSON.stringify({ + type: "webauthn.create", + challenge: "TU9DSw", + origin: "http://localhost:8103", // Doesn't match https://app.production.com + crossOrigin: false + })), + "transports": ["hybrid", "internal"], + "publicKeyAlgorithm": -7, + "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7skmKF73cIOFk48Wn2lV59nIG_LrYkyNKzjuJ3WrS-k0yMsBAbsRJipFmwFBtfTtZYgiYTNgUOwmAE5JiJBkvw", + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAQWGAfwysz2R5taOiCxqOkpP3AXpQECAyYgASFYIO7JJihe93CDhZOPFp9pVefZyBvy62JMjSs47id1q0vpIlggNMjLAQG7ESYqRZsBQbX07WWIImEzYFDsJgBOSYiQZL8" + }, + "type": "public-key", + "clientExtensionResults": { + "credProps": { + "rk": true + } + }, + "authenticatorAttachment": "platform" + }, + "code": code, + }, + }); + + expect(registerResponse.status).toBe(400); + expect(registerResponse.body).toMatchObject({ + code: "PASSKEY_REGISTRATION_FAILED", + error: expect.stringContaining("origin is not allowed") + }); + }); + + it("should FAIL passkey sign-in with non-matching wildcard domain", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + passkey_enabled: true, + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Sign up and register passkey with default localhost allowed + const res = await Auth.Password.signUpWithEmail(); + await Auth.Passkey.register(); + await Auth.signOut(); + + // Configure wildcard that doesn't match localhost + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.wildcard': { + baseUrl: 'https://*.example.com', + handlerPath: '/handler', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Initiate authentication + const initiateResponse = await niceBackendFetch("/api/v1/auth/passkey/initiate-passkey-authentication", { + method: "POST", + accessType: "client", + body: {}, + }); + expect(initiateResponse.status).toBe(200); + const { code } = initiateResponse.body; + + // Try to sign in with non-matching origin + const signinResponse = await niceBackendFetch("/api/v1/auth/passkey/sign-in", { + method: "POST", + accessType: "client", + body: { + "authentication_response": { + "id": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc", + "rawId": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc", + "response": { + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA", + "clientDataJSON": btoa(JSON.stringify({ + type: "webauthn.get", + challenge: "TU9DSw", + origin: "http://localhost:8103", // Doesn't match *.example.com + crossOrigin: false + })), + "signature": "MEUCIQDPFYXxm-ALPZVuP4YdXBr1INrfObXR6hukxTttYNnegAIgEfy5MlnIi10VwmilOmuT1TuuDBLw9GDSv9DQuIRZXRE", + "userHandle": "YzE3YzJjNjMtMTkxZi00MWZmLTlkNjEtYzBjOGVlMmVlMGQ0" + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + }, + "code": code, + }, + }); + + expect(signinResponse.status).toBe(400); + expect(signinResponse.body).toMatchObject({ + code: "PASSKEY_AUTHENTICATION_FAILED", + error: expect.stringContaining("origin is not allowed") + }); + }); + + it("should work with prefix wildcard pattern for passkey", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + passkey_enabled: true, + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + // Sign up and register passkey with default localhost allowed + const res = await Auth.Password.signUpWithEmail(); + await Auth.Passkey.register(); // This uses http://localhost:8103 + await Auth.signOut(); + + // Configure wildcard that matches localhost + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.wildcard': { + baseUrl: 'http://*:8103', // Will match localhost:8103 + handlerPath: '/', + }, + 'domains.allowLocalhost': false, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Initiate authentication + const initiateResponse = await niceBackendFetch("/api/v1/auth/passkey/initiate-passkey-authentication", { + method: "POST", + accessType: "client", + body: {}, + }); + expect(initiateResponse.status).toBe(200); + const { code } = initiateResponse.body; + + // Sign in with matching prefix pattern + const signinResponse = await niceBackendFetch("/api/v1/auth/passkey/sign-in", { + method: "POST", + accessType: "client", + body: { + "authentication_response": { + "id": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc", + "rawId": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc", + "response": { + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVFU5RFN3Iiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MTAzIiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", // Matches *:8103 + "signature": "MEUCIQDPFYXxm-ALPZVuP4YdXBr1INrfObXR6hukxTttYNnegAIgEfy5MlnIi10VwmilOmuT1TuuDBLw9GDSv9DQuIRZXRE", + "userHandle": "YzE3YzJjNjMtMTkxZi00MWZmLTlkNjEtYzBjOGVlMmVlMGQ0" + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + }, + "code": code, + }, + }); + + expect(signinResponse.status).toBe(200); + expect(signinResponse.body).toHaveProperty("user_id"); + }); + + it("should handle complex wildcard patterns correctly", async ({ expect }) => { + const { adminAccessToken } = await Project.createAndSwitch({ + config: { + passkey_enabled: true, + } + }); + + // Configure complex wildcard patterns + const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { + method: "PATCH", + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + 'domains.trustedDomains.complex1': { + baseUrl: 'https://api-*.*.example.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.complex2': { + baseUrl: 'https://**.api.example.com', + handlerPath: '/handler', + }, + 'domains.trustedDomains.complex3': { + baseUrl: 'https://*-staging.example.com', + handlerPath: '/handler', + }, + }), + }, + }); + expect(configResponse.status).toBe(200); + + // Verify the complex patterns are stored correctly + const getResponse = await niceBackendFetch("/api/v1/internal/config", { + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + method: "GET", + }); + expect(getResponse.status).toBe(200); + + const config = JSON.parse(getResponse.body.config_string); + expect(config.domains.trustedDomains.complex1.baseUrl).toBe('https://api-*.*.example.com'); + expect(config.domains.trustedDomains.complex2.baseUrl).toBe('https://**.api.example.com'); + expect(config.domains.trustedDomains.complex3.baseUrl).toBe('https://*-staging.example.com'); + }); +}); diff --git a/packages/stack-shared/src/helpers/production-mode.ts b/packages/stack-shared/src/helpers/production-mode.ts index 35f8393fbd..1ab7c5ac04 100644 --- a/packages/stack-shared/src/helpers/production-mode.ts +++ b/packages/stack-shared/src/helpers/production-mode.ts @@ -21,7 +21,9 @@ export function getProductionModeErrors(project: ProjectsCrud["Admin"]["Read"]): for (const { domain } of project.config.domains) { let url; try { - url = new URL(domain); + // For wildcard domains, replace wildcards with a valid placeholder to validate the URL structure + const normalizedDomain = domain.replace(/\*+/g, 'wildcard-placeholder'); + url = new URL(normalizedDomain); } catch (e) { captureError("production-mode-domain-not-valid", new StackAssertionError("Domain was somehow not a valid URL; we should've caught this when setting the domain in the first place", { domain, diff --git a/packages/stack-shared/src/utils/urls.tsx b/packages/stack-shared/src/utils/urls.tsx index b600d55254..64ae4c2e6d 100644 --- a/packages/stack-shared/src/utils/urls.tsx +++ b/packages/stack-shared/src/utils/urls.tsx @@ -36,6 +36,11 @@ import.meta.vitest?.test("isValidUrl", ({ expect }) => { }); export function isValidHostname(hostname: string) { + // Basic validation + if (!hostname || hostname.startsWith('.') || hostname.endsWith('.') || hostname.includes('..')) { + return false; + } + const url = createUrlIfValid(`https://${hostname}`); if (!url) return false; return url.hostname === hostname; @@ -54,6 +59,143 @@ import.meta.vitest?.test("isValidHostname", ({ expect }) => { expect(isValidHostname("example com")).toBe(false); }); +export function isValidHostnameWithWildcards(hostname: string) { + // Empty hostnames are invalid + if (!hostname) return false; + + // Check if it contains wildcards + const hasWildcard = hostname.includes('*'); + + if (!hasWildcard) { + // If no wildcards, validate as a normal hostname + return isValidHostname(hostname); + } + + // Basic validation checks that apply even with wildcards + // - Hostname cannot start or end with a dot + if (hostname.startsWith('.') || hostname.endsWith('.')) { + return false; + } + + // - No consecutive dots + if (hostname.includes('..')) { + return false; + } + + // For wildcard validation, check that non-wildcard parts contain valid characters + // Replace wildcards with a valid placeholder to check the rest + const testHostname = hostname.replace(/\*+/g, 'wildcard'); + + // Check if the resulting string would be a valid hostname + if (!/^[a-zA-Z0-9.-]+$/.test(testHostname)) { + return false; + } + + // Additional check: ensure the pattern makes sense + // Check each segment between wildcards + const segments = hostname.split(/\*+/); + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (segment === '') continue; // Empty segments are OK (consecutive wildcards) + + // First segment can't start with dot + if (i === 0 && segment.startsWith('.')) { + return false; + } + + // Last segment can't end with dot + if (i === segments.length - 1 && segment.endsWith('.')) { + return false; + } + + // No segment should have consecutive dots + if (segment.includes('..')) { + return false; + } + } + + return true; +} +import.meta.vitest?.test("isValidHostnameWithWildcards", ({ expect }) => { + // Test with valid regular hostnames + expect(isValidHostnameWithWildcards("example.com")).toBe(true); + expect(isValidHostnameWithWildcards("localhost")).toBe(true); + expect(isValidHostnameWithWildcards("sub.domain.example.com")).toBe(true); + + // Test with valid wildcard hostnames + expect(isValidHostnameWithWildcards("*.example.com")).toBe(true); + expect(isValidHostnameWithWildcards("a-*.example.com")).toBe(true); + expect(isValidHostnameWithWildcards("*.*.org")).toBe(true); + expect(isValidHostnameWithWildcards("**.example.com")).toBe(true); + expect(isValidHostnameWithWildcards("sub.**.com")).toBe(true); + expect(isValidHostnameWithWildcards("*-api.*.com")).toBe(true); + + // Test with invalid hostnames + expect(isValidHostnameWithWildcards("")).toBe(false); + expect(isValidHostnameWithWildcards("example.com/path")).toBe(false); + expect(isValidHostnameWithWildcards("https://example.com")).toBe(false); + expect(isValidHostnameWithWildcards("example com")).toBe(false); + expect(isValidHostnameWithWildcards(".example.com")).toBe(false); + expect(isValidHostnameWithWildcards("example.com.")).toBe(false); + expect(isValidHostnameWithWildcards("example..com")).toBe(false); + expect(isValidHostnameWithWildcards("*.example..com")).toBe(false); +}); + +export function matchHostnamePattern(pattern: string, hostname: string): boolean { + // If no wildcards, it's an exact match + if (!pattern.includes('*')) { + return pattern === hostname; + } + + // Convert the pattern to a regex + // First, escape all regex special characters except * + let regexPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + + // Use a placeholder for ** to handle it separately from single * + const doubleWildcardPlaceholder = '\x00DOUBLE_WILDCARD\x00'; + regexPattern = regexPattern.replace(/\*\*/g, doubleWildcardPlaceholder); + + // Replace single * with a pattern that matches anything except dots + regexPattern = regexPattern.replace(/\*/g, '[^.]*'); + + // Replace the double wildcard placeholder with a pattern that matches anything including dots + regexPattern = regexPattern.replace(new RegExp(doubleWildcardPlaceholder, 'g'), '.*'); + + // Anchor the pattern to match the entire hostname + regexPattern = '^' + regexPattern + '$'; + + try { + const regex = new RegExp(regexPattern); + return regex.test(hostname); + } catch { + return false; + } +} +import.meta.vitest?.test("matchHostnamePattern", ({ expect }) => { + // Test exact matches + expect(matchHostnamePattern("example.com", "example.com")).toBe(true); + expect(matchHostnamePattern("example.com", "other.com")).toBe(false); + + // Test single wildcard matches + expect(matchHostnamePattern("*.example.com", "api.example.com")).toBe(true); + expect(matchHostnamePattern("*.example.com", "www.example.com")).toBe(true); + expect(matchHostnamePattern("*.example.com", "example.com")).toBe(false); + expect(matchHostnamePattern("*.example.com", "api.v2.example.com")).toBe(false); + + // Test double wildcard matches + expect(matchHostnamePattern("**.example.com", "api.example.com")).toBe(true); + expect(matchHostnamePattern("**.example.com", "api.v2.example.com")).toBe(true); + expect(matchHostnamePattern("**.example.com", "a.b.c.example.com")).toBe(true); + expect(matchHostnamePattern("**.example.com", "example.com")).toBe(false); + + // Test complex patterns + expect(matchHostnamePattern("api-*.example.com", "api-v1.example.com")).toBe(true); + expect(matchHostnamePattern("api-*.example.com", "api-v2.example.com")).toBe(true); + expect(matchHostnamePattern("api-*.example.com", "api.example.com")).toBe(false); + expect(matchHostnamePattern("*.*.org", "mail.example.org")).toBe(true); + expect(matchHostnamePattern("*.*.org", "example.org")).toBe(false); +}); + export function isLocalhost(urlOrString: string | URL) { const url = createUrlIfValid(urlOrString); if (!url) return false; From 0ae980b75a51c53b3c0256b2d9cb1afe5780cece Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 5 Aug 2025 17:40:23 -0700 Subject: [PATCH 02/20] fix --- apps/backend/src/lib/redirect-urls.tsx | 166 +++++++++++-------------- 1 file changed, 75 insertions(+), 91 deletions(-) diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index 23c6d0c164..8947b057f2 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -2,109 +2,93 @@ import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls"; import { Tenancy } from "./tenancies"; -export function validateRedirectUrl( - urlOrString: string | URL, - tenancy: Tenancy, -): boolean { - const url = createUrlIfValid(urlOrString); - if (!url) return false; - if (tenancy.config.domains.allowLocalhost && isLocalhost(url)) { - return true; - } - return Object.values(tenancy.config.domains.trustedDomains).some((domain) => { - if (!domain.baseUrl) { - return false; - } - - const testUrl = url; - - // Check if the domain uses wildcards - const hasWildcard = domain.baseUrl.includes('*'); - - if (hasWildcard) { - // For wildcard domains, we need to parse the pattern manually - // Extract protocol, hostname pattern, and path - const protocolEnd = domain.baseUrl.indexOf('://'); - if (protocolEnd === -1) { - captureError("invalid-redirect-domain", new StackAssertionError("Invalid domain format; missing protocol", { - domain: domain.baseUrl, - })); - return false; - } - - const protocol = domain.baseUrl.substring(0, protocolEnd + 3); - const afterProtocol = domain.baseUrl.substring(protocolEnd + 3); - const pathStart = afterProtocol.indexOf('/'); - const hostPattern = pathStart === -1 ? afterProtocol : afterProtocol.substring(0, pathStart); - const basePath = pathStart === -1 ? '/' : afterProtocol.substring(pathStart); +/** + * Normalizes a URL to include explicit default ports for comparison + */ +function normalizePort(url: URL): string { + const defaultPorts: Record = { 'https:': '443', 'http:': '80' }; + const port = url.port || defaultPorts[url.protocol] || ''; + return port ? `${url.hostname}:${port}` : url.hostname; +} - // Check protocol - if (testUrl.protocol + '//' !== protocol) { - return false; - } +/** + * Checks if a URL uses the default port for its protocol + */ +function isDefaultPort(url: URL): boolean { + return !url.port || + (url.protocol === 'https:' && url.port === '443') || + (url.protocol === 'http:' && url.port === '80'); +} - // Check host (including port) with wildcard pattern - // We need to handle port matching correctly - const hasPortInPattern = hostPattern.includes(':'); +/** + * Checks if two URLs have matching ports (considering default ports) + */ +function portsMatch(url1: URL, url2: URL): boolean { + return normalizePort(url1) === normalizePort(url2); +} - if (hasPortInPattern) { - // Pattern includes port - match against full host (hostname:port) - // Need to normalize for default ports - let normalizedTestHost = testUrl.host; - if (testUrl.port === '' || - (testUrl.protocol === 'https:' && testUrl.port === '443') || - (testUrl.protocol === 'http:' && testUrl.port === '80')) { - // Add default port explicitly for matching when pattern has a port - const defaultPort = testUrl.protocol === 'https:' ? '443' : '80'; - normalizedTestHost = testUrl.hostname + ':' + (testUrl.port || defaultPort); - } +/** + * Validates a URL against a domain pattern (with or without wildcards) + */ +function matchesDomain(testUrl: URL, pattern: string, handlerPath: string): boolean { + const baseUrl = createUrlIfValid(pattern); + + // If pattern is invalid as a URL, it might contain wildcards + if (!baseUrl || pattern.includes('*')) { + // Parse wildcard pattern manually + const match = pattern.match(/^([^:]+:\/\/)([^/]*)(.*)$/); + if (!match) { + captureError("invalid-redirect-domain", new StackAssertionError("Invalid domain pattern", { pattern })); + return false; + } - if (!matchHostnamePattern(hostPattern, normalizedTestHost)) { - return false; - } - } else { - // Pattern doesn't include port - match hostname only and check port separately - if (!matchHostnamePattern(hostPattern, testUrl.hostname)) { - return false; - } + const [, protocol, hostPattern, basePath] = match; - // When no port is specified in pattern, only allow default ports - const isDefaultPort = - (testUrl.protocol === 'https:' && (testUrl.port === '' || testUrl.port === '443')) || - (testUrl.protocol === 'http:' && (testUrl.port === '' || testUrl.port === '80')); + // Check protocol + if (testUrl.protocol + '//' !== protocol) { + return false; + } - if (!isDefaultPort) { - return false; - } + // Check host with wildcard pattern + const hasPortInPattern = hostPattern.includes(':'); + if (hasPortInPattern) { + // Pattern includes port - match against normalized host:port + if (!matchHostnamePattern(hostPattern, normalizePort(testUrl))) { + return false; } - - // Check path - const handlerPath = domain.handlerPath || '/'; - const fullBasePath = basePath === '/' ? handlerPath : basePath + handlerPath; - return testUrl.pathname.startsWith(fullBasePath); } else { - // For non-wildcard domains, use the original logic - const baseUrl = createUrlIfValid(domain.baseUrl); - if (!baseUrl) { - captureError("invalid-redirect-domain", new StackAssertionError("Invalid redirect domain; maybe this should be fixed in the database", { - domain: domain.baseUrl, - })); + // Pattern doesn't include port - match hostname only, require default port + if (!matchHostnamePattern(hostPattern, testUrl.hostname) || !isDefaultPort(testUrl)) { return false; } + } - const protocolMatches = baseUrl.protocol === testUrl.protocol; - const hostnameMatches = baseUrl.hostname === testUrl.hostname; + // Check path + const fullPath = basePath === '/' ? handlerPath : basePath + handlerPath; + return testUrl.pathname.startsWith(fullPath || '/'); + } - // Check port matching for non-wildcard domains - const portMatches = baseUrl.port === testUrl.port || - (baseUrl.port === '' && testUrl.protocol === 'https:' && testUrl.port === '443') || - (baseUrl.port === '' && testUrl.protocol === 'http:' && testUrl.port === '80') || - (testUrl.port === '' && baseUrl.protocol === 'https:' && baseUrl.port === '443') || - (testUrl.port === '' && baseUrl.protocol === 'http:' && baseUrl.port === '80'); + // For non-wildcard patterns, use URL comparison + return baseUrl.protocol === testUrl.protocol && + baseUrl.hostname === testUrl.hostname && + portsMatch(baseUrl, testUrl) && + testUrl.pathname.startsWith(handlerPath || '/'); +} - const pathMatches = testUrl.pathname.startsWith(domain.handlerPath || '/'); +export function validateRedirectUrl( + urlOrString: string | URL, + tenancy: Tenancy, +): boolean { + const url = createUrlIfValid(urlOrString); + if (!url) return false; - return protocolMatches && hostnameMatches && portMatches && pathMatches; - } - }); + // Check localhost permission + if (tenancy.config.domains.allowLocalhost && isLocalhost(url)) { + return true; + } + + // Check trusted domains + return Object.values(tenancy.config.domains.trustedDomains).some(domain => + domain.baseUrl && matchesDomain(url, domain.baseUrl, domain.handlerPath || '/') + ); } From 8524a8af01f28719d4c8c54a2e0231951c167ea6 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 17:45:08 -0700 Subject: [PATCH 03/20] Update apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../app/api/latest/auth/oauth/callback/[provider_id]/route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c675751b69..5d878e6fe5 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 @@ -403,7 +403,7 @@ const handler = createSmartRouteHandler({ } catch (error) { if (error instanceof InvalidClientError) { if (error.message.includes("redirect_uri") || error.message.includes("redirectUri")) { - console.log("User is trying to authorize OAuth with an invalid redirect URI", error, oauthRequest); + console.log("User is trying to authorize OAuth with an invalid redirect URI", error, { redirectUri: oauthRequest.query?.redirect_uri, clientId: oauthRequest.query?.client_id }); throw new StatusError(400, "Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); } } else if (error instanceof InvalidScopeError) { From 05f5e44338f17af415bf938507eb0b470aa567a8 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 13 Aug 2025 17:50:31 -0700 Subject: [PATCH 04/20] move CLAUDE-KNOWLEDGE --- CLAUDE-KNOWLEDGE.md => .claude/CLAUDE-KNOWLEDGE.md | 0 CLAUDE.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename CLAUDE-KNOWLEDGE.md => .claude/CLAUDE-KNOWLEDGE.md (100%) diff --git a/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md similarity index 100% rename from CLAUDE-KNOWLEDGE.md rename to .claude/CLAUDE-KNOWLEDGE.md diff --git a/CLAUDE.md b/CLAUDE.md index cac840b9a3..89e16f0ffc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,4 +74,4 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - The project uses a custom route handler system in the backend for consistent API responses - Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass. - When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled. -- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). +- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the ./claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). From ee9be7dec740b7d098722ecee9261910c2b13631 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 00:53:20 +0000 Subject: [PATCH 05/20] refactor: use Map for defaultPorts to avoid prototype pollution Replace Record object with Map for defaultPorts to prevent potential prototype pollution vulnerabilities. Co-authored-by: Konsti Wohlwend --- apps/backend/src/lib/redirect-urls.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index 8947b057f2..27a6696939 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -6,8 +6,8 @@ import { Tenancy } from "./tenancies"; * Normalizes a URL to include explicit default ports for comparison */ function normalizePort(url: URL): string { - const defaultPorts: Record = { 'https:': '443', 'http:': '80' }; - const port = url.port || defaultPorts[url.protocol] || ''; + const defaultPorts = new Map([['https:', '443'], ['http:', '80']]); + const port = url.port || defaultPorts.get(url.protocol) || ''; return port ? `${url.hostname}:${port}` : url.hostname; } From b9a0b09eff19130a3568c907a238d95b395522e3 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:03:47 -0700 Subject: [PATCH 06/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts --- .../endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts index dcc9027563..05ff4e7a34 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts @@ -24,7 +24,6 @@ describe("OAuth with exact domain matching", () => { baseUrl: 'http://localhost:8107', handlerPath: '/handler', }, - 'domains.allowLocalhost': true, }), }, }); From 17b7e2b40007c41f085acc0cf987dfe98cc82ac7 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:05:38 -0700 Subject: [PATCH 07/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts --- .../endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts index 05ff4e7a34..0acc1c99e1 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts @@ -24,6 +24,7 @@ describe("OAuth with exact domain matching", () => { baseUrl: 'http://localhost:8107', handlerPath: '/handler', }, + 'domains.allowLocalhost': false, }), }, }); From 3ed23dacb239bc8c083026c04799dfb2c116b0b0 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:06:03 -0700 Subject: [PATCH 08/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts --- .../endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts index 0acc1c99e1..160b8bc511 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts @@ -230,7 +230,7 @@ describe("OAuth with exact domain matching", () => { baseUrl: 'http://localhost:8107', // This one matches! handlerPath: '/handler', }, - 'domains.allowLocalhost': true, + 'domains.allowLocalhost': false, }), }, }); From a56ae736039b772785aa55b962cc5a1b3c2b1ed7 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:07:04 -0700 Subject: [PATCH 09/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts --- .../endpoints/api/v1/auth/oauth/wildcard-domains.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts index a3bc418778..1cb080d0b5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -89,7 +89,7 @@ describe("OAuth with wildcard domains", () => { baseUrl: 'http://*.localhost:8107', handlerPath: '/handler', }, - 'domains.allowLocalhost': true, + 'domains.allowLocalhost': false, }), }, }); From d22367f34632de8a8c0eaa474252c533ed34fe6e Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:12:13 -0700 Subject: [PATCH 10/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts --- .../endpoints/api/v1/auth/oauth/wildcard-domains.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts index 1cb080d0b5..dbc89899dd 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -219,7 +219,7 @@ describe("OAuth with wildcard domains", () => { baseUrl: 'http://local*:8107', // Should match localhost handlerPath: '/handler', }, - 'domains.allowLocalhost': true, + 'domains.allowLocalhost': false, }), }, }); From 0e4bb559a061c6bb81a236880b37d3cbce8fc41b Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:12:26 -0700 Subject: [PATCH 11/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts --- .../endpoints/api/v1/auth/oauth/wildcard-domains.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts index dbc89899dd..9a68b5fe0c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -154,7 +154,7 @@ describe("OAuth with wildcard domains", () => { baseUrl: 'http://**.localhost:8107', handlerPath: '/handler', }, - 'domains.allowLocalhost': true, + 'domains.allowLocalhost': false, }), }, }); From 120dfc163fd271f7c134b90f8e659df69f7d76d0 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:12:51 -0700 Subject: [PATCH 12/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../endpoints/api/v1/auth/oauth/wildcard-domains.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts index 9a68b5fe0c..e1fe8cf983 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -269,7 +269,8 @@ describe("OAuth with wildcard domains", () => { oauth_providers: [{ id: "spotify", type: "shared" }], } }); - +}); + await InternalApiKey.createAndSetProjectKeys(); // Configure multiple domains, only one matches const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { method: "PATCH", From da6d04e3a256fa29a3ef6210c9b96de15cc01751 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 13 Aug 2025 18:13:07 -0700 Subject: [PATCH 13/20] Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts --- .../endpoints/api/v1/auth/oauth/wildcard-domains.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts index e1fe8cf983..d7e28e8abd 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -292,7 +292,7 @@ describe("OAuth with wildcard domains", () => { baseUrl: 'http://localhost:8107', // This one matches! handlerPath: '/handler', }, - 'domains.allowLocalhost': true, + 'domains.allowLocalhost': false, }), }, }); From 3eb48f15507461bae9378a1d50fb8d72f9951686 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 13 Aug 2025 18:20:03 -0700 Subject: [PATCH 14/20] fix --- CLAUDE.md | 3 + apps/backend/src/lib/redirect-urls.test.tsx | 62 +++++++++++++-------- apps/backend/src/lib/redirect-urls.tsx | 21 ++----- 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 89e16f0ffc..743c189775 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,3 +75,6 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass. - When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled. - Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the ./claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). + +### Code-related +- Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index efa71819eb..4e37b7cc06 100644 --- a/apps/backend/src/lib/redirect-urls.test.tsx +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { validateRedirectUrl } from './redirect-urls'; import { Tenancy } from './tenancies'; @@ -29,8 +29,10 @@ describe('validateRedirectUrl', () => { expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); expect(validateRedirectUrl('https://example.com/handler/callback', tenancy)).toBe(true); - expect(validateRedirectUrl('https://example.com/other', tenancy)).toBe(false); - expect(validateRedirectUrl('https://other.com/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://example.com/other', tenancy)).toBe(true); // Any path on trusted domain is valid + expect(validateRedirectUrl('https://example.com/', tenancy)).toBe(true); // Root path is also valid + expect(validateRedirectUrl('https://other.com/handler', tenancy)).toBe(false); // Different domain is not trusted + expect(validateRedirectUrl('https://example.com.other.com/handler', tenancy)).toBe(false); // Similar different domain is also not trusted }); it('should validate protocol matching', () => { @@ -44,7 +46,8 @@ describe('validateRedirectUrl', () => { }); expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('http://example.com/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://example.com/any/path', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('http://example.com/handler', tenancy)).toBe(false); // Wrong protocol }); }); @@ -60,10 +63,11 @@ describe('validateRedirectUrl', () => { }); expect(validateRedirectUrl('https://api.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://www.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://staging.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(false); - expect(validateRedirectUrl('https://api.v2.example.com/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api.example.com/any/path', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://www.example.com/', tenancy)).toBe(true); // Root path is valid + expect(validateRedirectUrl('https://staging.example.com/other', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(false); // Not a subdomain + expect(validateRedirectUrl('https://api.v2.example.com/handler', tenancy)).toBe(false); // Too many subdomains for single * }); it('should validate double wildcard patterns', () => { @@ -77,9 +81,10 @@ describe('validateRedirectUrl', () => { }); expect(validateRedirectUrl('https://api.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://api.v2.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://a.b.c.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api.example.com/', tenancy)).toBe(true); // Root path is valid + expect(validateRedirectUrl('https://api.v2.example.com/other/path', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://a.b.c.example.com/deep/nested/path', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(false); // Not a subdomain }); it('should validate wildcard patterns with prefixes', () => { @@ -93,10 +98,10 @@ describe('validateRedirectUrl', () => { }); expect(validateRedirectUrl('https://api-v1.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://api-v2.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://api-prod.example.com/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://api.example.com/handler', tenancy)).toBe(false); - expect(validateRedirectUrl('https://v1-api.example.com/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api-v2.example.com/any/path', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://api-prod.example.com/', tenancy)).toBe(true); // Root path is valid + expect(validateRedirectUrl('https://api.example.com/handler', tenancy)).toBe(false); // Missing prefix + expect(validateRedirectUrl('https://v1-api.example.com/handler', tenancy)).toBe(false); // Wrong prefix position }); it('should validate multiple wildcard patterns', () => { @@ -110,9 +115,10 @@ describe('validateRedirectUrl', () => { }); expect(validateRedirectUrl('https://mail.example.org/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://api.company.org/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('https://example.org/handler', tenancy)).toBe(false); - expect(validateRedirectUrl('https://a.b.c.org/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://mail.example.org/any/path', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://api.company.org/', tenancy)).toBe(true); // Root path is valid + expect(validateRedirectUrl('https://example.org/handler', tenancy)).toBe(false); // Not enough subdomain levels + expect(validateRedirectUrl('https://a.b.c.org/handler', tenancy)).toBe(false); // Too many subdomain levels }); }); @@ -145,7 +151,7 @@ describe('validateRedirectUrl', () => { }); describe('path validation', () => { - it('should validate handler path matching', () => { + it('should allow any path on trusted domains (handlerPath is only a default)', () => { const tenancy = createMockTenancy({ domains: { allowLocalhost: false, @@ -155,13 +161,15 @@ describe('validateRedirectUrl', () => { }, }); + // All paths on the trusted domain should be valid expect(validateRedirectUrl('https://example.com/auth/handler', tenancy)).toBe(true); expect(validateRedirectUrl('https://example.com/auth/handler/callback', tenancy)).toBe(true); - expect(validateRedirectUrl('https://example.com/auth', tenancy)).toBe(false); - expect(validateRedirectUrl('https://example.com/other/handler', tenancy)).toBe(false); + expect(validateRedirectUrl('https://example.com/auth', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://example.com/other/handler', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://example.com/', tenancy)).toBe(true); // Root is valid }); - it('should work with wildcard domains and path validation', () => { + it('should work with wildcard domains (any path is valid)', () => { const tenancy = createMockTenancy({ domains: { allowLocalhost: false, @@ -171,10 +179,12 @@ describe('validateRedirectUrl', () => { }, }); + // All paths on matched domains should be valid expect(validateRedirectUrl('https://api.example.com/api/auth', tenancy)).toBe(true); expect(validateRedirectUrl('https://app.example.com/api/auth/callback', tenancy)).toBe(true); - expect(validateRedirectUrl('https://api.example.com/api', tenancy)).toBe(false); - expect(validateRedirectUrl('https://api.example.com/other/auth', tenancy)).toBe(false); + expect(validateRedirectUrl('https://api.example.com/api', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://api.example.com/other/auth', tenancy)).toBe(true); // Any path is valid + expect(validateRedirectUrl('https://api.example.com/', tenancy)).toBe(true); // Root is valid }); }); @@ -453,9 +463,13 @@ describe('validateRedirectUrl', () => { }, }); + // Any path on trusted domains should be valid expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('https://example.com/any/path', tenancy)).toBe(true); expect(validateRedirectUrl('https://api.staging.com/auth', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.staging.com/different/path', tenancy)).toBe(true); expect(validateRedirectUrl('https://api.v2.production.com/callback', tenancy)).toBe(true); + expect(validateRedirectUrl('https://api.v2.production.com/', tenancy)).toBe(true); expect(validateRedirectUrl('https://other.com/handler', tenancy)).toBe(false); }); }); diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index 27a6696939..a7d486c074 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -30,7 +30,7 @@ function portsMatch(url1: URL, url2: URL): boolean { /** * Validates a URL against a domain pattern (with or without wildcards) */ -function matchesDomain(testUrl: URL, pattern: string, handlerPath: string): boolean { +function matchesDomain(testUrl: URL, pattern: string): boolean { const baseUrl = createUrlIfValid(pattern); // If pattern is invalid as a URL, it might contain wildcards @@ -42,7 +42,7 @@ function matchesDomain(testUrl: URL, pattern: string, handlerPath: string): bool return false; } - const [, protocol, hostPattern, basePath] = match; + const [, protocol, hostPattern] = match; // Check protocol if (testUrl.protocol + '//' !== protocol) { @@ -53,26 +53,17 @@ function matchesDomain(testUrl: URL, pattern: string, handlerPath: string): bool const hasPortInPattern = hostPattern.includes(':'); if (hasPortInPattern) { // Pattern includes port - match against normalized host:port - if (!matchHostnamePattern(hostPattern, normalizePort(testUrl))) { - return false; - } + return matchHostnamePattern(hostPattern, normalizePort(testUrl)); } else { // Pattern doesn't include port - match hostname only, require default port - if (!matchHostnamePattern(hostPattern, testUrl.hostname) || !isDefaultPort(testUrl)) { - return false; - } + return matchHostnamePattern(hostPattern, testUrl.hostname) && isDefaultPort(testUrl); } - - // Check path - const fullPath = basePath === '/' ? handlerPath : basePath + handlerPath; - return testUrl.pathname.startsWith(fullPath || '/'); } // For non-wildcard patterns, use URL comparison return baseUrl.protocol === testUrl.protocol && baseUrl.hostname === testUrl.hostname && - portsMatch(baseUrl, testUrl) && - testUrl.pathname.startsWith(handlerPath || '/'); + portsMatch(baseUrl, testUrl); } export function validateRedirectUrl( @@ -89,6 +80,6 @@ export function validateRedirectUrl( // Check trusted domains return Object.values(tenancy.config.domains.trustedDomains).some(domain => - domain.baseUrl && matchesDomain(url, domain.baseUrl, domain.handlerPath || '/') + domain.baseUrl && matchesDomain(url, domain.baseUrl) ); } From 414536c1c1b13ff696ec37b96298a02276d4720c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 01:26:00 +0000 Subject: [PATCH 15/20] Fix test issues: replace JSON utils, remove unused imports, fix config - Replace JSON.parse/JSON.stringify with parseJson/stringifyJson utilities - Remove unused localRedirectUrl imports from OAuth test files - Remove debug console.log statement in passkey test - Fix syntax error in OAuth wildcard test (missing closing brace) - Set proper allowLocalhost configuration values - Add proper imports for JSON utilities Co-authored-by: Konsti Wohlwend --- .../auth/oauth/exact-domain-matching.test.ts | 19 +++++++------- .../v1/auth/oauth/wildcard-domains.test.ts | 26 +++++++++---------- .../v1/auth/passkey/wildcard-domains.test.ts | 22 +++++++--------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts index 160b8bc511..810e9a2d5c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts @@ -1,6 +1,7 @@ import { describe } from "vitest"; -import { it, localRedirectUrl } from "../../../../../../helpers"; +import { it } from "../../../../../../helpers"; import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; +import { stringifyJson } from "@stackframe/stack-shared/dist/utils/json"; describe("OAuth with exact domain matching", () => { it("should allow OAuth with exact matching domain", async ({ expect }) => { @@ -19,7 +20,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.exact': { baseUrl: 'http://localhost:8107', handlerPath: '/handler', @@ -52,7 +53,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.production': { baseUrl: 'https://app.production.com', handlerPath: '/auth/handler', @@ -85,7 +86,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.subdomain': { baseUrl: 'https://app.example.com', handlerPath: '/handler', @@ -118,7 +119,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.withport': { baseUrl: 'http://localhost:3000', handlerPath: '/handler', @@ -151,7 +152,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.https': { baseUrl: 'https://localhost:8107', handlerPath: '/handler', @@ -184,7 +185,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.withpath': { baseUrl: 'http://localhost:8107', handlerPath: '/auth/oauth/callback', // Different path than default /handler @@ -217,7 +218,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.prod': { baseUrl: 'https://app.production.com', handlerPath: '/handler', @@ -257,7 +258,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.prod': { baseUrl: 'https://app.production.com', handlerPath: '/handler', diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts index d7e28e8abd..7ef22578cb 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -1,6 +1,7 @@ import { describe } from "vitest"; -import { it, localRedirectUrl } from "../../../../../../helpers"; +import { it } from "../../../../../../helpers"; import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; +import { parseJson, stringifyJson } from "@stackframe/stack-shared/dist/utils/json"; describe("OAuth with wildcard domains", () => { it("should work with exact domain configuration", async ({ expect }) => { @@ -19,12 +20,12 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.exact': { baseUrl: 'http://localhost:8107', handlerPath: '/handler', }, - 'domains.allowLocalhost': true, + 'domains.allowLocalhost': false, }), }, }); @@ -51,7 +52,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.exact': { baseUrl: 'https://app.example.com', handlerPath: '/handler', @@ -84,7 +85,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.wildcard': { baseUrl: 'http://*.localhost:8107', handlerPath: '/handler', @@ -116,7 +117,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.wildcard': { baseUrl: 'https://*.example.com', handlerPath: '/handler', @@ -149,7 +150,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.double': { baseUrl: 'http://**.localhost:8107', handlerPath: '/handler', @@ -181,7 +182,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.double': { baseUrl: 'https://**.example.org', // Different TLD - won't match localhost handlerPath: '/handler', @@ -214,7 +215,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.prefix': { baseUrl: 'http://local*:8107', // Should match localhost handlerPath: '/handler', @@ -246,7 +247,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.prefix': { baseUrl: 'http://api-*:8107', // Won't match localhost handlerPath: '/handler', @@ -269,7 +270,6 @@ describe("OAuth with wildcard domains", () => { oauth_providers: [{ id: "spotify", type: "shared" }], } }); -}); await InternalApiKey.createAndSetProjectKeys(); // Configure multiple domains, only one matches const configResponse = await niceBackendFetch("/api/v1/internal/config/override", { @@ -279,7 +279,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.prod': { baseUrl: 'https://app.production.com', handlerPath: '/handler', @@ -308,7 +308,7 @@ describe("OAuth with wildcard domains", () => { }); expect(getResponse.status).toBe(200); - const config = JSON.parse(getResponse.body.config_string); + const config = parseJson(getResponse.body.config_string); expect(Object.keys(config.domains.trustedDomains).length).toBe(3); }); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts index 75932f2c79..e26907ab6b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts @@ -1,6 +1,7 @@ import { describe } from "vitest"; import { it } from "../../../../../../helpers"; import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; +import { parseJson, stringifyJson } from "@stackframe/stack-shared/dist/utils/json"; describe("Passkey with wildcard domains", () => { it("should store wildcard domains in config correctly", async ({ expect }) => { @@ -18,7 +19,7 @@ describe("Passkey with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.exact': { baseUrl: 'https://app.example.com', handlerPath: '/handler', @@ -54,7 +55,7 @@ describe("Passkey with wildcard domains", () => { }); expect(getResponse.status).toBe(200); - const config = JSON.parse(getResponse.body.config_string); + const config = parseJson(getResponse.body.config_string); expect(config.domains.trustedDomains).toMatchObject({ 'exact': { baseUrl: 'https://app.example.com', @@ -96,7 +97,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.wildcard': { baseUrl: 'http://*:8103', // Will match http://localhost:8103 and any host on port 8103 handlerPath: '/', @@ -149,9 +150,6 @@ describe("Passkey with wildcard domains", () => { }, }); - if (registerResponse.status !== 200) { - console.log("Register failed with:", registerResponse.body); - } expect(registerResponse.status).toBe(200); expect(registerResponse.body).toHaveProperty("user_handle"); }); @@ -175,7 +173,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.double': { baseUrl: 'http://**host:8103', // Will match localhost:8103 handlerPath: '/', @@ -237,7 +235,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.exact': { baseUrl: 'https://app.production.com', handlerPath: '/handler', @@ -315,7 +313,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.wildcard': { baseUrl: 'https://*.example.com', handlerPath: '/handler', @@ -387,7 +385,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.wildcard': { baseUrl: 'http://*:8103', // Will match localhost:8103 handlerPath: '/', @@ -448,7 +446,7 @@ describe("Passkey with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: JSON.stringify({ + config_override_string: stringifyJson({ 'domains.trustedDomains.complex1': { baseUrl: 'https://api-*.*.example.com', handlerPath: '/handler', @@ -476,7 +474,7 @@ describe("Passkey with wildcard domains", () => { }); expect(getResponse.status).toBe(200); - const config = JSON.parse(getResponse.body.config_string); + const config = parseJson(getResponse.body.config_string); expect(config.domains.trustedDomains.complex1.baseUrl).toBe('https://api-*.*.example.com'); expect(config.domains.trustedDomains.complex2.baseUrl).toBe('https://**.api.example.com'); expect(config.domains.trustedDomains.complex3.baseUrl).toBe('https://*-staging.example.com'); From 29e9c2b20f1ac64712362ac58e71a848474d374c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:14:24 +0000 Subject: [PATCH 16/20] Fix path concatenation vulnerability in user page Prevents double slashes when handlerPath is '/' and endpointPath starts with '/' by using proper path joining logic: handlerPath.replace(/\/?$/, '/') + endpointPath.replace(/^\//, '') Co-authored-by: Konsti Wohlwend --- .../projects/[projectId]/users/[userId]/page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9742a5b68c..b2feeff8bd 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 @@ -572,7 +572,7 @@ function SendEmailWithDomainDialog({ baseUrl = domain.domain; handlerPath = domain.handlerPath; } - const callbackUrl = new URL(handlerPath + endpointPath, baseUrl).toString(); + const callbackUrl = new URL(handlerPath.replace(/\/?$/, '/') + endpointPath.replace(/^\//, ''), baseUrl).toString(); await onSubmit(callbackUrl); }} /> From 713591cd30c3632374dcba8c095ce1a511a19044 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:42:45 +0000 Subject: [PATCH 17/20] Fix wildcard domains test failures by adding wildcard URL validation support - Add wildcardUrlSchema to schema-fields.ts that validates both regular URLs and wildcard URL patterns - Update config schema to use wildcardUrlSchema for baseUrl field in trustedDomains - The new schema handles wildcards by replacing them with placeholders for URL parsing, then validates the hostname pattern using existing isValidHostnameWithWildcards function - This fixes the 25 failing tests in passkey wildcard-domains.test.ts that were getting 400 status codes due to config validation rejecting wildcard URLs like "http://*:8103" --- packages/stack-shared/src/config/schema.ts | 2 +- packages/stack-shared/src/schema-fields.ts | 35 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index f60c713ec6..d0c27be299 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -213,7 +213,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ trustedDomains: yupRecord( userSpecifiedIdSchema("trustedDomainId"), yupObject({ - baseUrl: schemaFields.urlSchema, + baseUrl: schemaFields.wildcardUrlSchema, handlerPath: schemaFields.handlerPathSchema, }), ), diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 6b3a8b6d8b..34a4e5aad3 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -340,6 +340,41 @@ export const urlSchema = yupString().test({ message: (params) => `${params.path} is not a valid URL`, test: (value) => value == null || isValidUrl(value) }); +/** + * URL schema that supports wildcard patterns in hostnames (e.g., "https://*.example.com", "http://*:8080") + */ +export const wildcardUrlSchema = yupString().test({ + name: 'no-spaces', + message: (params) => `${params.path} contains spaces`, + test: (value) => value == null || !value.includes(' ') +}).test({ + name: 'wildcard-url', + message: (params) => `${params.path} is not a valid URL or wildcard URL pattern`, + test: (value) => { + if (value == null) return true; + + // If it doesn't contain wildcards, use the regular URL validation + if (!value.includes('*')) { + return isValidUrl(value); + } + + // For wildcard URLs, validate the structure by replacing wildcards with placeholders + try { + // Replace wildcards with valid placeholders for URL parsing + const normalizedUrl = value.replace(/\*+/g, 'wildcard-placeholder'); + const url = new URL(normalizedUrl); + + // Extract original hostname pattern from the input + const hostPattern = url.hostname.replace(/wildcard-placeholder/g, '*'); + + // Validate the wildcard hostname pattern using the existing function + const { isValidHostnameWithWildcards } = require('./utils/urls'); + return isValidHostnameWithWildcards(hostPattern); + } catch (e) { + return false; + } + } +}); export const jsonSchema = yupMixed().nullable().defined().transform((value) => JSON.parse(JSON.stringify(value))); export const jsonStringSchema = yupString().test("json", (params) => `${params.path} is not valid JSON`, (value) => { if (value == null) return true; From 3961fa6fa07552a6c87e76021dadf0794161791e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 05:11:39 +0000 Subject: [PATCH 18/20] Fix ESM import and improve wildcard URL validation - Replace CommonJS require() with ESM import for isValidHostnameWithWildcards - Reject wildcards outside hostname (username, password, pathname, search, hash) - Only allow http/https protocols for wildcard URLs - Use more unique placeholder to avoid collisions Co-authored-by: Konsti Wohlwend --- packages/stack-shared/src/schema-fields.ts | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 34a4e5aad3..f0d38d2e6f 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -8,7 +8,7 @@ import { decodeBasicAuthorizationHeader } from "./utils/http"; import { allProviders } from "./utils/oauth"; import { deepPlainClone, omit, typedFromEntries } from "./utils/objects"; import { deindent } from "./utils/strings"; -import { isValidUrl } from "./utils/urls"; +import { isValidUrl, isValidHostnameWithWildcards } from "./utils/urls"; import { isUuid } from "./utils/uuids"; const MAX_IMAGE_SIZE_BASE64_BYTES = 1_000_000; // 1MB @@ -360,15 +360,31 @@ export const wildcardUrlSchema = yupString().test({ // For wildcard URLs, validate the structure by replacing wildcards with placeholders try { + const PLACEHOLDER = 'wildcard-placeholder'; // Replace wildcards with valid placeholders for URL parsing - const normalizedUrl = value.replace(/\*+/g, 'wildcard-placeholder'); + const normalizedUrl = value.replace(/\*/g, PLACEHOLDER); const url = new URL(normalizedUrl); + + // Only allow wildcards in the hostname; reject anywhere else + if ( + url.username.includes(PLACEHOLDER) || + url.password.includes(PLACEHOLDER) || + url.pathname.includes(PLACEHOLDER) || + url.search.includes(PLACEHOLDER) || + url.hash.includes(PLACEHOLDER) + ) { + return false; + } + + // Only http/https are acceptable + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return false; + } // Extract original hostname pattern from the input - const hostPattern = url.hostname.replace(/wildcard-placeholder/g, '*'); + const hostPattern = url.hostname.split(PLACEHOLDER).join('*'); // Validate the wildcard hostname pattern using the existing function - const { isValidHostnameWithWildcards } = require('./utils/urls'); return isValidHostnameWithWildcards(hostPattern); } catch (e) { return false; From fa81c3c31dc04eb80510baed97a618fbfc65693c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 19 Aug 2025 22:32:40 -0700 Subject: [PATCH 19/20] fixes --- .github/recurseml-rules/code_patterns.mdc | 1 - apps/dashboard/src/lib/utils.tsx | 3 +-- .../auth/oauth/exact-domain-matching.test.ts | 17 +++++++-------- .../v1/auth/oauth/wildcard-domains.test.ts | 21 +++++++++---------- .../v1/auth/passkey/wildcard-domains.test.ts | 19 ++++++++--------- 5 files changed, 28 insertions(+), 33 deletions(-) diff --git a/.github/recurseml-rules/code_patterns.mdc b/.github/recurseml-rules/code_patterns.mdc index e09a8e1ee3..87f9e65591 100644 --- a/.github/recurseml-rules/code_patterns.mdc +++ b/.github/recurseml-rules/code_patterns.mdc @@ -8,7 +8,6 @@ The following conventions MUST be followed in new code. DON'T report code patterns outside of the examples explicitly listed below: - Never use `void asyncFunction()` or `asyncFunction().catch(console.error)` - use `runAsynchronously(asyncFunction)` instead -- Use `parseJson`/`stringifyJson` from `stack-shared/utils/json` instead of `JSON.parse`/`JSON.stringify` - Instead of Vercel `waitUntil`, use `runAsynchronously(promise, { promiseCallback: waitUntil })` - Don't concatenate URLs as strings - avoid patterns like `/users/${userId}` - Replace non-null assertions with `?? throwErr("message", { extraData })` pattern diff --git a/apps/dashboard/src/lib/utils.tsx b/apps/dashboard/src/lib/utils.tsx index a6c50b572a..943d02e6b2 100644 --- a/apps/dashboard/src/lib/utils.tsx +++ b/apps/dashboard/src/lib/utils.tsx @@ -1,5 +1,4 @@ import { getPublicEnvVar } from "@/lib/env"; -import { parseJson } from "@stackframe/stack-shared/dist/utils/json"; import { clsx, type ClassValue } from "clsx"; import { redirect } from "next/navigation"; import { twMerge } from "tailwind-merge"; @@ -21,7 +20,7 @@ export function devFeaturesEnabledForProject(projectId: string) { if (projectId === "internal") { return true; } - const allowedProjectIds = parseJson(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]"); + const allowedProjectIds = JSON.parse(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]"); if (allowedProjectIds.status !== "ok" || !Array.isArray(allowedProjectIds.data)) { return false; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts index 810e9a2d5c..18c2739c49 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts @@ -1,7 +1,6 @@ import { describe } from "vitest"; import { it } from "../../../../../../helpers"; import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; -import { stringifyJson } from "@stackframe/stack-shared/dist/utils/json"; describe("OAuth with exact domain matching", () => { it("should allow OAuth with exact matching domain", async ({ expect }) => { @@ -20,7 +19,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.exact': { baseUrl: 'http://localhost:8107', handlerPath: '/handler', @@ -53,7 +52,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.production': { baseUrl: 'https://app.production.com', handlerPath: '/auth/handler', @@ -86,7 +85,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.subdomain': { baseUrl: 'https://app.example.com', handlerPath: '/handler', @@ -119,7 +118,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.withport': { baseUrl: 'http://localhost:3000', handlerPath: '/handler', @@ -152,7 +151,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.https': { baseUrl: 'https://localhost:8107', handlerPath: '/handler', @@ -185,7 +184,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.withpath': { baseUrl: 'http://localhost:8107', handlerPath: '/auth/oauth/callback', // Different path than default /handler @@ -218,7 +217,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.prod': { baseUrl: 'https://app.production.com', handlerPath: '/handler', @@ -258,7 +257,7 @@ describe("OAuth with exact domain matching", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.prod': { baseUrl: 'https://app.production.com', handlerPath: '/handler', diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts index 7ef22578cb..d1b8385cd5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-domains.test.ts @@ -1,7 +1,6 @@ import { describe } from "vitest"; import { it } from "../../../../../../helpers"; import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; -import { parseJson, stringifyJson } from "@stackframe/stack-shared/dist/utils/json"; describe("OAuth with wildcard domains", () => { it("should work with exact domain configuration", async ({ expect }) => { @@ -20,7 +19,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.exact': { baseUrl: 'http://localhost:8107', handlerPath: '/handler', @@ -52,7 +51,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.exact': { baseUrl: 'https://app.example.com', handlerPath: '/handler', @@ -85,7 +84,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.wildcard': { baseUrl: 'http://*.localhost:8107', handlerPath: '/handler', @@ -117,7 +116,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.wildcard': { baseUrl: 'https://*.example.com', handlerPath: '/handler', @@ -150,7 +149,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.double': { baseUrl: 'http://**.localhost:8107', handlerPath: '/handler', @@ -182,7 +181,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.double': { baseUrl: 'https://**.example.org', // Different TLD - won't match localhost handlerPath: '/handler', @@ -215,7 +214,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.prefix': { baseUrl: 'http://local*:8107', // Should match localhost handlerPath: '/handler', @@ -247,7 +246,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.prefix': { baseUrl: 'http://api-*:8107', // Won't match localhost handlerPath: '/handler', @@ -279,7 +278,7 @@ describe("OAuth with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.prod': { baseUrl: 'https://app.production.com', handlerPath: '/handler', @@ -308,7 +307,7 @@ describe("OAuth with wildcard domains", () => { }); expect(getResponse.status).toBe(200); - const config = parseJson(getResponse.body.config_string); + const config = JSON.parse(getResponse.body.config_string); expect(Object.keys(config.domains.trustedDomains).length).toBe(3); }); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts index e26907ab6b..67e31a38b9 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/passkey/wildcard-domains.test.ts @@ -1,7 +1,6 @@ import { describe } from "vitest"; import { it } from "../../../../../../helpers"; import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; -import { parseJson, stringifyJson } from "@stackframe/stack-shared/dist/utils/json"; describe("Passkey with wildcard domains", () => { it("should store wildcard domains in config correctly", async ({ expect }) => { @@ -19,7 +18,7 @@ describe("Passkey with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.exact': { baseUrl: 'https://app.example.com', handlerPath: '/handler', @@ -55,7 +54,7 @@ describe("Passkey with wildcard domains", () => { }); expect(getResponse.status).toBe(200); - const config = parseJson(getResponse.body.config_string); + const config = JSON.parse(getResponse.body.config_string); expect(config.domains.trustedDomains).toMatchObject({ 'exact': { baseUrl: 'https://app.example.com', @@ -97,7 +96,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.wildcard': { baseUrl: 'http://*:8103', // Will match http://localhost:8103 and any host on port 8103 handlerPath: '/', @@ -173,7 +172,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.double': { baseUrl: 'http://**host:8103', // Will match localhost:8103 handlerPath: '/', @@ -235,7 +234,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.exact': { baseUrl: 'https://app.production.com', handlerPath: '/handler', @@ -313,7 +312,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.wildcard': { baseUrl: 'https://*.example.com', handlerPath: '/handler', @@ -385,7 +384,7 @@ describe("Passkey with wildcard domains", () => { method: "PATCH", accessType: "admin", body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.wildcard': { baseUrl: 'http://*:8103', // Will match localhost:8103 handlerPath: '/', @@ -446,7 +445,7 @@ describe("Passkey with wildcard domains", () => { 'x-stack-admin-access-token': adminAccessToken, }, body: { - config_override_string: stringifyJson({ + config_override_string: JSON.stringify({ 'domains.trustedDomains.complex1': { baseUrl: 'https://api-*.*.example.com', handlerPath: '/handler', @@ -474,7 +473,7 @@ describe("Passkey with wildcard domains", () => { }); expect(getResponse.status).toBe(200); - const config = parseJson(getResponse.body.config_string); + const config = JSON.parse(getResponse.body.config_string); expect(config.domains.trustedDomains.complex1.baseUrl).toBe('https://api-*.*.example.com'); expect(config.domains.trustedDomains.complex2.baseUrl).toBe('https://**.api.example.com'); expect(config.domains.trustedDomains.complex3.baseUrl).toBe('https://*-staging.example.com'); From 2d93d257d3c9a4986141c0eb5b429075f2041662 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 19 Aug 2025 22:48:50 -0700 Subject: [PATCH 20/20] fix tests (?) --- .../auth/oauth/exact-domain-matching.test.ts | 103 ++++++++++++++---- .../v1/auth/oauth/wildcard-domains.test.ts | 84 +++++++++++--- packages/stack-shared/src/schema-fields.ts | 8 +- 3 files changed, 151 insertions(+), 44 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts index 18c2739c49..91e5125d95 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domain-matching.test.ts @@ -21,8 +21,8 @@ describe("OAuth with exact domain matching", () => { body: { config_override_string: JSON.stringify({ 'domains.trustedDomains.exact': { - baseUrl: 'http://localhost:8107', - handlerPath: '/handler', + baseUrl: 'http://stack-test.localhost', + handlerPath: '/some-callback-url', }, 'domains.allowLocalhost': false, }), @@ -65,8 +65,20 @@ describe("OAuth with exact domain matching", () => { // Try to complete the OAuth flow - it should fail at the callback stage const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(); - expect(response.status).toBe(400); - expect(response.body).toBe("Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "REDIRECT_URL_NOT_WHITELISTED", + "error": "Redirect URL not whitelisted. Did you forget to add this domain to the trusted domains list on the Stack Auth dashboard?", + }, + "headers": Headers { + "set-cookie": ' at path '/'>, + "x-stack-known-error": "REDIRECT_URL_NOT_WHITELISTED", +