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 | it("intent-matches a first-party app to its declared integrations even without endpoints", () => { | 319 | it("intent-matches a first-party app to its declared integrations even without endpoints", () => { |
| 297 | const integration = IntegrationSlug.make("github_rest"); | 320 | const integration = IntegrationSlug.make("github_rest"); |
| 298 | const firstParty = app("first-party:github", { | 321 | const firstParty = app("first-party:github", { |
| @@ -53,6 +53,19 @@ export const isDcrClient = (app: OAuthClientOption): boolean => · 52 unmodified lines | |||
| 53 | export const isFirstPartyClient = (app: OAuthClientOption): boolean => | 53 | export const isFirstPartyClient = (app: OAuthClientOption): boolean => |
| 54 | app.origin.kind === "first_party"; | 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 | const hostOf = (url: string): string | undefined => { | 69 | const hostOf = (url: string): string | undefined => { |
| 57 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" | 70 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" |
| 58 | try { | 71 | try { |
| @@ -162,6 +175,9 @@ export function selectClientsForEndpoints( · 103 unmodified lines | |||
| 162 | /** The integration whose picker this is. A manual app stamped with this | 175 | /** The integration whose picker this is. A manual app stamped with this |
| 163 | * integration (recorded intent) is a tier-1 match regardless of host. */ | 176 | * integration (recorded intent) is a tier-1 match regardless of host. */ |
| 164 | readonly integration?: IntegrationSlug; | 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 | /** When set, an integration that targets a SPECIFIC server (MCP, whose | 181 | /** When set, an integration that targets a SPECIFIC server (MCP, whose |
| 166 | * endpoints are discovered at connect) must match by endpoint — absent | 182 | * endpoints are discovered at connect) must match by endpoint — absent |
| 167 | * endpoints mean NO match (show the register CTA), never "every app | 183 | * endpoints mean NO match (show the register CTA), never "every app |
| @@ -175,7 +191,9 @@ export function selectClientsForEndpoints( · 7 unmodified lines | |||
| 175 | readonly endpointMatched: boolean; | 191 | readonly endpointMatched: boolean; |
| 176 | } { | 192 | } { |
| 177 | // DCR clients are plumbing, never picker options. | 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 | const intent = endpoints.integration; | 198 | const intent = endpoints.integration; |
| 181 | const matchesIntent = (app: OAuthClientOption): boolean => { | 199 | const matchesIntent = (app: OAuthClientOption): boolean => { |
| @@ -262,6 +280,7 @@ export function useOAuthClientsForIntegration(opts: { · 80 unmodified lines | |||
| 262 | readonly tokenUrl?: string; | 280 | readonly tokenUrl?: string; |
| 263 | readonly authorizationUrl?: string; | 281 | readonly authorizationUrl?: string; |
| 264 | readonly integration?: IntegrationSlug; | 282 | readonly integration?: IntegrationSlug; |
| 283 | readonly scopes?: readonly string[]; | ||
| 265 | readonly requireEndpointMatch?: boolean; | 284 | readonly requireEndpointMatch?: boolean; |
| 266 | }): UseOAuthClientsResult { | 285 | }): UseOAuthClientsResult { |
| 267 | // Read the optimistic list so a just-registered/edited/removed app paints | 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 | tokenUrl: opts.tokenUrl, | 302 | tokenUrl: opts.tokenUrl, |
| 284 | authorizationUrl: opts.authorizationUrl, | 303 | authorizationUrl: opts.authorizationUrl, |
| 285 | integration: opts.integration, | 304 | integration: opts.integration, |
| 305 | scopes: opts.scopes, | ||
| 286 | requireEndpointMatch: opts.requireEndpointMatch, | 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 | if (!loaded) { | 318 | if (!loaded) { |