Skip to content

Commit 9eb8632

Browse files
committed
Fix OAuth redirect URI mismatch for org-scoped client-id metadata
Org-scoped client-id metadata documents registered their callback with an executor_org query param on redirect_uri, but the client sends the bare callback and the org is carried in the OAuth state (#1147). Providers that exact-match redirect_uri (PostHog) rejected the authorize request with "Mismatching redirect URI". Org targets keep their distinct client_id URL but now register the same bare callback redirect_uri as every other target.
1 parent b1e73d2 commit 9eb8632

3 files changed

Lines changed: 28 additions & 24 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@executor-js/api": patch
3+
---
4+
5+
Fix OAuth "Mismatching redirect URI" for org-scoped client-id metadata documents
6+
7+
Org-scoped client-id metadata documents registered their callback as
8+
`redirect_uri` with an `executor_org` query param, but the client always sends
9+
the bare callback and the org is carried in the OAuth `state`. Providers that
10+
compare `redirect_uri` as an exact string (such as PostHog) rejected the
11+
authorize request. Org targets now keep their distinct `client_id` URL but
12+
register the same bare callback `redirect_uri` as every other target.

packages/core/api/src/server/oauth-client-metadata.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest";
33
import { oauthClientIdMetadataDocumentFromRequest } from "./oauth-client-metadata";
44

55
describe("OAuth client ID metadata document", () => {
6-
it("builds an org-scoped hosted document from a target path", () => {
6+
it("builds an org-scoped hosted document with a bare callback redirect_uri", () => {
77
const metadata = oauthClientIdMetadataDocumentFromRequest({
88
requestUrl: "/api/oauth/client-id-metadata/acme.json",
99
webRequest: new Request("http://127.0.0.1:42384/api/oauth/client-id-metadata/acme.json", {
@@ -12,12 +12,13 @@ describe("OAuth client ID metadata document", () => {
1212
mountPrefix: "/api",
1313
});
1414

15+
// The org is identified by the per-org client_id URL, not by a query param
16+
// on redirect_uri (which the client never sends, and the callback reads
17+
// from `state`). redirect_uri must stay bare for exact-match providers.
1518
expect(metadata.client_id).toBe(
1619
"http://100.81.219.45:42384/api/oauth/client-id-metadata/acme.json",
1720
);
18-
expect(metadata.redirect_uris).toEqual([
19-
"http://100.81.219.45:42384/api/oauth/callback?executor_org=acme",
20-
]);
21+
expect(metadata.redirect_uris).toEqual(["http://100.81.219.45:42384/api/oauth/callback"]);
2122
expect(metadata.token_endpoint_auth_method).toBe("none");
2223
expect(metadata.application_type).toBe("web");
2324
});
@@ -65,7 +66,7 @@ describe("OAuth client ID metadata document", () => {
6566
expect(metadata.application_type).toBe("native");
6667
});
6768

68-
it("still reads the org selector from the legacy query form", () => {
69+
it("ignores a legacy executor_org query param when building redirect_uri", () => {
6970
const metadata = oauthClientIdMetadataDocumentFromRequest({
7071
requestUrl: "/api/oauth/client-id-metadata.json?executor_org=acme",
7172
webRequest: new Request("http://127.0.0.1:42384/api/oauth/client-id-metadata.json", {
@@ -74,11 +75,11 @@ describe("OAuth client ID metadata document", () => {
7475
mountPrefix: "/api",
7576
});
7677

78+
// A stray executor_org query param is inert: it stays on the client_id URL
79+
// (which is just the request URL) but never leaks into redirect_uri.
7780
expect(metadata.client_id).toBe(
7881
"http://100.81.219.45:42384/api/oauth/client-id-metadata.json?executor_org=acme",
7982
);
80-
expect(metadata.redirect_uris).toEqual([
81-
"http://100.81.219.45:42384/api/oauth/callback?executor_org=acme",
82-
]);
83+
expect(metadata.redirect_uris).toEqual(["http://100.81.219.45:42384/api/oauth/callback"]);
8384
});
8485
});

packages/core/api/src/server/oauth-client-metadata.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX =
88
`${OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH}/` as const;
99
export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET = "default" as const;
1010
export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET = "local" as const;
11-
const OAUTH_CALLBACK_ORG_QUERY_PARAM = "executor_org" as const;
1211

1312
type MetadataTarget =
1413
| typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET
@@ -80,17 +79,6 @@ const metadataTargetFromPath = ({
8079
return target ? target : undefined;
8180
};
8281

83-
const orgSlugFromMetadataTarget = (target: MetadataTarget | undefined): string | undefined => {
84-
if (!target) return undefined;
85-
if (
86-
target === OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET ||
87-
target === OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET
88-
) {
89-
return undefined;
90-
}
91-
return target;
92-
};
93-
9482
export const oauthClientIdMetadataDocumentUrlFromRequest = ({
9583
requestUrl,
9684
webRequest,
@@ -148,11 +136,14 @@ export const oauthClientIdMetadataDocumentFromRequest = ({
148136
};
149137
}
150138

139+
// The org selector travels in the OAuth `state` (see #1147 and apps/cloud
140+
// start.ts, which reads it back from state on the callback), never as a
141+
// provider-facing query param on redirect_uri. Org targets get a distinct
142+
// `client_id` URL, but all targets register the SAME bare callback so the
143+
// redirect_uri the client sends matches this document exactly. Providers
144+
// (e.g. PostHog) compare redirect_uri as an exact string, so an extra query
145+
// param here would fail with "Mismatching redirect URI".
151146
const redirectUri = new URL(callbackPathWithMountPrefix(mountPrefix), url.origin);
152-
const orgSlug =
153-
orgSlugFromMetadataTarget(target) ??
154-
url.searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM)?.trim();
155-
if (orgSlug) redirectUri.searchParams.set(OAUTH_CALLBACK_ORG_QUERY_PARAM, orgSlug);
156147

157148
return {
158149
client_id: url.toString(),

0 commit comments

Comments
 (0)