Skip to content

Commit eda1217

Browse files
committed
always request maximal google scope
1 parent 398fae7 commit eda1217

5 files changed

Lines changed: 79 additions & 15 deletions

File tree

‎.changeset/brave-turkeys-beam.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ const refreshProviderGrantRefArtifact = (input: {
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‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,8 @@ const makeGoogleWorkspaceTestServer = Effect.acquireRelease(
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‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ export const refreshOAuth2AccessToken = (input: {
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: {
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‎

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
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: {
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;

0 commit comments

Comments
 (0)