Code to support Oauth login (#881)

This commit is contained in:
Tommaso Sciortino 2025-06-09 15:14:06 -07:00 committed by GitHub
parent f11414a424
commit 5c9e526f0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 424 additions and 0 deletions

View File

@ -0,0 +1,7 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const DEFAULT_ENDPOINT = 'https://cloudcode-pa.googleapis.com';

View File

@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OAuth2Client } from 'google-auth-library';
import { ClientMetadata } from './metadata.js';
import { DEFAULT_ENDPOINT } from './constants.js';
const LOAD_CODE_ASSIST_ENDPOINT = '/v1internal:loadCodeAssist';
export async function doLoadCodeAssist(
req: LoadCodeAssistRequest,
oauth2Client: OAuth2Client,
): Promise<LoadCodeAssistResponse> {
console.log('LoadCodeAssist req: ', JSON.stringify(req));
const authHeaders = await oauth2Client.getRequestHeaders();
const headers = { 'Content-Type': 'application/json', ...authHeaders };
const res: Response = await fetch(
new URL(LOAD_CODE_ASSIST_ENDPOINT, DEFAULT_ENDPOINT),
{
method: 'POST',
headers,
body: JSON.stringify(req),
},
);
const data: LoadCodeAssistResponse =
(await res.json()) as LoadCodeAssistResponse;
console.log('LoadCodeAssist res: ', JSON.stringify(data));
return data;
}
export interface LoadCodeAssistRequest {
cloudaicompanionProject?: string;
metadata: ClientMetadata;
}
/**
* Represents LoadCodeAssistResponse proto json field
* http://google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=224
*/
export interface LoadCodeAssistResponse {
currentTier?: GeminiUserTier | null;
allowedTiers?: GeminiUserTier[] | null;
ineligibleTiers?: IneligibleTier[] | null;
cloudaicompanionProject?: string | null;
}
/**
* GeminiUserTier reflects the structure received from the CCPA when calling LoadCodeAssist.
*/
export interface GeminiUserTier {
id: UserTierId;
name: string;
description: string;
// This value is used to declare whether a given tier requires the user to configure the project setting on the IDE settings or not.
userDefinedCloudaicompanionProject?: boolean | null;
isDefault?: boolean;
privacyNotice?: PrivacyNotice;
hasAcceptedTos?: boolean;
hasOnboardedPreviously?: boolean;
}
/**
* List of predefined reason codes when a tier is blocked from a specific tier.
* https://source.corp.google.com/piper///depot/google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=378
*/
export enum IneligibleTierReasonCode {
// go/keep-sorted start
DASHER_USER = 'DASHER_USER',
INELIGIBLE_ACCOUNT = 'INELIGIBLE_ACCOUNT',
NON_USER_ACCOUNT = 'NON_USER_ACCOUNT',
RESTRICTED_AGE = 'RESTRICTED_AGE',
RESTRICTED_NETWORK = 'RESTRICTED_NETWORK',
UNKNOWN = 'UNKNOWN',
UNKNOWN_LOCATION = 'UNKNOWN_LOCATION',
UNSUPPORTED_LOCATION = 'UNSUPPORTED_LOCATION',
// go/keep-sorted end
}
/**
* Includes information specifying the reasons for a user's ineligibility for a specific tier.
* @param reasonCode mnemonic code representing the reason for in-eligibility.
* @param reasonMessage message to display to the user.
* @param tierId id of the tier.
* @param tierName name of the tier.
*/
export interface IneligibleTier {
reasonCode: IneligibleTierReasonCode;
reasonMessage: string;
tierId: UserTierId;
tierName: string;
}
/**
* UserTierId represents IDs returned from the Cloud Code Private API representing a user's tier
*
* //depot/google3/cloud/developer_experience/cloudcode/pa/service/usertier.go;l=16
*/
export enum UserTierId {
FREE = 'free-tier',
LEGACY = 'legacy-tier',
STANDARD = 'standard-tier',
}
/**
* PrivacyNotice reflects the structure received from the CCPA in regards to a tier
* privacy notice.
*/
export interface PrivacyNotice {
showNotice: boolean;
noticeText?: string;
}

