History

always request maximal google scope

Rhys Sullivan committed 8 months ago

Commit eda12171 parent 398fae75 files · +79 −15Browse files

Unified · Split

5 files
  1. .changeset/brave-turkeys-beam.md +5 −0
  2. packages/control-plane/src/runtime/auth-leases.ts +1 −0
  3. packages/control-plane/src/runtime/control-plane-runtime.test.ts +2 −6
  4. packages/control-plane/src/runtime/oauth2-pkce.ts +9 −0
  5. packages/control-plane/src/runtime/source-adapters/google-discovery.ts +62 −9
.changeset/brave-turkeys-beam.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Always request maximal scope for Google Apis
packages/control-plane/src/runtime/auth-leases.ts+1−0
@@ -243,6 +243,7 @@ const refreshProviderGrantRefArtifact = (input: { · 242 unmodified lines
243243 clientAuthentication: grant.clientAuthentication,
244244 clientSecret,
245245 refreshToken,
246+ scopes: config.requiredScopes.length > 0 ? config.requiredScopes : null,
246247 });
247248
248249 const storeSecretMaterial = createDefaultSecretMaterialStorer({
packages/control-plane/src/runtime/control-plane-runtime.test.ts+2−6
@@ -206,12 +206,8 @@ const makeGoogleWorkspaceTestServer = Effect.acquireRelease( · 205 unmodified lines
206206 : Array.isArray(request.headers.authorization)
207207 ? (request.headers.authorization[0] ?? "")
208208 : "";
209− discoveryAuthorizations.push(authorizationHeader);
210−
211− if (authorizationHeader !== `Bearer ${currentAccessToken ?? ""}`) {
212− response.statusCode = 401;
213− response.end("Unauthorized");
214− return;
209+ if (authorizationHeader.length > 0) {
210+ discoveryAuthorizations.push(authorizationHeader);
215211 }
216212
217213 const scope = discoveryScopes.get(`${service}:${version}`)
packages/control-plane/src/runtime/oauth2-pkce.ts+9−0
@@ -153,6 +153,11 @@ export const refreshOAuth2AccessToken = (input: { · 152 unmodified lines
153153 clientAuthentication: OAuth2ClientAuthenticationMethod;
154154 clientSecret?: string | null;
155155 refreshToken: string;
156+ /** Optional scope restriction. When provided, the returned access token will
157+ * be limited to this subset of the originally granted scopes. This is used
158+ * by Google Discovery sources to exclude narrow scopes (e.g. gmail.metadata)
159+ * that cause the API server to restrict functionality. */
160+ scopes?: ReadonlyArray<string> | null;
156161 }): Effect.Effect<OAuth2TokenResponse, Error, never> =>
157162 Effect.gen(function* () {
158163 const body = new URLSearchParams({
@@ -165,6 +170,10 @@ export const refreshOAuth2AccessToken = (input: { · 6 unmodified lines
165170 body.set("client_secret", input.clientSecret);
166171 }
167172
173+ if (input.scopes && input.scopes.length > 0) {
174+ body.set("scope", input.scopes.join(" "));
175+ }
176+
168177 return yield* postFormToOAuth2TokenEndpoint({
169178 tokenEndpoint: input.tokenEndpoint,
170179 body,
packages/control-plane/src/runtime/source-adapters/google-discovery.ts+62−9
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 buildGoogleDiscoveryToolPresentation,
88 compileGoogleDiscoveryToolDefinitions,
99 extractGoogleDiscoveryManifest,
10+ type GoogleDiscoveryToolManifest,
1011 type GoogleDiscoveryToolProviderData,
1112 } from "@executor/codemode-google-discovery";
1213 import type { Source } from "#schema";
@@ -205,19 +206,71 @@ const googleDiscoveryCatalogOperationFromDefinition = (input: { · 192 unmodified lines
205206 };
206207 };
207208
209+/**
210+ * Google's API server enforces a "most restrictive matching scope" policy: when
211+ * a narrow scope (e.g. gmail.metadata) is granted alongside a broader scope
212+ * (e.g. gmail.readonly), the server may restrict behaviour to the narrow scope.
213+ *
214+ * For Gmail, this means having gmail.metadata in the grant blocks the `q`
215+ * parameter on messages.list and prevents reading message bodies, even when
216+ * gmail.readonly or gmail.modify are also granted.
217+ *
218+ * To avoid this, we compute the maximal non-redundant scope set from the
219+ * discovery document's per-method scope declarations. A scope is "subsumed" if
220+ * every method that accepts it also accepts some other scope in the set — meaning
221+ * the other scope is strictly broader. Subsumed scopes are dropped so that
222+ * Google's server never picks the narrower one.
223+ */
224+const computeMaximalScopes = (manifest: GoogleDiscoveryToolManifest): ReadonlyArray<string> => {
225+ const topLevelScopes = Object.keys(manifest.oauthScopes ?? {});
226+ if (topLevelScopes.length === 0) return [];
227+
228+ // Build a map of scope -> set of method IDs that accept it
229+ const scopeToMethods = new Map<string, Set<string>>();
230+ for (const scope of topLevelScopes) {
231+ scopeToMethods.set(scope, new Set());
232+ }
233+ for (const method of manifest.methods) {
234+ for (const scope of method.scopes) {
235+ scopeToMethods.get(scope)?.add(method.methodId);
236+ }
237+ }
238+
239+ // A scope is subsumed if there exists another scope whose method set is a
240+ // strict superset of this scope's method set. Remove subsumed scopes.
241+ const maximal = topLevelScopes.filter((scope) => {
242+ const methods = scopeToMethods.get(scope);
243+ if (!methods || methods.size === 0) return true; // keep scopes not used by any method
244+ return !topLevelScopes.some((other) => {
245+ if (other === scope) return false;
246+ const otherMethods = scopeToMethods.get(other);
247+ if (!otherMethods || otherMethods.size <= methods.size) return false;
248+ // Check if `other` is a strict superset of `scope`
249+ for (const m of methods) {
250+ if (!otherMethods.has(m)) return false;
251+ }
252+ return true;
253+ });
254+ });
255+
256+ return maximal;
257+};
258+
208259 const googleDiscoveryOauth2SetupConfig = (source: Source) =>
209260 Effect.gen(function* () {
210261 const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source);
211262 const configuredScopes = bindingConfig.scopes ?? [];
212− const scopes = configuredScopes.length > 0
213− ? configuredScopes
214− : yield* fetchGoogleDiscoveryDocumentWithHeaders({
215− url: bindingConfig.discoveryUrl,
216− headers: bindingConfig.defaultHeaders ?? undefined,
217− }).pipe(
218− Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)),
219− Effect.map((manifest) => Object.keys(manifest.oauthScopes ?? {})),
220− );
263+ const manifest = yield* fetchGoogleDiscoveryDocumentWithHeaders({
264+ url: bindingConfig.discoveryUrl,
265+ headers: bindingConfig.defaultHeaders ?? undefined,
266+ }).pipe(
267+ Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)),
268+ Effect.catchAll(() => Effect.succeed(null)),
269+ );
270+ const discoveryScopes = manifest ? computeMaximalScopes(manifest) : [];
271+ const scopes = discoveryScopes.length > 0
272+ ? [...new Set([...discoveryScopes, ...configuredScopes])]
273+ : configuredScopes;
221274
222275 if (scopes.length === 0) {
223276 return null;