From abd22a753deee186f995ce2d98fd058e12965e35 Mon Sep 17 00:00:00 2001 From: Ruchika Goel Date: Mon, 27 Oct 2025 13:34:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(ID=20token=20support):=20Add=20ID=20token?= =?UTF-8?q?=20support=20for=20authenticating=20to=20MC=E2=80=A6=20(#12031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adam Weidman --- docs/tools/mcp-server.md | 25 ++++++ packages/core/src/config/config.ts | 2 + .../core/src/mcp/google-auth-provider.test.ts | 88 ++++++++++++++++++- packages/core/src/mcp/google-auth-provider.ts | 57 ++++++++++-- 4 files changed, 164 insertions(+), 8 deletions(-) diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 47f169ba38..685a637cf8 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -150,6 +150,11 @@ Each server configuration supports the following properties: server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. +- **`allow_unscoped_id_tokens_cloud_run`** (boolean): When `true` and the MCP + server host is a Cloud Run service (`*.run.app`), the CLI will use Google + Application Default Credentials (ADC) to generate an unscoped ID token and + send it as `Authorization: Bearer `. When using this flag, do not set + OAuth scopes; they are not needed. - **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. @@ -281,6 +286,26 @@ property: } ``` +#### Google Credential with Cloud Run ID tokens + +When connecting to a Cloud Run service endpoint (`*.run.app`), you must opt into +ID token based authentication using ADC. Note that the generated ID token is +unscoped. + +```json +{ + "mcpServers": { + "googleCloudServer": { + "url": "https://my-gcp-service.run.app/sse", + "authProviderType": "google_credentials", + "allow_unscoped_id_tokens_cloud_run": true + } + } +} +``` + +Note: Only `*.run.app` hosts are supported for this flag. + #### Service Account Impersonation To authenticate with a server using Service Account Impersonation, you must set diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 78632d0480..5e3a337218 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -193,6 +193,8 @@ export class MCPServerConfig { // OAuth configuration readonly oauth?: MCPOAuthConfig, readonly authProviderType?: AuthProviderType, + // When true, use Google ADC to fetch ID tokens for Cloud Run + readonly allow_unscoped_id_tokens_cloud_run?: boolean, // Service Account Configuration /* targetAudience format: CLIENT_ID.apps.googleusercontent.com */ readonly targetAudience?: string, diff --git a/packages/core/src/mcp/google-auth-provider.test.ts b/packages/core/src/mcp/google-auth-provider.test.ts index efe959ff3c..ce86d7a2ab 100644 --- a/packages/core/src/mcp/google-auth-provider.test.ts +++ b/packages/core/src/mcp/google-auth-provider.test.ts @@ -20,12 +20,16 @@ describe('GoogleCredentialProvider', () => { }, } as MCPServerConfig; + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should throw an error if no scopes are provided', () => { const config = { url: 'https://test.googleapis.com', } as MCPServerConfig; expect(() => new GoogleCredentialProvider(config)).toThrow( - 'Scopes must be provided in the oauth config for Google Credentials provider', + 'Scopes must be provided in the oauth config for Google Credentials provider (or enable allow_unscoped_id_tokens_for_cloud_run to use ID tokens for Cloud Run endpoints)', ); }); @@ -80,7 +84,19 @@ describe('GoogleCredentialProvider', () => { ); }); - describe('with provider instance', () => { + it('should not allow run.app host even when unscoped ID token flag is not present', () => { + const config = { + url: 'https://test.run.app', + oauth: { + scopes: ['scope1', 'scope2'], + }, + } as MCPServerConfig; + expect(() => new GoogleCredentialProvider(config)).toThrow( + 'To enable the Cloud Run MCP Server at https://test.run.app please set allow_unscoped_id_tokens_cloud_run:true in the MCP Server config.', + ); + }); + + describe('with provider instance (Access Tokens)', () => { let provider: GoogleCredentialProvider; let mockGetAccessToken: Mock; let mockClient: { @@ -154,4 +170,72 @@ describe('GoogleCredentialProvider', () => { vi.useRealTimers(); }); }); + + describe('ID token flow (allow_unscoped_id_tokens_cloud_run)', () => { + let mockFetchIdToken: Mock; + let mockIdClient: { + idTokenProvider: { + fetchIdToken: Mock; + }; + }; + + beforeEach(() => { + mockFetchIdToken = vi.fn(); + mockIdClient = { + idTokenProvider: { + fetchIdToken: mockFetchIdToken, + }, + }; + (GoogleAuth.prototype.getIdTokenClient as Mock).mockResolvedValue( + mockIdClient, + ); + }); + + it('should return ID token when flag is enabled and derive audience from hostname', async () => { + const config = { + url: 'https://test.run.app/path', + allow_unscoped_id_tokens_cloud_run: true, + } as MCPServerConfig; + const payload = { exp: Math.floor(Date.now() / 1000) + 3600 }; + const validToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`; + mockFetchIdToken.mockResolvedValue(validToken); + + const provider = new GoogleCredentialProvider(config); + const tokens = await provider.tokens(); + expect(tokens?.access_token).toBe(validToken); + expect(GoogleAuth.prototype.getIdTokenClient).toHaveBeenCalledWith( + 'test.run.app', + ); + expect(mockFetchIdToken).toHaveBeenCalledWith('test.run.app'); + }); + + it('should return undefined and log error when fetching ID token fails', async () => { + const config = { + url: 'https://test.run.app/path', + allow_unscoped_id_tokens_cloud_run: true, + } as MCPServerConfig; + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockFetchIdToken.mockRejectedValue(new Error('Fetch failed')); + + const provider = new GoogleCredentialProvider(config); + const tokens = await provider.tokens(); + expect(tokens).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to get ID token from Google ADC', + expect.any(Error), + ); + consoleErrorSpy.mockRestore(); + }); + + it('should not require scopes when flag allow_unscoped_id_tokens_cloud_run is true', () => { + const config = { + url: 'https://test.run.app', + allow_unscoped_id_tokens_cloud_run: true, + } as MCPServerConfig; + + expect(() => new GoogleCredentialProvider(config)).not.toThrow(); + }); + }); }); diff --git a/packages/core/src/mcp/google-auth-provider.ts b/packages/core/src/mcp/google-auth-provider.ts index d152b4d256..3159798095 100644 --- a/packages/core/src/mcp/google-auth-provider.ts +++ b/packages/core/src/mcp/google-auth-provider.ts @@ -13,12 +13,17 @@ import type { } from '@modelcontextprotocol/sdk/shared/auth.js'; import { GoogleAuth } from 'google-auth-library'; import type { MCPServerConfig } from '../config/config.js'; -import { FIVE_MIN_BUFFER_MS } from './oauth-utils.js'; +import { OAuthUtils, FIVE_MIN_BUFFER_MS } from './oauth-utils.js'; +const CLOUD_RUN_HOST_REGEX = /^(.*\.)?run\.app$/; + +// An array of hosts that are allowed to use the Google Credential provider. const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/]; export class GoogleCredentialProvider implements OAuthClientProvider { private readonly auth: GoogleAuth; + private readonly useIdToken: boolean = false; + private readonly audience?: string; private cachedToken?: OAuthTokens; private tokenExpiryTime?: number; @@ -42,20 +47,35 @@ export class GoogleCredentialProvider implements OAuthClientProvider { } const hostname = new URL(url).hostname; - if (!ALLOWED_HOSTS.some((pattern) => pattern.test(hostname))) { + const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname); + if (!this.config?.allow_unscoped_id_tokens_cloud_run && isRunAppHost) { + throw new Error( + `To enable the Cloud Run MCP Server at ${url} please set allow_unscoped_id_tokens_cloud_run:true in the MCP Server config.`, + ); + } + if (this.config?.allow_unscoped_id_tokens_cloud_run && isRunAppHost) { + this.useIdToken = true; + } + this.audience = hostname; + + if ( + !this.useIdToken && + !ALLOWED_HOSTS.some((pattern) => pattern.test(hostname)) + ) { throw new Error( `Host "${hostname}" is not an allowed host for Google Credential provider.`, ); } - const scopes = this.config?.oauth?.scopes; - if (!scopes || scopes.length === 0) { + // If we are using the access token flow, we MUST have scopes. + if (!this.useIdToken && !this.config?.oauth?.scopes) { throw new Error( - 'Scopes must be provided in the oauth config for Google Credentials provider', + 'Scopes must be provided in the oauth config for Google Credentials provider (or enable allow_unscoped_id_tokens_for_cloud_run to use ID tokens for Cloud Run endpoints)', ); } + this.auth = new GoogleAuth({ - scopes, + scopes: this.config?.oauth?.scopes, }); } @@ -81,6 +101,31 @@ export class GoogleCredentialProvider implements OAuthClientProvider { this.cachedToken = undefined; this.tokenExpiryTime = undefined; + // If allow_unscoped_id_tokens_for_cloud_run is configured, use ID tokens. + if (this.useIdToken) { + try { + const idClient = await this.auth.getIdTokenClient(this.audience!); + const idToken = await idClient.idTokenProvider.fetchIdToken( + this.audience!, + ); + + const newToken: OAuthTokens = { + access_token: idToken, + token_type: 'Bearer', + }; + + const expiryTime = OAuthUtils.parseTokenExpiry(idToken); + if (expiryTime) { + this.tokenExpiryTime = expiryTime; + this.cachedToken = newToken; + } + return newToken; + } catch (e) { + console.error('Failed to get ID token from Google ADC', e); + return undefined; + } + } + const client = await this.auth.getClient(); const accessTokenResponse = await client.getAccessToken();