View File

@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OAuth2Client } from 'google-auth-library';
import * as http from 'http';
import url from 'url';
import crypto from 'crypto';
import * as net from 'net';
// OAuth Client ID used to initiate OAuth2Client class.
const OAUTH_CLIENT_ID =
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
// OAuth Secret value used to initiate OAuth2Client class.
const OAUTH_CLIENT_NOT_SO_SECRET = process.env.GCA_OAUTH_SECRET;
// OAuth Scopes for Cloud Code authorization.
const OAUTH_SCOPE = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
const HTTP_REDIRECT = 301;
const SIGN_IN_SUCCESS_URL =
'https://developers.google.com/gemini-code-assist/auth_success_gemini';
const SIGN_IN_FAILURE_URL =
'https://developers.google.com/gemini-code-assist/auth_failure_gemini';
export async function doGCALogin(): Promise<OAuth2Client> {
const redirectPort: number = await getAvailablePort();
const client: OAuth2Client = await createOAuth2Client(redirectPort);
await login(client, redirectPort);
return client;
}
function createOAuth2Client(redirectPort: number): OAuth2Client {
return new OAuth2Client({
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_NOT_SO_SECRET,
redirectUri: `http://localhost:${redirectPort}/oauth2redirect`,
});
}
/**
* Returns first available port in user's machine
* @returns port number
*/
function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
let port = 0;
try {
const server = net.createServer();
server.listen(0, () => {
const address = server.address()! as net.AddressInfo;
port = address.port;
});
server.on('listening', () => {
server.close();
server.unref();
});
server.on('error', (e) => reject(e));
server.on('close', () => resolve(port));
} catch (e) {
reject(e);
}
});
}
function login(oAuth2Client: OAuth2Client, port: number): Promise<boolean> {
return new Promise((resolve, reject) => {
const state = crypto.randomBytes(32).toString('hex');
const authURL: string = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: OAUTH_SCOPE,
state,
});
console.log('Login:\n\n', authURL);
const server = http
.createServer(async (req, res) => {
try {
if (req.url!.indexOf('/oauth2callback') > -1) {
// acquire the code from the querystring, and close the web server.
const qs = new url.URL(req.url!).searchParams;
if (qs.get('error')) {
console.error(`Error during authentication: ${qs.get('error')}`);
res.writeHead(HTTP_REDIRECT, { Location: SIGN_IN_FAILURE_URL });
res.end();
resolve(false);
} else if (qs.get('state') !== state) {
//check state value
console.log('State mismatch. Possible CSRF attack');
res.end('State mismatch. Possible CSRF attack');
resolve(false);
} else if (!qs.get('code')) {
const { tokens } = await oAuth2Client.getToken(qs.get('code')!);
console.log('Logged in! Tokens:\n\n', tokens);
oAuth2Client.setCredentials(tokens);
res.writeHead(HTTP_REDIRECT, { Location: SIGN_IN_SUCCESS_URL });
res.end();
resolve(true);
}
}
} catch (e) {
reject(e);
}
server.close();
})
.listen(port);
});
}

View File

@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface ClientMetadata {
ideType?: ClientMetadataIdeType | null;
ideVersion?: string | null;
pluginVersion?: string | null;
platform?: ClientMetadataPlatform | null;
updateChannel?: string | null;
duetProject?: string | null;
pluginType?: ClientMetadataPluginType | null;
ideName?: string | null;
}
export type ClientMetadataIdeType =
| 'IDE_UNSPECIFIED'
| 'VSCODE'
| 'INTELLIJ'
| 'VSCODE_CLOUD_WORKSTATION'
| 'INTELLIJ_CLOUD_WORKSTATION'
| 'CLOUD_SHELL';
export type ClientMetadataPlatform =
| 'PLATFORM_UNSPECIFIED'
| 'DARWIN_AMD64'
| 'DARWIN_ARM64'
| 'LINUX_AMD64'
| 'LINUX_ARM64'
| 'WINDOWS_AMD64';
export type ClientMetadataPluginType =
| 'PLUGIN_UNSPECIFIED'
| 'CLOUD_CODE'
| 'GEMINI'
| 'AIPLUGIN_INTELLIJ'
| 'AIPLUGIN_STUDIO';

