Skip to content

Commit c85d12c

Browse files
authored
Support CIMD OAuth for API sources
Add OAuth Client ID Metadata Document (CIMD) support so an API source can be connected without Dynamic Client Registration or a manually registered app. When the authorization server advertises client_id_metadata_document_supported, the connect flow uses a public PKCE client whose client_id is this host's metadata-document URL, and the provider fetches that document to learn the client's redirect URIs. Also stops spec-derived OAuth scope catalogs (an OpenAPI source can declare hundreds of scopes) from triggering a spurious reconnect prompt when the provider grants a narrower set.
1 parent cc94666 commit c85d12c

33 files changed

Lines changed: 1133 additions & 110 deletions

packages/core/api/src/integrations/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ const OAuthDescriptor = Schema.Struct({
4747
discoveryUrl: Schema.optional(Schema.String),
4848
authorizationUrl: Schema.optional(Schema.String),
4949
tokenUrl: Schema.optional(Schema.String),
50+
resource: Schema.optional(Schema.NullOr(Schema.String)),
5051
scopes: Schema.optional(Schema.Array(Schema.String)),
5152
registrationEndpoint: Schema.optional(Schema.String),
5253
supportsDynamicRegistration: Schema.optional(Schema.Boolean),
54+
supportsClientIdMetadataDocument: Schema.optional(Schema.Boolean),
5355
});
5456

5557
/** A single declared auth method — mirrors the SDK's `AuthMethodDescriptor`. */

packages/core/api/src/oauth-popup.test.ts

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -235,44 +235,33 @@ describe("runOAuthCallback", () => {
235235
expect(received[1]!.callbackDomain).toBeNull();
236236
});
237237

