feat(ID token support): Add ID token support for authenticating to MC… (#12031)

Co-authored-by: Adam Weidman <adamfweidman@google.com>
This commit is contained in:
Ruchika Goel
2025-10-27 13:34:38 -07:00
committed by GitHub
parent 9e8f7c074c
commit abd22a753d
4 changed files with 164 additions and 8 deletions

View File

@@ -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 <token>`. 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

View File

@@ -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,

View File

@@ -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();
});
});
});

View File

@@ -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();