Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dd48f4f
Wildcard domains
N2D4 Aug 6, 2025
0ae980b
fix
N2D4 Aug 6, 2025
bab1c28
Merge branch 'dev' into wildcard-domains
N2D4 Aug 6, 2025
753e870
Merge dev into wildcard-domains
N2D4 Aug 7, 2025
c340f25
Merge dev into wildcard-domains
N2D4 Aug 8, 2025
f41243a
Merge dev into wildcard-domains
N2D4 Aug 9, 2025
b697f44
Merge dev into wildcard-domains
N2D4 Aug 12, 2025
8524a8a
Update apps/backend/src/app/api/latest/auth/oauth/callback/[provider_…
N2D4 Aug 14, 2025
31def15
Merge branch 'dev' into wildcard-domains
N2D4 Aug 14, 2025
05f5e44
move CLAUDE-KNOWLEDGE
N2D4 Aug 14, 2025
ee9be7d
refactor: use Map for defaultPorts to avoid prototype pollution
claude[bot] Aug 14, 2025
b9a0b09
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domai…
N2D4 Aug 14, 2025
17b7e2b
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domai…
N2D4 Aug 14, 2025
3ed23da
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/exact-domai…
N2D4 Aug 14, 2025
a56ae73
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-do…
N2D4 Aug 14, 2025
d22367f
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-do…
N2D4 Aug 14, 2025
0e4bb55
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-do…
N2D4 Aug 14, 2025
120dfc1
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-do…
N2D4 Aug 14, 2025
da6d04e
Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/wildcard-do…
N2D4 Aug 14, 2025
3eb48f1
fix
N2D4 Aug 14, 2025
414536c
Fix test issues: replace JSON utils, remove unused imports, fix config
claude[bot] Aug 14, 2025
cd46fb2
Merge dev into wildcard-domains
N2D4 Aug 15, 2025
b55e385
Merge dev into wildcard-domains
N2D4 Aug 19, 2025
e0e8a3a
Merge branch 'dev' into wildcard-domains
N2D4 Aug 20, 2025
29e9c2b
Fix path concatenation vulnerability in user page
claude[bot] Aug 20, 2025
713591c
Fix wildcard domains test failures by adding wildcard URL validation …
claude[bot] Aug 20, 2025
89930e8
Merge branch 'dev' into wildcard-domains
N2D4 Aug 20, 2025
3961fa6
Fix ESM import and improve wildcard URL validation
claude[bot] Aug 20, 2025
fa81c3c
fixes
N2D4 Aug 20, 2025
2d93d25
fix tests (?)
N2D4 Aug 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .claude/CLAUDE-KNOWLEDGE.md
Original file line number Diff line number Diff line change
@@ -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'), '.*');
```
Comment on lines +18 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Escape literal dots and anchor the regex to avoid over-matching

As written, the dots in hostnames will act as “any character” in regex and may over-match. Also, add start/end anchors. Consider escaping the pattern before wildcard replacement.

Apply this diff to the snippet to properly escape, anchor, and handle both single and double wildcards:

-const doubleWildcardPlaceholder = '\x00DOUBLE_WILDCARD\x00';
-regexPattern = regexPattern.replace(/\*\*/g, doubleWildcardPlaceholder);
-regexPattern = regexPattern.replace(/\*/g, '[^.]*');
-regexPattern = regexPattern.replace(new RegExp(doubleWildcardPlaceholder, 'g'), '.*');
+const doubleWildcardPlaceholder = '\x00DOUBLE_WILDCARD\x00';
+// Escape regex special chars first (especially dots), then restore placeholders
+const escapeRegex = (s: string) => s.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
+let regexPattern = escapeRegex(hostnamePattern);
+regexPattern = regexPattern.replace(/\*\*/g, doubleWildcardPlaceholder);
+regexPattern = regexPattern.replace(/\*/g, '[^.]*');      // * => one label
+regexPattern = regexPattern.replace(new RegExp(doubleWildcardPlaceholder, 'g'), '.*'); // ** => any depth
+regexPattern = `^${regexPattern}$`;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In .claude/CLAUDE-KNOWLEDGE.md around lines 18 to 25, the wildcard-to-regex
snippet fails to escape literal dots and other regex metacharacters and is not
anchored, causing over-matching; fix it by first escaping the input pattern's
regex metacharacters (e.g., replace . ? + ^ $ { } ( ) | [ ] \ with escaped
versions), then use a double-wildcard placeholder: replace all '**' with the
placeholder, replace remaining '*' with '[^.]*', restore the placeholder to
'.*', and finally wrap the resulting regex with start (^) and end ($) anchors so
it matches the full hostname.


### 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);
```
Comment on lines +30 to +38
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Make the manual hostname extraction robust (no scheme or path edge cases)

The snippet assumes a scheme is present; protocolEnd can be -1, leading to surprising substrings. Also handle empty paths and trim whitespace.

-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);
+const raw = domain.baseUrl.trim();
+const schemeSep = raw.indexOf('://');
+const afterProtocol = schemeSep === -1 ? raw : raw.slice(schemeSep + 3);
+const slashIdx = afterProtocol.indexOf('/');
+const authority = slashIdx === -1 ? afterProtocol : afterProtocol.slice(0, slashIdx);
+// Strip optional port for hostname-only matching
+const portIdx = authority.lastIndexOf(':');
+const hostnamePattern = portIdx > -1 ? authority.slice(0, portIdx) : authority;


## 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 <noreply@anthropic.com>
EOF
)"
```

### Q: What commands should you run before considering a task complete?
A: Always run:
1. `pnpm test run <relevant-test-files>` - 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.
30 changes: 30 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
N2D4 marked this conversation as resolved.
}
]
}
]
}
}
1 change: 0 additions & 1 deletion .github/recurseml-rules/code_patterns.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 17 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file-filters>`. 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 <file-filters>`
- **Lint code**: `pnpm lint`
- **Type check**: `pnpm typecheck`

### Testing
- **Run all tests**: `pnpm test --no-watch`
- **Run some tests**: `pnpm test --no-watch <file-filters>`
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 <file-filters>`

### Database Commands
- **Generate migration**: `pnpm db:migration-gen`
Expand Down Expand Up @@ -62,15 +66,15 @@ 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/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.
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,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, { redirectUri: oauthRequest.query?.redirect_uri, clientId: oauthRequest.query?.client_id });
throw new KnownErrors.RedirectUrlNotWhitelisted();
}
} else if (error instanceof InvalidScopeError) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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({
Expand Down
Loading