238-
it("prefers `error` over `error_description` but falls back when absent", async () => {
239-
const received: Array<{
240-
state: string;
241-
code: string | null;
242-
error: string | null;
243-
callbackDomain: string | null;
244-
}> = [];
245-
const complete = (params: {
246-
state: string;
247-
code: string | null;
248-
error: string | null;
249-
callbackDomain: string | null;
250-
}) => {
251-
received.push(params);
238+
it("renders provider OAuth errors without invoking completeOAuth", async () => {
239+
let completeCalls = 0;
240+
const complete = () => {
241+
completeCalls += 1;
252242
return Effect.succeed({
253243
kind: "oauth2" as const,
254244
accessTokenSecretId: "s",
255245
refreshTokenSecretId: null,
256246
});
257247
};
258-
await Effect.runPromise(
259-
runOAuthCallback<GoogleAuth, never, never>({
260-
complete,
261-
urlParams: { state: "s1", error: "access_denied" },
262-
toErrorMessage: () => ({ short: "" }),
263-
channelName: "c",
264-
}),
265-
);
266-
await Effect.runPromise(
248+
const html = await Effect.runPromise(
267249
runOAuthCallback<GoogleAuth, never, never>({
268250
complete,
269-
urlParams: { state: "s2", error_description: "user cancelled" },
251+
urlParams: {
252+
state: "s1",
253+
error: "invalid_scope",
254+
error_description: "unknown scope wizard_session:write",
255+
},
270256
toErrorMessage: () => ({ short: "" }),
271257
channelName: "c",
272258
}),
273259
);
274-
expect(received[0]!.error).toBe("access_denied");
275-
expect(received[1]!.error).toBe("user cancelled");
260+
expect(completeCalls).toBe(0);
261+
expect(html).toContain("<title>Connection failed</title>");
262+
expect(html).toContain("OAuth provider rejected authorization");
263+
expect(html).toContain("invalid_scope");
264+
expect(html).toContain("unknown scope wizard_session:write");
276265
});
277266

278267
it("renders a failure popup when completeOAuth fails and uses toErrorMessage", async () => {

packages/core/api/src/oauth-popup.ts

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ export type RunOAuthCallbackInput<TAuth, E, R> = {
155155
readonly channelName: string;
156156
};
157157

158+
const providerErrorMessage = (params: OAuthCallbackUrlParams): PopupErrorMessage | null => {
159+
const error = params.error ?? null;
160+
const description = params.error_description ?? null;
161+
const value = error ?? description;
162+
if (!value) return null;
163+
return {
164+
short: "OAuth provider rejected authorization",
165+
details: error && description && description !== error ? `${error}: ${description}` : value,
166+
};
167+
};
168+
158169
/**
159170
* Run a plugin's `completeOAuth` against URL params from the OAuth redirect,
160171
* wrap the success / failure in an `OAuthPopupResult`, and return the HTML
@@ -165,35 +176,49 @@ export type RunOAuthCallbackInput<TAuth, E, R> = {
165176
*/
166177
export const runOAuthCallback = <TAuth, E, R>(
167178
input: RunOAuthCallbackInput<TAuth, E, R>,
168-
): Effect.Effect<string, never, R> =>
169-
input
170-
.complete({
171-
state: input.urlParams.state,
172-
code: input.urlParams.code ?? null,
173-
error: input.urlParams.error ?? input.urlParams.error_description ?? null,
174-
callbackDomain: input.urlParams.domain ?? input.urlParams.site ?? null,
175-
})
176-
.pipe(
177-
Effect.map(
178-
(auth): OAuthPopupResult<TAuth> => ({
179-
type: OAUTH_POPUP_MESSAGE_TYPE,
180-
ok: true,
181-
sessionId: input.urlParams.state,
182-
...auth,
183-
}),
184-
),
185-
Effect.catchCause((cause) => {
186-
const { short, details } = input.toErrorMessage(Cause.squash(cause));
187-
return Effect.succeed<OAuthPopupResult<TAuth>>({
179+
): Effect.Effect<string, never, R> => {
180+
const providerError = providerErrorMessage(input.urlParams);
181+
const result =
182+
providerError == null
183+
? input
184+
.complete({
185+
state: input.urlParams.state,
186+
code: input.urlParams.code ?? null,
187+
error: null,
188+
callbackDomain: input.urlParams.domain ?? input.urlParams.site ?? null,
189+
})
190+
.pipe(
191+
Effect.map(
192+
(auth): OAuthPopupResult<TAuth> => ({
193+
type: OAUTH_POPUP_MESSAGE_TYPE,
194+
ok: true,
195+
sessionId: input.urlParams.state,
196+
...auth,
197+
}),
198+
),
199+
)
200+
: Effect.succeed<OAuthPopupResult<TAuth>>({
188201
type: OAUTH_POPUP_MESSAGE_TYPE,
189202
ok: false,
190203
sessionId: input.urlParams.state ?? null,
191-
error: short,
192-
...(details && details !== short ? { errorDetails: details } : {}),
204+
error: providerError.short,
205+
...(providerError.details ? { errorDetails: providerError.details } : {}),
193206
});
194-
}),
195-
Effect.tap((result) =>
196-
Effect.sync(() => completionListener?.(result as OAuthPopupResult<unknown>)),
197-
),
198-
Effect.map((result) => popupDocument(result, input.channelName)),
199-
);
207+
208+
return result.pipe(
209+
Effect.catchCause((cause) => {
210+
const { short, details } = input.toErrorMessage(Cause.squash(cause));
211+
return Effect.succeed<OAuthPopupResult<TAuth>>({
212+
type: OAUTH_POPUP_MESSAGE_TYPE,
213+
ok: false,
214+
sessionId: input.urlParams.state ?? null,
215+
error: short,
216+
...(details && details !== short ? { errorDetails: details } : {}),
217+
});
218+
}),
219+
Effect.tap((result) =>
220+
Effect.sync(() => completionListener?.(result as OAuthPopupResult<unknown>)),
221+
),
222+
Effect.map((result) => popupDocument(result, input.channelName)),
223+
);
224+
};

packages/core/api/src/oauth/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ const ProbeResponse = Schema.Struct({
209209
scopesSupported: Schema.optional(Schema.Array(Schema.String)),
210210
registrationEndpoint: Schema.optional(Schema.NullOr(Schema.String)),
211211
tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)),
212+
clientIdMetadataDocumentSupported: Schema.optional(Schema.Boolean),
212213
});
213214

214215
// ---------------------------------------------------------------------------

packages/core/sdk/src/core-tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ const OAuthProbeOutput = Schema.Struct({
293293
scopesSupported: Schema.optional(Schema.Array(Schema.String)),
294294
registrationEndpoint: Schema.optional(Schema.NullOr(Schema.String)),
295295
tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)),
296+
clientIdMetadataDocumentSupported: Schema.optional(Schema.Boolean),
296297
});
297298
const OAuthStartInput = Schema.Struct({
298299
client: Schema.String,
@@ -794,6 +795,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {
794795
scopesSupported: result.scopesSupported,
795796
registrationEndpoint: result.registrationEndpoint ?? null,
796797
tokenEndpointAuthMethodsSupported: result.tokenEndpointAuthMethodsSupported,
798+
clientIdMetadataDocumentSupported: result.clientIdMetadataDocumentSupported,
797799
})),
798800
}),
799801
tool({

packages/core/sdk/src/integration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,16 @@ export interface AuthMethodOAuthDescriptor {
5252
readonly discoveryUrl?: string;
5353
readonly authorizationUrl?: string;
5454
readonly tokenUrl?: string;
55+
readonly resource?: string | null;
5556
readonly scopes?: readonly string[];
5657
readonly registrationEndpoint?: string;
5758
/** True when the integration is known to support RFC 7591 dynamic client
5859
* registration (drives the transparent auto-register connect flow). */
5960
readonly supportsDynamicRegistration?: boolean;
61+
/** True when the authorization server supports Client ID Metadata Document
62+
* clients. The UI can create a local public OAuth client using this host's
63+
* metadata-document URL as `client_id`, with no provider app registration. */
64+
readonly supportsClientIdMetadataDocument?: boolean;
6065
}
6166

6267
/** A single declared auth method on an integration's catalog response. */

packages/core/sdk/src/oauth-client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,15 @@ export interface OAuthAuthentication {
3232
readonly kind: "oauth2";
3333
readonly authorizationUrl: string;
3434
readonly tokenUrl: string;
35+
/** RFC 8707 Resource Indicator to bind the OAuth flow to this protected
36+
* resource, when discovered from protected-resource metadata. */
37+
readonly resource?: string | null;
3538
readonly scopes: readonly string[];
39+
/** True when the authorization server supports OAuth Client ID Metadata
40+
* Document (CIMD). The local OAuth client is then a public PKCE client whose
41+
* `client_id` is this host's metadata-document URL, not a provider-side
42+
* registered app id. */
43+
readonly supportsClientIdMetadataDocument?: boolean;
3644
}
3745

3846
/** A registered OAuth app — pure app identity: clientId/secret + its endpoints.
@@ -138,6 +146,9 @@ export interface OAuthProbeResult {
138146
/** RFC 8414 `token_endpoint_auth_methods_supported`. Surfaced so DCR can pick
139147
* a public ("none") client when the server allows it. */
140148
readonly tokenEndpointAuthMethodsSupported?: readonly string[];
149+
/** Draft OAuth Client ID Metadata Document support, advertised by providers
150+
* such as PostHog as `client_id_metadata_document_supported`. */
151+
readonly clientIdMetadataDocumentSupported?: boolean;
141152
}
142153

143154
/** Mint an OAuth client via RFC 7591 Dynamic Client Registration and persist it.

packages/core/sdk/src/oauth-discovery.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({
7272
authorization_endpoint: Schema.String,
7373
token_endpoint: Schema.String,
7474
registration_endpoint: Schema.optional(Schema.String),
75+
client_id_metadata_document_supported: Schema.optional(Schema.Boolean),
7576
scopes_supported: Schema.optional(StringArray),
7677
response_types_supported: Schema.optional(StringArray),
7778
grant_types_supported: Schema.optional(StringArray),

packages/core/sdk/src/oauth-scope-union.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,44 @@ describe("oauth.start integration-driven scopes", () => {
220220
),
221221
);
222222

223+
it.effect("filters stale declared scopes against authorization-server metadata", () =>
224+
Effect.scoped(
225+
Effect.gen(function* () {
226+
const server = yield* serveOAuthTestServer({ scopes: ["calendar", "drive"] });
227+
const plugins = [
228+
memoryCredentialsPlugin(),
229+
makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }),
230+
] as const;
231+
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
232+
yield* executor.acme.seed();
233+
234+
yield* executor.oauth.createClient({
235+
owner: "org",
236+
slug: CLIENT,
237+
authorizationUrl: server.authorizationEndpoint,
238+
tokenUrl: server.tokenEndpoint,
239+
grant: "authorization_code",
240+
clientId: "test-client",
241+
clientSecret: "test-secret",
242+
resource: server.resourceUrl,
243+
});
244+
245+
const started = yield* executor.oauth.start({
246+
owner: "org",
247+
client: CLIENT,
248+
clientOwner: "org",
249+
name: ConnectionName.make("main"),
250+
integration: INTEG,
251+
template: TEMPLATE,
252+
});
253+
expect(started.status).toBe("redirect");
254+
if (started.status !== "redirect") return;
255+
256+
expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["calendar", "drive"]);
257+
}),
258+
),
259+
);
260+
223261
it.effect("(b) when the integration declares no oauth scopes, start requests none", () =>
224262
Effect.scoped(
225263
Effect.gen(function* () {

packages/core/sdk/src/oauth-service.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,15 @@ const refreshItemIdFor = (accessId: string): string => `${accessId}:refresh`;
180180
/** Order-preserving de-duplication of a scope list. */
181181
const dedupeScopes = (scopes: readonly string[]): readonly string[] => [...new Set(scopes)];
182182

183+
const intersectScopes = (
184+
requested: readonly string[],
185+
supported: readonly string[] | undefined,
186+
): readonly string[] => {
187+
if (!supported || supported.length === 0) return requested;
188+
const supportedSet = new Set(supported);
189+
return requested.filter((scope) => supportedSet.has(scope));
190+
};
191+
183192
const recordedOAuthScope = (
184193
token: OAuth2TokenResponse,
185194
requestedScopes: readonly string[],
@@ -354,6 +363,28 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
354363
// EXPLICIT — no localhost default. `null` means this executor has no OAuth
355364
// callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
356365
const redirectUri = deps.redirectUri;
366+
const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy };
367+
368+
const filterAuthorizationCodeScopes = (
369+
client: LoadedOAuthClient,
370+
requestedScopes: readonly string[],
371+
): Effect.Effect<readonly string[], never> =>
372+
Effect.gen(function* () {
373+
if (requestedScopes.length === 0) return requestedScopes;
374+
const resource = client.resource
375+
? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe(
376+
Effect.catch(() => Effect.succeed(null)),
377+
Effect.provide(httpClientLayer),
378+
)
379+
: null;
380+
const issuer =
381+
resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin;
382+
const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe(
383+
Effect.catch(() => Effect.succeed(null)),
384+
Effect.provide(httpClientLayer),
385+
);
386+
return intersectScopes(requestedScopes, as?.metadata.scopes_supported);
387+
}).pipe(Effect.catch(() => Effect.succeed(requestedScopes)));
357388

358389
// Caps on server-controlled discovery input — a hostile or buggy server must
359390
// not be able to hang `oauth.start` or overflow the authorize URL.
@@ -818,6 +849,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
818849
message: REDIRECT_URI_REQUIRED_MESSAGE,
819850
});
820851
}
852+
// Prune stale DECLARED scopes against the AS's advertised set, but leave
853+
// resource-discovered scopes untouched: an RFC 9728 `scopes_supported`
854+
// list is already authoritative (§7.2) and must not be re-narrowed by a
855+
// divergent authorization server.
856+
const authorizationRequestedScopes =
857+
scopePolicy.kind === "discover"
858+
? requestedScopes
859+
: yield* filterAuthorizationCodeScopes(client, requestedScopes);
821860

