feat(oauth): per-provider customCallbackUrl for redirect_uri (#1512)

## Summary

Replaces the request-host-header-derived OAuth `redirect_uri` with a
config-driven `customCallbackUrl` field on each environment-level OAuth
provider.

Resolution of the `redirect_uri` we send to providers (and that
customers register in their provider app config):

- **Shared providers** → always the stack-auth-branded callback, so
Stack's shared OAuth apps keep working. `customCallbackUrl` is
schema-forbidden when `isShared` is true.
- **Custom + `customCallbackUrl` set** → the configured URL verbatim.
- **Custom without it (legacy)** → the stack-auth-branded callback, so
providers registered before this field are unaffected.
- **New custom providers set up in the dashboard** → the env-aware
hexclave-branded callback (prod → `api.hexclave.com`, dev/staging →
siblings, self-host/localhost → `NEXT_PUBLIC_STACK_API_URL` unchanged).

## Details

- **Schema** (`schema.ts`, `schema-fields.ts`): optional
`customCallbackUrl` after `clientSecret`, with a `.when('isShared')`
rule rejecting any value for shared providers; added to the provider
default factory.
- **Shared host helper** (`utils/cloud-hosts.tsx`, new):
`CLOUD_HOST_PAIRS` moved into stack-shared with `getCloudApiUrlSiblings`
/ `getStackAuthApiBaseUrl` / `getHexclaveApiBaseUrl`;
`request-api-url.ts` re-exports it so the JWT `iss` logic is untouched.
- **Runtime** (`oauth/index.tsx` + all 13 provider `create()`s):
`getProvider` resolves the full `redirect_uri` from config instead of
the request host; providers now take `redirectUri` instead of `apiUrl`.
The JWT `iss` path still uses the request host.
- **Dashboard** (`page-client.tsx`, `providers.tsx`,
`oauth-callback-url.ts` new): brand-new custom providers get the
hexclave callback; existing providers keep whatever they had (edits
never silently move a registered redirect URL); the displayed Redirect
URL mirrors backend resolution.
- **Docs** (`migration.mdx`): existing `api.stack-auth.com` callbacks
keep working; only recreated providers use the hexclave URL.

## Notes / scope decisions

- **Dashboard-only injection**: SDK/CLI/legacy-config-created custom
providers fall back to the stack-auth callback (they don't auto-get the
hexclave URL).
- **shared → standard** conversions keep the stack-auth fallback rather
than flipping to hexclave (the safe path that never breaks a registered
redirect).

## Test plan

- [x] `typecheck` + `lint` green across stack-shared, backend,
dashboard, e2e
- [x] cloud-hosts unit tests, schema tests, schema fuzzer pass
- [x] e2e: shared-provider `customCallbackUrl` rejected (400);
standard-provider `customCallbackUrl` accepted and round-trips
- [ ] e2e OAuth authorize/callback flow (needs running stack) — reasoned
unaffected since localhost isn't a cloud host, so the redirect base
stays localhost as before

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a per-provider `customCallbackUrl` for OAuth `redirect_uri`,
removing the request-host dependency and making redirects predictable.
Shared providers always use the Stack-branded callback; new or converted
custom providers default to the Hexclave-branded callback. Existing
callbacks keep working; no changes needed unless you recreate or convert
a provider.

- **New Features**
- Added `customCallbackUrl` on provider configs (URL-validated;
forbidden when `isShared` is true).
- `getProvider` now resolves a config-driven `redirectUri`; providers
take `redirectUri` instead of `apiUrl` (pure resolver with in-source +
e2e tests to lock legacy behavior).
- Introduced `@stackframe/stack-shared` `utils/cloud-hosts.tsx` and
dashboard helpers to show the resolved Redirect URL and set the Hexclave
callback for new providers and when converting shared → standard.

- **Bug Fixes**
- OAuth callback now handles legitimate cross-host flows by recording
the authorize host and skipping the host-scoped CSRF cookie when
authorize and callback hosts differ, relying on server-side state and
PKCE.

<sup>Written for commit 32d95fcdcb.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/stack-auth/pull/1512?utm_source=github">Review
in cubic</a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Preserve and display custom OAuth callback/redirect URLs in the
dashboard; provider creation/edit flows respect existing custom URLs.
* Added cloud-host mapping and redirect-uri helpers to resolve branded
API callback bases.

* **Bug Fixes**
* Improved cross-host OAuth callback handling and CSRF validation for
reliable cross-host flows.

* **Tests**
* Added E2E and unit tests covering callback URL behavior and host
mapping.

* **Documentation**
* Updated migration guidance for callback URL changes and recreation
scenarios.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1512?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-05-28 12:28:38 -07:00 committed by Madison
parent 409ac117e9
commit 49e3c48d64
31 changed files with 443 additions and 99 deletions

View File

@ -140,7 +140,7 @@ export const GET = createSmartRouteHandler({
// Hexclave rebrand: prefer the new query param name, accept the legacy one,
// and only fall back to "redirect" when neither was provided.
const responseMode = query.hexclave_response_mode ?? query.stack_response_mode ?? "redirect";
const providerObj = await getProvider(provider, { apiUrl: getApiUrlForRequest(fullReq) });
const providerObj = await getProvider(provider);
const oauthUrl = providerObj.getAuthorizationUrl({
codeVerifier: innerCodeVerifier,
state: innerState,
@ -169,6 +169,11 @@ export const GET = createSmartRouteHandler({
turnstileResult: turnstileAssessment.status,
turnstileVisibleChallengeResult: turnstileAssessment.visibleChallengeResult,
responseMode,
// Record the host that received /authorize so the callback can detect a
// legitimate cross-host landing (the redirect_uri/callback host is
// config-derived and may be a sibling brand) and not fail the
// host-scoped CSRF cookie check.
authorizeApiUrl: getApiUrlForRequest(fullReq),
} satisfies InferType<typeof oauthCookieSchema>,
expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes),
},

View File

@ -115,9 +115,19 @@ const handler = createSmartRouteHandler({
throw new HexclaveAssertionError("Invalid outer info");
}
// The inner CSRF cookie is host-scoped to the host that served /authorize.
// The OAuth redirect_uri (and thus this callback's host) is config-derived
// and can be a sibling brand (e.g. authorize on api.hexclave.com, callback
// on api.stack-auth.com for a legacy/shared provider). Cookies can't span
// those hosts, so when the landing host legitimately differs from the
// authorize host we skip the cookie check and rely on the single-use,
// server-side outer info plus the outer flow's PKCE — the same protection
// JSON mode already uses.
const isCrossHostCallback = !!outerInfo.authorizeApiUrl && outerInfo.authorizeApiUrl !== apiUrl;
// JSON-mode requests use PKCE for CSRF protection and don't set a cookie.
// Only check the CSRF cookie for browser-redirect mode requests.
if (outerInfo.responseMode !== 'json') {
// Only check the CSRF cookie for same-host browser-redirect mode requests.
if (outerInfo.responseMode !== 'json' && !isCrossHostCallback) {
// Hexclave rebrand: read whichever inner-OAuth cookie name is present (prefer the new name), and delete both.
const cookieStore = await cookies();
const cookieInfo = cookieStore.get("hexclave-oauth-inner-" + innerState)
@ -164,7 +174,7 @@ const handler = createSmartRouteHandler({
throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id));
}
const providerObj = await getProvider(provider as any, { apiUrl });
const providerObj = await getProvider(provider as any);
let callbackResult: Awaited<ReturnType<typeof providerObj.getCallback>>;
try {
callbackResult = await providerObj.getCallback({

View File

@ -5,7 +5,6 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../../access-token-helpers";
@ -42,11 +41,7 @@ export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(
throw new KnownErrors.OAuthConnectionNotConnectedToUser();
}
// See sibling `[provider_id]/access-token/crud.tsx` — `redirect_uri` is
// unused for refresh/check paths, so the apiUrl value passed here is safe
// to pin to the deployment default even when the request came in on the
// other branded host.
const providerInstance = await getProvider(provider, { apiUrl: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") });
const providerInstance = await getProvider(provider);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const oauthAccount = await prisma.projectUserOAuthAccount.findFirst({

View File

@ -5,7 +5,6 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { isSharedAccessTokenBlocked, retrieveOrRefreshAccessToken } from "../../../access-token-helpers";
@ -39,10 +38,9 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
// The connected-accounts access-token flow only uses the OAuth provider's
// refresh and access-token-validity methods; neither uses `redirect_uri`.
// The CRUD handler interface does not surface the inbound request to this
// callback, so we pass the deployment default API URL instead of the
// request's host-derived one. Safe because the value is unused downstream.
const providerInstance = await getProvider(provider, { apiUrl: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") });
// `getProvider` resolves the callback URL from the provider's own config, so
// this flow doesn't need to supply one.
const providerInstance = await getProvider(provider);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
// Legacy endpoint: search tokens across ALL accounts for this provider and user

View File

@ -235,6 +235,10 @@ export async function createOrUpdateProjectWithLegacyConfig(
isShared: provider.type === "shared",
clientId: provider.client_id,
clientSecret: provider.client_secret,
// Injecting the hexclave-branded callback for new providers is the
// dashboard's job; this legacy path leaves it unset so providers fall
// back to the stack-auth callback.
customCallbackUrl: undefined,
facebookConfigId: provider.facebook_config_id,
microsoftTenantId: provider.microsoft_tenant_id,
appleBundles: provider.apple_bundle_ids ? typedFromEntries(provider.apple_bundle_ids.map(bundleId => [generateUuid(), { bundleId }] as const)) : undefined,

View File

@ -1,25 +1,15 @@
import { CLOUD_HOST_PAIRS } from "@stackframe/stack-shared/dist/utils/cloud-hosts";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { captureError, HexclaveAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
/**
* Single source of truth for the stack-auth hexclave host pairs that this
* backend treats as equivalent siblings. Each `[stackAuthHost, hexclaveHost]`
* pair is used in two places:
*
* 1. `CLOUD_API_HOST_BY_REQUEST_HOST` below the allowlist of hosts we are
* willing to resolve into a JWT `iss` claim or an OAuth `redirect_uri`.
* 2. `issuerHostAliases` in `tokens.tsx` the bidirectional validator alias
* map, so a token issued under either host validates against the other.
*
* Deriving both lists from this single list prevents drift (a host can sign
* but no sibling can validate, or vice versa) that bug ate us once on the
* staging pair before this consolidation.
* The stack-auth hexclave cloud host pairs live in stack-shared
* (`utils/cloud-hosts.ts`) so the dashboard and OAuth callback logic can share
* them. Re-exported here because `tokens.tsx` imports it from this module to
* build `issuerHostAliases` (and the source-of-truth comment lives with the
* pairs themselves).
*/
export const CLOUD_HOST_PAIRS: ReadonlyArray<readonly [string, string]> = [
["api.stack-auth.com", "api.hexclave.com"],
["api.dev.stack-auth.com", "api.dev.hexclave.com"],
["api.staging.stack-auth.com", "api.staging.hexclave.com"],
];
export { CLOUD_HOST_PAIRS };
/**
* Cloud hosts where this backend serves customer SDK traffic. Each request

View File

@ -52,6 +52,13 @@ export const oauthCookieSchema = yupObject({
turnstileResult: yupString().oneOf(turnstileResultValues).optional(),
turnstileVisibleChallengeResult: yupString().oneOf(turnstileResultValues).optional(),
responseMode: yupString().oneOf(['json', 'redirect']).optional(),
// The host-derived API URL of the request that started /authorize. The
// browser-redirect CSRF cookie is host-scoped to that host, but the OAuth
// `redirect_uri` (and thus the callback host) is now config-derived and can be
// a sibling brand. The callback uses this to detect a legitimate cross-host
// landing and skip the cookie check (server-side state + outer PKCE still
// apply). Optional for in-flight flows started before this field existed.
authorizeApiUrl: yupString().optional(),
});
type UserType = 'normal' | 'restricted' | 'anonymous';

View File

@ -1,6 +1,7 @@
import { DEFAULT_BRANCH_ID, Tenancy } from "@/lib/tenancies";
import { DiscordProvider } from "@/oauth/providers/discord";
import OAuth2Server from "@node-oauth/oauth2-server";
import { getStackAuthApiBaseUrl } from "@stackframe/stack-shared/dist/utils/cloud-hosts";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { HexclaveAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { OAuthModel } from "./model";
@ -18,16 +19,6 @@ import { SpotifyProvider } from "./providers/spotify";
import { TwitchProvider } from "./providers/twitch";
import { XProvider } from "./providers/x";
type GetProviderOptions = {
// Host-derived API URL — gets stamped into the OAuth provider's
// `redirect_uri` (the URL sent to Google/GitHub/etc. and registered in their
// app config). See `request-api-url.ts`. Pass `getApiUrlForRequest(fullReq)`
// from a route handler. Customers whose providers were registered against
// `api.stack-auth.com` will continue to have authorize calls send that exact
// URL; customers on `api.hexclave.com` will see the hexclave-branded URL.
apiUrl: string,
};
const _providers = {
github: GithubProvider,
google: GoogleProvider,
@ -66,12 +57,57 @@ export function getProjectBranchFromClientId(clientId: string): [projectId: stri
return [projectId, branchId];
}
// Resolves the OAuth `redirect_uri` we send to the provider (Google/GitHub/...)
// and that the customer registers in their provider app config.
//
// - shared providers -> always the stack-auth-branded callback,
// so Stack's shared OAuth apps keep working
// - custom + `customCallbackUrl` -> the configured URL verbatim (new custom
// providers get a hexclave-branded URL)
// - custom without it (legacy) -> the stack-auth-branded callback, so
// providers registered before this field
// are unaffected
//
// `deploymentApiUrl` is this deployment's `NEXT_PUBLIC_STACK_API_URL`. The
// stack-auth brand is derived from it (mapping cloud siblings), falling back to
// it unchanged for self-hosted / localhost. This intentionally no longer depends
// on the request host header.
function getRedirectUri(
provider: Tenancy['config']['auth']['oauth']['providers'][string],
providerType: string,
deploymentApiUrl: string,
): string {
if (!provider.isShared && provider.customCallbackUrl) {
return provider.customCallbackUrl;
}
const stackAuthBaseUrl = getStackAuthApiBaseUrl(deploymentApiUrl);
return `${stackAuthBaseUrl}/api/v1/auth/oauth/callback/${providerType}`;
}
import.meta.vitest?.test("getRedirectUri keeps existing customers on the stack-auth callback", ({ expect }) => {
const legacyCustom = { type: "github", isShared: false, customCallbackUrl: undefined } as any;
const sharedProvider = { type: "github", isShared: true } as any;
const newCustom = { type: "github", isShared: false, customCallbackUrl: "https://api.hexclave.com/api/v1/auth/oauth/callback/github" } as any;
// On a hexclave-branded deployment, existing customers (legacy custom + shared)
// still get the stack-auth callback they registered — unchanged by the rebrand.
expect(getRedirectUri(legacyCustom, "github", "https://api.hexclave.com")).toBe("https://api.stack-auth.com/api/v1/auth/oauth/callback/github");
expect(getRedirectUri(sharedProvider, "github", "https://api.hexclave.com")).toBe("https://api.stack-auth.com/api/v1/auth/oauth/callback/github");
// Only providers that explicitly set customCallbackUrl get the new brand.
expect(getRedirectUri(newCustom, "github", "https://api.hexclave.com")).toBe("https://api.hexclave.com/api/v1/auth/oauth/callback/github");
// On a stack-auth-branded deployment, unchanged too.
expect(getRedirectUri(legacyCustom, "github", "https://api.stack-auth.com")).toBe("https://api.stack-auth.com/api/v1/auth/oauth/callback/github");
// Self-host / localhost (not a cloud sibling): falls back to the deployment URL.
expect(getRedirectUri(legacyCustom, "github", "http://localhost:8102")).toBe("http://localhost:8102/api/v1/auth/oauth/callback/github");
});
export async function getProvider(
provider: Tenancy['config']['auth']['oauth']['providers'][string],
options: GetProviderOptions,
): Promise<OAuthBaseProvider> {
const { apiUrl } = options;
const providerType = provider.type || throwErr("Provider type is required for shared providers");
const redirectUri = getRedirectUri(provider, providerType, getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
if (provider.isShared) {
const clientId = _getEnvForProvider(providerType).clientId;
const clientSecret = _getEnvForProvider(providerType).clientSecret;
@ -79,12 +115,12 @@ export async function getProvider(
if (clientSecret !== "MOCK") {
throw new HexclaveAssertionError("If OAuth provider client ID is set to MOCK, then client secret must also be set to MOCK");
}
return await mockProvider.create(providerType, { apiUrl });
return await mockProvider.create(providerType, { redirectUri });
} else {
return await _providers[providerType].create({
clientId,
clientSecret,
apiUrl,
redirectUri,
});
}
} else {
@ -93,7 +129,7 @@ export async function getProvider(
clientSecret: provider.clientSecret || throwErr("Client secret is required for standard providers"),
facebookConfigId: provider.facebookConfigId,
microsoftTenantId: provider.microsoftTenantId,
apiUrl,
redirectUri,
});
}
}

View File

@ -10,14 +10,14 @@ export class AppleProvider extends OAuthBaseProvider {
super(...args);
}
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
const { apiUrl, ...rest } = options;
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
const { redirectUri, ...rest } = options;
return new AppleProvider(
...(await OAuthBaseProvider.createConstructorArgs({
issuer: "https://appleid.apple.com",
authorizationEndpoint: "https://appleid.apple.com/auth/authorize",
tokenEndpoint: "https://appleid.apple.com/auth/token",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/apple",
redirectUri,
jwksUri: "https://appleid.apple.com/auth/keys",
baseScope: "name email",
authorizationExtraParams: { "response_mode": "form_post" },

View File

@ -8,14 +8,14 @@ export class BitbucketProvider extends OAuthBaseProvider {
super(...args);
}
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
const { apiUrl, ...rest } = options;
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
const { redirectUri, ...rest } = options;
return new BitbucketProvider(
...(await OAuthBaseProvider.createConstructorArgs({
issuer: "https://bitbucket.org",
authorizationEndpoint: "https://bitbucket.org/site/oauth2/authorize",
tokenEndpoint: "https://bitbucket.org/site/oauth2/access_token",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/bitbucket",
redirectUri,
baseScope: "account email",
...rest,
}))

View File

@ -11,14 +11,14 @@ export class DiscordProvider extends OAuthBaseProvider {
static async create(options: {
clientId: string,
clientSecret: string,
apiUrl: string,
redirectUri: string,
}) {
const { apiUrl, ...rest } = options;
const { redirectUri, ...rest } = options;
return new DiscordProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://discord.com",
authorizationEndpoint: "https://discord.com/oauth2/authorize",
tokenEndpoint: "https://discord.com/api/oauth2/token",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/discord",
redirectUri,
baseScope: "identify email",
...rest,
}));

View File

@ -13,14 +13,14 @@ export class FacebookProvider extends OAuthBaseProvider {
clientId: string,
clientSecret: string,
facebookConfigId?: string,
apiUrl: string,
redirectUri: string,
}) {
const { apiUrl, ...rest } = options;
const { redirectUri, ...rest } = options;
return new FacebookProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://www.facebook.com",
authorizationEndpoint: "https://facebook.com/v20.0/dialog/oauth/",
tokenEndpoint: "https://graph.facebook.com/v20.0/oauth/access_token",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/facebook",
redirectUri,
baseScope: "openid public_profile email",
openid: true,
jwksUri: "https://www.facebook.com/.well-known/oauth/openid/jwks",

View File

@ -13,16 +13,16 @@ export class GithubProvider extends OAuthBaseProvider {
static async create(options: {
clientId: string,
clientSecret: string,
apiUrl: string,
redirectUri: string,
}) {
const { apiUrl, ...rest } = options;
const { redirectUri, ...rest } = options;
return new GithubProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://github.com",
alternativeIssuers: ["https://github.com/login/oauth"],
authorizationEndpoint: "https://github.com/login/oauth/authorize",
tokenEndpoint: "https://github.com/login/oauth/access_token",
userinfoEndpoint: "https://api.github.com/user",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/github",
redirectUri,
baseScope: "user:email",
// GitHub can return either non-expiring OAuth-App-style access tokens, or
// expiring user tokens with refresh tokens. If GitHub gives us expires_in,

View File

@ -8,15 +8,15 @@ export class GitlabProvider extends OAuthBaseProvider {
super(...args);
}
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
const { apiUrl, ...rest } = options;
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
const { redirectUri, ...rest } = options;
return new GitlabProvider(
...(await OAuthBaseProvider.createConstructorArgs({
issuer: "https://gitlab.com",
authorizationEndpoint: "https://gitlab.com/oauth/authorize",
tokenEndpoint: "https://gitlab.com/oauth/token",
userinfoEndpoint: "https://gitlab.com/api/v4/user",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/gitlab",
redirectUri,
baseScope: "read_user",
...rest,
}))

View File

@ -11,15 +11,15 @@ export class GoogleProvider extends OAuthBaseProvider {
static async create(options: {
clientId: string,
clientSecret: string,
apiUrl: string,
redirectUri: string,
}) {
const { apiUrl, ...rest } = options;
const { redirectUri, ...rest } = options;
return new GoogleProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://accounts.google.com",
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/google",
redirectUri,
openid: true,
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
baseScope: "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",

View File

@ -10,14 +10,14 @@ export class LinkedInProvider extends OAuthBaseProvider {
super(...args);
}
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
const { apiUrl, ...rest } = options;
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
const { redirectUri, ...rest } = options;
return new LinkedInProvider(
...(await OAuthBaseProvider.createConstructorArgs({
issuer: "https://www.linkedin.com/oauth",
authorizationEndpoint: "https://www.linkedin.com/oauth/v2/authorization",
tokenEndpoint: "https://www.linkedin.com/oauth/v2/accessToken",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/linkedin",
redirectUri,
baseScope: "openid profile email",
openid: true,
jwksUri: "https://www.linkedin.com/oauth/openid/jwks",

View File

@ -12,17 +12,17 @@ export class MicrosoftProvider extends OAuthBaseProvider {
clientId: string,
clientSecret: string,
microsoftTenantId?: string,
apiUrl: string,
redirectUri: string,
}) {
const tenantId = encodeURIComponent(options.microsoftTenantId || "consumers");
const { apiUrl, ...rest } = options;
const { redirectUri, ...rest } = options;
return new MicrosoftProvider(...await OAuthBaseProvider.createConstructorArgs({
// Note that it is intentional to have tenantid instead of tenantId, also intentional to not be a template literal. This will be replaced by the openid-client library.
// The library only supports azure tenancy with the discovery endpoint but not the manual setup, so we patch it to enable the tenantid replacement.
issuer: "https://login.microsoftonline.com/{tenantid}/v2.0",
authorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
tokenEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/microsoft",
redirectUri,
baseScope: "User.Read openid",
openid: true,
jwksUri: `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`,

View File

@ -9,10 +9,10 @@ export class MockProvider extends OAuthBaseProvider {
super(...args);
}
static async create(providerId: string, options: { apiUrl: string }) {
static async create(providerId: string, options: { redirectUri: string }) {
return new MockProvider(...await OAuthBaseProvider.createConstructorArgs({
discoverFromUrl: getEnvVariable("STACK_OAUTH_MOCK_URL"),
redirectUri: `${options.apiUrl}/api/v1/auth/oauth/callback/${providerId}`,
redirectUri: options.redirectUri,
baseScope: "openid offline_access",
openid: true,
clientId: providerId,

View File

@ -11,14 +11,14 @@ export class SpotifyProvider extends OAuthBaseProvider {
static async create(options: {
clientId: string,
clientSecret: string,
apiUrl: string,
redirectUri: string,
}) {
const { apiUrl, ...rest } = options;
const { redirectUri, ...rest } = options;
return new SpotifyProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://accounts.spotify.com",
authorizationEndpoint: "https://accounts.spotify.com/authorize",
tokenEndpoint: "https://accounts.spotify.com/api/token",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/spotify",
redirectUri,
baseScope: "user-read-email user-read-private",
...rest,
}));

View File

@ -11,15 +11,15 @@ export class TwitchProvider extends OAuthBaseProvider {
static async create(options: {
clientId: string,
clientSecret: string,
apiUrl: string,
redirectUri: string,
}) {
const { apiUrl, ...rest } = options;
const { redirectUri, ...rest } = options;
return new TwitchProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://id.twitch.tv",
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
tokenEndpoint: "https://id.twitch.tv/oauth2/token",
tokenEndpointAuthMethod: "client_secret_post",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/twitch",
redirectUri,
baseScope: "user:read:email",
...rest,
}));

View File

@ -9,14 +9,14 @@ export class XProvider extends OAuthBaseProvider {
super(...args);
}
static async create(options: { clientId: string, clientSecret: string, apiUrl: string }) {
const { apiUrl, ...rest } = options;
static async create(options: { clientId: string, clientSecret: string, redirectUri: string }) {
const { redirectUri, ...rest } = options;
return new XProvider(
...(await OAuthBaseProvider.createConstructorArgs({
issuer: "https://twitter.com",
authorizationEndpoint: "https://twitter.com/i/oauth2/authorize",
tokenEndpoint: "https://api.x.com/2/oauth2/token",
redirectUri: apiUrl + "/api/v1/auth/oauth/callback/x",
redirectUri,
baseScope: "users.read offline.access tweet.read",
...rest,
}))

View File

@ -0,0 +1,50 @@
import { getPublicEnvVar } from "@/lib/env";
import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema";
import { getHexclaveApiBaseUrl, getStackAuthApiBaseUrl } from "@stackframe/stack-shared/dist/utils/cloud-hosts";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { urlString } from "@stackframe/stack-shared/dist/utils/urls";
type ConfigOAuthProvider = CompleteConfig['auth']['oauth']['providers'][string];
function apiUrlEnv(): string {
return getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL')
?? throwErr("NEXT_PUBLIC_STACK_API_URL is required to build OAuth callback URLs");
}
function callbackPath(providerId: string): string {
return urlString`/api/v1/auth/oauth/callback/${providerId}`;
}
/**
* The hexclave-branded callback URL written into `customCallbackUrl` when a new
* custom OAuth provider is set up. Env-aware: maps this deployment's
* `NEXT_PUBLIC_STACK_API_URL` to its hexclave sibling (self-host/localhost fall
* back to the env var unchanged).
*/
export function getNewProviderCallbackUrl(providerId: string): string {
return getHexclaveApiBaseUrl(apiUrlEnv()) + callbackPath(providerId);
}
/**
* The stack-auth-branded callback URL used by providers without a
* `customCallbackUrl` (shared providers and custom providers created before the
* field existed).
*/
export function getDefaultProviderCallbackUrl(providerId: string): string {
return getStackAuthApiBaseUrl(apiUrlEnv()) + callbackPath(providerId);
}
/**
* The redirect URL to register with the provider, shown in the (standard-mode)
* provider dialog. Mirrors what the standard write path persists and what the
* backend then sends as `redirect_uri`:
* - already standard -> its customCallbackUrl, or the stack-auth fallback for
* legacy providers that never had one
* - brand-new, or converting shared -> standard -> the new (hexclave) callback
*/
export function resolveProviderCallbackUrl(providerId: string, existing: ConfigOAuthProvider | undefined): string {
if (existing && !existing.isShared) {
return existing.customCallbackUrl ?? getDefaultProviderCallbackUrl(providerId);
}
return getNewProviderCallbackUrl(providerId);
}

View File

@ -40,6 +40,7 @@ import { useId, useMemo, useState } from "react";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { getNewProviderCallbackUrl } from "./oauth-callback-url";
import { ProviderIcon, ProviderSettingDialog, ProviderSettingSwitch, TurnOffProviderDialog } from "./providers";
type AdminOAuthProviderConfig = AdminProject['config']['oauthProviders'][number];
@ -102,7 +103,10 @@ function ConfirmSignUpDisabledDialog(props: {
);
}
function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): CompleteConfig['auth']['oauth']['providers'][string] {
function adminProviderToConfigProvider(
provider: AdminOAuthProviderConfig,
existing: CompleteConfig['auth']['oauth']['providers'][string] | undefined,
): CompleteConfig['auth']['oauth']['providers'][string] {
switch (provider.type) {
case 'shared': {
return {
@ -110,6 +114,9 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp
isShared: true,
clientId: undefined,
clientSecret: undefined,
// Shared providers always use Stack's shared OAuth app; customCallbackUrl
// is forbidden by the schema for them.
customCallbackUrl: undefined,
facebookConfigId: undefined,
microsoftTenantId: undefined,
appleBundles: undefined,
@ -123,6 +130,13 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp
isShared: false,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
// Setting up a standard provider (brand-new, or converting shared ->
// standard) means registering a fresh OAuth app, so it gets the
// hexclave-branded callback URL. A provider that was already standard
// keeps whatever it had — legacy ones without a customCallbackUrl keep
// falling back to the stack-auth callback so edits never silently change
// an already-registered redirect URL.
customCallbackUrl: (existing && !existing.isShared) ? existing.customCallbackUrl : getNewProviderCallbackUrl(provider.id),
facebookConfigId: provider.facebookConfigId,
microsoftTenantId: provider.microsoftTenantId,
appleBundles: provider.appleBundleIds?.length
@ -142,6 +156,7 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const oauthProviders = project.config.oauthProviders;
const config = project.useConfig();
const updateConfig = useUpdateConfig();
const [providerSearch, setProviderSearch] = useState("");
const filteredProviders = allProviders
@ -175,7 +190,7 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
await updateConfig({
adminApp: stackAdminApp,
configUpdate: {
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider),
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider, config.auth.oauth.providers[provider.id]),
},
pushable: false,
});
@ -204,6 +219,7 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
const stackAdminApp = useAdminApp();
const completeConfig = stackAdminApp.useProject().useConfig();
const updateConfig = useUpdateConfig();
const [turnOffProviderDialogOpen, setTurnOffProviderDialogOpen] = useState(false);
const [providerSettingDialogOpen, setProviderSettingDialogOpen] = useState(false);
@ -212,7 +228,7 @@ function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
await updateConfig({
adminApp: stackAdminApp,
configUpdate: {
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider),
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider, completeConfig.auth.oauth.providers[provider.id]),
},
pushable: false,
});

View File

@ -10,7 +10,6 @@ import {
DesignInput,
DesignPillToggle,
} from "@stackframe/dashboard-ui-components";
import { getPublicEnvVar } from '@/lib/env';
import { ArrowRightIcon, InfoIcon, WarningCircleIcon } from "@phosphor-icons/react";
import { AdminProject } from "@stackframe/stack";
import { yupBoolean, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
@ -20,6 +19,8 @@ import { useState, type ReactNode } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useWatch } from "react-hook-form";
import * as yup from "yup";
import { useAdminApp } from "../use-admin-app";
import { resolveProviderCallbackUrl } from "./oauth-callback-url";
export function ProviderIcon(props: { id: string, size?: "sm" | "md" | "lg" }) {
const size = props.size ?? "md";
@ -139,11 +140,13 @@ function PillToggleControl({
}
function RedirectInline({ providerId }: { providerId: string }) {
const config = useAdminApp().useProject().useConfig();
const redirectUrl = resolveProviderCallbackUrl(providerId, config.auth.oauth.providers[providerId]);
return (
<div className="flex flex-col gap-1">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Redirect URL</span>
<Typography type="footnote" className="break-all">
<InlineCode>{`${getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') ?? ''}${urlString`/api/v1/auth/oauth/callback/${providerId}`}`}</InlineCode>
<InlineCode>{redirectUrl}</InlineCode>
</Typography>
</div>
);

View File

@ -115,6 +115,56 @@ it("should be able to fetch the inner callback URL by following the OAuth provid
expect(innerCallbackUrl.pathname).toBe("/api/v1/auth/oauth/callback/spotify");
});
it("sends the original (default) redirect_uri for a provider configured before customCallbackUrl existed, and the configured one when set", async ({ expect }) => {
// Simulate an "old customer": a standard provider whose stored config predates
// this feature and therefore has no customCallbackUrl. Alongside it, a provider
// that opted into the new behavior with an explicit (different-brand) callback.
await Project.createAndSwitch();
await Project.updateConfig({
"auth.oauth.providers.github": {
type: "github",
isShared: false,
clientId: "legacy-client-id",
clientSecret: "legacy-client-secret",
allowSignIn: true,
allowConnectedAccounts: true,
},
"auth.oauth.providers.spotify": {
type: "spotify",
isShared: false,
clientId: "new-client-id",
clientSecret: "new-client-secret",
customCallbackUrl: "https://api.hexclave.com/api/v1/auth/oauth/callback/spotify",
allowSignIn: true,
allowConnectedAccounts: true,
},
});
// The redirect_uri we hand the provider is carried as a query param on the
// authorize redirect; read it without following the redirect to the provider.
const redirectUriFor = async (provider: string) => {
const response = await niceBackendFetch(`/api/v1/auth/oauth/authorize/${provider}`, {
redirect: "manual",
query: await Auth.OAuth.getAuthorizeQuery(),
});
expect(response.status).toBe(307);
const location = response.headers.get("location");
expect(location).toBeTruthy();
return new URL(location!).searchParams.get("redirect_uri");
};
// Old customer: still gets the default deployment callback (locally the
// localhost API URL) — exactly what they already registered with the provider,
// and NOT the new hexclave-branded URL.
const githubRedirectUri = await redirectUriFor("github");
expect(new URL(githubRedirectUri!).origin).toBe(localhostUrl("02"));
expect(new URL(githubRedirectUri!).pathname).toBe("/api/v1/auth/oauth/callback/github");
expect(githubRedirectUri).not.toBe("https://api.hexclave.com/api/v1/auth/oauth/callback/github");
// Provider that opted in: the configured customCallbackUrl is sent verbatim.
expect(await redirectUriFor("spotify")).toBe("https://api.hexclave.com/api/v1/auth/oauth/callback/spotify");
});
it("should fail if an invalid client_id is provided", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", {
redirect: "manual",

View File

@ -426,6 +426,38 @@ describe("oauth config", () => {
}
`);
});
it("accepts customCallbackUrl on a standard oauth provider", async ({ expect }) => {
const { adminAccessToken } = await Project.createAndSwitch();
const setResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
method: "PATCH",
accessType: "admin",
headers: adminHeaders(adminAccessToken),
body: {
config_override_string: JSON.stringify({
'auth.oauth.providers.google': {
type: 'google',
isShared: false,
clientId: 'google-client-id',
clientSecret: 'google-client-secret',
customCallbackUrl: 'https://api.hexclave.com/api/v1/auth/oauth/callback/google',
allowSignIn: true,
allowConnectedAccounts: true,
},
}),
},
});
expect(setResponse.status).toBe(200);
const configResponse = await niceBackendFetch("/api/v1/internal/config", {
method: "GET",
accessType: "admin",
headers: adminHeaders(adminAccessToken),
});
const config = JSON.parse(configResponse.body.config_string);
expect(config.auth.oauth.providers.google.customCallbackUrl).toBe('https://api.hexclave.com/api/v1/auth/oauth/callback/google');
});
});

View File

@ -33,15 +33,7 @@ import { StackClientApp, StackProvider, useStackApp } from "@stackframe/stack";
import { HexclaveClientApp, HexclaveProvider, useHexclaveApp } from "@hexclave/next";
```
## 2. Update your OAuth callback URLs
After migrating, Hexclave sends providers (Google, GitHub, Microsoft, Apple, Facebook, Discord, LinkedIn, GitLab, Bitbucket, Spotify, Twitch, X) a `redirect_uri` on `api.hexclave.com`. Update each enabled provider's OAuth app callback URL to:
```
https://api.hexclave.com/api/v1/auth/oauth/callback/<provider>
```
## 3. Update hardcoded references
## 2. Update hardcoded references
Sweep your codebase and replace:
@ -77,4 +69,10 @@ const { payload } = await jose.jwtVerify(token, jwks, {
The same applies to the anonymous (`/api/v1/projects-anonymous-users/...`) and restricted (`/api/v1/projects-restricted-users/...`) issuer variants.
You don't need to update your OAuth provider callback URLs — your current `api.stack-auth.com` callback URLs keep working. However, if you recreate an OAuth provider on the dashboard, you'll need to use the new callback URL:
```
https://api.hexclave.com/api/v1/auth/oauth/callback/<provider>
```
Questions? [Discord](https://discord.hexclave.com) or [team@hexclave.com](mailto:team@hexclave.com).

View File

@ -209,6 +209,10 @@ const environmentSchemaFuzzerConfig = [{
isShared: [true, false],
clientId: ["some-client-id"],
clientSecret: ["some-client-secret"],
// Kept undefined: the schema forbids customCallbackUrl when isShared is
// true, and the fuzzer randomizes each field independently so it cannot
// honor that coupling. The accept path is covered by an e2e test.
customCallbackUrl: [undefined] as (string | undefined)[],
facebookConfigId: ["some-facebook-config-id"],
microsoftTenantId: ["some-microsoft-tenant-id"],
appleBundles: [{ "some-bundle-id": [{ bundleId: ["com.example.app"] }] }],

View File

@ -382,6 +382,11 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
isShared: yupBoolean(),
clientId: schemaFields.oauthClientIdSchema.optional(),
clientSecret: schemaFields.oauthClientSecretSchema.optional(),
customCallbackUrl: schemaFields.oauthCustomCallbackUrlSchema.optional().when('isShared', {
is: true,
then: (schema) => schema.oneOf([undefined], 'customCallbackUrl cannot be set for shared OAuth providers'),
otherwise: (schema) => schema,
}),
facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(),
microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(),
appleBundles: yupRecord(
@ -758,6 +763,7 @@ const organizationConfigDefaults = {
allowConnectedAccounts: false,
clientId: undefined,
clientSecret: undefined,
customCallbackUrl: undefined,
facebookConfigId: undefined,
microsoftTenantId: undefined,
appleBundles: undefined,
@ -1129,6 +1135,53 @@ export async function sanitizeOrganizationConfig(config: OrganizationRenderedCon
}
/**
* Cross-field config-override checks that `getConfigOverrideErrors` cannot
* express. That function rebuilds a per-field "restricted" schema from
* `schema.describe()`, which carries only static `oneOf`/`notOneOf` and drops
* `.when(...)` conditionals so cross-property rules (e.g. an OAuth provider's
* `customCallbackUrl` being forbidden when `isShared` is true) are not enforced
* there. We walk the override and reject the known violations explicitly.
*
* Shape-agnostic on purpose: a provider object can arrive under a dotted key
* (`auth.oauth.providers.google`), inside a record value, or fully nested, so we
* recurse and flag any object carrying both `isShared: true` and a
* `customCallbackUrl` value. Those two keys only co-occur on OAuth providers, so
* there are no false positives.
*/
export function getCrossFieldConfigOverrideError(configOverride: unknown): string | null {
let error: string | null = null;
const walk = (node: unknown): void => {
if (error !== null || !isObjectLike(node)) return;
if (!Array.isArray(node)) {
const obj = node as Record<string, unknown>;
if (obj.isShared === true && obj.customCallbackUrl !== undefined && obj.customCallbackUrl !== null) {
error = "customCallbackUrl cannot be set for shared OAuth providers";
return;
}
}
for (const value of Object.values(node as Record<string, unknown>)) {
walk(value);
}
};
walk(configOverride);
return error;
}
import.meta.vitest?.test("getCrossFieldConfigOverrideError flags shared providers with a customCallbackUrl", ({ expect }) => {
const url = "https://api.hexclave.com/api/v1/auth/oauth/callback/google";
// dotted-key form (dashboard / e2e)
expect(getCrossFieldConfigOverrideError({ 'auth.oauth.providers.google': { isShared: true, customCallbackUrl: url } })).toContain("customCallbackUrl");
// nested record form (legacy/neon write whole map)
expect(getCrossFieldConfigOverrideError({ 'auth.oauth.providers': { google: { isShared: true, customCallbackUrl: url } } })).toContain("customCallbackUrl");
// allowed: shared without a customCallbackUrl
expect(getCrossFieldConfigOverrideError({ 'auth.oauth.providers.google': { isShared: true } })).toBeNull();
// allowed: custom provider with a customCallbackUrl
expect(getCrossFieldConfigOverrideError({ 'auth.oauth.providers.google': { isShared: false, customCallbackUrl: url } })).toBeNull();
// allowed: shared with an explicit null (clearing) customCallbackUrl
expect(getCrossFieldConfigOverrideError({ 'auth.oauth.providers.google': { isShared: true, customCallbackUrl: null } })).toBeNull();
});
/**
* Does not require a base config, and hence solely relies on the override itself to validate the config. If it returns
* no error, you know that the

View File

@ -592,6 +592,7 @@ export const oauthEnabledSchema = yupBoolean().meta({ openapiField: { descriptio
export const oauthTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'OAuth provider type, one of shared, standard. "shared" uses Stack shared OAuth keys and it is only meant for development. "standard" uses your own OAuth keys and will show your logo and company name when signing in with the provider.', exampleValue: 'standard' } });
export const oauthClientIdSchema = yupString().meta({ openapiField: { description: 'OAuth client ID. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-id' } });
export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } });
export const oauthCustomCallbackUrlSchema = urlSchema.meta({ openapiField: { description: 'The OAuth redirect/callback URL sent to the provider. When omitted, the default callback URL is used. Cannot be set for shared providers.', exampleValue: 'https://api.hexclave.com/api/v1/auth/oauth/callback/google' } });
export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } });
export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } });
export const oauthAppleBundleIdsSchema = yupArray(yupString().defined()).meta({ openapiField: { description: 'Apple Bundle IDs for native iOS/macOS apps. Required for native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID).', exampleValue: ['com.example.ios', 'com.example.macos'] } });

View File

@ -0,0 +1,92 @@
/**
* Single source of truth for the stack-auth hexclave host pairs that the
* cloud deployment treats as equivalent siblings. Each
* `[stackAuthHost, hexclaveHost]` pair is consumed in a few places:
*
* 1. `CLOUD_API_HOST_BY_REQUEST_HOST` in the backend's `request-api-url.ts`
* the allowlist of request hosts we resolve into a JWT `iss` claim.
* 2. `issuerHostAliases` in `tokens.tsx` the bidirectional validator alias
* map, so a token issued under either host validates against the other.
* 3. `getCloudApiUrlSiblings` below resolves the branded base URLs used for
* OAuth `redirect_uri` callbacks (both at runtime and in the dashboard).
*
* Deriving all of these from this one list prevents drift.
*/
export const CLOUD_HOST_PAIRS: ReadonlyArray<readonly [string, string]> = [
["api.stack-auth.com", "api.hexclave.com"],
["api.dev.stack-auth.com", "api.dev.hexclave.com"],
["api.staging.stack-auth.com", "api.staging.hexclave.com"],
];
function hostFromApiUrlOrHost(input: string): string | undefined {
try {
return new URL(input).host.split(":")[0].toLowerCase();
} catch {
const firstHost = input.split(",")[0]?.trim();
if (!firstHost) return undefined;
return firstHost.split(":")[0].toLowerCase();
}
}
/**
* Given an API URL (or bare host), if its host belongs to a known
* stack-auth hexclave cloud pair, return both branded base URLs. Returns
* null for unknown hosts (localhost, vercel previews, self-host custom
* domains).
*/
export function getCloudApiUrlSiblings(apiUrlOrHost: string | undefined | null): { stackAuth: string, hexclave: string } | null {
if (!apiUrlOrHost) return null;
const host = hostFromApiUrlOrHost(apiUrlOrHost);
if (!host) return null;
for (const [stackAuthHost, hexclaveHost] of CLOUD_HOST_PAIRS) {
if (host === stackAuthHost || host === hexclaveHost) {
return { stackAuth: `https://${stackAuthHost}`, hexclave: `https://${hexclaveHost}` };
}
}
return null;
}
/**
* The stack-auth-branded base URL for the given deployment API URL. Used as the
* OAuth `redirect_uri` base for shared providers and for custom providers that
* predate `customCallbackUrl` (so existing flows keep hitting
* `api.stack-auth.com`). Unknown hosts fall back to the input unchanged.
*/
export function getStackAuthApiBaseUrl(apiUrl: string): string {
return getCloudApiUrlSiblings(apiUrl)?.stackAuth ?? apiUrl;
}
/**
* The hexclave-branded base URL for the given deployment API URL. Used when a
* new custom OAuth provider is set up, so its `customCallbackUrl` points at the
* hexclave brand. Unknown hosts fall back to the input unchanged.
*/
export function getHexclaveApiBaseUrl(apiUrl: string): string {
return getCloudApiUrlSiblings(apiUrl)?.hexclave ?? apiUrl;
}
import.meta.vitest?.test("getCloudApiUrlSiblings maps both sides of each cloud pair", ({ expect }) => {
for (const [stackAuthHost, hexclaveHost] of CLOUD_HOST_PAIRS) {
const expected = { stackAuth: `https://${stackAuthHost}`, hexclave: `https://${hexclaveHost}` };
for (const host of [stackAuthHost, hexclaveHost]) {
expect(getCloudApiUrlSiblings(host)).toEqual(expected);
expect(getCloudApiUrlSiblings(`https://${host}`)).toEqual(expected);
expect(getCloudApiUrlSiblings(`https://${host.toUpperCase()}:443`)).toEqual(expected);
}
}
});
import.meta.vitest?.test("getCloudApiUrlSiblings returns null for unknown hosts", ({ expect }) => {
expect(getCloudApiUrlSiblings("http://localhost:8102")).toBeNull();
expect(getCloudApiUrlSiblings("https://my-app.vercel.app")).toBeNull();
expect(getCloudApiUrlSiblings(undefined)).toBeNull();
expect(getCloudApiUrlSiblings("")).toBeNull();
});
import.meta.vitest?.test("getStackAuthApiBaseUrl / getHexclaveApiBaseUrl resolve brands and fall back", ({ expect }) => {
expect(getStackAuthApiBaseUrl("https://api.hexclave.com")).toBe("https://api.stack-auth.com");
expect(getHexclaveApiBaseUrl("https://api.stack-auth.com")).toBe("https://api.hexclave.com");
expect(getStackAuthApiBaseUrl("https://api.dev.stack-auth.com")).toBe("https://api.dev.stack-auth.com");
expect(getHexclaveApiBaseUrl("http://localhost:8102")).toBe("http://localhost:8102");
expect(getStackAuthApiBaseUrl("http://localhost:8102")).toBe("http://localhost:8102");
});