mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 15:04:16 -07: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
|
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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user