From 5c9e526f0e967afe75c4948e4b077db286f626f2 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Mon, 9 Jun 2025 15:14:06 -0700 Subject: [PATCH] Code to support Oauth login (#881) --- packages/core/src/code_assist/constants.ts | 7 ++ packages/core/src/code_assist/load.ts | 115 ++++++++++++++++++++ packages/core/src/code_assist/login.ts | 119 +++++++++++++++++++++ packages/core/src/code_assist/metadata.ts | 37 +++++++ packages/core/src/code_assist/onboard.ts | 90 ++++++++++++++++ packages/core/src/code_assist/setup.ts | 56 ++++++++++ 6 files changed, 424 insertions(+) create mode 100644 packages/core/src/code_assist/constants.ts create mode 100644 packages/core/src/code_assist/load.ts create mode 100644 packages/core/src/code_assist/login.ts create mode 100644 packages/core/src/code_assist/metadata.ts create mode 100644 packages/core/src/code_assist/onboard.ts create mode 100644 packages/core/src/code_assist/setup.ts diff --git a/packages/core/src/code_assist/constants.ts b/packages/core/src/code_assist/constants.ts new file mode 100644 index 00000000..898b6136 --- /dev/null +++ b/packages/core/src/code_assist/constants.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DEFAULT_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; diff --git a/packages/core/src/code_assist/load.ts b/packages/core/src/code_assist/load.ts new file mode 100644 index 00000000..507268a6 --- /dev/null +++ b/packages/core/src/code_assist/load.ts @@ -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 { + 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; +} diff --git a/packages/core/src/code_assist/login.ts b/packages/core/src/code_assist/login.ts new file mode 100644 index 00000000..a94b21d4 --- /dev/null +++ b/packages/core/src/code_assist/login.ts @@ -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 { + 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 { + 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 { + 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); + }); +} diff --git a/packages/core/src/code_assist/metadata.ts b/packages/core/src/code_assist/metadata.ts new file mode 100644 index 00000000..7f74f962 --- /dev/null +++ b/packages/core/src/code_assist/metadata.ts @@ -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'; diff --git a/packages/core/src/code_assist/onboard.ts b/packages/core/src/code_assist/onboard.ts new file mode 100644 index 00000000..fc04fe35 --- /dev/null +++ b/packages/core/src/code_assist/onboard.ts @@ -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 { + 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; +} diff --git a/packages/core/src/code_assist/setup.ts b/packages/core/src/code_assist/setup.ts new file mode 100644 index 00000000..d8a6dd45 --- /dev/null +++ b/packages/core/src/code_assist/setup.ts @@ -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 { + 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 || ''; +}