diff --git a/.changeset/fix-oauth-redirect-fapi.md b/.changeset/fix-oauth-redirect-fapi.md new file mode 100644 index 00000000..801b1c92 --- /dev/null +++ b/.changeset/fix-oauth-redirect-fapi.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Fix the OAuth provider walkthrough in `clerk deploy` printing the redirect URI on the wrong subdomain. Previously, the walkthrough showed `https://accounts.{domain}/v1/oauth_callback`, but the callback is served by the Frontend API, so pasting the value into a provider console caused `redirect_uri_mismatch`. The walkthrough now prints the instance's `frontend_api_url` (e.g. `https://clerk.{domain}/v1/oauth_callback`), matching the value shown in the Clerk Dashboard. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index bcaf8baf..93e3d69d 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -189,7 +189,11 @@ Production instances return `422` if you try to enable a provider without creden When the user chooses the guided walkthrough, these values are derived from their domain: -| Field | Value | -| ----------------------------- | --------------------------------------------- | -| Authorized JavaScript origins | `https://{domain}`, `https://www.{domain}` | -| Authorized redirect URI | `https://accounts.{domain}/v1/oauth_callback` | +| Field | Value | +| ----------------------------- | ------------------------------------------ | +| Authorized JavaScript origins | `https://{domain}`, `https://www.{domain}` | +| Authorized redirect URI | `{frontend_api_url}/v1/oauth_callback` | + +The redirect URI is served by the Frontend API (`clerk.{domain}`), not the Account +Portal (`accounts.{domain}`). Use the `frontend_api_url` returned on the domain +object rather than reconstructing it from the domain name. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 9e327c68..04d3ea0b 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -229,48 +229,7 @@ describe("deploy", () => { mockGetApplicationDomainStatus.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); - mockCreateProductionInstance.mockImplementation( - (_appId: string, params: { domain: string }) => { - const hostname = params.domain; - return { - object: "instance", - id: "ins_prod_mock", - environment_type: "production" as const, - active_domain: { - object: "domain", - id: "dmn_prod_mock", - name: hostname, - is_satellite: false, - is_provider_domain: false, - frontend_api_url: `https://clerk.${hostname}`, - development_origin: "", - cname_targets: [ - { - host: `clerk.${hostname}`, - value: "frontend-api.clerk.services", - required: true, - }, - { - host: `accounts.${hostname}`, - value: "accounts.clerk.services", - required: true, - }, - { - host: `clkmail.${hostname}`, - value: `mail.${hostname}.nam1.clerk.services`, - required: true, - }, - ], - created_at: "2026-05-06T00:00:00Z", - updated_at: "2026-05-06T00:00:00Z", - }, - publishable_key: "pk_live_test", - secret_key: "sk_live_test", - created_at: 1770000000000, - updated_at: 1770000000000, - }; - }, - ); + stubCreateProductionInstance(); mockTriggerApplicationDomainDNSCheck.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); @@ -314,6 +273,56 @@ describe("deploy", () => { return deploy(options); } + function stubCreateProductionInstance( + overrides: { + frontendApiUrl?: string; + cnameTargets?: { host: string; value: string; required: boolean }[]; + } = {}, + ) { + mockCreateProductionInstance.mockImplementation( + (_appId: string, params: { domain: string }) => { + const hostname = params.domain; + return { + object: "instance", + id: "ins_prod_mock", + environment_type: "production" as const, + active_domain: { + object: "domain", + id: "dmn_prod_mock", + name: hostname, + is_satellite: false, + is_provider_domain: false, + frontend_api_url: overrides.frontendApiUrl ?? `https://clerk.${hostname}`, + development_origin: "", + cname_targets: overrides.cnameTargets ?? [ + { + host: `clerk.${hostname}`, + value: "frontend-api.clerk.services", + required: true, + }, + { + host: `accounts.${hostname}`, + value: "accounts.clerk.services", + required: true, + }, + { + host: `clkmail.${hostname}`, + value: `mail.${hostname}.nam1.clerk.services`, + required: true, + }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, + publishable_key: "pk_live_test", + secret_key: "sk_live_test", + created_at: 1770000000000, + updated_at: 1770000000000, + }; + }, + ); + } + async function runDeployUntilPause(options: Parameters[0] = {}) { try { await runDeploy(options); @@ -1208,6 +1217,38 @@ describe("deploy", () => { client_secret: "github-secret", }, }); + const err = stripAnsi(captured.err); + expect(err).toContain("https://clerk.example.com/v1/oauth_callback"); + expect(err).not.toContain("https://accounts.example.com/v1/oauth_callback"); + }); + + test("OAuth walkthrough prints the Frontend API redirect URI from the created domain", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + // Distinctive URL proves the value is threaded from the API response + // rather than string-built from the domain name. + stubCreateProductionInstance({ + frontendApiUrl: "https://clerk-fapi.example.com", + cnameTargets: [], + }); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true); // Create production instance? + mockOpenBrowser.mockResolvedValueOnce({ ok: true, launcher: "test" }); + mockSelect + .mockResolvedValueOnce("walkthrough") // Google OAuth credentials + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("skip"); // DNS verification + mockInput.mockResolvedValueOnce("example.com").mockResolvedValueOnce("fake-client-id-12345"); + mockPassword.mockResolvedValueOnce("fake-secret"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + // The callback is a Frontend API endpoint; the walkthrough must print the + // API-reported frontend_api_url, never the Account Portal subdomain. + expect(err).toContain("https://clerk-fapi.example.com/v1/oauth_callback"); + expect(err).not.toContain("https://accounts.example.com/v1/oauth_callback"); }); test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index b1115a02..c6f9f207 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -195,6 +195,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { productionInstanceId: production.id, productionDomainId: production.active_domain.id, domain: productionDomain, + frontendApiUrl: production.active_domain.frontend_api_url, pending: { type: "oauth", provider: oauthProviders[0]?.provider ?? "google" }, oauthProviders: oauthProviders.map((descriptor) => descriptor.provider), completedOAuthProviders, @@ -521,6 +522,7 @@ async function runOAuthSetup( descriptor, state.domain, productionInstanceId, + state.frontendApiUrl, ); if (!saved) { throw deployPausedError({ @@ -556,6 +558,7 @@ async function collectAndSaveOAuthCredentials( descriptor: OAuthProviderDescriptor, domain: string, productionInstanceId: string, + frontendApiUrl?: string, ): Promise { for (const line of providerSetupIntro(descriptor)) log.info(line); log.blank(); @@ -567,7 +570,7 @@ async function collectAndSaveOAuthCredentials( } if (choice === "walkthrough") { - await showOAuthWalkthrough(descriptor, domain); + await showOAuthWalkthrough(descriptor, domain, frontendApiUrl); choice = await chooseOAuthCredentialAction(descriptor, { includeWalkthrough: false }); if (choice === "skip") { return false; diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index ecf55888..7eb52d67 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -439,18 +439,23 @@ export function providerSetupIntro(provider: OAuthProvider | OAuthProviderDescri export async function showOAuthWalkthrough( provider: OAuthProvider | OAuthProviderDescriptor, domain: string, + frontendApiUrl?: string, ): Promise { const descriptor = providerDescriptorFromInput(provider); const slug = descriptor?.provider ?? (provider as OAuthProvider); const label = descriptor?.label ?? providerLabel(slug); const docsUrl = descriptor?.docsUrl ?? providerDocsUrl(slug); + // The OAuth callback is served by the Frontend API (clerk.), not the + // Account Portal (accounts.). Prefer the API-reported URL over + // reconstructing it from the domain. + const callbackBase = frontendApiUrl?.replace(/\/+$/, "") || `https://clerk.${domain}`; log.info(`\nConfigure your ${bold(label)} OAuth app with these values:\n`); log.info(` ${dim("Authorized JavaScript origins")}`); log.info(` ${cyan(`https://${domain}`)}`); log.info(` ${cyan(`https://www.${domain}`)}`); log.info(` ${dim(descriptor?.redirectLabel ?? providerRedirectLabel(slug))}`); - log.info(` ${cyan(`https://accounts.${domain}/v1/oauth_callback`)}`); + log.info(` ${cyan(`${callbackBase}/v1/oauth_callback`)}`); const gotcha = descriptor?.gotcha ?? providerGotcha(slug); if (gotcha) { log.blank(); diff --git a/packages/cli-core/src/commands/deploy/state.ts b/packages/cli-core/src/commands/deploy/state.ts index e1fe5c5d..72b789b3 100644 --- a/packages/cli-core/src/commands/deploy/state.ts +++ b/packages/cli-core/src/commands/deploy/state.ts @@ -10,6 +10,7 @@ export type DeployOperationState = { productionInstanceId?: string; productionDomainId?: string; domain: string; + frontendApiUrl?: string; pending: { type: "dns" } | { type: "oauth"; provider: string }; oauthProviders: string[]; completedOAuthProviders: string[]; diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 0d2836df..f9ab0ce9 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -217,6 +217,7 @@ export async function resolveLiveDeploySnapshot( productionInstanceId, productionDomainId: domain.id, domain: domain.name, + frontendApiUrl: domain.frontend_api_url, oauthProviders, oauthProviderDescriptors, completedOAuthProviders,