mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Trust hosted domains
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
This commit is contained in:
parent
9fc7332ead
commit
99f07e9516
@ -518,5 +518,8 @@ A: Track startup auth transitions as pending client-app promises and make `_getS
|
||||
## 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: Should built-with hosted handler domains be manually configured as trusted domains?
|
||||
A: No. Treat the hosted handler origin for the project, such as `https://<project-id>.built-with-stack-auth.com` or the origin derived from `NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE`, as an implicit trusted redirect domain on both client and backend validation paths. The hosted template must put `{projectId}` in the hostname so every project has its own origin; path-based templates like `https://host/{projectId}/{hostedPath}` are not safe for implicit origin trust.
|
||||
|
||||
## 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".
|
||||
|
||||
@ -1,8 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls';
|
||||
import { getOAuthRedirectUrisForTenancy, validateRedirectHostname, isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls';
|
||||
import { Tenancy } from './tenancies';
|
||||
|
||||
describe('validateRedirectUrl', () => {
|
||||
const withHostedHandlerEnv = <T,>(
|
||||
values: {
|
||||
hostedHandlerUrlTemplate?: string,
|
||||
hostedHandlerDomainSuffix?: string,
|
||||
stackPortPrefix?: string,
|
||||
},
|
||||
callback: () => T,
|
||||
): T => {
|
||||
const processEnv = Reflect.get(process, "env");
|
||||
const oldHostedHandlerUrlTemplate = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE");
|
||||
const oldHostedHandlerDomainSuffix = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX");
|
||||
const oldStackPortPrefix = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_PORT_PREFIX");
|
||||
try {
|
||||
for (const [key, value] of Object.entries({
|
||||
NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE: values.hostedHandlerUrlTemplate,
|
||||
NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX: values.hostedHandlerDomainSuffix,
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX: values.stackPortPrefix,
|
||||
})) {
|
||||
if (value == null) {
|
||||
Reflect.deleteProperty(processEnv, key);
|
||||
} else {
|
||||
Reflect.set(processEnv, key, value);
|
||||
}
|
||||
}
|
||||
return callback();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries({
|
||||
NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE: oldHostedHandlerUrlTemplate,
|
||||
NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX: oldHostedHandlerDomainSuffix,
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX: oldStackPortPrefix,
|
||||
})) {
|
||||
if (value == null) {
|
||||
Reflect.deleteProperty(processEnv, key);
|
||||
} else {
|
||||
Reflect.set(processEnv, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createMockTenancy = (config: Partial<Tenancy['config']>): Tenancy => {
|
||||
return {
|
||||
config: {
|
||||
@ -13,10 +53,87 @@ describe('validateRedirectUrl', () => {
|
||||
},
|
||||
...config,
|
||||
},
|
||||
project: {
|
||||
id: "12345678-1234-4234-8234-123456789abc",
|
||||
},
|
||||
} as Tenancy;
|
||||
};
|
||||
|
||||
describe('exact domain matching', () => {
|
||||
it('should implicitly validate hosted handler domains for the project', () => {
|
||||
withHostedHandlerEnv({
|
||||
hostedHandlerUrlTemplate: "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}",
|
||||
stackPortPrefix: "92",
|
||||
}, () => {
|
||||
const tenancy = createMockTenancy({
|
||||
domains: {
|
||||
allowLocalhost: false,
|
||||
trustedDomains: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(validateRedirectUrl('http://12345678-1234-4234-8234-123456789abc.localhost:9209/anything', tenancy)).toBe(true);
|
||||
expect(validateRedirectUrl('http://other-project.localhost:9209/anything', tenancy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject hosted handler URL templates that put the project ID in the path', () => {
|
||||
withHostedHandlerEnv({
|
||||
hostedHandlerUrlTemplate: "http://localhost:9209/{projectId}/{hostedPath}",
|
||||
}, () => {
|
||||
const tenancy = createMockTenancy({
|
||||
domains: {
|
||||
allowLocalhost: false,
|
||||
trustedDomains: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => validateRedirectUrl('http://localhost:9209/anything', tenancy))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
[StackAssertionError: The hosted handler URL template must put {projectId} in the hostname.
|
||||
|
||||
This is likely an error in Stack. Please make sure you are running the newest version and report it.]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include the implicit hosted callback in OAuth redirect URIs', () => {
|
||||
withHostedHandlerEnv({}, () => {
|
||||
const tenancy = createMockTenancy({
|
||||
domains: {
|
||||
allowLocalhost: false,
|
||||
trustedDomains: {
|
||||
'1': { baseUrl: 'https://example.com', handlerPath: '/handler' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOAuthRedirectUrisForTenancy(tenancy)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"https://example.com/handler",
|
||||
"https://12345678-1234-4234-8234-123456789abc.built-with-stack-auth.com/handler/oauth-callback",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate Turnstile hostnames through the same trusted domains', () => {
|
||||
withHostedHandlerEnv({}, () => {
|
||||
const tenancy = createMockTenancy({
|
||||
domains: {
|
||||
allowLocalhost: false,
|
||||
trustedDomains: {
|
||||
'1': { baseUrl: 'https://*.example.com', handlerPath: '/handler' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(validateRedirectHostname('app.example.com', tenancy)).toBe(true);
|
||||
expect(validateRedirectHostname('12345678-1234-4234-8234-123456789abc.built-with-stack-auth.com', tenancy)).toBe(true);
|
||||
expect(validateRedirectHostname('evil.example.test', tenancy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate exact domain matches', () => {
|
||||
const tenancy = createMockTenancy({
|
||||
domains: {
|
||||
|
||||
@ -1,14 +1,52 @@
|
||||
import { isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls";
|
||||
import { getEnvVariable, getProcessEnv } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { getHostedHandlerTrustedDomain as getHostedHandlerTrustedDomainFromConfig, isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls";
|
||||
import { Tenancy } from "./tenancies";
|
||||
|
||||
export { isAcceptedNativeAppUrl };
|
||||
|
||||
export function getHostedHandlerTrustedDomain(projectId: string): string {
|
||||
return getHostedHandlerTrustedDomainFromConfig({
|
||||
projectId,
|
||||
hostedHandlerDomainSuffix: getProcessEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX"),
|
||||
hostedHandlerUrlTemplate: getProcessEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE"),
|
||||
stackPortPrefix: getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"),
|
||||
});
|
||||
}
|
||||
|
||||
export function getTrustedDomainsForTenancy(tenancy: Tenancy): string[] {
|
||||
return [
|
||||
...Object.values(tenancy.config.domains.trustedDomains)
|
||||
.map(domain => domain.baseUrl)
|
||||
.filter((baseUrl): baseUrl is string => baseUrl != null),
|
||||
getHostedHandlerTrustedDomain(tenancy.project.id),
|
||||
];
|
||||
}
|
||||
|
||||
export function getOAuthRedirectUrisForTenancy(tenancy: Tenancy): string[] {
|
||||
return [
|
||||
...Object.values(tenancy.config.domains.trustedDomains)
|
||||
.filter((domain) => domain.baseUrl)
|
||||
.map((domain) => new URL(domain.handlerPath, domain.baseUrl).toString()),
|
||||
new URL("/handler/oauth-callback", getHostedHandlerTrustedDomain(tenancy.project.id)).toString(),
|
||||
];
|
||||
}
|
||||
|
||||
export function validateRedirectUrl(
|
||||
urlOrString: string | URL,
|
||||
tenancy: Tenancy,
|
||||
): boolean {
|
||||
return validateRedirectUrlAgainstTrustedDomains(urlOrString, {
|
||||
allowLocalhost: tenancy.config.domains.allowLocalhost,
|
||||
trustedDomains: Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl),
|
||||
trustedDomains: getTrustedDomainsForTenancy(tenancy),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateRedirectHostname(hostname: string, tenancy: Tenancy): boolean {
|
||||
return validateRedirectUrlAgainstTrustedDomains(`https://${hostname}`, {
|
||||
allowLocalhost: tenancy.config.domains.allowLocalhost,
|
||||
trustedDomains: getTrustedDomainsForTenancy(tenancy),
|
||||
}) || validateRedirectUrlAgainstTrustedDomains(`http://${hostname}`, {
|
||||
allowLocalhost: tenancy.config.domains.allowLocalhost,
|
||||
trustedDomains: getTrustedDomainsForTenancy(tenancy),
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,8 +10,8 @@ import {
|
||||
turnstileDevelopmentKeys,
|
||||
turnstilePhaseValues,
|
||||
} from "@stackframe/stack-shared/dist/utils/turnstile";
|
||||
import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls";
|
||||
import { BestEffortEndUserRequestContext, getBestEffortEndUserRequestContext } from "./end-users";
|
||||
import { validateRedirectHostname } from "./redirect-urls";
|
||||
import { Tenancy } from "./tenancies";
|
||||
|
||||
|
||||
@ -47,15 +47,7 @@ type SiteverifyResponse = {
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function isAllowedTurnstileHostname(hostname: string, tenancy: Tenancy): boolean {
|
||||
if (tenancy.config.domains.allowLocalhost && isLocalhost(`http://${hostname}`)) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(tenancy.config.domains.trustedDomains).some(({ baseUrl }) => {
|
||||
if (baseUrl == null) return false;
|
||||
const pattern = createUrlIfValid(baseUrl)?.hostname
|
||||
?? baseUrl.match(/^[^:]+:\/\/([^/:]+)/)?.[1];
|
||||
return pattern != null && matchHostnamePattern(pattern, hostname);
|
||||
});
|
||||
return validateRedirectHostname(hostname, tenancy);
|
||||
}
|
||||
|
||||
function getSecretKey(override?: string): string {
|
||||
@ -324,7 +316,7 @@ import.meta.vitest?.describe("verifyTurnstileToken(...)", () => {
|
||||
expect(captureErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const allowMyapp = (h: string) => h === "myapp.com" || matchHostnamePattern("*.myapp.com", h);
|
||||
const allowMyapp = (h: string) => h === "myapp.com" || h.endsWith(".myapp.com");
|
||||
|
||||
test("returns invalid when hostname does not match allowed hostnames", async ({ expect }) => {
|
||||
stubFetch({ success: true, action: "sign_up_with_credential", hostname: "evil.example.com" });
|
||||
|
||||
@ -3,7 +3,7 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { withExternalDbSyncUpdate } from "@/lib/external-db-sync";
|
||||
import { checkApiKeySet } from "@/lib/internal-api-keys";
|
||||
import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls";
|
||||
import { getOAuthRedirectUrisForTenancy, isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls";
|
||||
import { getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies";
|
||||
import { createRefreshTokenObj, decodeAccessToken, generateAccessTokenFromRefreshTokenIfValid, isRefreshTokenValid } from "@/lib/tokens";
|
||||
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
|
||||
@ -60,13 +60,9 @@ export class OAuthModel implements AuthorizationCodeModel {
|
||||
|
||||
let redirectUris: string[] = [];
|
||||
try {
|
||||
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());
|
||||
// This may include wildcard domains and the implicit hosted handler domain;
|
||||
// model.validateRedirectUri(...) performs the authoritative trust check.
|
||||
redirectUris = getOAuthRedirectUrisForTenancy(tenancy);
|
||||
} catch (e) {
|
||||
captureError("get-oauth-redirect-urls", {
|
||||
error: e,
|
||||
|
||||
@ -747,7 +747,7 @@ export default function PageClient() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">Same-email social login policy</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">Determines what happens when a user uses a new social login provider with an email that's already connected to an account</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">Determines what happens when a user uses a new social login provider with an email that's already connected to an account</div>
|
||||
</div>
|
||||
<DesignSelectorDropdown
|
||||
value={mergeStrategy}
|
||||
|
||||
@ -6,7 +6,86 @@ type TrustedDomainConfig = {
|
||||
trustedDomains: readonly (string | null | undefined)[],
|
||||
};
|
||||
|
||||
const defaultHostedHandlerDomainSuffix = ".built-with-stack-auth.com";
|
||||
const hostedHandlerProjectIdPlaceholder = "{projectId}";
|
||||
const hostedHandlerPathPlaceholder = "{hostedPath}";
|
||||
const defaultPorts = new Map<string, string>([['https:', '443'], ['http:', '80']]);
|
||||
const hostedHandlerTemplateProjectIdA = "00000000-0000-4000-8000-000000000000";
|
||||
const hostedHandlerTemplateProjectIdB = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function replaceStackPortPrefix(input: string | undefined, stackPortPrefix: string | undefined): string | undefined {
|
||||
if (input == null) return undefined;
|
||||
return stackPortPrefix ? input.replace(/\$\{NEXT_PUBLIC_STACK_PORT_PREFIX:-81\}/g, stackPortPrefix) : input;
|
||||
}
|
||||
|
||||
function getHostedHandlerUrlFromTemplate(template: string, projectId: string, hostedPath: string): string {
|
||||
return template
|
||||
.replaceAll(hostedHandlerProjectIdPlaceholder, projectId)
|
||||
.replaceAll(hostedHandlerPathPlaceholder, hostedPath);
|
||||
}
|
||||
|
||||
function assertHostedHandlerTemplateHasProjectOrigin(template: string): void {
|
||||
const projectUrlA = new URL(getHostedHandlerUrlFromTemplate(template, hostedHandlerTemplateProjectIdA, "handler"));
|
||||
const projectUrlB = new URL(getHostedHandlerUrlFromTemplate(template, hostedHandlerTemplateProjectIdB, "handler"));
|
||||
if (projectUrlA.origin === projectUrlB.origin || !projectUrlA.hostname.includes(hostedHandlerTemplateProjectIdA)) {
|
||||
throw new StackAssertionError("The hosted handler URL template must put {projectId} in the hostname.", {
|
||||
hostedHandlerUrlTemplate: template,
|
||||
hint: "Use a project-specific origin like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}', not a shared-origin path like 'https://example.com/{projectId}/{hostedPath}'.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getHostedHandlerTrustedDomain(options: {
|
||||
projectId: string,
|
||||
hostedHandlerDomainSuffix?: string,
|
||||
hostedHandlerUrlTemplate?: string,
|
||||
stackPortPrefix?: string,
|
||||
}): string {
|
||||
return new URL(getHostedHandlerUrlFromConfig({
|
||||
...options,
|
||||
hostedPath: "handler",
|
||||
})).origin;
|
||||
}
|
||||
|
||||
export function getHostedHandlerUrlFromConfig(options: {
|
||||
projectId: string,
|
||||
hostedPath: string,
|
||||
hostedHandlerDomainSuffix?: string,
|
||||
hostedHandlerUrlTemplate?: string,
|
||||
stackPortPrefix?: string,
|
||||
}): string {
|
||||
const configuredTemplate = replaceStackPortPrefix(options.hostedHandlerUrlTemplate, options.stackPortPrefix);
|
||||
return configuredTemplate == null
|
||||
? (() => {
|
||||
const domainSuffix = replaceStackPortPrefix(options.hostedHandlerDomainSuffix, options.stackPortPrefix) ?? defaultHostedHandlerDomainSuffix;
|
||||
if (!domainSuffix.startsWith(".")) {
|
||||
throw new StackAssertionError("The hosted handler domain suffix must start with a dot.", {
|
||||
domainSuffix,
|
||||
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX to a value like '.built-with-stack-auth.com'.",
|
||||
});
|
||||
}
|
||||
return `https://${options.projectId}${domainSuffix}/${options.hostedPath}`;
|
||||
})()
|
||||
: (() => {
|
||||
if (!configuredTemplate.includes(hostedHandlerProjectIdPlaceholder) || !configuredTemplate.includes(hostedHandlerPathPlaceholder)) {
|
||||
throw new StackAssertionError("The hosted handler URL template must contain {projectId} and {hostedPath}.", {
|
||||
hostedHandlerUrlTemplate: configuredTemplate,
|
||||
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE to a value like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}'.",
|
||||
});
|
||||
}
|
||||
assertHostedHandlerTemplateHasProjectOrigin(configuredTemplate);
|
||||
return getHostedHandlerUrlFromTemplate(configuredTemplate, options.projectId, options.hostedPath);
|
||||
})();
|
||||
}
|
||||
|
||||
export function getImplicitlyTrustedDomainsForProject(options: {
|
||||
projectId: string,
|
||||
hostedHandlerDomainSuffix?: string,
|
||||
hostedHandlerUrlTemplate?: string,
|
||||
stackPortPrefix?: string,
|
||||
}): string[] {
|
||||
return [getHostedHandlerTrustedDomain(options)];
|
||||
}
|
||||
|
||||
function normalizePort(url: URL): string {
|
||||
const port = url.port || defaultPorts.get(url.protocol) || '';
|
||||
@ -137,6 +216,37 @@ import.meta.vitest?.test("validateRedirectUrl matches exact and wildcard trusted
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("validateRedirectUrl trusts implicit hosted handler domains", ({ expect }) => {
|
||||
const projectId = "12345678-1234-4234-8234-123456789abc";
|
||||
expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/anything`, {
|
||||
allowLocalhost: false,
|
||||
trustedDomains: getImplicitlyTrustedDomainsForProject({ projectId }),
|
||||
})).toBe(true);
|
||||
expect(validateRedirectUrl("https://other-project.built-with-stack-auth.com/anything", {
|
||||
allowLocalhost: false,
|
||||
trustedDomains: getImplicitlyTrustedDomainsForProject({ projectId }),
|
||||
})).toBe(false);
|
||||
expect(validateRedirectUrl(`http://${projectId}.localhost:9209/anything`, {
|
||||
allowLocalhost: false,
|
||||
trustedDomains: getImplicitlyTrustedDomainsForProject({
|
||||
projectId,
|
||||
hostedHandlerUrlTemplate: "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}",
|
||||
stackPortPrefix: "92",
|
||||
}),
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("getImplicitlyTrustedDomainsForProject rejects shared-origin hosted templates", ({ expect }) => {
|
||||
expect(() => getImplicitlyTrustedDomainsForProject({
|
||||
projectId: "12345678-1234-4234-8234-123456789abc",
|
||||
hostedHandlerUrlTemplate: "https://host.example.com/{projectId}/{hostedPath}",
|
||||
})).toThrowErrorMatchingInlineSnapshot(`
|
||||
[StackAssertionError: The hosted handler URL template must put {projectId} in the hostname.
|
||||
|
||||
This is likely an error in Stack. Please make sure you are running the newest version and report it.]
|
||||
`);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("validateRedirectUrl respects default and explicit ports", ({ expect }) => {
|
||||
expect(validateRedirectUrl("https://example.com:443/path", {
|
||||
allowLocalhost: false,
|
||||
|
||||
@ -56,7 +56,7 @@ import { NotificationCategory } from "../../notification-categories";
|
||||
import { TeamPermission } from "../../permissions";
|
||||
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
|
||||
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
|
||||
import { buildCliAuthConfirmUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
|
||||
import { buildCliAuthConfirmUrl, getHostedHandlerUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
|
||||
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
|
||||
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
|
||||
import { _StackAdminAppImplIncomplete } from "./admin-app-impl";
|
||||
@ -1190,7 +1190,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
const project = Result.orThrow(await this._currentProjectCache.getOrWait([], "write-only"));
|
||||
return {
|
||||
allowLocalhost: project.config.allow_localhost,
|
||||
trustedDomains: project.config.domains.map(d => d.domain),
|
||||
trustedDomains: [
|
||||
...project.config.domains.map(d => d.domain),
|
||||
new URL(getHostedHandlerUrl({ projectId: this.projectId, pagePath: "" })).origin,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -171,7 +171,7 @@ describe("handler URL targets", () => {
|
||||
});
|
||||
|
||||
it("uses the full hosted handler URL template when configured", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{projectId}/{hostedPath}");
|
||||
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}");
|
||||
vi.stubEnv("NEXT_PUBLIC_STACK_PORT_PREFIX", "93");
|
||||
|
||||
const urls = resolveHandlerUrls({
|
||||
@ -181,8 +181,8 @@ describe("handler URL targets", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(urls.signIn).toBe("http://localhost:9309/project-id/handler/sign-in");
|
||||
expect(urls.accountSettings).toBe("http://localhost:9309/project-id/handler/account-settings");
|
||||
expect(urls.signIn).toBe("http://project-id.localhost:9309/handler/sign-in");
|
||||
expect(urls.accountSettings).toBe("http://project-id.localhost:9309/handler/account-settings");
|
||||
});
|
||||
|
||||
it("validates the hosted handler URL template placeholders", () => {
|
||||
@ -195,6 +195,21 @@ describe("handler URL targets", () => {
|
||||
},
|
||||
})).toThrowError(/\{projectId\} and \{hostedPath\}/);
|
||||
});
|
||||
|
||||
it("rejects hosted handler URL templates that put the project ID in the path", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://localhost:9309/{projectId}/{hostedPath}");
|
||||
|
||||
expect(() => resolveHandlerUrls({
|
||||
projectId: "project-id",
|
||||
urls: {
|
||||
default: { type: "hosted" },
|
||||
},
|
||||
})).toThrowErrorMatchingInlineSnapshot(`
|
||||
[StackAssertionError: The hosted handler URL template must put {projectId} in the hostname.
|
||||
|
||||
This is likely an error in Stack. Please make sure you are running the newest version and report it.]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalHandlerUrlTarget", () => {
|
||||
|
||||
@ -1,22 +1,14 @@
|
||||
import { getCustomPagePrompts, type CustomPagePrompt } from "@stackframe/stack-shared/dist/interface/handler-urls";
|
||||
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { getHostedHandlerUrlFromConfig } from "@stackframe/stack-shared/dist/utils/redirect-urls";
|
||||
import { envVars } from "../env";
|
||||
import { DefaultHandlerUrlTarget, HandlerPageUrls, HandlerUrlOptions, HandlerUrlTarget, HandlerUrls, ResolvedHandlerUrls } from "./common";
|
||||
|
||||
const defaultHostedHandlerDomainSuffix = ".built-with-stack-auth.com";
|
||||
const hostedHandlerProjectIdPlaceholder = "{projectId}";
|
||||
const hostedHandlerPathPlaceholder = "{hostedPath}";
|
||||
const localUrlPlaceholderOrigin = "http://example.com";
|
||||
const schemePrefixRegex = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
|
||||
|
||||
const customPagePrompts: Record<keyof Omit<HandlerPageUrls, "handler">, CustomPagePrompt> = getCustomPagePrompts();
|
||||
|
||||
const replaceStackPortPrefix = <T extends string | undefined>(input: T): T => {
|
||||
if (!input) return input;
|
||||
const prefix = envVars.NEXT_PUBLIC_STACK_PORT_PREFIX;
|
||||
return prefix ? input.replace(/\$\{NEXT_PUBLIC_STACK_PORT_PREFIX:-81\}/g, prefix) as T : input;
|
||||
};
|
||||
|
||||
const joinHandlerComponentPath = (basePath: string, pagePath: string): string => {
|
||||
const normalizedBasePath = basePath.endsWith("/") && basePath.length > 1
|
||||
? basePath.slice(0, -1)
|
||||
@ -92,33 +84,6 @@ const getHostedPagePathForHandlerName = (handlerName: keyof HandlerUrls): string
|
||||
}
|
||||
};
|
||||
|
||||
export const getHostedHandlerDomainSuffix = (): string => {
|
||||
const configuredValue = envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX
|
||||
?? defaultHostedHandlerDomainSuffix;
|
||||
const domainSuffix = replaceStackPortPrefix(configuredValue);
|
||||
if (!domainSuffix.startsWith(".")) {
|
||||
throw new StackAssertionError("The hosted handler domain suffix must start with a dot.", {
|
||||
domainSuffix,
|
||||
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX to a value like '.built-with-stack-auth.com'.",
|
||||
});
|
||||
}
|
||||
return domainSuffix;
|
||||
};
|
||||
|
||||
const getHostedHandlerUrlTemplate = (): string => {
|
||||
const configuredTemplate = replaceStackPortPrefix(envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE);
|
||||
if (configuredTemplate != null) {
|
||||
if (!configuredTemplate.includes(hostedHandlerProjectIdPlaceholder) || !configuredTemplate.includes(hostedHandlerPathPlaceholder)) {
|
||||
throw new StackAssertionError("The hosted handler URL template must contain {projectId} and {hostedPath}.", {
|
||||
hostedHandlerUrlTemplate: configuredTemplate,
|
||||
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE to a value like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}'.",
|
||||
});
|
||||
}
|
||||
return configuredTemplate;
|
||||
}
|
||||
return `https://${hostedHandlerProjectIdPlaceholder}${getHostedHandlerDomainSuffix()}/${hostedHandlerPathPlaceholder}`;
|
||||
};
|
||||
|
||||
const resolveCustomTargetUrl = (options: {
|
||||
target: { type: "custom", url: string, version: number },
|
||||
handlerName: keyof HandlerUrls,
|
||||
@ -139,11 +104,13 @@ const resolveCustomTargetUrl = (options: {
|
||||
export const getHostedHandlerUrl = (options: { projectId: string, pagePath: string }): string => {
|
||||
const normalizedPagePath = options.pagePath.replace(/^\/+/, "");
|
||||
const hostedPath = normalizedPagePath.length > 0 ? `handler/${normalizedPagePath}` : "handler";
|
||||
const template = getHostedHandlerUrlTemplate();
|
||||
const templateFilled = template
|
||||
.replaceAll(hostedHandlerProjectIdPlaceholder, options.projectId)
|
||||
.replaceAll(hostedHandlerPathPlaceholder, hostedPath);
|
||||
return new URL(templateFilled).toString();
|
||||
return getHostedHandlerUrlFromConfig({
|
||||
projectId: options.projectId,
|
||||
hostedPath,
|
||||
hostedHandlerDomainSuffix: envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX,
|
||||
hostedHandlerUrlTemplate: envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE,
|
||||
stackPortPrefix: envVars.NEXT_PUBLIC_STACK_PORT_PREFIX,
|
||||
});
|
||||
};
|
||||
|
||||
const isRelativeUrlString = (url: string): boolean => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user