822861
// authorization_code: persist a session + build the authorize URL.
823862
const verifier = createPkceCodeVerifier();
@@ -843,11 +882,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
843882
redirect_url: flowRedirectUri,
844883
pkce_verifier: verifier,
845884
identity_label: input.identityLabel ?? null,
846-
// Persist the requested scope set (the integration's declared or
847-
// discovered scopes) so `complete`'s recorded-scope fallback reflects
848-
// exactly what was requested when the AS omits `scope`, without
849-
// re-resolving it at completion.
850-
payload: { owner: input.owner, clientOwner: input.clientOwner, requestedScopes },
885+
// Persist the requested scope set (declared ∪ client, filtered to the
886+
// authorization-code flow) so `complete`'s recorded-scope fallback
887+
// reflects exactly what was requested when the AS omits `scope`,
888+
// without re-resolving the integration's declared scopes at completion.
889+
payload: {
890+
owner: input.owner,
891+
clientOwner: input.clientOwner,
892+
requestedScopes: authorizationRequestedScopes,
893+
},
851894
expires_at: expiresAt,
852895
created_at: now,
853896
}),
@@ -859,7 +902,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
859902
authorizationUrl: client.authorizationUrl,
860903
clientId: client.clientId,
861904
redirectUrl: flowRedirectUri,
862-
scopes: requestedScopes,
905+
scopes: authorizationRequestedScopes,
863906
state: providerState,
864907
codeChallenge: challenge,
865908
resource: client.resource ?? undefined,
@@ -1127,6 +1170,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
11271170
scopesSupported: resource?.metadata.scopes_supported ?? as.metadata.scopes_supported,
11281171
registrationEndpoint: as.metadata.registration_endpoint ?? null,
11291172
tokenEndpointAuthMethodsSupported: as.metadata.token_endpoint_auth_methods_supported,
1173+
clientIdMetadataDocumentSupported:
1174+
as.metadata.client_id_metadata_document_supported === true,
11301175
} satisfies OAuthProbeResult;
11311176
}).pipe(Effect.provide(httpClientLayer));
11321177

0 commit comments

Comments
 (0)