View File

@ -0,0 +1,90 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OAuth2Client } from 'google-auth-library';
import { ClientMetadata } from './metadata.js';
import { DEFAULT_ENDPOINT } from './constants.js';
const ONBOARD_USER_ENDPOINT = '/v1internal:onboardUser';
export async function doOnboardUser(
req: OnboardUserRequest,
oauth2Client: OAuth2Client,
): Promise<LongrunningOperationResponse> {
console.log('OnboardUser req: ', JSON.stringify(req));
const authHeaders = await oauth2Client.getRequestHeaders();
const headers = { 'Content-Type': 'application/json', ...authHeaders };
const res: Response = await fetch(
new URL(ONBOARD_USER_ENDPOINT, DEFAULT_ENDPOINT),
{
method: 'POST',
headers,
body: JSON.stringify(req),
},
);
const data: LongrunningOperationResponse =
(await res.json()) as LongrunningOperationResponse;
console.log('OnboardUser res: ', JSON.stringify(data));
return data;
}
/**
* Proto signature of OnboardUserRequest as payload to OnboardUser call
*/
export interface OnboardUserRequest {
tierId: string | undefined;
cloudaicompanionProject: string | undefined;
metadata: ClientMetadata | undefined;
}
/**
* Represents LongrunningOperation proto
* http://google3/google/longrunning/operations.proto;rcl=698857719;l=107
*/
export interface LongrunningOperationResponse {
name: string;
done?: boolean;
response?: OnboardUserResponse;
}
/**
* Represents OnboardUserResponse proto
* http://google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=215
*/
export interface OnboardUserResponse {
// tslint:disable-next-line:enforce-name-casing This is the name of the field in the proto.
cloudaicompanionProject?: {
id: string;
name: string;
};
}
/**
* Status code of user license status
* it does not stricly correspond to the proto
* Error value is an additional value assigned to error responses from OnboardUser
*/
export enum OnboardUserStatusCode {
Default = 'DEFAULT',
Notice = 'NOTICE',
Warning = 'WARNING',
Error = 'ERROR',
}
/**
* Status of user onboarded to gemini
*/
export interface OnboardUserStatus {
statusCode: OnboardUserStatusCode;
displayMessage: string;
helpLink: HelpLinkUrl | undefined;
}
export interface HelpLinkUrl {
description: string;
url: string;
}

View File

@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OAuth2Client } from 'google-auth-library';
import { ClientMetadata } from './metadata.js';
import { doLoadCodeAssist, LoadCodeAssistResponse } from './load.js';
import { doGCALogin } from './login.js';
import {
doOnboardUser,
LongrunningOperationResponse,
OnboardUserRequest,
} from './onboard.js';
export async function doSetup(): Promise<string> {
const oauth2Client: OAuth2Client = await doGCALogin();
const clientMetadata: ClientMetadata = {
ideType: 'IDE_UNSPECIFIED',
ideVersion: null,
pluginVersion: null,
platform: 'PLATFORM_UNSPECIFIED',
updateChannel: null,
duetProject: 'aipp-internal-testing',
pluginType: 'GEMINI',
ideName: null,
};
// Call LoadCodeAssist.
const loadCodeAssistRes: LoadCodeAssistResponse = await doLoadCodeAssist(
{
cloudaicompanionProject: 'aipp-internal-testing',
metadata: clientMetadata,
},
oauth2Client,
);
// Call OnboardUser until long running operation is complete.
const onboardUserReq: OnboardUserRequest = {
tierId: 'legacy-tier',
cloudaicompanionProject: loadCodeAssistRes.cloudaicompanionProject || '',
metadata: clientMetadata,
};
let lroRes: LongrunningOperationResponse = await doOnboardUser(
onboardUserReq,
oauth2Client,
);
while (!lroRes.done) {
await new Promise((f) => setTimeout(f, 5000));
lroRes = await doOnboardUser(onboardUserReq, oauth2Client);
}
return lroRes.response?.cloudaicompanionProject?.id || '';
}