Skip to content

Commit abd22a7

Browse files
feat(ID token support): Add ID token support for authenticating to MC… (#12031)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
1 parent 9e8f7c0 commit abd22a7

4 files changed

Lines changed: 164 additions & 8 deletions

File tree

docs/tools/mcp-server.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ Each server configuration supports the following properties:
150150
server. Tools listed here will not be available to the model, even if they are
151151
exposed by the server. **Note:** `excludeTools` takes precedence over
152152
`includeTools` - if a tool is in both lists, it will be excluded.
153+
- **`allow_unscoped_id_tokens_cloud_run`** (boolean): When `true` and the MCP
154+
server host is a Cloud Run service (`*.run.app`), the CLI will use Google
155+
Application Default Credentials (ADC) to generate an unscoped ID token and
156+
send it as `Authorization: Bearer <token>`. When using this flag, do not set
157+
OAuth scopes; they are not needed.
153158
- **`targetAudience`** (string): The OAuth Client ID allowlisted on the
154159
IAP-protected application you are trying to access. Used with
155160
`authProviderType: 'service_account_impersonation'`.
@@ -281,6 +286,26 @@ property:
281286
}
282287
```
283288

289+
#### Google Credential with Cloud Run ID tokens
290+
291+
When connecting to a Cloud Run service endpoint (`*.run.app`), you must opt into
292+
ID token based authentication using ADC. Note that the generated ID token is
293+
unscoped.
294+
295+
```json
296+
{
297+
"mcpServers": {
298+
"googleCloudServer": {
299+
"url": "https://my-gcp-service.run.app/sse",
300+
"authProviderType": "google_credentials",
301+
"allow_unscoped_id_tokens_cloud_run": true
302+
}
303+
}
304+
}
305+
```
306+
307+
Note: Only `*.run.app` hosts are supported for this flag.
308+
284309
#### Service Account Impersonation
285310

286311
To authenticate with a server using Service Account Impersonation, you must set

packages/core/src/config/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ export class MCPServerConfig {
193193
// OAuth configuration
194194
readonly oauth?: MCPOAuthConfig,
195195
readonly authProviderType?: AuthProviderType,
196+
// When true, use Google ADC to fetch ID tokens for Cloud Run
197+
readonly allow_unscoped_id_tokens_cloud_run?: boolean,
196198
// Service Account Configuration
197199
/* targetAudience format: CLIENT_ID.apps.googleusercontent.com */
198200
readonly targetAudience?: string,

packages/core/src/mcp/google-auth-provider.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ describe('GoogleCredentialProvider', () => {
2020
},
2121
} as MCPServerConfig;
2222

23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
2327
it('should throw an error if no scopes are provided', () => {
2428
const config = {
2529
url: 'https://test.googleapis.com',
2630
} as MCPServerConfig;
2731
expect(() => new GoogleCredentialProvider(config)).toThrow(
28-
'Scopes must be provided in the oauth config for Google Credentials provider',
32+
'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)',
2933
);
3034
});
3135

@@ -80,7 +84,19 @@ describe('GoogleCredentialProvider', () => {
8084
);
8185
});
8286

83-
describe('with provider instance', () => {
87+
it('should not allow run.app host even when unscoped ID token flag is not present', () => {
88+
const config = {
89+
url: 'https://test.run.app',
90+
oauth: {
91+
scopes: ['scope1', 'scope2'],
92+
},
93+
} as MCPServerConfig;
94+
expect(() => new GoogleCredentialProvider(config)).toThrow(
95+
'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.',
96+
);
97+
});
98+
99+
describe('with provider instance (Access Tokens)', () => {
84100
let provider: GoogleCredentialProvider;
85101
let mockGetAccessToken: Mock;
86102
let mockClient: {
@@ -154,4 +170,72 @@ describe('GoogleCredentialProvider', () => {
154170
vi.useRealTimers();
155171
});
156172
});
173+
174+
describe('ID token flow (allow_unscoped_id_tokens_cloud_run)', () => {
175+
let mockFetchIdToken: Mock;
176+
let mockIdClient: {
177+
idTokenProvider: {
178+
fetchIdToken: Mock;
179+
};
180+
};
181+
182+
beforeEach(() => {
183+
mockFetchIdToken = vi.fn();
184+
mockIdClient = {
185+
idTokenProvider: {
186+
fetchIdToken: mockFetchIdToken,
187+
},
188+
};
189+
(GoogleAuth.prototype.getIdTokenClient as Mock).mockResolvedValue(
190+
mockIdClient,
191+
);
192+
});
193+
194+
it('should return ID token when flag is enabled and derive audience from hostname', async () => {
195+
const config = {
196+
url: 'https://test.run.app/path',
197+
allow_unscoped_id_tokens_cloud_run: true,
198+
} as MCPServerConfig;
199+
const payload = { exp: Math.floor(Date.now() / 1000) + 3600 };
200+
const validToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`;
201+
mockFetchIdToken.mockResolvedValue(validToken);
202+
203+
const provider = new GoogleCredentialProvider(config);
204+
const tokens = await provider.tokens();
205+
expect(tokens?.access_token).toBe(validToken);
206+
expect(GoogleAuth.prototype.getIdTokenClient).toHaveBeenCalledWith(
207+
'test.run.app',
208+
);
209+
expect(mockFetchIdToken).toHaveBeenCalledWith('test.run.app');
210+
});
211+
212+
it('should return undefined and log error when fetching ID token fails', async () => {
213+
const config = {
214+
url: 'https://test.run.app/path',
215+
allow_unscoped_id_tokens_cloud_run: true,
216+
} as MCPServerConfig;
217+
const consoleErrorSpy = vi
218+
.spyOn(console, 'error')
219+
.mockImplementation(() => {});
220+
mockFetchIdToken.mockRejectedValue(new Error('Fetch failed'));
221+
222+
const provider = new GoogleCredentialProvider(config);
223+
const tokens = await provider.tokens();
224+
expect(tokens).toBeUndefined();
225+
expect(consoleErrorSpy).toHaveBeenCalledWith(
226+
'Failed to get ID token from Google ADC',
227+
expect.any(Error),
228+
);
229+
consoleErrorSpy.mockRestore();
230+
});
231+
232+
it('should not require scopes when flag allow_unscoped_id_tokens_cloud_run is true', () => {
233+
const config = {
234+
url: 'https://test.run.app',
235+
allow_unscoped_id_tokens_cloud_run: true,
236+
} as MCPServerConfig;
237+
238+
expect(() => new GoogleCredentialProvider(config)).not.toThrow();
239+
});
240+
});
157241
});

