History

Launch built-in Google OAuth for Calendar and Sheets

Rhys Sullivan committed 2 months ago

Commit ac3bea51 parent 67ee8a013 files · +313 −10Browse files

Showing the changes to packages/react/src/plugins (2 of 13 files). Show all

Unified · Split

2 files
  1. packages/react/src/plugins/use-effective-oauth-client.test.ts +23 −0
  2. packages/react/src/plugins/use-effective-oauth-client.tsx +29 −2
packages/react/src/plugins/use-effective-oauth-client.test.ts+23−0
@@ -293,6 +293,29 @@ describe("selectClientsForEndpoints", () => { · 292 unmodified lines
293293 ]);
294294 });
295295
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+
296319 it("intent-matches a first-party app to its declared integrations even without endpoints", () => {
297320 const integration = IntegrationSlug.make("github_rest");
298321 const firstParty = app("first-party:github", {
packages/react/src/plugins/use-effective-oauth-client.tsx+29−2
@@ -53,6 +53,19 @@ export const isDcrClient = (app: OAuthClientOption): boolean => · 52 unmodified lines
5353 export const isFirstPartyClient = (app: OAuthClientOption): boolean =>
5454 app.origin.kind === "first_party";
5555
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+
5669 const hostOf = (url: string): string | undefined => {
5770 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
5871 try {
@@ -162,6 +175,9 @@ export function selectClientsForEndpoints( · 103 unmodified lines
162175 /** The integration whose picker this is. A manual app stamped with this
163176 * integration (recorded intent) is a tier-1 match regardless of host. */
164177 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[];
165181 /** When set, an integration that targets a SPECIFIC server (MCP, whose
166182 * endpoints are discovered at connect) must match by endpoint — absent
167183 * endpoints mean NO match (show the register CTA), never "every app
@@ -175,7 +191,9 @@ export function selectClientsForEndpoints( · 7 unmodified lines
175191 readonly endpointMatched: boolean;
176192 } {
177193 // 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+ );
179197
180198 const intent = endpoints.integration;
181199 const matchesIntent = (app: OAuthClientOption): boolean => {
@@ -262,6 +280,7 @@ export function useOAuthClientsForIntegration(opts: { · 80 unmodified lines
262280 readonly tokenUrl?: string;
263281 readonly authorizationUrl?: string;
264282 readonly integration?: IntegrationSlug;
283+ readonly scopes?: readonly string[];
265284 readonly requireEndpointMatch?: boolean;
266285 }): UseOAuthClientsResult {
267286 // Read the optimistic list so a just-registered/edited/removed app paints
@@ -283,9 +302,17 @@ export function useOAuthClientsForIntegration(opts: { · 15 unmodified lines
283302 tokenUrl: opts.tokenUrl,
284303 authorizationUrl: opts.authorizationUrl,
285304 integration: opts.integration,
305+ scopes: opts.scopes,
286306 requireEndpointMatch: opts.requireEndpointMatch,
287307 }),
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+ ],
289316 );
290317
291318 if (!loaded) {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t