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
123 }),123 }),
124 /** Host-operated app declared in executor config — every org connects124 /** Host-operated app declared in executor config — every org connects
125 * through it; nothing to paste. `integrations` ranks it as the default125 * 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. */
127 Schema.Struct({128 Schema.Struct({
128 kind: Schema.Literal("first_party"),129 kind: Schema.Literal("first_party"),
129 integrations: Schema.optional(Schema.Array(IntegrationSlug)),130 integrations: Schema.optional(Schema.Array(IntegrationSlug)),
131 allowedScopes: Schema.optional(Schema.Array(Schema.String)),
130 }),132 }),
131 ]),133 ]),
132});134});
packages/core/sdk/src/oauth-client.ts+20−0
@@ -88,6 +88,10 @@ export type OAuthClientOrigin = · 87 unmodified lines
88 * one Google app deliberately backs gmail, calendar, drive, …. */88 * one Google app deliberately backs gmail, calendar, drive, …. */
89 readonly kind: "first_party";89 readonly kind: "first_party";
90 readonly integrations?: readonly IntegrationSlug[];90 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[];
91 };95 };
92 96
93/** Slug namespace separating config-declared first-party apps from stored97/** Slug namespace separating config-declared first-party apps from stored
@@ -122,8 +126,24 @@ export interface FirstPartyOAuthClientConfig { · 28 unmodified lines
122 * exact-match default for those integrations. Endpoint-host matching still126 * exact-match default for those integrations. Endpoint-host matching still
123 * applies when omitted. */127 * applies when omitted. */
124 readonly integrations?: readonly IntegrationSlug[];128 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[];
125}133}
126 134
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. */
138export 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
127export type CreateOAuthClientInput = OAuthClient & {147export type CreateOAuthClientInput = OAuthClient & {
128 /** Stored-row origins only — `first_party` is config-declared, never created148 /** Stored-row origins only — `first_party` is config-declared, never created
129 * through this surface (the service also rejects the slug namespace). */149 * 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
176 const server = yield* serveOAuthTestServer({ scopes: ["read"] });176 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
177 const { executor } = yield* makeTestWorkspaceHarness({177 const { executor } = yield* makeTestWorkspaceHarness({
178 plugins,178 plugins,
179 firstPartyOAuthClients: [firstPartyClientFor(server)],179 firstPartyOAuthClients: [
180 { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] },
181 ],
180 });182 });
181 yield* executor.acme.seed();183 yield* executor.acme.seed();
182 184
@@ -193,13 +195,45 @@ describe("first-party oauth clients", () => { · 10 unmodified lines
193 const clients = yield* executor.oauth.listClients();195 const clients = yield* executor.oauth.listClients();
194 expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);196 expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);
195 const firstParty = clients[0]!;197 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 });
197 expect(firstParty.clientId).toBe("test-client");203 expect(firstParty.clientId).toBe("test-client");
198 expect("clientSecret" in firstParty).toBe(false);204 expect("clientSecret" in firstParty).toBe(false);
199 }),205 }),
200 ),206 ),
201 );207 );
202 208
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
203 it.effect("createClient and removeClient reject the reserved first-party namespace", () =>237 it.effect("createClient and removeClient reject the reserved first-party namespace", () =>
204 Effect.scoped(238 Effect.scoped(
205 Effect.gen(function* () {239 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+36−1
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
36 OAuthRegisterDynamicError,36 OAuthRegisterDynamicError,
37 OAuthSessionNotFoundError,37 OAuthSessionNotFoundError,
38 OAuthStartError,38 OAuthStartError,
39 firstPartyOAuthClientAllowsScopes,
39 firstPartyOAuthClientSlug,40 firstPartyOAuthClientSlug,
40 isFirstPartyOAuthClientSlug,41 isFirstPartyOAuthClientSlug,
41 type ConnectResult,42 type ConnectResult,
@@ -1027,7 +1028,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 985 unmodified lines
1027 tokenUrl: config.tokenUrl,1028 tokenUrl: config.tokenUrl,
1028 resource: null,1029 resource: null,
1029 clientId: config.clientId,1030 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 },
1031 }),1036 }),
1032 );1037 );
1033 return deps.fuma1038 return deps.fuma
@@ -1218,6 +1223,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 184 unmodified lines
1218 )1223 )
1219 : dedupeScopes(scopePolicy.scopes);1224 : dedupeScopes(scopePolicy.scopes);
1220 1225
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
1221 // client_credentials: exchange immediately and mint the connection.1242 // client_credentials: exchange immediately and mint the connection.
1222 if (client.grant === "client_credentials") {1243 if (client.grant === "client_credentials") {
1223 const token = yield* exchangeClientCredentials({1244 const token = yield* exchangeClientCredentials({
@@ -1399,6 +1420,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 175 unmodified lines
1399 restartRequired: true,1420 restartRequired: true,
1400 });1421 });
1401 }1422 }
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 }
1402 1437
1403 // The PKCE verifier is minted by `start` for every authorization_code1438 // The PKCE verifier is minted by `start` for every authorization_code
1404 // session. A null/missing one means a corrupt session row — exchanging1439 // 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
17 googleStandardUserOAuthPresets,17 googleStandardUserOAuthPresets,
18 googleOAuthConsentScopes,18 googleOAuthConsentScopes,
19 googleOAuthConsentScopesForPreset,19 googleOAuthConsentScopesForPreset,
20 googleCatalogOAuthScopesForPreset,
20 googleServiceSlug,21 googleServiceSlug,
21 googleAudienceWarningsForUrls,22 googleAudienceWarningsForUrls,
22 googlePresetForDiscoveryUrl,23 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
317 "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" },317 "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" },
318};318};
319 319
320/** Complete Google OAuth scope set requested by a catalog preset, including
321 * identity scopes used to label and distinguish connected accounts. */
322export const googleCatalogOAuthScopesForPreset = (presetId: string): readonly string[] =>
323 compactGoogleOAuthScopes([
324 ...GOOGLE_IDENTITY_SCOPES,
325 ...googleOAuthConsentScopesForPreset(presetId),
326 ]);
327
320const googleCatalogAuthTemplate = (presetId: string) => [328const googleCatalogAuthTemplate = (presetId: string) => [
321 {329 {
322 slug: GOOGLE_OAUTH_SECURITY_SCHEME,330 slug: GOOGLE_OAUTH_SECURITY_SCHEME,
323 kind: "oauth2" as const,331 kind: "oauth2" as const,
324 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,332 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
325 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,333 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
326 scopes: compactGoogleOAuthScopes([334 scopes: googleCatalogOAuthScopesForPreset(presetId),
327 ...GOOGLE_IDENTITY_SCOPES,
328 ...googleOAuthConsentScopesForPreset(presetId),
329 ]),
330 },335 },
331];336];
332 337
packages/react/src/components/add-account-modal.tsx+1−0
@@ -1516,6 +1516,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1515 unmodified lines
1516 // unrelated provider's app.1516 // unrelated provider's app.
1517 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,1517 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
1518 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,1518 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1519 scopes: method?.oauth?.scopes,
1519 // Recorded intent: a manual app registered from THIS integration's dialog is1520 // Recorded intent: a manual app registered from THIS integration's dialog is
1520 // a tier-1 match regardless of host.1521 // a tier-1 match regardless of host.
1521 integration,1522 integration,
packages/react/src/plugins/use-effective-oauth-client.test.ts+23−0
@@ -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", {
packages/react/src/plugins/use-effective-oauth-client.tsx+29−2
@@ -53,6 +53,19 @@ export const isDcrClient = (app: OAuthClientOption): boolean => · 52 unmodified lines
53export const isFirstPartyClient = (app: OAuthClientOption): boolean =>53export 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. */
59const 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
56const hostOf = (url: string): string | undefined => {69const 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 this175 /** 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, whose181 /** When set, an integration that targets a SPECIFIC server (MCP, whose
166 * endpoints are discovered at connect) must match by endpoint — absent182 * endpoints are discovered at connect) must match by endpoint — absent
167 * endpoints mean NO match (show the register CTA), never "every app183 * 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 paints286 // 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) {

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