packages/core/src/mcp/google-auth-provider.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ import type {
1313
} from '@modelcontextprotocol/sdk/shared/auth.js';
1414
import { GoogleAuth } from 'google-auth-library';
1515
import type { MCPServerConfig } from '../config/config.js';
16-
import { FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
16+
import { OAuthUtils, FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
1717

18+
const CLOUD_RUN_HOST_REGEX = /^(.*\.)?run\.app$/;
19+
20+
// An array of hosts that are allowed to use the Google Credential provider.
1821
const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/];
1922

2023
export class GoogleCredentialProvider implements OAuthClientProvider {
2124
private readonly auth: GoogleAuth;
25+
private readonly useIdToken: boolean = false;
26+
private readonly audience?: string;
2227
private cachedToken?: OAuthTokens;
2328
private tokenExpiryTime?: number;
2429

@@ -42,20 +47,35 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
4247
}
4348

4449
const hostname = new URL(url).hostname;
45-
if (!ALLOWED_HOSTS.some((pattern) => pattern.test(hostname))) {
50+
const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname);
51+
if (!this.config?.allow_unscoped_id_tokens_cloud_run && isRunAppHost) {
52+
throw new Error(
53+
`To enable the Cloud Run MCP Server at ${url} please set allow_unscoped_id_tokens_cloud_run:true in the MCP Server config.`,
54+
);
55+
}
56+
if (this.config?.allow_unscoped_id_tokens_cloud_run && isRunAppHost) {
57+
this.useIdToken = true;
58+
}
59+
this.audience = hostname;
60+
61+
if (
62+
!this.useIdToken &&
63+
!ALLOWED_HOSTS.some((pattern) => pattern.test(hostname))
64+
) {
4665
throw new Error(
4766
`Host "${hostname}" is not an allowed host for Google Credential provider.`,
4867
);
4968
}
5069

51-
const scopes = this.config?.oauth?.scopes;
52-
if (!scopes || scopes.length === 0) {
70+
// If we are using the access token flow, we MUST have scopes.
71+
if (!this.useIdToken && !this.config?.oauth?.scopes) {
5372
throw new Error(
54-
'Scopes must be provided in the oauth config for Google Credentials provider',
73+
'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)',
5574
);
5675
}
76+
5777
this.auth = new GoogleAuth({
58-
scopes,
78+
scopes: this.config?.oauth?.scopes,
5979
});
6080
}
6181

@@ -81,6 +101,31 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
81101
this.cachedToken = undefined;
82102
this.tokenExpiryTime = undefined;
83103

104+
// If allow_unscoped_id_tokens_for_cloud_run is configured, use ID tokens.
105+
if (this.useIdToken) {
106+
try {
107+
const idClient = await this.auth.getIdTokenClient(this.audience!);
108+
const idToken = await idClient.idTokenProvider.fetchIdToken(
109+
this.audience!,
110+
);
111+
112+
const newToken: OAuthTokens = {
113+
access_token: idToken,
114+
token_type: 'Bearer',
115+
};
116+
117+
const expiryTime = OAuthUtils.parseTokenExpiry(idToken);
118+
if (expiryTime) {
119+
this.tokenExpiryTime = expiryTime;
120+
this.cachedToken = newToken;
121+
}
122+
return newToken;
123+
} catch (e) {
124+
console.error('Failed to get ID token from Google ADC', e);
125+
return undefined;
126+
}
127+
}
128+
84129
const client = await this.auth.getClient();
85130
const accessTokenResponse = await client.getAccessToken();
86131

0 commit comments

Comments
 (0)