Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-oauth-redirect-fapi.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 8 additions & 4 deletions packages/cli-core/src/commands/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
125 changes: 83 additions & 42 deletions packages/cli-core/src/commands/deploy/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
);
Expand Down Expand Up @@ -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<typeof deploy>[0] = {}) {
try {
await runDeploy(options);
Expand Down Expand Up @@ -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 () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/cli-core/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
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,
Expand Down Expand Up @@ -521,6 +522,7 @@ async function runOAuthSetup(
descriptor,
state.domain,
productionInstanceId,
state.frontendApiUrl,
);
if (!saved) {
throw deployPausedError({
Expand Down Expand Up @@ -556,6 +558,7 @@ async function collectAndSaveOAuthCredentials(
descriptor: OAuthProviderDescriptor,
domain: string,
productionInstanceId: string,
frontendApiUrl?: string,
): Promise<boolean> {
for (const line of providerSetupIntro(descriptor)) log.info(line);
log.blank();
Expand All @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion packages/cli-core/src/commands/deploy/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,18 +439,23 @@ export function providerSetupIntro(provider: OAuthProvider | OAuthProviderDescri
export async function showOAuthWalkthrough(
provider: OAuthProvider | OAuthProviderDescriptor,
domain: string,
frontendApiUrl?: string,
): Promise<void> {
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.<domain>), not the
// Account Portal (accounts.<domain>). 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();
Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export async function resolveLiveDeploySnapshot(
productionInstanceId,
productionDomainId: domain.id,
domain: domain.name,
frontendApiUrl: domain.frontend_api_url,
oauthProviders,
oauthProviderDescriptors,
completedOAuthProviders,
Expand Down