mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user