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 (9 of 13 files). Show all

Unified · Split

9 files
  1. packages/core/api/src/oauth/api.ts +3 −1
  2. packages/core/sdk/src/oauth-client.ts +20 −0
  3. packages/core/sdk/src/oauth-first-party.test.ts +36 −2
  4. packages/core/sdk/src/oauth-service.ts +36 −1
  5. packages/plugins/openapi/src/providers/google/index.ts +1 −0
  6. packages/plugins/openapi/src/providers/google/presets.ts +9 −4
  7. packages/react/src/components/add-account-modal.tsx +1 −0
  8. packages/react/src/plugins/use-effective-oauth-client.test.ts +23 −0
  9. packages/react/src/plugins/use-effective-oauth-client.tsx +29 −2
packages/core/api/src/oauth/api.ts+3−1
@@ -123,10 +123,12 @@ const OAuthClientSummaryResponse = Schema.Struct({ · 122 unmodified lines
123123 }),
124124 /** Host-operated app declared in executor config — every org connects
125125 * through it; nothing to paste. `integrations` ranks it as the default
126− * for those integrations in the picker. */
126+ * for those integrations; `allowedScopes` is the host-enforced scope
127+ * boundary the picker mirrors before offering it. */
127128 Schema.Struct({
128129 kind: Schema.Literal("first_party"),
129130 integrations: Schema.optional(Schema.Array(IntegrationSlug)),
131+ allowedScopes: Schema.optional(Schema.Array(Schema.String)),
130132 }),
131133 ]),
132134 });
packages/core/sdk/src/oauth-client.ts+20−0
@@ -88,6 +88,10 @@ export type OAuthClientOrigin = · 87 unmodified lines
8888 * one Google app deliberately backs gmail, calendar, drive, …. */
8989 readonly kind: "first_party";
9090 readonly integrations?: readonly IntegrationSlug[];
91+ /** OAuth scopes this deployment permits the app to request. Omitted means
92+ * the provider app is unrestricted; present means every requested scope
93+ * must be in this set. This is public policy metadata, not a secret. */
94+ readonly allowedScopes?: readonly string[];
9195 };
9296
9397 /** Slug namespace separating config-declared first-party apps from stored
@@ -122,8 +126,24 @@ export interface FirstPartyOAuthClientConfig { · 28 unmodified lines
122126 * exact-match default for those integrations. Endpoint-host matching still
123127 * applies when omitted. */
124128 readonly integrations?: readonly IntegrationSlug[];
129+ /** OAuth scopes this deployment permits the app to request. Omit to allow
130+ * every scope declared by a matching integration. When present, OAuth start
131+ * and completion fail unless every requested scope belongs to this set. */
132+ readonly allowedScopes?: readonly string[];
125133 }
126134
135+/** Whether a first-party app may request an integration's complete OAuth scope
136+ * set. An omitted policy preserves provider-wide clients; an explicit policy
137+ * is fail-closed and requires every requested scope to be listed. */
138+export const firstPartyOAuthClientAllowsScopes = (
139+ config: Pick<FirstPartyOAuthClientConfig, "allowedScopes">,
140+ requestedScopes: readonly string[],
141+): boolean => {
142+ if (config.allowedScopes === undefined) return true;
143+ const allowed = new Set(config.allowedScopes);
144+ return requestedScopes.every((scope) => allowed.has(scope));
145+};
146+
127147 export type CreateOAuthClientInput = OAuthClient & {
128148 /** Stored-row origins only — `first_party` is config-declared, never created
129149 * through this surface (the service also rejects the slug namespace). */
packages/core/sdk/src/oauth-first-party.test.ts+36−2
@@ -176,7 +176,9 @@ describe("first-party oauth clients", () => { · 175 unmodified lines
176176 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
177177 const { executor } = yield* makeTestWorkspaceHarness({
178178 plugins,
179− firstPartyOAuthClients: [firstPartyClientFor(server)],
179+ firstPartyOAuthClients: [
180+ { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] },
181+ ],
180182 });
181183 yield* executor.acme.seed();
182184
@@ -193,13 +195,45 @@ describe("first-party oauth clients", () => { · 10 unmodified lines
193195 const clients = yield* executor.oauth.listClients();
194196 expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);
195197 const firstParty = clients[0]!;
196− expect(firstParty.origin).toEqual({ kind: "first_party", integrations: [INTEG] });
198+ expect(firstParty.origin).toEqual({
199+ kind: "first_party",
200+ integrations: [INTEG],
201+ allowedScopes: ["openid", "read"],
202+ });
197203 expect(firstParty.clientId).toBe("test-client");
198204 expect("clientSecret" in firstParty).toBe(false);
199205 }),
200206 ),
201207 );
202208
209+ it.effect("a scope-limited first-party app rejects an integration outside its policy", () =>
210+ Effect.scoped(
211+ Effect.gen(function* () {
212+ const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });
213+ const { executor } = yield* makeTestWorkspaceHarness({
214+ plugins,
215+ firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }],
216+ });
217+ yield* executor.acme.seed(["write"]);
218+
219+ const error = yield* executor.oauth
220+ .start({
221+ owner: "org",
222+ client: FIRST_PARTY,
223+ clientOwner: "org",
224+ name: ConnectionName.make("blocked"),
225+ integration: INTEG,
226+ template: TEMPLATE,
227+ })
228+ .pipe(Effect.flip);
229+
230+ expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true);
231+ const startError = error as OAuthStartError;
232+ expect(startError.message).toContain("not enabled for integration acme");
233+ }),
234+ ),
235+ );
236+
203237 it.effect("createClient and removeClient reject the reserved first-party namespace", () =>
204238 Effect.scoped(
205239 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+36−1
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 OAuthRegisterDynamicError,
3737 OAuthSessionNotFoundError,
3838 OAuthStartError,
39+ firstPartyOAuthClientAllowsScopes,
3940 firstPartyOAuthClientSlug,
4041 isFirstPartyOAuthClientSlug,
4142 type ConnectResult,
@@ -1027,7 +1028,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 985 unmodified lines
10271028 tokenUrl: config.tokenUrl,
10281029 resource: null,
10291030 clientId: config.clientId,
1030− origin: { kind: "first_party", integrations: config.integrations ?? [] },
1031+ origin: {
1032+ kind: "first_party",
1033+ ...(config.integrations !== undefined ? { integrations: config.integrations } : {}),
1034+ ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}),
1035+ },
10311036 }),
10321037 );
10331038 return deps.fuma
@@ -1218,6 +1223,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 184 unmodified lines
12181223 )
12191224 : dedupeScopes(scopePolicy.scopes);
12201225
1226+ // An explicitly scope-limited first-party app is an authorization
1227+ // boundary, not picker decoration. Endpoint matching can associate one
1228+ // Google client with every Google API, so enforce the complete requested
1229+ // set here before persisting an OAuth session or redirecting the browser.
1230+ if (firstPartyFlow) {
1231+ const firstParty = firstPartyBySlug.get(String(input.client));
1232+ if (
1233+ firstParty !== undefined &&
1234+ !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes)
1235+ ) {
1236+ return yield* new OAuthStartError({
1237+ message: `The built-in OAuth app is not enabled for integration ${input.integration}.`,
1238+ });
1239+ }
1240+ }
1241+
12211242 // client_credentials: exchange immediately and mint the connection.
12221243 if (client.grant === "client_credentials") {
12231244 const token = yield* exchangeClientCredentials({
@@ -1399,6 +1420,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 175 unmodified lines
13991420 restartRequired: true,
14001421 });
14011422 }
1423+ if (isFirstPartyOAuthClientSlug(String(session.clientSlug))) {
1424+ const firstParty = firstPartyBySlug.get(String(session.clientSlug));
1425+ if (
1426+ firstParty !== undefined &&
1427+ firstParty.allowedScopes !== undefined &&
1428+ (session.requestedScopes === null ||
1429+ !firstPartyOAuthClientAllowsScopes(firstParty, session.requestedScopes))
1430+ ) {
1431+ return yield* new OAuthCompleteError({
1432+ message: `The built-in OAuth app is no longer enabled for integration ${session.integration}; restart the flow.`,
1433+ restartRequired: true,
1434+ });
1435+ }
1436+ }
14021437
14031438 // The PKCE verifier is minted by `start` for every authorization_code
14041439 // session. A null/missing one means a corrupt session row — exchanging
packages/plugins/openapi/src/providers/google/index.ts+1−0
@@ -17,6 +17,7 @@ export { · 16 unmodified lines
1717 googleStandardUserOAuthPresets,
1818 googleOAuthConsentScopes,
1919 googleOAuthConsentScopesForPreset,
20+ googleCatalogOAuthScopesForPreset,
2021 googleServiceSlug,
2122 googleAudienceWarningsForUrls,
2223 googlePresetForDiscoveryUrl,
packages/plugins/openapi/src/providers/google/presets.ts+9−4
@@ -317,16 +317,21 @@ const GOOGLE_HEALTH_CHECKS: Readonly<Record<string, HealthCheckSpec>> = { · 316 unmodified lines
317317 "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" },
318318 };
319319
320+/** Complete Google OAuth scope set requested by a catalog preset, including
321+ * identity scopes used to label and distinguish connected accounts. */
322+export const googleCatalogOAuthScopesForPreset = (presetId: string): readonly string[] =>
323+ compactGoogleOAuthScopes([
324+ ...GOOGLE_IDENTITY_SCOPES,
325+ ...googleOAuthConsentScopesForPreset(presetId),
326+ ]);
327+
320328 const googleCatalogAuthTemplate = (presetId: string) => [
321329 {
322330 slug: GOOGLE_OAUTH_SECURITY_SCHEME,
323331 kind: "oauth2" as const,
324332 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
325333 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
326− scopes: compactGoogleOAuthScopes([
327− ...GOOGLE_IDENTITY_SCOPES,
328− ...googleOAuthConsentScopesForPreset(presetId),
329− ]),
334+ scopes: googleCatalogOAuthScopesForPreset(presetId),
330335 },
331336 ];
332337
packages/react/src/components/add-account-modal.tsx+1−0
@@ -1516,6 +1516,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1515 unmodified lines
15161516 // unrelated provider's app.
15171517 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
15181518 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1519+ scopes: method?.oauth?.scopes,
15191520 // Recorded intent: a manual app registered from THIS integration's dialog is
15201521 // a tier-1 match regardless of host.
15211522 integration,
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