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
5Always 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
243 clientAuthentication: grant.clientAuthentication,243 clientAuthentication: grant.clientAuthentication,
244 clientSecret,244 clientSecret,
245 refreshToken,245 refreshToken,
246 scopes: config.requiredScopes.length > 0 ? config.requiredScopes : null,
246 });247 });
247 248
248 const storeSecretMaterial = createDefaultSecretMaterialStorer({249 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
206 : Array.isArray(request.headers.authorization)206 : Array.isArray(request.headers.authorization)
207 ? (request.headers.authorization[0] ?? "")207 ? (request.headers.authorization[0] ?? "")
208 : "";208 : "";
209 discoveryAuthorizations.push(authorizationHeader);209 if (authorizationHeader.length > 0) {
210 210 discoveryAuthorizations.push(authorizationHeader);
211 if (authorizationHeader !== `Bearer ${currentAccessToken ?? ""}`) {
212 response.statusCode = 401;
213 response.end("Unauthorized");
214 return;
215 }211 }
216 212
217 const scope = discoveryScopes.get(`${service}:${version}`)213 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
153 clientAuthentication: OAuth2ClientAuthenticationMethod;153 clientAuthentication: OAuth2ClientAuthenticationMethod;
154 clientSecret?: string | null;154 clientSecret?: string | null;
155 refreshToken: string;155 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;
156}): Effect.Effect<OAuth2TokenResponse, Error, never> =>161}): Effect.Effect<OAuth2TokenResponse, Error, never> =>
157 Effect.gen(function* () {162 Effect.gen(function* () {
158 const body = new URLSearchParams({163 const body = new URLSearchParams({
@@ -165,6 +170,10 @@ export const refreshOAuth2AccessToken = (input: { · 6 unmodified lines
165 body.set("client_secret", input.clientSecret);170 body.set("client_secret", input.clientSecret);
166 }171 }
167 172
173 if (input.scopes && input.scopes.length > 0) {
174 body.set("scope", input.scopes.join(" "));
175 }
176
168 return yield* postFormToOAuth2TokenEndpoint({177 return yield* postFormToOAuth2TokenEndpoint({
169 tokenEndpoint: input.tokenEndpoint,178 tokenEndpoint: input.tokenEndpoint,
170 body,179 body,
packages/control-plane/src/runtime/source-adapters/google-discovery.ts+62−9
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 buildGoogleDiscoveryToolPresentation,7 buildGoogleDiscoveryToolPresentation,
8 compileGoogleDiscoveryToolDefinitions,8 compileGoogleDiscoveryToolDefinitions,
9 extractGoogleDiscoveryManifest,9 extractGoogleDiscoveryManifest,
10 type GoogleDiscoveryToolManifest,
10 type GoogleDiscoveryToolProviderData,11 type GoogleDiscoveryToolProviderData,
11} from "@executor/codemode-google-discovery";12} from "@executor/codemode-google-discovery";
12import type { Source } from "#schema";13import type { Source } from "#schema";
@@ -205,19 +206,71 @@ const googleDiscoveryCatalogOperationFromDefinition = (input: { · 192 unmodified lines
205 };206 };
206};207};
207 208
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 */
224const 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
208const googleDiscoveryOauth2SetupConfig = (source: Source) =>259const googleDiscoveryOauth2SetupConfig = (source: Source) =>
209 Effect.gen(function* () {260 Effect.gen(function* () {
210 const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source);261 const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source);
211 const configuredScopes = bindingConfig.scopes ?? [];262 const configuredScopes = bindingConfig.scopes ?? [];
212 const scopes = configuredScopes.length > 0263 const manifest = yield* fetchGoogleDiscoveryDocumentWithHeaders({
213 ? configuredScopes264 url: bindingConfig.discoveryUrl,
214 : yield* fetchGoogleDiscoveryDocumentWithHeaders({265 headers: bindingConfig.defaultHeaders ?? undefined,
215 url: bindingConfig.discoveryUrl,266 }).pipe(
216 headers: bindingConfig.defaultHeaders ?? undefined,267 Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)),
217 }).pipe(268 Effect.catchAll(() => Effect.succeed(null)),
218 Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)),269 );
219 Effect.map((manifest) => Object.keys(manifest.oauthScopes ?? {})),270 const discoveryScopes = manifest ? computeMaximalScopes(manifest) : [];
220 );271 const scopes = discoveryScopes.length > 0
272 ? [...new Set([...discoveryScopes, ...configuredScopes])]
273 : configuredScopes;
221 274
222 if (scopes.length === 0) {275 if (scopes.length === 0) {
223 return null;276 return null;