diff --git a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx index 81864b4ea..29e0680a8 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx @@ -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, expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes), }, diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index a54eb2e9f..5ffd2bb26 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -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>; try { callbackResult = await providerObj.getCallback({ diff --git a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx index 17d5af421..ee8891c4c 100644 --- a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx +++ b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx @@ -5,7 +5,6 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { KnownErrors } from "@hexclave/shared"; import { connectedAccountAccessTokenCrud } from "@hexclave/shared/dist/interface/crud/connected-accounts"; import { userIdOrMeSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; -import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { createLazyProxy } from "@hexclave/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({ diff --git a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx index 7d0e56b96..a7fb50eef 100644 --- a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx +++ b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx @@ -5,7 +5,6 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { KnownErrors } from "@hexclave/shared"; import { connectedAccountAccessTokenCrud } from "@hexclave/shared/dist/interface/crud/connected-accounts"; import { userIdOrMeSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; -import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { createLazyProxy } from "@hexclave/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 diff --git a/apps/backend/src/lib/end-users.tsx b/apps/backend/src/lib/end-users.tsx index 048eaef41..f5adf1e96 100644 --- a/apps/backend/src/lib/end-users.tsx +++ b/apps/backend/src/lib/end-users.tsx @@ -92,6 +92,15 @@ function parseCoordinate(raw: string | null | undefined): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +function decodeVercelGeoHeader(raw: string | null | undefined): string | undefined { + if (raw == null || raw === "") return undefined; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy): | { maybeSpoofed: true, spoofedInfo: EndUserInfoInner } | { maybeSpoofed: false, exactInfo: EndUserInfoInner } @@ -133,7 +142,7 @@ function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy): const geoLocation: EndUserLocation = { countryCode: rawCountryCode ? normalizeCountryCode(rawCountryCode) : undefined, regionCode: (isVercelTrusted ? allHeaders.get("x-vercel-ip-country-region") : undefined) || undefined, - cityName: (isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined) || undefined, + cityName: decodeVercelGeoHeader(isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined), latitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-latitude") : null), longitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-longitude") : null), tzIdentifier: (isVercelTrusted ? allHeaders.get("x-vercel-ip-timezone") : undefined) || undefined, @@ -144,7 +153,7 @@ function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy): const spoofedGeoLocation: EndUserLocation = trustedProxy === "" ? { countryCode: rawSpoofedCountryCode ? normalizeCountryCode(rawSpoofedCountryCode) : undefined, regionCode: allHeaders.get("x-vercel-ip-country-region") || undefined, - cityName: allHeaders.get("x-vercel-ip-city") || undefined, + cityName: decodeVercelGeoHeader(allHeaders.get("x-vercel-ip-city")), latitude: parseCoordinate(allHeaders.get("x-vercel-ip-latitude")), longitude: parseCoordinate(allHeaders.get("x-vercel-ip-longitude")), tzIdentifier: allHeaders.get("x-vercel-ip-timezone") || undefined, @@ -315,4 +324,48 @@ import.meta.vitest?.describe("getBrowserEndUserInfo(...)", () => { }, }); }); + + test("decodes URL-encoded city names from Vercel geo headers", () => { + // Vercel percent-encodes city names, so a multi-word city arrives as "San%20Francisco". + const result = getBrowserEndUserInfo(new Headers({ + "user-agent": "Mozilla/5.0", + "x-vercel-forwarded-for": "203.0.113.10", + "x-vercel-ip-country": "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-city": "San%20Francisco", + "x-vercel-ip-latitude": "37.77", + "x-vercel-ip-longitude": "-122.41", + "x-vercel-ip-timezone": "America/Los_Angeles", + }), "vercel"); + + expect(result).toEqual({ + maybeSpoofed: false, + exactInfo: { + ip: "203.0.113.10", + countryCode: "US", + regionCode: "CA", + cityName: "San Francisco", + latitude: 37.77, + longitude: -122.41, + tzIdentifier: "America/Los_Angeles", + }, + }); + }); + + test("falls back to the raw city name when it is not valid percent-encoding", () => { + // A lone "%" is invalid percent-encoding; decoding must not throw, just pass it through. + const result = getBrowserEndUserInfo(new Headers({ + "user-agent": "Mozilla/5.0", + "x-vercel-forwarded-for": "203.0.113.10", + "x-vercel-ip-city": "100% Real City", + }), "vercel"); + + expect(result).toEqual({ + maybeSpoofed: false, + exactInfo: { + ip: "203.0.113.10", + cityName: "100% Real City", + }, + }); + }); }); diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 7bc3e3990..a30be0a30 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -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, diff --git a/apps/backend/src/lib/request-api-url.ts b/apps/backend/src/lib/request-api-url.ts index 7da334189..55ff6c249 100644 --- a/apps/backend/src/lib/request-api-url.ts +++ b/apps/backend/src/lib/request-api-url.ts @@ -1,25 +1,15 @@ +import { CLOUD_HOST_PAIRS } from "@hexclave/shared/dist/utils/cloud-hosts"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { captureError, HexclaveAssertionError } from "@hexclave/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 = [ - ["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 diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index ac7835679..01fa4f5ad 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -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'; diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index 1e5a9a80f..0d3226c39 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -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 "@hexclave/shared/dist/utils/cloud-hosts"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError, throwErr } from "@hexclave/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 { - 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, }); } } diff --git a/apps/backend/src/oauth/providers/apple.tsx b/apps/backend/src/oauth/providers/apple.tsx index 9852a9136..29801f962 100644 --- a/apps/backend/src/oauth/providers/apple.tsx +++ b/apps/backend/src/oauth/providers/apple.tsx @@ -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" }, diff --git a/apps/backend/src/oauth/providers/bitbucket.tsx b/apps/backend/src/oauth/providers/bitbucket.tsx index 01de25d92..aa187a5e7 100644 --- a/apps/backend/src/oauth/providers/bitbucket.tsx +++ b/apps/backend/src/oauth/providers/bitbucket.tsx @@ -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, })) diff --git a/apps/backend/src/oauth/providers/discord.tsx b/apps/backend/src/oauth/providers/discord.tsx index 66536dd8d..d14ec337b 100644 --- a/apps/backend/src/oauth/providers/discord.tsx +++ b/apps/backend/src/oauth/providers/discord.tsx @@ -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, })); diff --git a/apps/backend/src/oauth/providers/facebook.tsx b/apps/backend/src/oauth/providers/facebook.tsx index 3ef2a2571..e35bb848d 100644 --- a/apps/backend/src/oauth/providers/facebook.tsx +++ b/apps/backend/src/oauth/providers/facebook.tsx @@ -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", diff --git a/apps/backend/src/oauth/providers/github.tsx b/apps/backend/src/oauth/providers/github.tsx index ee5c92a5e..eff2f5e7e 100644 --- a/apps/backend/src/oauth/providers/github.tsx +++ b/apps/backend/src/oauth/providers/github.tsx @@ -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, diff --git a/apps/backend/src/oauth/providers/gitlab.tsx b/apps/backend/src/oauth/providers/gitlab.tsx index d2214403c..6c73286c6 100644 --- a/apps/backend/src/oauth/providers/gitlab.tsx +++ b/apps/backend/src/oauth/providers/gitlab.tsx @@ -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, })) diff --git a/apps/backend/src/oauth/providers/google.tsx b/apps/backend/src/oauth/providers/google.tsx index 16decaf28..a81b26378 100644 --- a/apps/backend/src/oauth/providers/google.tsx +++ b/apps/backend/src/oauth/providers/google.tsx @@ -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", diff --git a/apps/backend/src/oauth/providers/linkedin.tsx b/apps/backend/src/oauth/providers/linkedin.tsx index dd59783d3..f91142a33 100644 --- a/apps/backend/src/oauth/providers/linkedin.tsx +++ b/apps/backend/src/oauth/providers/linkedin.tsx @@ -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", diff --git a/apps/backend/src/oauth/providers/microsoft.tsx b/apps/backend/src/oauth/providers/microsoft.tsx index a127d3b1e..af6fc7f4b 100644 --- a/apps/backend/src/oauth/providers/microsoft.tsx +++ b/apps/backend/src/oauth/providers/microsoft.tsx @@ -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`, diff --git a/apps/backend/src/oauth/providers/mock.tsx b/apps/backend/src/oauth/providers/mock.tsx index 85165e4b6..63bae31c6 100644 --- a/apps/backend/src/oauth/providers/mock.tsx +++ b/apps/backend/src/oauth/providers/mock.tsx @@ -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, diff --git a/apps/backend/src/oauth/providers/spotify.tsx b/apps/backend/src/oauth/providers/spotify.tsx index c48380c7f..6b7029f8e 100644 --- a/apps/backend/src/oauth/providers/spotify.tsx +++ b/apps/backend/src/oauth/providers/spotify.tsx @@ -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, })); diff --git a/apps/backend/src/oauth/providers/twitch.tsx b/apps/backend/src/oauth/providers/twitch.tsx index 72f2ecc70..bf6849193 100644 --- a/apps/backend/src/oauth/providers/twitch.tsx +++ b/apps/backend/src/oauth/providers/twitch.tsx @@ -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, })); diff --git a/apps/backend/src/oauth/providers/x.tsx b/apps/backend/src/oauth/providers/x.tsx index c222936a2..a48223d5f 100644 --- a/apps/backend/src/oauth/providers/x.tsx +++ b/apps/backend/src/oauth/providers/x.tsx @@ -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, })) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/oauth-callback-url.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/oauth-callback-url.ts new file mode 100644 index 000000000..69a2f14c5 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/oauth-callback-url.ts @@ -0,0 +1,50 @@ +import { getPublicEnvVar } from "@/lib/env"; +import type { CompleteConfig } from "@hexclave/shared/dist/config/schema"; +import { getHexclaveApiBaseUrl, getStackAuthApiBaseUrl } from "@hexclave/shared/dist/utils/cloud-hosts"; +import { throwErr } from "@hexclave/shared/dist/utils/errors"; +import { urlString } from "@hexclave/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); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 2c990a460..da8d5a618 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -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, }); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index d34111e44..b6c93a26d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -10,7 +10,6 @@ import { DesignInput, DesignPillToggle, } from "@hexclave/dashboard-ui-components"; -import { getPublicEnvVar } from '@/lib/env'; import { ArrowRightIcon, InfoIcon, WarningCircleIcon } from "@phosphor-icons/react"; import { AdminProject } from "@hexclave/next"; import { yupBoolean, yupObject, yupString } from "@hexclave/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 (
Redirect URL - {`${getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') ?? ''}${urlString`/api/v1/auth/oauth/callback/${providerId}`}`} + {redirectUrl}
); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts index 1fe010c55..aa97e64bb 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts @@ -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", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index 22c77ad5d..da78433d2 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -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'); + }); }); diff --git a/docs-mintlify/migration.mdx b/docs-mintlify/migration.mdx index 3b1be172b..e0a568325 100644 --- a/docs-mintlify/migration.mdx +++ b/docs-mintlify/migration.mdx @@ -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/ -``` - -## 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/ +``` + Questions? [Discord](https://discord.hexclave.com) or [team@hexclave.com](mailto:team@hexclave.com). diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index 9ed8a29e3..891216528 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -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"] }] }], diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 18c466c97..a7e23e3fb 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -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, @@ -1128,7 +1134,6 @@ export async function sanitizeOrganizationConfig(config: OrganizationRenderedCon }; } - /** * 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 diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index f50206972..168b0399f 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -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'] } }); diff --git a/packages/stack-shared/src/utils/cloud-hosts.tsx b/packages/stack-shared/src/utils/cloud-hosts.tsx new file mode 100644 index 000000000..0a2352326 --- /dev/null +++ b/packages/stack-shared/src/utils/cloud-hosts.tsx @@ -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 = [ + ["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"); +}); diff --git a/packages/template/README.md b/packages/template/README.md index 862883ac3..ea4bca373 100644 --- a/packages/template/README.md +++ b/packages/template/README.md @@ -1,26 +1,10 @@ -# Hexclave: Open-source Clerk/Auth0 alternative +# This package has been renamed. -## [📘 Docs](https://docs.hexclave.com) | [☁️ Hosted Version](https://hexclave.com/) | [✨ Demo](https://demo.hexclave.com/) | [🎮 Discord](https://discord.hexclave.com) | [GitHub](https://github.com/hexclave/hexclave) +Stack Auth is now Hexclave! The new packages are: -Hexclave is a managed user authentication solution. It is developer-friendly and fully open-source (licensed under MIT and AGPL). +- @hexclave/next +- @hexclave/react +- @hexclave/js +- @hexclave/cli -Hexclave gets you started in just five minutes, after which you'll be ready to use all of its features as you grow your project. Our managed service is completely optional and you can export your user data and self-host, for free, at any time. - -We support Next.js frontends, along with any backend that can use our [REST API](https://docs.hexclave.com/rest-api/overview). Check out our [setup guide](https://docs.hexclave.com/getting-started/setup) to get started. - -## 📦 Installation & Setup - -1. Run Hexclave's installation wizard with the following command: - ```bash - npx @hexclave/cli@latest init - ``` -2. Then, create an account on the [Hexclave dashboard](https://app.hexclave.com/projects), create a new project with an API key, and copy its environment variables into the .env.local file of your Next.js project: - ``` - NEXT_PUBLIC_STACK_PROJECT_ID= - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= - STACK_SECRET_SERVER_KEY= - ``` -3. That's it! You can run your app with `npm run dev` and go to [http://localhost:3000/handler/signup](http://localhost:3000/handler/signup) to see the sign-up page. You can also check out the account settings page at [http://localhost:3000/handler/account-settings](http://localhost:3000/handler/account-settings). - - -Check out the [documentation](https://docs.hexclave.com/getting-started/setup) for a more detailed guide. +See the [migration guide](https://docs.hexclave.com/migration) for more information.