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
+25
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 server. Tools listed here will not be available to the model, even if they are
exposed by the server. **Note:** `excludeTools` takes precedence over exposed by the server. **Note:** `excludeTools` takes precedence over
`includeTools` - if a tool is in both lists, it will be excluded. `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 - **`targetAudience`** (string): The OAuth Client ID allowlisted on the
IAP-protected application you are trying to access. Used with IAP-protected application you are trying to access. Used with
`authProviderType: 'service_account_impersonation'`. `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 #### Service Account Impersonation
To authenticate with a server using Service Account Impersonation, you must set To authenticate with a server using Service Account Impersonation, you must set
+2
View File
@@ -193,6 +193,8 @@ export class MCPServerConfig {
// OAuth configuration // OAuth configuration
readonly oauth?: MCPOAuthConfig, readonly oauth?: MCPOAuthConfig,
readonly authProviderType?: AuthProviderType, 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 // Service Account Configuration
/* targetAudience format: CLIENT_ID.apps.googleusercontent.com */ /* targetAudience format: CLIENT_ID.apps.googleusercontent.com */
readonly targetAudience?: string, readonly targetAudience?: string,
@@ -20,12 +20,16 @@ describe('GoogleCredentialProvider', () => {
}, },
} as MCPServerConfig; } as MCPServerConfig;
beforeEach(() => {
vi.clearAllMocks();
});
it('should throw an error if no scopes are provided', () => { it('should throw an error if no scopes are provided', () => {
const config = { const config = {
url: 'https://test.googleapis.com', url: 'https://test.googleapis.com',
} as MCPServerConfig; } as MCPServerConfig;
expect(() => new GoogleCredentialProvider(config)).toThrow( 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 provider: GoogleCredentialProvider;
let mockGetAccessToken: Mock; let mockGetAccessToken: Mock;
let mockClient: { let mockClient: {
@@ -154,4 +170,72 @@ describe('GoogleCredentialProvider', () => {
vi.useRealTimers(); 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();
});
});
}); });
+51 -6
View File
@@ -13,12 +13,17 @@ import type {
} from '@modelcontextprotocol/sdk/shared/auth.js'; } from '@modelcontextprotocol/sdk/shared/auth.js';
import { GoogleAuth } from 'google-auth-library'; import { GoogleAuth } from 'google-auth-library';
import type { MCPServerConfig } from '../config/config.js'; 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$/]; const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/];
export class GoogleCredentialProvider implements OAuthClientProvider { export class GoogleCredentialProvider implements OAuthClientProvider {
private readonly auth: GoogleAuth; private readonly auth: GoogleAuth;
private readonly useIdToken: boolean = false;
private readonly audience?: string;
private cachedToken?: OAuthTokens; private cachedToken?: OAuthTokens;
private tokenExpiryTime?: number; private tokenExpiryTime?: number;
@@ -42,20 +47,35 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
} }
const hostname = new URL(url).hostname; 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( throw new Error(
`Host "${hostname}" is not an allowed host for Google Credential provider.`, `Host "${hostname}" is not an allowed host for Google Credential provider.`,
); );
} }
const scopes = this.config?.oauth?.scopes; // If we are using the access token flow, we MUST have scopes.
if (!scopes || scopes.length === 0) { if (!this.useIdToken && !this.config?.oauth?.scopes) {
throw new Error( 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({ this.auth = new GoogleAuth({
scopes, scopes: this.config?.oauth?.scopes,
}); });
} }
@@ -81,6 +101,31 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
this.cachedToken = undefined; this.cachedToken = undefined;
this.tokenExpiryTime = 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 client = await this.auth.getClient();
const accessTokenResponse = await client.getAccessToken(); const accessTokenResponse = await client.getAccessToken();