Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .claude/CLAUDE-KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,5 +512,11 @@ A: Seed the normal initial environment config before marking the project as `isD
## Q: What can cause React error #185 immediately on dashboard load?
A: React error #185 is a maximum update depth error. In the dashboard root, `useSyncExternalStore` snapshot getters must return cached referentially stable values. Returning a fresh object such as `{ status: "healthy" }` from `getSnapshot` on every call can make React think the external store changed on every render and loop immediately. Use module-level constants for stable snapshots.

## Q: How should client-side OAuth callback and nested cross-domain auth avoid racing session consumers?
A: Track startup auth transitions as pending client-app promises and make `_getSession`/react-like `_useSession` wait for them when using the default persistent token store. Auth-transition code that needs to inspect the current session should explicitly call `_getSession(..., { awaitPendingAuthResolutions: false })` instead of relying on a global reentrancy flag.

## Q: When should hosted OAuth callback handling auto-start on a client app page?
A: Only auto-start hosted OAuth callback handling when the current URL has `code` and `state` and the matching `stack-oauth-outer-${state}` verifier cookie exists. Generic `code/state` or `errorCode/message` query parameters are not Stack-owned enough to run callback processing automatically on every hosted app page.

## Q: How should the npm publish workflow create the post-publish dev version bump?
A: The workflow needs a full checkout using the fine-grained `NPM_PUBLISH_VERSION_UPDATE_PR_PAT` secret. It then fetches `origin/dev`, checks out `dev`, creates a non-interactive patch changeset, runs `pnpm changeset version`, copies the generated `packages/template/package.json` version line back into `packages/template/package-template.json`, and commit/pushes `chore: update package versions`. Because direct pushes to `dev` are blocked by repository rules requiring PRs and the `all-good` status check, the PAT's owning user or bot account must be added to the ruleset bypass list with "Always allow" rather than "For pull requests only".
95 changes: 6 additions & 89 deletions apps/backend/src/lib/redirect-urls.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,14 @@
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls";
import { isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls";
import { Tenancy } from "./tenancies";

/**
* Normalizes a URL to include explicit default ports for comparison
*/
function normalizePort(url: URL): string {
const defaultPorts = new Map<string, string>([['https:', '443'], ['http:', '80']]);
const port = url.port || defaultPorts.get(url.protocol) || '';
return port ? `${url.hostname}:${port}` : url.hostname;
}

/**
* 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');
}

/**
* Checks if two URLs have matching ports (considering default ports)
*/
function portsMatch(url1: URL, url2: URL): boolean {
return normalizePort(url1) === normalizePort(url2);
}

/**
* Validates a URL against a domain pattern (with or without wildcards)
*/
function matchesDomain(testUrl: URL, pattern: 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;
}

const [, protocol, hostPattern] = match;

// Check protocol
if (testUrl.protocol + '//' !== protocol) {
return false;
}

// Check host with wildcard pattern
const hasPortInPattern = hostPattern.includes(':');
if (hasPortInPattern) {
// Pattern includes port - match against normalized host:port
return matchHostnamePattern(hostPattern, normalizePort(testUrl));
} else {
// Pattern doesn't include port - match hostname only, require default port
return matchHostnamePattern(hostPattern, testUrl.hostname) && isDefaultPort(testUrl);
}
}

// For non-wildcard patterns, use URL comparison
return baseUrl.protocol === testUrl.protocol &&
baseUrl.hostname === testUrl.hostname &&
portsMatch(baseUrl, testUrl);
}

/**
* Checks if URL is an accepted native app SDK redirect URL.
* These are safe because they can only be handled by native apps,
* not web browsers.
*/
export function isAcceptedNativeAppUrl(urlOrString: string): boolean {
const url = createUrlIfValid(urlOrString);
if (!url) return false;

return url.protocol === 'stack-auth-mobile-oauth-url:';
}
export { isAcceptedNativeAppUrl };

export function validateRedirectUrl(
urlOrString: string | URL,
tenancy: Tenancy,
): boolean {
const url = createUrlIfValid(urlOrString);
if (!url) return false;

// 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)
);
return validateRedirectUrlAgainstTrustedDomains(urlOrString, {
allowLocalhost: tenancy.config.domains.allowLocalhost,
trustedDomains: Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl),
});
}
Loading
Loading