Skip to content

Commit eb34ece

Browse files
feat: support oauth (#214)
1 parent 9bd9ab2 commit eb34ece

File tree

21 files changed

+510
-69
lines changed

21 files changed

+510
-69
lines changed

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
PORT = 3000
22
GCP_STDIO = false
33

4+
OAUTH_ENABLED = true
5+
GOOGLE_OAUTH_CLIENT_ID = YOUR_CLIENT_ID
6+
GOOGLE_OAUTH_CLIENT_SECRET = YOUR_CLIENT_SECRET
7+
GOOGLE_OAUTH_AUDIENCE = YOUR_AUDIENCE # generally, same as your client id
8+
GOOGLE_OAUTH_REDIRECT_URI = http://localhost:7777/oauth/callback
9+
410
OAUTH_PROTECTED_RESOURCE = http://localhost:${PORT}/mcp
511
OAUTH_AUTHORIZATION_SERVER = http://localhost:${PORT}/auth/google
612
OAUTH_AUTHORIZATION_ENDPOINT = https://accounts.google.com/o/oauth2/v2/auth

constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export const SCOPES = {
55
};
66
export const BEARER_METHODS_SUPPORTED = ['header'];
77
export const RESPONSE_TYPES_SUPPORTED = ['code'];
8+
export const GCLOUD_AUTH = 'gcloud_auth';

lib/clients.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17+
import { OAuth2Client } from 'google-auth-library';
18+
import { GCLOUD_AUTH } from '../constants.js';
19+
20+
const CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID;
21+
const CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
22+
const REDIRECT_URI = process.env.GOOGLE_OAUTH_REDIRECT_URI;
1723
const AUTHORIZATION_HEADER = 'Authorization';
1824
const BEARER_PREFIX = 'Bearer';
19-
const GCLOUD_AUTH = 'gcloud_auth';
2025

2126
function keyGenerator(projectId, accessToken) {
2227
return accessToken !== GCLOUD_AUTH ? projectId + accessToken : projectId;
@@ -32,6 +37,7 @@ const clients = {
3237
logging: new Map(),
3338
billing: new Map(),
3439
projects: new Map(),
40+
oauth: new Map(),
3541
};
3642

3743
function getAuthClient(accessToken) {
@@ -44,6 +50,47 @@ function getAuthClient(accessToken) {
4450
};
4551
}
4652

53+
/**
54+
* Wraps an OAuth2Client to be compatible with gRPC-based Google Cloud clients.
55+
*
56+
* Some Google Cloud SDK clients (like Cloud Run, Service Usage, etc.) use gRPC
57+
* and expect the `getRequestHeaders()` method of the auth client to return a
58+
* `Map` of headers. However, the standard `OAuth2Client` from `google-auth-library`
59+
* returns a plain Javascript object (e.g., `{ Authorization: 'Bearer ...' }`).
60+
*
61+
* This wrapper uses a Proxy to intercept calls to `getRequestHeaders`. It calls
62+
* the original method and converts the result from a plain object to a `Map`
63+
* if necessary, ensuring compatibility with gRPC-based clients while maintaining
64+
* the original behavior for other properties.
65+
*
66+
* @param {OAuth2Client} authClient - The original OAuth2Client instance.
67+
* @returns {Proxy<OAuth2Client>} A proxy compatible with gRPC clients.
68+
*/
69+
function wrapForGrpc(authClient) {
70+
return new Proxy(authClient, {
71+
get(target, prop, receiver) {
72+
if (prop === 'getRequestHeaders') {
73+
return async (...args) => {
74+
const headers = await target.getRequestHeaders(...args);
75+
if (headers instanceof Map) {
76+
return headers;
77+
}
78+
// Convert plain object (from OAuth2Client) to Map (expected by grpc-js)
79+
const headerMap = new Map();
80+
for (const [k, v] of Object.entries(headers)) {
81+
headerMap.set(k, v);
82+
}
83+
return headerMap;
84+
};
85+
}
86+
return Reflect.get(target, prop, receiver);
87+
},
88+
});
89+
}
90+
91+
// Services that use HTTP/REST (or prefer Object headers) instead of gRPC (Map headers)
92+
const HTTP_SERVICES = ['storage', 'logging'];
93+
4794
export async function getClient(
4895
service,
4996
key,
@@ -55,7 +102,14 @@ export async function getClient(
55102
const ClientClass = await loadClient();
56103
const finalOptions = { ...options };
57104
if (accessToken && accessToken !== GCLOUD_AUTH) {
58-
finalOptions.authClient = getAuthClient(accessToken);
105+
const oauthClient = await getOAuthClient(accessToken);
106+
// Storage and Logging use HTTP and expect the native OAuth2Client (headers as Object).
107+
// Other services (Run, ServiceUsage, etc.) use gRPC and expect headers as Map.
108+
if (HTTP_SERVICES.includes(service)) {
109+
finalOptions.authClient = oauthClient;
110+
} else {
111+
finalOptions.authClient = wrapForGrpc(oauthClient);
112+
}
59113
}
60114
clients[service].set(key, new ClientClass(finalOptions));
61115
}
@@ -220,3 +274,19 @@ export async function getProjectsClient(accessToken = GCLOUD_AUTH) {
220274
accessToken
221275
);
222276
}
277+
278+
/**
279+
* Gets an OAuth2 Client for the specified access token.
280+
* @param {string} accessToken - The access token.
281+
* @returns {Promise<OAuth2Client>}
282+
*/
283+
export async function getOAuthClient(accessToken) {
284+
// Use the access token itself as the key since it's unique per session/user
285+
if (!clients.oauth.has(accessToken)) {
286+
const client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
287+
client.setCredentials({ access_token: accessToken });
288+
clients.oauth.set(accessToken, client);
289+
}
290+
291+
return clients.oauth.get(accessToken);
292+
}

lib/cloud-api/auth.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { GoogleAuth } from 'google-auth-library';
2323
* @async
2424
* @returns {Promise<boolean>} A promise that resolves to true if GCP auth are found, and false otherwise.
2525
*/
26+
//TODO: Rename ensureGCPCredentials to ensureADCCredentials
2627
export async function ensureGCPCredentials() {
2728
console.error('Checking for Google Cloud Application Default Credentials...');
2829
try {

lib/cloud-api/billing.js

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import { getBillingClient } from '../clients.js';
3030
* @returns {Promise<Array<{name: string, displayName: string, open: boolean}>>} A promise that resolves to an array of billing account objects,
3131
* each with 'name', 'displayName', and 'open' status. Returns an empty array on error.
3232
*/
33-
export async function listBillingAccounts() {
34-
const client = await getBillingClient();
33+
export async function listBillingAccounts(accessToken) {
34+
const client = await getBillingClient('global', accessToken);
3535
try {
3636
const [accounts] = await client.listBillingAccounts();
3737
if (!accounts || accounts.length === 0) {
@@ -59,9 +59,10 @@ export async function listBillingAccounts() {
5959
*/
6060
export async function attachProjectToBillingAccount(
6161
projectId,
62-
billingAccountName
62+
billingAccountName,
63+
accessToken
6364
) {
64-
const client = await getBillingClient();
65+
const client = await getBillingClient(projectId, accessToken);
6566
const projectName = `projects/${projectId}`;
6667

6768
if (!projectId) {
@@ -111,8 +112,8 @@ export async function attachProjectToBillingAccount(
111112
* @param {string} projectId - The ID of the project to check.
112113
* @returns {Promise<boolean>} A promise that resolves to true if billing is enabled, false otherwise.
113114
*/
114-
export async function isBillingEnabled(projectId) {
115-
const client = await getBillingClient();
115+
export async function isBillingEnabled(projectId, accessToken) {
116+
const client = await getBillingClient(projectId, accessToken);
116117
const projectName = `projects/${projectId}`;
117118
try {
118119
// getProjectBillingInfo requires cloudbilling.googleapis.com API to check billing status
@@ -136,10 +137,14 @@ export async function isBillingEnabled(projectId) {
136137
* @param {string} projectId The project ID to check billing for.
137138
* @param {function} progressCallback A callback function for progress updates.
138139
*/
139-
export async function ensureBillingEnabled(projectId, progressCallback) {
140-
if (!(await isBillingEnabled(projectId))) {
140+
export async function ensureBillingEnabled(
141+
projectId,
142+
accessToken,
143+
progressCallback
144+
) {
145+
if (!(await isBillingEnabled(projectId, accessToken))) {
141146
// Billing is disabled, try to fix it.
142-
const accounts = await listBillingAccounts();
147+
const accounts = await listBillingAccounts(accessToken);
143148

144149
if (accounts && accounts.length === 1 && accounts[0].open) {
145150
// Exactly one open account found, try to attach it.
@@ -151,7 +156,8 @@ export async function ensureBillingEnabled(projectId, progressCallback) {
151156

152157
const attachmentResult = await attachProjectToBillingAccount(
153158
projectId,
154-
account.name
159+
account.name,
160+
accessToken
155161
);
156162

157163
if (!attachmentResult || !attachmentResult.billingEnabled) {

lib/cloud-api/build.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,23 @@ export async function triggerCloudBuild(
5353
targetRepoName,
5454
targetImageUrl,
5555
hasDockerfile,
56+
accessToken,
5657
progressCallback
5758
) {
5859
const serviceName = targetImageUrl.split('/').pop().split(/[:@]/)[0];
5960
const parent = `projects/${projectId}/locations/${location}`;
6061
const serviceFullName = `${parent}/services/${serviceName}`;
61-
const buildsClient = await getBuildsClient(projectId);
62-
const runClient = await getRunClient(projectId);
62+
const buildsClient = await getBuildsClient(projectId, accessToken);
63+
const runClient = await getRunClient(projectId, accessToken);
6364
const serviceExists = await checkCloudRunServiceExists(
6465
projectId,
6566
location,
6667
serviceName,
68+
accessToken,
6769
progressCallback
6870
);
69-
const cloudBuildClient = await getCloudBuildClient(projectId);
70-
const loggingClient = await getLoggingClient(projectId);
71+
const cloudBuildClient = await getCloudBuildClient(projectId, accessToken);
72+
const loggingClient = await getLoggingClient(projectId, accessToken);
7173
let buildSteps;
7274

7375
const servicePatch = {

lib/cloud-api/helpers.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,16 @@ async function checkAndEnableApi(
100100
* @returns {Promise<void>} A promise that resolves when the API is enabled.
101101
* @throws {Error} If the API fails to enable or if there's an issue checking its status.
102102
*/
103-
export async function enableApiWithRetry(projectId, api, progressCallback) {
104-
const serviceUsageClient = await getServiceUsageClient(projectId);
103+
export async function enableApiWithRetry(
104+
projectId,
105+
api,
106+
accessToken,
107+
progressCallback
108+
) {
109+
const serviceUsageClient = await getServiceUsageClient(
110+
projectId,
111+
accessToken
112+
);
105113
const serviceName = `projects/${projectId}/services/${api}`;
106114
try {
107115
await checkAndEnableApi(
@@ -148,20 +156,25 @@ export async function enableApiWithRetry(projectId, api, progressCallback) {
148156
* @throws {Error} If an API fails to enable or if there's an issue checking its status.
149157
* @returns {Promise<void>} A promise that resolves when all specified APIs are enabled.
150158
*/
151-
export async function ensureApisEnabled(projectId, apis, progressCallback) {
159+
export async function ensureApisEnabled(
160+
projectId,
161+
apis,
162+
accessToken,
163+
progressCallback
164+
) {
152165
for (const api of PREREQUISITE_APIS) {
153-
await enableApiWithRetry(projectId, api, progressCallback);
166+
await enableApiWithRetry(projectId, api, accessToken, progressCallback);
154167
}
155168

156169
// Before enabling other APIs, ensure billing is enabled.
157-
await ensureBillingEnabled(projectId, progressCallback);
170+
await ensureBillingEnabled(projectId, accessToken, progressCallback);
158171

159172
const message = 'Checking and enabling required APIs...';
160173
console.log(message);
161174
if (progressCallback) progressCallback({ level: 'info', data: message });
162175

163176
for (const api of apis) {
164-
await enableApiWithRetry(projectId, api, progressCallback);
177+
await enableApiWithRetry(projectId, api, accessToken, progressCallback);
165178
}
166179
const successMsg = 'All required APIs are enabled.';
167180
console.log(successMsg);

lib/cloud-api/projects.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import { getProjectsClient } from '../clients.js';
2626
* @function listProjects
2727
* @returns {Promise<Array<{id: string}>>} A promise that resolves to an array of project objects, each with an 'id' property. Returns an empty array on error.
2828
*/
29-
export async function listProjects() {
30-
const client = await getProjectsClient();
29+
export async function listProjects(accessToken) {
30+
const client = await getProjectsClient(accessToken);
3131
try {
3232
const [projects] = await client.searchProjects();
3333
return projects.map((project) => ({
@@ -71,8 +71,8 @@ export function generateProjectId() {
7171
* @param {string} [parent] - Optional. The resource name of the parent under which the project is to be created. e.g., "organizations/123" or "folders/456".
7272
* @returns {Promise<{projectId: string}|null>} A promise that resolves to an object containing the new project's ID.
7373
*/
74-
export async function createProject(projectId, parent) {
75-
const client = await getProjectsClient();
74+
export async function createProject(projectId, parent, accessToken) {
75+
const client = await getProjectsClient(accessToken);
7676
let projectIdToUse = projectId;
7777

7878
if (!projectIdToUse) {
@@ -112,10 +112,14 @@ export async function createProject(projectId, parent) {
112112
* @param {string} [parent] - Optional. The resource name of the parent under which the project is to be created. e.g., "organizations/123" or "folders/456".
113113
* @returns {Promise<{projectId: string, billingMessage: string}>} A promise that resolves to an object containing the project ID and a billing status message.
114114
*/
115-
export async function createProjectAndAttachBilling(projectIdParam, parent) {
115+
export async function createProjectAndAttachBilling(
116+
projectIdParam,
117+
parent,
118+
accessToken
119+
) {
116120
let newProject;
117121
try {
118-
newProject = await createProject(projectIdParam, parent);
122+
newProject = await createProject(projectIdParam, parent, accessToken);
119123
} catch (error) {
120124
throw new Error(`Failed to create project: ${error.message}`);
121125
}
@@ -128,7 +132,7 @@ export async function createProjectAndAttachBilling(projectIdParam, parent) {
128132
let billingMessage = `Project ${projectId} created successfully.`;
129133

130134
try {
131-
const billingAccounts = await listBillingAccounts();
135+
const billingAccounts = await listBillingAccounts(accessToken);
132136
if (billingAccounts && billingAccounts.length > 0) {
133137
const firstBillingAccount = billingAccounts.find((acc) => acc.open); // Prefer an open account
134138
if (firstBillingAccount) {
@@ -137,7 +141,8 @@ export async function createProjectAndAttachBilling(projectIdParam, parent) {
137141
);
138142
const billingInfo = await attachProjectToBillingAccount(
139143
projectId,
140-
firstBillingAccount.name
144+
firstBillingAccount.name,
145+
accessToken
141146
);
142147
if (billingInfo && billingInfo.billingEnabled) {
143148
billingMessage += ` It has been attached to billing account ${firstBillingAccount.displayName}.`;
@@ -171,8 +176,8 @@ export async function createProjectAndAttachBilling(projectIdParam, parent) {
171176
* @param {string} projectId - The ID of the project to delete.
172177
* @returns {Promise<void>} A promise that resolves when the delete operation is initiated.
173178
*/
174-
export async function deleteProject(projectId) {
175-
const client = await getProjectsClient();
179+
export async function deleteProject(projectId, accessToken) {
180+
const client = await getProjectsClient(accessToken);
176181
try {
177182
console.log(`Attempting to delete project with ID: ${projectId}`);
178183
await client.deleteProject({ name: `projects/${projectId}` });

lib/cloud-api/registry.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ import { logAndProgress } from '../util/helpers.js';
3333
*/
3434
export async function ensureArtifactRegistryRepoExists(
3535
projectId,
36+
accessToken,
3637
location,
3738
repositoryId,
3839
format = 'DOCKER',
3940
progressCallback
4041
) {
41-
const artifactRegistryClient = await getArtifactRegistryClient(projectId);
42+
const artifactRegistryClient = await getArtifactRegistryClient(
43+
projectId,
44+
accessToken
45+
);
4246
const parent = `projects/${projectId}/locations/${location}`;
4347
const repoPath = artifactRegistryClient.repositoryPath(
4448
projectId,

0 commit comments

Comments
 (0)