Showing the changes to packages/react/src/plugins (2 of 13 files). Show all
Unified · Split
| @@ -293,6 +293,29 @@ describe("selectClientsForEndpoints", () => { · 292 unmodified lines | ||
| 293 | 293 | ]); |
| 294 | 294 | }); |
| 295 | 295 | |
| 296 | + it("hides a scope-limited first-party app from another API on the same provider", () => { | |
| 297 | + const firstParty = app("first-party:google", { | |
| 298 | + owner: "org", | |
| 299 | + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 300 | + tokenUrl: "https://oauth2.googleapis.com/token", | |
| 301 | + origin: { | |
| 302 | + kind: "first_party", | |
| 303 | + allowedScopes: ["openid", "email", "https://www.googleapis.com/auth/calendar"], | |
| 304 | + }, | |
| 305 | + }); | |
| 306 | + const result = selectClientsForEndpoints([firstParty], { | |
| 307 | + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 308 | + tokenUrl: "https://oauth2.googleapis.com/token", | |
| 309 | + integration: IntegrationSlug.make("renamed_gmail"), | |
| 310 | + scopes: ["openid", "email", "https://mail.google.com/"], | |
| 311 | + }); | |
| 312 | + | |
| 313 | + expect(result.endpointMatched).toBe(false); | |
| 314 | + expect(result.matched).toEqual([]); | |
| 315 | + expect(result.nearMatches).toEqual([]); | |
| 316 | + expect(result.unmatched).toEqual([]); | |
| 317 | + }); | |
| 318 | + | |
| 296 | 319 | it("intent-matches a first-party app to its declared integrations even without endpoints", () => { |
| 297 | 320 | const integration = IntegrationSlug.make("github_rest"); |
| 298 | 321 | const firstParty = app("first-party:github", { |
| @@ -53,6 +53,19 @@ export const isDcrClient = (app: OAuthClientOption): boolean => · 52 unmodified lines | ||
| 53 | 53 | export const isFirstPartyClient = (app: OAuthClientOption): boolean => |
| 54 | 54 | app.origin.kind === "first_party"; |
| 55 | 55 | |
| 56 | +/** Mirror the host's first-party scope boundary in the picker. The server is | |
| 57 | + * authoritative; this prevents offering a built-in app for a flow it will | |
| 58 | + * reject. An explicit policy with unknown scopes fails closed. */ | |
| 59 | +const firstPartyClientAllowsScopes = ( | |
| 60 | + app: OAuthClientOption, | |
| 61 | + requestedScopes: readonly string[] | undefined, | |
| 62 | +): boolean => { | |
| 63 | + if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true; | |
| 64 | + if (requestedScopes === undefined) return false; | |
| 65 | + const allowed = new Set(app.origin.allowedScopes); | |
| 66 | + return requestedScopes.every((scope) => allowed.has(scope)); | |
| 67 | +}; | |
| 68 | + | |
| 56 | 69 | const hostOf = (url: string): string | undefined => { |
| 57 | 70 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" |
| 58 | 71 | try { |
| @@ -162,6 +175,9 @@ export function selectClientsForEndpoints( · 103 unmodified lines | ||
| 162 | 175 | /** The integration whose picker this is. A manual app stamped with this |
| 163 | 176 | * integration (recorded intent) is a tier-1 match regardless of host. */ |
| 164 | 177 | readonly integration?: IntegrationSlug; |
| 178 | + /** Complete scope set declared by the selected OAuth auth method. Used to | |
| 179 | + * hide scope-limited first-party apps that the host will reject. */ | |
| 180 | + readonly scopes?: readonly string[]; | |
| 165 | 181 | /** When set, an integration that targets a SPECIFIC server (MCP, whose |
| 166 | 182 | * endpoints are discovered at connect) must match by endpoint — absent |
| 167 | 183 | * endpoints mean NO match (show the register CTA), never "every app |
| @@ -175,7 +191,9 @@ export function selectClientsForEndpoints( · 7 unmodified lines | ||
| 175 | 191 | readonly endpointMatched: boolean; |
| 176 | 192 | } { |
| 177 | 193 | // DCR clients are plumbing, never picker options. |
| 178 | − const manual = all.filter((app) => !isDcrClient(app)); | |
| 194 | + const manual = all.filter( | |
| 195 | + (app) => !isDcrClient(app) && firstPartyClientAllowsScopes(app, endpoints.scopes), | |
| 196 | + ); | |
| 179 | 197 | |
| 180 | 198 | const intent = endpoints.integration; |
| 181 | 199 | const matchesIntent = (app: OAuthClientOption): boolean => { |
| @@ -262,6 +280,7 @@ export function useOAuthClientsForIntegration(opts: { · 80 unmodified lines | ||
| 262 | 280 | readonly tokenUrl?: string; |
| 263 | 281 | readonly authorizationUrl?: string; |
| 264 | 282 | readonly integration?: IntegrationSlug; |
| 283 | + readonly scopes?: readonly string[]; | |
| 265 | 284 | readonly requireEndpointMatch?: boolean; |
| 266 | 285 | }): UseOAuthClientsResult { |
| 267 | 286 | // Read the optimistic list so a just-registered/edited/removed app paints |
| @@ -283,9 +302,17 @@ export function useOAuthClientsForIntegration(opts: { · 15 unmodified lines | ||
| 283 | 302 | tokenUrl: opts.tokenUrl, |
| 284 | 303 | authorizationUrl: opts.authorizationUrl, |
| 285 | 304 | integration: opts.integration, |
| 305 | + scopes: opts.scopes, | |
| 286 | 306 | requireEndpointMatch: opts.requireEndpointMatch, |
| 287 | 307 | }), |
| 288 | − [all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.requireEndpointMatch], | |
| 308 | + [ | |
| 309 | + all, | |
| 310 | + opts.tokenUrl, | |
| 311 | + opts.authorizationUrl, | |
| 312 | + opts.integration, | |
| 313 | + opts.scopes, | |
| 314 | + opts.requireEndpointMatch, | |
| 315 | + ], | |
| 289 | 316 | ); |
| 290 | 317 | |
| 291 | 318 | if (!loaded) { |