From 4e1dc0e1b749dd566c3a8da3b1dcbd7c41387d2e Mon Sep 17 00:00:00 2001 From: Omkar Date: Thu, 16 Jan 2025 16:10:46 +0530 Subject: [PATCH 01/19] PCC-1882: Integrating auth0 login --- packages/cli/src/cli/commands/login.ts | 33 +++++++++++++------------- packages/cli/src/lib/addonApiHelper.ts | 14 +++++++---- packages/cli/src/lib/apiConfig.ts | 30 +++++++++++++---------- packages/cli/src/lib/localStorage.ts | 6 +++-- 4 files changed, 48 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/cli/commands/login.ts b/packages/cli/src/cli/commands/login.ts index a239fa88..9a01e825 100644 --- a/packages/cli/src/cli/commands/login.ts +++ b/packages/cli/src/cli/commands/login.ts @@ -3,10 +3,10 @@ import http from "http"; import { dirname, join } from "path"; import url, { fileURLToPath } from "url"; import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; -import { OAuth2Client } from "google-auth-library"; import nunjucks from "nunjucks"; import open from "open"; import ora from "ora"; +import queryString from "query-string"; import destroyer from "server-destroy"; import AddOnApiHelper from "../../lib/addonApiHelper"; import { getApiConfig } from "../../lib/apiConfig"; @@ -18,7 +18,7 @@ import { errorHandler } from "../exceptions"; nunjucks.configure({ autoescape: true }); -const OAUTH_SCOPES = ["https://www.googleapis.com/auth/userinfo.email"]; +const AUTH0_SCOPES = "openid profile article:read offline_access"; function login(extraScopes: string[]): Promise { return new Promise( @@ -34,25 +34,24 @@ function login(extraScopes: string[]): Promise { !extraScopes?.length || extraScopes.find((x) => scopes?.includes(x)) ) { - const jwtPayload = parseJwt(authData.id_token as string); + const tokenPayload = parseJwt(authData.access_token as string); spinner.succeed( - `You are already logged in as ${jwtPayload.email}.`, + `You are already logged in as ${tokenPayload.user_email}.`, ); return resolve(); } } const apiConfig = await getApiConfig(); - const oAuth2Client = new OAuth2Client({ - clientId: apiConfig.googleClientId, - redirectUri: apiConfig.googleRedirectUri, - }); - - // Generate the url that will be used for the consent dialog. - const authorizeUrl = oAuth2Client.generateAuthUrl({ - access_type: "offline", - scope: [...OAUTH_SCOPES, ...extraScopes], - }); + const authorizeUrl = `${apiConfig.auth0Issuer}/authorize?${queryString.stringify( + { + response_type: "code", + client_id: apiConfig.auth0ClientId, + redirect_uri: apiConfig.auth0RedirectUri, + scope: AUTH0_SCOPES, + audience: apiConfig.auth0Audience, + }, + )}`; const server = http.createServer(async (req, res) => { try { @@ -69,18 +68,18 @@ function login(extraScopes: string[]): Promise { join(currDir, "../templates/loginSuccess.html"), ); const credentials = await AddOnApiHelper.getToken(code as string); - const jwtPayload = parseJwt(credentials.id_token as string); + const tokenPayload = parseJwt(credentials.access_token as string); await persistAuthDetails(credentials); res.end( nunjucks.renderString(content.toString(), { - email: jwtPayload.email, + email: tokenPayload.email, }), ); server.destroy(); spinner.succeed( - `You are successfully logged in as ${jwtPayload.email}`, + `You are successfully logged in as ${tokenPayload.user_email}`, ); resolve(); } diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 21368711..50636ce7 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -1,6 +1,5 @@ import { SmartComponentMapZod } from "@pantheon-systems/pcc-sdk-core/types"; import axios, { AxiosError, HttpStatusCode } from "axios"; -import { Credentials } from "google-auth-library"; import ora from "ora"; import queryString from "query-string"; import login from "../cli/commands/login"; @@ -9,10 +8,17 @@ import { getApiConfig } from "./apiConfig"; import { getLocalAuthDetails } from "./localStorage"; import { toKebabCase } from "./utils"; +interface Credentials { + id_token: string; + refresh_token: string; + access_token: string; + scope: string; +} + class AddOnApiHelper { static async getToken(code: string): Promise { const resp = await axios.post( - `${(await getApiConfig()).OAUTH_ENDPOINT}/token`, + `${(await getApiConfig()).AUTH0_ENDPOINT}/token`, { code: code, }, @@ -21,7 +27,7 @@ class AddOnApiHelper { } static async refreshToken(refreshToken: string): Promise { const resp = await axios.post( - `${(await getApiConfig()).OAUTH_ENDPOINT}/refresh`, + `${(await getApiConfig()).AUTH0_ENDPOINT}/refresh`, { refreshToken, }, @@ -58,7 +64,7 @@ class AddOnApiHelper { } return { - idToken: authDetails.id_token, + idToken: authDetails.access_token, oauthToken: authDetails.access_token, }; } diff --git a/packages/cli/src/lib/apiConfig.ts b/packages/cli/src/lib/apiConfig.ts index 7eb5f3f6..23e6d45b 100644 --- a/packages/cli/src/lib/apiConfig.ts +++ b/packages/cli/src/lib/apiConfig.ts @@ -8,8 +8,10 @@ export enum TargetEnvironment { type ApiConfig = { addOnApiEndpoint: string; - googleClientId: string; - googleRedirectUri: string; + auth0ClientId: string; + auth0RedirectUri: string; + auth0Audience: string; + auth0Issuer: string; playgroundUrl: string; }; @@ -17,23 +19,27 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { [TargetEnvironment.production]: { addOnApiEndpoint: "https://us-central1-pantheon-content-cloud.cloudfunctions.net/addOnApi", - googleClientId: + auth0ClientId: "432998952749-6eurouamlt7mvacb6u4e913m3kg4774c.apps.googleusercontent.com", - googleRedirectUri: "http://localhost:3030/oauth-redirect", + auth0RedirectUri: "http://localhost:3030/oauth-redirect", + auth0Audience: "https://addonapi-cxog5ytt4a-uc.a.run.app", + auth0Issuer: "https://dev-m4eh6wq011fxmahi.us.auth0.com", playgroundUrl: "https://live-collabcms-fe-demo.appa.pantheon.site", }, [TargetEnvironment.staging]: { - addOnApiEndpoint: - "https://us-central1-pantheon-content-cloud-staging.cloudfunctions.net/addOnApi", - googleClientId: - "142470191541-8o14j77pvagisc66s48kl4ub91f9c7b8.apps.googleusercontent.com", - googleRedirectUri: "http://localhost:3030/oauth-redirect", + addOnApiEndpoint: "http://localhost:8080", + auth0ClientId: "RAHxEbc251zD529hByapcv6Dcp3pmv4P", + auth0RedirectUri: "http://localhost:3030/oauth-redirect", + auth0Audience: "https://addonapi-cxog5ytt4a-uc.a.run.app", + auth0Issuer: "https://dev-m4eh6wq011fxmahi.us.auth0.com", playgroundUrl: "https://multi-staging-collabcms-fe-demo.appa.pantheon.site", }, [TargetEnvironment.test]: { addOnApiEndpoint: "https://test-jest.comxyz/addOnApi", - googleClientId: "test-google-com", - googleRedirectUri: "http://localhost:3030/oauth-redirect", + auth0ClientId: "test-google-com", + auth0RedirectUri: "http://localhost:3030/oauth-redirect", + auth0Audience: "https://addonapi-cxog5ytt4a-uc.a.run.app", + auth0Issuer: "https://dev-m4eh6wq011fxmahi.us.auth0.com", playgroundUrl: "https://test-playground.site", }, }; @@ -53,6 +59,6 @@ export const getApiConfig = async () => { API_KEY_ENDPOINT: `${apiConfig.addOnApiEndpoint}/api-key`, SITE_ENDPOINT: `${apiConfig.addOnApiEndpoint}/sites`, DOCUMENT_ENDPOINT: `${apiConfig.addOnApiEndpoint}/articles`, - OAUTH_ENDPOINT: `${apiConfig.addOnApiEndpoint}/oauth`, + AUTH0_ENDPOINT: `${apiConfig.addOnApiEndpoint}/auth0/`, }; }; diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index 80f3724e..a3144998 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -1,5 +1,6 @@ import { readFileSync, writeFileSync } from "fs"; import path from "path"; +import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import { ensureFile, remove } from "fs-extra"; import { Credentials } from "google-auth-library"; import { PCC_ROOT_DIR } from "../constants"; @@ -31,11 +32,12 @@ export const getLocalAuthDetails = async ( return null; } + const tokenPayload = parseJwt(credentials.access_token as string); // Check if token is expired - if (credentials.expiry_date) { + if (tokenPayload.exp) { const currentTime = await AddOnApiHelper.getCurrentTime(); - if (currentTime < credentials.expiry_date) { + if (currentTime < tokenPayload.exp * 1000) { return credentials; } } From 31fc89426881d397f70c6e551718e92a5c442383 Mon Sep 17 00:00:00 2001 From: Omkar Date: Thu, 16 Jan 2025 17:08:36 +0530 Subject: [PATCH 02/19] PCC-1882: Refreshing auth0 access token --- packages/cli/src/lib/addonApiHelper.ts | 27 +++++++++++++++----------- packages/cli/src/lib/localStorage.ts | 11 +++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 50636ce7..41cef7ca 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -8,31 +8,36 @@ import { getApiConfig } from "./apiConfig"; import { getLocalAuthDetails } from "./localStorage"; import { toKebabCase } from "./utils"; -interface Credentials { +export interface PersistedTokens { id_token: string; refresh_token: string; access_token: string; scope: string; + expires_in: number; } class AddOnApiHelper { - static async getToken(code: string): Promise { + static async getToken(code: string): Promise { const resp = await axios.post( `${(await getApiConfig()).AUTH0_ENDPOINT}/token`, { code: code, }, ); - return resp.data as Credentials; + return resp.data as PersistedTokens; } - static async refreshToken(refreshToken: string): Promise { - const resp = await axios.post( - `${(await getApiConfig()).AUTH0_ENDPOINT}/refresh`, - { - refreshToken, - }, - ); - return resp.data as Credentials; + static async refreshToken(refreshToken: string): Promise { + const apiConfig = await getApiConfig(); + const url = `${apiConfig.auth0Issuer}/oauth/token`; + const response = await axios.post(url, { + grant_type: "refresh_token", + client_id: apiConfig.auth0ClientId, + refresh_token: refreshToken, + }); + return { + refresh_token: refreshToken, + ...response.data, + } as PersistedTokens; } static async getCurrentTime(): Promise { diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index a3144998..3faef3d4 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -2,22 +2,21 @@ import { readFileSync, writeFileSync } from "fs"; import path from "path"; import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import { ensureFile, remove } from "fs-extra"; -import { Credentials } from "google-auth-library"; import { PCC_ROOT_DIR } from "../constants"; import { Config } from "../types/config"; -import AddOnApiHelper from "./addonApiHelper"; +import AddOnApiHelper, { PersistedTokens } from "./addonApiHelper"; export const AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); export const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); export const getLocalAuthDetails = async ( requiredScopes?: string[], -): Promise => { - let credentials: Credentials; +): Promise => { + let credentials: PersistedTokens; try { credentials = JSON.parse( readFileSync(AUTH_FILE_PATH).toString(), - ) as Credentials; + ) as PersistedTokens; } catch (_err) { return null; } @@ -62,7 +61,7 @@ export const getLocalConfigDetails = async (): Promise => { }; export const persistAuthDetails = async ( - payload: Credentials, + payload: PersistedTokens, ): Promise => { await persistDetailsToFile(payload, AUTH_FILE_PATH); }; From 98857bdbd68669febb85fde08da664b19d2b42b4 Mon Sep 17 00:00:00 2001 From: Omkar Date: Thu, 16 Jan 2025 20:16:49 +0530 Subject: [PATCH 03/19] PCC-1882: Adding google account argument to site creation --- packages/cli/src/cli/commands/sites/site.ts | 143 ++++++++++++++++++-- packages/cli/src/cli/index.ts | 11 +- packages/cli/src/lib/addonApiHelper.ts | 30 ++++ packages/cli/src/lib/apiConfig.ts | 11 ++ packages/cli/src/lib/localStorage.ts | 23 ++++ 5 files changed, 205 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/cli/commands/sites/site.ts b/packages/cli/src/cli/commands/sites/site.ts index 03b94b7a..2b402a3a 100644 --- a/packages/cli/src/cli/commands/sites/site.ts +++ b/packages/cli/src/cli/commands/sites/site.ts @@ -1,22 +1,141 @@ +import { readFileSync } from "fs"; +import http from "http"; +import { dirname, join } from "path"; +import url, { fileURLToPath } from "url"; +import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import chalk from "chalk"; import dayjs from "dayjs"; +import { OAuth2Client } from "google-auth-library"; +import nunjucks from "nunjucks"; +import open from "open"; import ora from "ora"; +import queryString from "query-string"; +import destroyer from "server-destroy"; import AddOnApiHelper from "../../../lib/addonApiHelper"; +import { getApiConfig } from "../../../lib/apiConfig"; import { printTable } from "../../../lib/cliDisplay"; +import { + getGoogleAuthDetails, + persistGoogleAuthDetails, +} from "../../../lib/localStorage"; import { errorHandler } from "../../exceptions"; -export const createSite = errorHandler(async (url: string) => { - const spinner = ora("Creating site...").start(); - try { - const siteId = await AddOnApiHelper.createSite(url); - spinner.succeed( - `Successfully created the site with given details. Id: ${siteId}`, - ); - } catch (e) { - spinner.fail(); - throw e; - } -}); +const OAUTH_SCOPES = ["https://www.googleapis.com/auth/userinfo.email"]; + +const connectGoogleAccount = async (googleAccount: string): Promise => { + return new Promise( + // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor + async (resolve, reject) => { + const spinner = ora("Connecting Google account...").start(); + try { + const accounts = await getGoogleAuthDetails(); + const googleAuth = (accounts || []).find((acc) => { + const payload = parseJwt(acc.id_token as string); + return payload.email === googleAccount; + }); + if (googleAuth) { + const tokenPayload = parseJwt(googleAuth.id_token as string); + spinner.succeed( + `Google account(${tokenPayload.email}) is connected.`, + ); + return resolve(); + } + + const apiConfig = await getApiConfig(); + const oAuth2Client = new OAuth2Client({ + clientId: apiConfig.googleClientId, + redirectUri: apiConfig.googleRedirectUri, + }); + + // Generate the url that will be used for the consent dialog. + const authorizeUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: OAUTH_SCOPES, + }); + + const server = http.createServer(async (req, res) => { + try { + if (!req.url) { + throw new Error("No URL path provided"); + } + + if (req.url.indexOf("/oauth-redirect") > -1) { + const qs = new url.URL(req.url, "http://localhost:3030") + .searchParams; + const code = qs.get("code"); + const currDir = dirname(fileURLToPath(import.meta.url)); + const content = readFileSync( + join(currDir, "../templates/loginSuccess.html"), + ); + const credentials = await AddOnApiHelper.getGoogleToken( + code as string, + ); + const jwtPayload = parseJwt(credentials.id_token as string); + + res.end( + nunjucks.renderString(content.toString(), { + email: jwtPayload.email, + }), + ); + server.destroy(); + + if (jwtPayload.email !== googleAccount) { + spinner.fail( + "Selected account doesn't match with provided email address.", + ); + return reject(); + } + + await persistGoogleAuthDetails(credentials); + spinner.succeed( + `You have successfully connected the Google account: ${jwtPayload.email}`, + ); + resolve(); + } + } catch (e) { + spinner.fail(); + reject(e); + } + }); + + destroyer(server); + + server.listen(3030, () => { + open(authorizeUrl, { wait: true }).then((cp) => cp.kill()); + }); + } catch (e) { + spinner.fail(); + reject(e); + } + }, + ); +}; + +export const createSite = errorHandler<{ url: string; googleAccount: string }>( + async ({ url, googleAccount }) => { + const spinner = ora("Creating site...").start(); + if (!googleAccount) { + spinner.fail("You must provide Google workspace account"); + return; + } + + try { + await connectGoogleAccount(googleAccount); + } catch { + return; + } + + try { + const siteId = await AddOnApiHelper.createSite(url); + spinner.succeed( + `Successfully created the site with given details. Id: ${siteId}`, + ); + } catch (e) { + spinner.fail(); + throw e; + } + }, +); export const deleteSite = errorHandler<{ id: string; diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index fbc2a138..cd4a52d1 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -394,8 +394,17 @@ yargs(hideBin(process.argv)) type: "string", demandOption: true, }); + yargs.option("googleAccount", { + describe: "Google workspace account email", + type: "string", + demandOption: false, + }); }, - async (args) => await createSite(args.url as string), + async (args) => + await createSite({ + url: args.url as string, + googleAccount: args.googleAccount as string, + }), ) .command( "delete [options]", diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 41cef7ca..2e2f6ab4 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -26,6 +26,15 @@ class AddOnApiHelper { ); return resp.data as PersistedTokens; } + static async getGoogleToken(code: string): Promise { + const resp = await axios.post( + `${(await getApiConfig()).OAUTH_ENDPOINT}/token`, + { + code: code, + }, + ); + return resp.data as PersistedTokens; + } static async refreshToken(refreshToken: string): Promise { const apiConfig = await getApiConfig(); const url = `${apiConfig.auth0Issuer}/oauth/token`; @@ -73,6 +82,27 @@ class AddOnApiHelper { oauthToken: authDetails.access_token, }; } + static async getGoogleIdToken( + requiredScopes?: string[], + ): Promise<{ idToken: string; oauthToken: string }>; + static async getGoogleIdToken(requiredScopes?: string[]) { + let authDetails = await getLocalAuthDetails(requiredScopes); + + // If auth details not found, try user logging in + if (!authDetails) { + // Clears older spinner if any + ora().clear(); + + await login(requiredScopes || []); + authDetails = await getLocalAuthDetails(requiredScopes); + if (!authDetails) throw new UserNotLoggedIn(); + } + + return { + idToken: authDetails.access_token, + oauthToken: authDetails.access_token, + }; + } static async getDocument( documentId: string, diff --git a/packages/cli/src/lib/apiConfig.ts b/packages/cli/src/lib/apiConfig.ts index 23e6d45b..eea44078 100644 --- a/packages/cli/src/lib/apiConfig.ts +++ b/packages/cli/src/lib/apiConfig.ts @@ -12,6 +12,8 @@ type ApiConfig = { auth0RedirectUri: string; auth0Audience: string; auth0Issuer: string; + googleClientId: string; + googleRedirectUri: string; playgroundUrl: string; }; @@ -24,6 +26,9 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { auth0RedirectUri: "http://localhost:3030/oauth-redirect", auth0Audience: "https://addonapi-cxog5ytt4a-uc.a.run.app", auth0Issuer: "https://dev-m4eh6wq011fxmahi.us.auth0.com", + googleClientId: + "432998952749-6eurouamlt7mvacb6u4e913m3kg4774c.apps.googleusercontent.com", + googleRedirectUri: "http://localhost:3030/oauth-redirect", playgroundUrl: "https://live-collabcms-fe-demo.appa.pantheon.site", }, [TargetEnvironment.staging]: { @@ -32,6 +37,9 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { auth0RedirectUri: "http://localhost:3030/oauth-redirect", auth0Audience: "https://addonapi-cxog5ytt4a-uc.a.run.app", auth0Issuer: "https://dev-m4eh6wq011fxmahi.us.auth0.com", + googleClientId: + "142470191541-bmomms4luuhoc68g903rscgr9qa3150b.apps.googleusercontent.com", + googleRedirectUri: "http://localhost:3030/oauth-redirect", playgroundUrl: "https://multi-staging-collabcms-fe-demo.appa.pantheon.site", }, [TargetEnvironment.test]: { @@ -40,6 +48,8 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { auth0RedirectUri: "http://localhost:3030/oauth-redirect", auth0Audience: "https://addonapi-cxog5ytt4a-uc.a.run.app", auth0Issuer: "https://dev-m4eh6wq011fxmahi.us.auth0.com", + googleClientId: "test-google-com", + googleRedirectUri: "http://localhost:3030/oauth-redirect", playgroundUrl: "https://test-playground.site", }, }; @@ -60,5 +70,6 @@ export const getApiConfig = async () => { SITE_ENDPOINT: `${apiConfig.addOnApiEndpoint}/sites`, DOCUMENT_ENDPOINT: `${apiConfig.addOnApiEndpoint}/articles`, AUTH0_ENDPOINT: `${apiConfig.addOnApiEndpoint}/auth0/`, + OAUTH_ENDPOINT: `${apiConfig.addOnApiEndpoint}/oauth/`, }; }; diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index 3faef3d4..7f72db65 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -2,11 +2,13 @@ import { readFileSync, writeFileSync } from "fs"; import path from "path"; import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import { ensureFile, remove } from "fs-extra"; +import { Credentials as GoogleCredentials } from "google-auth-library"; import { PCC_ROOT_DIR } from "../constants"; import { Config } from "../types/config"; import AddOnApiHelper, { PersistedTokens } from "./addonApiHelper"; export const AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); +export const GOOGLE_AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "google.json"); export const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); export const getLocalAuthDetails = async ( @@ -52,6 +54,18 @@ export const getLocalAuthDetails = async ( } }; +export const getGoogleAuthDetails = async (): Promise< + GoogleCredentials[] | undefined +> => { + try { + return JSON.parse( + readFileSync(GOOGLE_AUTH_FILE_PATH).toString(), + ) as GoogleCredentials[]; + } catch (_err) { + return; + } +}; + export const getLocalConfigDetails = async (): Promise => { try { return JSON.parse(readFileSync(CONFIG_FILE_PATH).toString()); @@ -65,6 +79,15 @@ export const persistAuthDetails = async ( ): Promise => { await persistDetailsToFile(payload, AUTH_FILE_PATH); }; +export const persistGoogleAuthDetails = async ( + payload: PersistedTokens, +): Promise => { + const existingAccounts = await getGoogleAuthDetails(); + const newAccounts = []; + if (existingAccounts) newAccounts.push(...existingAccounts); + newAccounts.push(payload); + await persistDetailsToFile(newAccounts, GOOGLE_AUTH_FILE_PATH); +}; export const persistConfigDetails = async (payload: Config): Promise => { await persistDetailsToFile(payload, CONFIG_FILE_PATH); From 45175eef30f4f781669416cd71d0a5e6bf458a1a Mon Sep 17 00:00:00 2001 From: Omkar Date: Thu, 16 Jan 2025 20:37:09 +0530 Subject: [PATCH 04/19] PCC-1882: Updated success template for account connection setup --- packages/cli/src/cli/commands/login.ts | 4 +-- packages/cli/src/cli/commands/logout.ts | 3 +- packages/cli/src/cli/commands/sites/site.ts | 2 +- .../cli/templates/accountConnectSuccess.html | 36 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 packages/cli/templates/accountConnectSuccess.html diff --git a/packages/cli/src/cli/commands/login.ts b/packages/cli/src/cli/commands/login.ts index 9a01e825..5c6e0bde 100644 --- a/packages/cli/src/cli/commands/login.ts +++ b/packages/cli/src/cli/commands/login.ts @@ -36,7 +36,7 @@ function login(extraScopes: string[]): Promise { ) { const tokenPayload = parseJwt(authData.access_token as string); spinner.succeed( - `You are already logged in as ${tokenPayload.user_email}.`, + `You are already logged in as ${tokenPayload["pcc/email"]}.`, ); return resolve(); } @@ -79,7 +79,7 @@ function login(extraScopes: string[]): Promise { server.destroy(); spinner.succeed( - `You are successfully logged in as ${tokenPayload.user_email}`, + `You are successfully logged in as ${tokenPayload["pcc/email"]}`, ); resolve(); } diff --git a/packages/cli/src/cli/commands/logout.ts b/packages/cli/src/cli/commands/logout.ts index 1a4feb97..00e00a53 100644 --- a/packages/cli/src/cli/commands/logout.ts +++ b/packages/cli/src/cli/commands/logout.ts @@ -1,12 +1,13 @@ import { existsSync, rmSync } from "fs"; import ora from "ora"; -import { AUTH_FILE_PATH } from "../../lib/localStorage"; +import { AUTH_FILE_PATH, GOOGLE_AUTH_FILE_PATH } from "../../lib/localStorage"; import { errorHandler } from "../exceptions"; const logout = async () => { const spinner = ora("Logging you out...").start(); try { if (existsSync(AUTH_FILE_PATH)) rmSync(AUTH_FILE_PATH); + if (existsSync(GOOGLE_AUTH_FILE_PATH)) rmSync(GOOGLE_AUTH_FILE_PATH); spinner.succeed("Successfully logged you out from PPC client!"); } catch (e) { spinner.fail(); diff --git a/packages/cli/src/cli/commands/sites/site.ts b/packages/cli/src/cli/commands/sites/site.ts index 2b402a3a..b013c1f2 100644 --- a/packages/cli/src/cli/commands/sites/site.ts +++ b/packages/cli/src/cli/commands/sites/site.ts @@ -65,7 +65,7 @@ const connectGoogleAccount = async (googleAccount: string): Promise => { const code = qs.get("code"); const currDir = dirname(fileURLToPath(import.meta.url)); const content = readFileSync( - join(currDir, "../templates/loginSuccess.html"), + join(currDir, "../templates/accountConnectSuccess.html"), ); const credentials = await AddOnApiHelper.getGoogleToken( code as string, diff --git a/packages/cli/templates/accountConnectSuccess.html b/packages/cli/templates/accountConnectSuccess.html new file mode 100644 index 00000000..f63ee81d --- /dev/null +++ b/packages/cli/templates/accountConnectSuccess.html @@ -0,0 +1,36 @@ + + + + + + PCC CLI + + + + +

Account connected

+ PCC CLI was successfully able to connect: + +

{{ email }}

+ + You can now close this tab and return to PCC CLI. + + + \ No newline at end of file From 9d14fe25e678c0e12fd632836f35b45506091e03 Mon Sep 17 00:00:00 2001 From: Omkar Date: Thu, 23 Jan 2025 00:10:28 +0530 Subject: [PATCH 05/19] PCC-1882: Update Auth0 scopes --- packages/cli/src/cli/commands/login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/login.ts b/packages/cli/src/cli/commands/login.ts index 5c6e0bde..071750c8 100644 --- a/packages/cli/src/cli/commands/login.ts +++ b/packages/cli/src/cli/commands/login.ts @@ -18,7 +18,7 @@ import { errorHandler } from "../exceptions"; nunjucks.configure({ autoescape: true }); -const AUTH0_SCOPES = "openid profile article:read offline_access"; +const AUTH0_SCOPES = "openid profile create:session offline_access"; function login(extraScopes: string[]): Promise { return new Promise( From 71ab031d7438e357a3a7a706b25007a9e18e4b0b Mon Sep 17 00:00:00 2001 From: Omkar Date: Mon, 27 Jan 2025 10:04:06 +0530 Subject: [PATCH 06/19] PCC-1882: ingtermediate --- .../cli/src/cli/commands/import/markdown.ts | 13 +- packages/cli/src/cli/commands/import/utils.ts | 15 +- packages/cli/src/cli/commands/login.ts | 112 +---- packages/cli/src/cli/commands/sites/site.ts | 123 +----- packages/cli/src/cli/commands/whoAmI.ts | 11 +- packages/cli/src/cli/exceptions.ts | 7 + packages/cli/src/cli/index.ts | 2 +- packages/cli/src/lib/addonApiHelper.ts | 215 ++++------ packages/cli/src/lib/auth.ts | 382 ++++++++++++++++++ packages/cli/src/lib/localStorage.ts | 78 +--- 10 files changed, 521 insertions(+), 437 deletions(-) create mode 100644 packages/cli/src/lib/auth.ts diff --git a/packages/cli/src/cli/commands/import/markdown.ts b/packages/cli/src/cli/commands/import/markdown.ts index 63d86b2a..149317d9 100644 --- a/packages/cli/src/cli/commands/import/markdown.ts +++ b/packages/cli/src/cli/commands/import/markdown.ts @@ -8,7 +8,7 @@ import { drive_v3, google } from "googleapis"; import ora from "ora"; import showdown from "showdown"; import AddOnApiHelper from "../../../lib/addonApiHelper"; -import { getLocalAuthDetails } from "../../../lib/localStorage"; +import { GoogleAuthProvider } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; import { errorHandler } from "../../exceptions"; @@ -38,11 +38,14 @@ export const importFromMarkdown = errorHandler( const content = fs.readFileSync(filePath).toString(); // Check user has required permission to create drive file - await AddOnApiHelper.getIdToken([ + await AddOnApiHelper.getGoogleTokens([ "https://www.googleapis.com/auth/drive.file", ]); - const authDetails = await getLocalAuthDetails(); - if (!authDetails) { + const provider = new GoogleAuthProvider([ + "https://www.googleapis.com/auth/drive.file", + ]); + const tokens = await provider.getTokens(); + if (!tokens) { logger.error(chalk.red(`ERROR: Failed to retrieve login details.`)); exit(1); } @@ -50,7 +53,7 @@ export const importFromMarkdown = errorHandler( // Create Google Doc const spinner = ora("Creating document on the Google Drive...").start(); const oauth2Client = new OAuth2Client(); - oauth2Client.setCredentials(authDetails); + oauth2Client.setCredentials(tokens); const drive = google.drive({ version: "v3", auth: oauth2Client, diff --git a/packages/cli/src/cli/commands/import/utils.ts b/packages/cli/src/cli/commands/import/utils.ts index 990869f2..0d803d28 100644 --- a/packages/cli/src/cli/commands/import/utils.ts +++ b/packages/cli/src/cli/commands/import/utils.ts @@ -4,7 +4,7 @@ import type { GaxiosResponse } from "gaxios"; import { OAuth2Client } from "google-auth-library"; import { drive_v3, google } from "googleapis"; import AddOnApiHelper from "../../../lib/addonApiHelper"; -import { getLocalAuthDetails } from "../../../lib/localStorage"; +import { GoogleAuthProvider } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; export function preprocessBaseURL(originalBaseURL: string) { @@ -33,19 +33,22 @@ export function preprocessBaseURL(originalBaseURL: string) { } export async function getAuthedDrive(logger: Logger) { - await AddOnApiHelper.getIdToken([ + await AddOnApiHelper.getGoogleTokens([ "https://www.googleapis.com/auth/drive.file", ]); - const authDetails = await getLocalAuthDetails(); - - if (!authDetails) { + // TODO: Add domain + const provider = new GoogleAuthProvider([ + "https://www.googleapis.com/auth/drive.file", + ]); + const tokens = await provider.getTokens(); + if (!tokens) { logger.error(chalk.red(`ERROR: Failed to retrieve login details. `)); exit(1); } const oauth2Client = new OAuth2Client(); - oauth2Client.setCredentials(authDetails); + oauth2Client.setCredentials(tokens); return google.drive({ version: "v3", auth: oauth2Client, diff --git a/packages/cli/src/cli/commands/login.ts b/packages/cli/src/cli/commands/login.ts index 071750c8..e58eef78 100644 --- a/packages/cli/src/cli/commands/login.ts +++ b/packages/cli/src/cli/commands/login.ts @@ -1,107 +1,23 @@ -import { readFileSync } from "fs"; -import http from "http"; -import { dirname, join } from "path"; -import url, { fileURLToPath } from "url"; -import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import nunjucks from "nunjucks"; -import open from "open"; -import ora from "ora"; -import queryString from "query-string"; -import destroyer from "server-destroy"; -import AddOnApiHelper from "../../lib/addonApiHelper"; -import { getApiConfig } from "../../lib/apiConfig"; -import { - getLocalAuthDetails, - persistAuthDetails, -} from "../../lib/localStorage"; +import { getAuthProvider } from "../../lib/auth"; import { errorHandler } from "../exceptions"; nunjucks.configure({ autoescape: true }); -const AUTH0_SCOPES = "openid profile create:session offline_access"; - -function login(extraScopes: string[]): Promise { - return new Promise( - // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor - async (resolve, reject) => { - const spinner = ora("Logging you in...").start(); - try { - const authData = await getLocalAuthDetails(extraScopes); - if (authData) { - const scopes = authData.scope?.split(" "); - - if ( - !extraScopes?.length || - extraScopes.find((x) => scopes?.includes(x)) - ) { - const tokenPayload = parseJwt(authData.access_token as string); - spinner.succeed( - `You are already logged in as ${tokenPayload["pcc/email"]}.`, - ); - return resolve(); - } - } - - const apiConfig = await getApiConfig(); - const authorizeUrl = `${apiConfig.auth0Issuer}/authorize?${queryString.stringify( - { - response_type: "code", - client_id: apiConfig.auth0ClientId, - redirect_uri: apiConfig.auth0RedirectUri, - scope: AUTH0_SCOPES, - audience: apiConfig.auth0Audience, - }, - )}`; - - const server = http.createServer(async (req, res) => { - try { - if (!req.url) { - throw new Error("No URL path provided"); - } - - if (req.url.indexOf("/oauth-redirect") > -1) { - const qs = new url.URL(req.url, "http://localhost:3030") - .searchParams; - const code = qs.get("code"); - const currDir = dirname(fileURLToPath(import.meta.url)); - const content = readFileSync( - join(currDir, "../templates/loginSuccess.html"), - ); - const credentials = await AddOnApiHelper.getToken(code as string); - const tokenPayload = parseJwt(credentials.access_token as string); - await persistAuthDetails(credentials); - - res.end( - nunjucks.renderString(content.toString(), { - email: tokenPayload.email, - }), - ); - server.destroy(); - - spinner.succeed( - `You are successfully logged in as ${tokenPayload["pcc/email"]}`, - ); - resolve(); - } - } catch (e) { - spinner.fail(); - reject(e); - } - }); - - destroyer(server); - - server.listen(3030, () => { - open(authorizeUrl, { wait: true }).then((cp) => cp.kill()); - }); - } catch (e) { - spinner.fail(); - reject(e); - } - }, - ); +async function login({ + authType, + scopes, +}: { + authType: "auth0" | "google"; + scopes?: string[]; +}): Promise { + const provider = getAuthProvider(authType, scopes); + await provider.login(); } -export default errorHandler(login); +export default errorHandler<{ + authType: "auth0" | "google"; + scopes?: string[]; +}>(login); export const LOGIN_EXAMPLES = [ { description: "Login the user", command: "$0 login" }, ]; diff --git a/packages/cli/src/cli/commands/sites/site.ts b/packages/cli/src/cli/commands/sites/site.ts index b013c1f2..cada533b 100644 --- a/packages/cli/src/cli/commands/sites/site.ts +++ b/packages/cli/src/cli/commands/sites/site.ts @@ -1,115 +1,9 @@ -import { readFileSync } from "fs"; -import http from "http"; -import { dirname, join } from "path"; -import url, { fileURLToPath } from "url"; -import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import chalk from "chalk"; import dayjs from "dayjs"; -import { OAuth2Client } from "google-auth-library"; -import nunjucks from "nunjucks"; -import open from "open"; import ora from "ora"; -import queryString from "query-string"; -import destroyer from "server-destroy"; import AddOnApiHelper from "../../../lib/addonApiHelper"; -import { getApiConfig } from "../../../lib/apiConfig"; import { printTable } from "../../../lib/cliDisplay"; -import { - getGoogleAuthDetails, - persistGoogleAuthDetails, -} from "../../../lib/localStorage"; -import { errorHandler } from "../../exceptions"; - -const OAUTH_SCOPES = ["https://www.googleapis.com/auth/userinfo.email"]; - -const connectGoogleAccount = async (googleAccount: string): Promise => { - return new Promise( - // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor - async (resolve, reject) => { - const spinner = ora("Connecting Google account...").start(); - try { - const accounts = await getGoogleAuthDetails(); - const googleAuth = (accounts || []).find((acc) => { - const payload = parseJwt(acc.id_token as string); - return payload.email === googleAccount; - }); - if (googleAuth) { - const tokenPayload = parseJwt(googleAuth.id_token as string); - spinner.succeed( - `Google account(${tokenPayload.email}) is connected.`, - ); - return resolve(); - } - - const apiConfig = await getApiConfig(); - const oAuth2Client = new OAuth2Client({ - clientId: apiConfig.googleClientId, - redirectUri: apiConfig.googleRedirectUri, - }); - - // Generate the url that will be used for the consent dialog. - const authorizeUrl = oAuth2Client.generateAuthUrl({ - access_type: "offline", - scope: OAUTH_SCOPES, - }); - - const server = http.createServer(async (req, res) => { - try { - if (!req.url) { - throw new Error("No URL path provided"); - } - - if (req.url.indexOf("/oauth-redirect") > -1) { - const qs = new url.URL(req.url, "http://localhost:3030") - .searchParams; - const code = qs.get("code"); - const currDir = dirname(fileURLToPath(import.meta.url)); - const content = readFileSync( - join(currDir, "../templates/accountConnectSuccess.html"), - ); - const credentials = await AddOnApiHelper.getGoogleToken( - code as string, - ); - const jwtPayload = parseJwt(credentials.id_token as string); - - res.end( - nunjucks.renderString(content.toString(), { - email: jwtPayload.email, - }), - ); - server.destroy(); - - if (jwtPayload.email !== googleAccount) { - spinner.fail( - "Selected account doesn't match with provided email address.", - ); - return reject(); - } - - await persistGoogleAuthDetails(credentials); - spinner.succeed( - `You have successfully connected the Google account: ${jwtPayload.email}`, - ); - resolve(); - } - } catch (e) { - spinner.fail(); - reject(e); - } - }); - - destroyer(server); - - server.listen(3030, () => { - open(authorizeUrl, { wait: true }).then((cp) => cp.kill()); - }); - } catch (e) { - spinner.fail(); - reject(e); - } - }, - ); -}; +import { errorHandler, IncorrectAccount } from "../../exceptions"; export const createSite = errorHandler<{ url: string; googleAccount: string }>( async ({ url, googleAccount }) => { @@ -120,18 +14,17 @@ export const createSite = errorHandler<{ url: string; googleAccount: string }>( } try { - await connectGoogleAccount(googleAccount); - } catch { - return; - } - - try { - const siteId = await AddOnApiHelper.createSite(url); + const siteId = await AddOnApiHelper.createSite(url, googleAccount); spinner.succeed( `Successfully created the site with given details. Id: ${siteId}`, ); } catch (e) { - spinner.fail(); + if (e instanceof IncorrectAccount) { + spinner.fail( + "Selected account doesn't match with account provided in the CLI.", + ); + return; + } throw e; } }, diff --git a/packages/cli/src/cli/commands/whoAmI.ts b/packages/cli/src/cli/commands/whoAmI.ts index 7bf6368d..332064d1 100644 --- a/packages/cli/src/cli/commands/whoAmI.ts +++ b/packages/cli/src/cli/commands/whoAmI.ts @@ -1,16 +1,17 @@ import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import chalk from "chalk"; -import { getLocalAuthDetails } from "../../lib/localStorage"; +import { Auth0Provider } from "../../lib/auth"; import { errorHandler } from "../exceptions"; const printWhoAmI = async () => { try { - const authData = await getLocalAuthDetails(); - if (!authData) { + const provider = new Auth0Provider(); + const tokens = await provider.getTokens(); + if (!tokens) { console.log("You aren't logged in."); } else { - const jwtPayload = parseJwt(authData.id_token as string); - console.log(`You're logged in as ${jwtPayload.email}`); + const jwtPayload = parseJwt(tokens.access_token as string); + console.log(`You're logged in as ${jwtPayload["pcc/email"]}`); } } catch (e) { chalk.red("Something went wrong - couldn't retrieve auth info."); diff --git a/packages/cli/src/cli/exceptions.ts b/packages/cli/src/cli/exceptions.ts index cf49f8c8..8dd44acd 100644 --- a/packages/cli/src/cli/exceptions.ts +++ b/packages/cli/src/cli/exceptions.ts @@ -15,6 +15,13 @@ export class UserNotLoggedIn extends Error { this.name = this.constructor.name; } } + +export class IncorrectAccount extends Error { + constructor() { + super("Selected account doesn't match with account provided in the CLI."); + this.name = this.constructor.name; + } +} export class HTTPNotFound extends Error { constructor() { super("Not Found"); diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index cd4a52d1..ff7b4197 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -896,7 +896,7 @@ yargs(hideBin(process.argv)) () => { // noop }, - async () => await login([]), + async () => await login({ authType: "auth0" }), ) .command( "logout", diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 2e2f6ab4..bb757914 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -1,54 +1,12 @@ import { SmartComponentMapZod } from "@pantheon-systems/pcc-sdk-core/types"; import axios, { AxiosError, HttpStatusCode } from "axios"; -import ora from "ora"; import queryString from "query-string"; -import login from "../cli/commands/login"; import { HTTPNotFound, UserNotLoggedIn } from "../cli/exceptions"; import { getApiConfig } from "./apiConfig"; -import { getLocalAuthDetails } from "./localStorage"; +import { Auth0Provider, GoogleAuthProvider, PersistedTokens } from "./auth"; import { toKebabCase } from "./utils"; -export interface PersistedTokens { - id_token: string; - refresh_token: string; - access_token: string; - scope: string; - expires_in: number; -} - class AddOnApiHelper { - static async getToken(code: string): Promise { - const resp = await axios.post( - `${(await getApiConfig()).AUTH0_ENDPOINT}/token`, - { - code: code, - }, - ); - return resp.data as PersistedTokens; - } - static async getGoogleToken(code: string): Promise { - const resp = await axios.post( - `${(await getApiConfig()).OAUTH_ENDPOINT}/token`, - { - code: code, - }, - ); - return resp.data as PersistedTokens; - } - static async refreshToken(refreshToken: string): Promise { - const apiConfig = await getApiConfig(); - const url = `${apiConfig.auth0Issuer}/oauth/token`; - const response = await axios.post(url, { - grant_type: "refresh_token", - client_id: apiConfig.auth0ClientId, - refresh_token: refreshToken, - }); - return { - refresh_token: refreshToken, - ...response.data, - } as PersistedTokens; - } - static async getCurrentTime(): Promise { try { const resp = await axios.get( @@ -61,47 +19,32 @@ class AddOnApiHelper { } } - static async getIdToken( - requiredScopes?: string[], - ): Promise<{ idToken: string; oauthToken: string }>; - static async getIdToken(requiredScopes?: string[]) { - let authDetails = await getLocalAuthDetails(requiredScopes); + static async getAuth0Tokens(): Promise { + const provider = new Auth0Provider(); + let tokens = await provider.getTokens(); + if (tokens) return tokens; - // If auth details not found, try user logging in - if (!authDetails) { - // Clears older spinner if any - ora().clear(); + // Login user if token is not found + await provider.login(); + tokens = await provider.getTokens(); + if (tokens) return tokens; - await login(requiredScopes || []); - authDetails = await getLocalAuthDetails(requiredScopes); - if (!authDetails) throw new UserNotLoggedIn(); - } - - return { - idToken: authDetails.access_token, - oauthToken: authDetails.access_token, - }; + throw new UserNotLoggedIn(); } - static async getGoogleIdToken( - requiredScopes?: string[], - ): Promise<{ idToken: string; oauthToken: string }>; - static async getGoogleIdToken(requiredScopes?: string[]) { - let authDetails = await getLocalAuthDetails(requiredScopes); - - // If auth details not found, try user logging in - if (!authDetails) { - // Clears older spinner if any - ora().clear(); - - await login(requiredScopes || []); - authDetails = await getLocalAuthDetails(requiredScopes); - if (!authDetails) throw new UserNotLoggedIn(); - } - - return { - idToken: authDetails.access_token, - oauthToken: authDetails.access_token, - }; + static async getGoogleTokens( + scopes?: string[], + email?: string, + ): Promise { + const provider = new GoogleAuthProvider(scopes, email); + let tokens = await provider.getTokens(); + if (tokens) return tokens; + + // Login user if token is not found + await provider.login(); + tokens = await provider.getTokens(); + if (tokens) return tokens; + + throw new UserNotLoggedIn(); } static async getDocument( @@ -109,7 +52,9 @@ class AddOnApiHelper { insertIfMissing = false, title?: string, ): Promise
{ - const { idToken, oauthToken } = await this.getIdToken(); + // TODO: Add required scopes and domain + const { id_token: idToken, access_token: oauthToken } = + await this.getGoogleTokens(); const resp = await axios.get( `${(await getApiConfig()).DOCUMENT_ENDPOINT}/${documentId}`, @@ -136,7 +81,7 @@ class AddOnApiHelper { fieldTitle: string, fieldType: string, ): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.post( `${(await getApiConfig()).SITE_ENDPOINT}/${siteId}/metadata`, @@ -149,7 +94,7 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }, @@ -167,7 +112,7 @@ class AddOnApiHelper { verbose?: boolean, ): Promise
{ - const { idToken, oauthToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); if (verbose) { console.log("update document", { @@ -191,8 +136,7 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, - "oauth-token": oauthToken, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }, @@ -202,9 +146,9 @@ class AddOnApiHelper { } static async publishDocument(documentId: string) { - const { idToken, oauthToken } = await this.getIdToken([ - "https://www.googleapis.com/auth/drive", - ]); + // TODO: Add domain + const { id_token: idToken, access_token: oauthToken } = + await this.getGoogleTokens(["https://www.googleapis.com/auth/drive"]); if (!idToken || !oauthToken) { throw new UserNotLoggedIn(); @@ -243,9 +187,9 @@ class AddOnApiHelper { baseUrl?: string; }, ): Promise { - const { idToken, oauthToken } = await this.getIdToken([ - "https://www.googleapis.com/auth/drive", - ]); + // TODO: Add domain + const { id_token: idToken, access_token: oauthToken } = + await this.getGoogleTokens(["https://www.googleapis.com/auth/drive"]); if (!idToken || !oauthToken) { throw new UserNotLoggedIn(); @@ -273,7 +217,7 @@ class AddOnApiHelper { static async createApiKey({ siteId, }: { siteId?: string } = {}): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const resp = await axios.post( (await getApiConfig()).API_KEY_ENDPOINT, @@ -282,7 +226,7 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); @@ -290,11 +234,11 @@ class AddOnApiHelper { } static async listApiKeys(): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const resp = await axios.get((await getApiConfig()).API_KEY_ENDPOINT, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }); @@ -302,12 +246,12 @@ class AddOnApiHelper { } static async revokeApiKey(id: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); try { await axios.delete(`${(await getApiConfig()).API_KEY_ENDPOINT}/${id}`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }); } catch (err) { @@ -319,12 +263,17 @@ class AddOnApiHelper { } } - static async createSite(url: string): Promise { - const { idToken } = await this.getIdToken(); + static async createSite(url: string, googleAccount: string): Promise { + // Add domain + const { id_token: idToken } = await this.getGoogleTokens( + undefined, + googleAccount, + ); const resp = await axios.post( (await getApiConfig()).SITE_ENDPOINT, { name: "", url, emailList: "" }, + // Add domain { headers: { Authorization: `Bearer ${idToken}`, @@ -339,7 +288,7 @@ class AddOnApiHelper { transferToSiteId: string | null | undefined, force: boolean, ): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const resp = await axios.delete( queryString.stringifyUrl({ @@ -351,7 +300,7 @@ class AddOnApiHelper { }), { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); @@ -363,11 +312,11 @@ class AddOnApiHelper { }: { withConnectionStatus?: boolean; }): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const resp = await axios.get((await getApiConfig()).SITE_ENDPOINT, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, params: { withConnectionStatus, @@ -378,13 +327,13 @@ class AddOnApiHelper { } static async getSite(siteId: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const resp = await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${siteId}`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); @@ -393,27 +342,27 @@ class AddOnApiHelper { } static async updateSite(id: string, url: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.patch( `${(await getApiConfig()).SITE_ENDPOINT}/${id}`, { url }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); } static async getServersideComponentSchema(id: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/components`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); @@ -423,7 +372,7 @@ class AddOnApiHelper { id: string, componentSchema: typeof SmartComponentMapZod, ): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.post( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/components`, @@ -432,39 +381,39 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); } static async removeComponentSchema(id: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.delete( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/components`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); } static async listAdmins(id: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); return ( await axios.get(`${(await getApiConfig()).SITE_ENDPOINT}/${id}/admins`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }) ).data; } static async addAdmin(id: string, email: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.patch( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/admins`, @@ -473,18 +422,18 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); } static async removeAdmin(id: string, email: string): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.delete(`${(await getApiConfig()).SITE_ENDPOINT}/${id}/admins`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, data: { email, @@ -493,14 +442,14 @@ class AddOnApiHelper { } static async listCollaborators(id: string): Promise { - const idToken = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); return ( await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/collaborators`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ) @@ -508,7 +457,7 @@ class AddOnApiHelper { } static async addCollaborator(id: string, email: string): Promise { - const idToken = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.patch( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/collaborators`, @@ -517,20 +466,20 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); } static async removeCollaborator(id: string, email: string): Promise { - const idToken = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); await axios.delete( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/collaborators`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, data: { email, @@ -553,7 +502,7 @@ class AddOnApiHelper { preferredEvents?: string[]; }, ): Promise { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const configuredWebhook = webhookUrl || webhookSecret || preferredEvents; @@ -571,7 +520,7 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); @@ -587,13 +536,13 @@ class AddOnApiHelper { offset?: number; }, ) { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const resp = await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${siteId}/webhookLogs`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, params: { limit, @@ -606,13 +555,13 @@ class AddOnApiHelper { } static async fetchAvailableWebhookEvents(siteId: string) { - const { idToken } = await this.getIdToken(); + const { access_token: accessToken } = await this.getAuth0Tokens(); const resp = await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${siteId}/availableWebhookEvents`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${accessToken}`, }, }, ); diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts new file mode 100644 index 00000000..0f8a8b9c --- /dev/null +++ b/packages/cli/src/lib/auth.ts @@ -0,0 +1,382 @@ +import { readFileSync } from "fs"; +import http from "http"; +import path, { dirname, join } from "path"; +import url, { fileURLToPath } from "url"; +import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; +import axios from "axios"; +import { OAuth2Client } from "google-auth-library"; +import nunjucks from "nunjucks"; +import open from "open"; +import ora from "ora"; +import queryString from "query-string"; +import destroyer from "server-destroy"; +import { IncorrectAccount } from "../cli/exceptions"; +import { PCC_ROOT_DIR } from "../constants"; +import AddOnApiHelper from "./addonApiHelper"; +import { getApiConfig } from "./apiConfig"; +import { persistDetailsToFile } from "./localStorage"; + +const DEFAULT_AUTH0_SCOPES = [ + "openid", + "profile", + "create:session", + "offline_access", +]; +const DEFAULT_GOOGLE_SCOPES = [ + "https://www.googleapis.com/auth/userinfo.email", +]; + +export const AUTH0_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); +export const GOOGLE_AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "google.json"); +export const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); + +export interface PersistedTokens { + id_token: string; + refresh_token: string; + access_token: string; + scope: string; + token_type: string; +} + +abstract class BaseAuthProvider { + protected scopes: string[] | undefined; + protected email: string | undefined; + constructor(scopes?: string[], email?: string) { + this.scopes = scopes; + this.email = email; + } + abstract generateToken(code: string): Promise; + abstract refreshToken(refreshToken: string): Promise; + abstract getTokens(): Promise; + abstract readFile(): Promise; + abstract persist(cred: PersistedTokens | PersistedTokens[]): Promise; + abstract login(): Promise; +} + +export class Auth0Provider extends BaseAuthProvider { + constructor(scopes?: string[], email?: string) { + super(); + this.scopes = scopes || DEFAULT_AUTH0_SCOPES; + this.email = email; + } + async generateToken(code: string): Promise { + const resp = await axios.post( + `${(await getApiConfig()).AUTH0_ENDPOINT}/token`, + { + code: code, + }, + ); + return resp.data as PersistedTokens; + } + + async refreshToken(refreshToken: string): Promise { + const apiConfig = await getApiConfig(); + const url = `${apiConfig.auth0Issuer}/oauth/token`; + const response = await axios.post(url, { + grant_type: "refresh_token", + client_id: apiConfig.auth0ClientId, + refresh_token: refreshToken, + }); + return { + refresh_token: refreshToken, + ...response.data, + } as PersistedTokens; + } + + async readFile(): Promise { + try { + return JSON.parse( + readFileSync(AUTH0_FILE_PATH).toString(), + ) as PersistedTokens; + } catch (_err) { + return null; + } + } + + async getTokens(): Promise { + const credentials = await this.readFile(); + if (!credentials) return null; + + // Return null if required scope is not present + const grantedScopes = new Set(credentials.scope?.split(" ") || []); + if (!DEFAULT_AUTH0_SCOPES.every((i) => grantedScopes.has(i))) { + return null; + } + + const tokenPayload = parseJwt(credentials.access_token as string); + // Check if token is expired + if (tokenPayload.exp) { + const currentTime = await AddOnApiHelper.getCurrentTime(); + + if (currentTime < tokenPayload.exp * 1000) { + return credentials; + } + } + + try { + const newCred = await this.refreshToken( + credentials.refresh_token as string, + ); + this.persist(newCred); + return newCred; + } catch (_err) { + return null; + } + } + + async persist(cred: PersistedTokens) { + await persistDetailsToFile(cred, AUTH0_FILE_PATH); + } + + async login(): Promise { + return new Promise( + // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor + async (resolve, reject) => { + const spinner = ora("Logging you in...").start(); + try { + const authData = await this.getTokens(); + if (authData) { + const tokenPayload = parseJwt(authData.access_token as string); + spinner.succeed( + `You are already logged in as ${tokenPayload["pcc/email"]}.`, + ); + return resolve(); + } + + const apiConfig = await getApiConfig(); + const authorizeUrl = `${apiConfig.auth0Issuer}/authorize?${queryString.stringify( + { + response_type: "code", + client_id: apiConfig.auth0ClientId, + redirect_uri: apiConfig.auth0RedirectUri, + scope: DEFAULT_AUTH0_SCOPES.join(" "), + audience: apiConfig.auth0Audience, + }, + )}`; + + const server = http.createServer(async (req, res) => { + try { + if (!req.url) { + throw new Error("No URL path provided"); + } + + if (req.url.indexOf("/oauth-redirect") > -1) { + const qs = new url.URL(req.url, "http://localhost:3030") + .searchParams; + const code = qs.get("code"); + const currDir = dirname(fileURLToPath(import.meta.url)); + const content = readFileSync( + join(currDir, "../templates/loginSuccess.html"), + ); + const credentials = await this.generateToken(code as string); + const tokenPayload = parseJwt( + credentials.access_token as string, + ); + await this.persist(credentials); + + res.end( + nunjucks.renderString(content.toString(), { + email: tokenPayload["pcc/email"], + }), + ); + server.destroy(); + + spinner.succeed( + `You are successfully logged in as ${tokenPayload["pcc/email"]}`, + ); + resolve(); + } + } catch (e) { + spinner.fail(); + reject(e); + } + }); + + destroyer(server); + + server.listen(3030, () => { + open(authorizeUrl, { wait: true }).then((cp) => cp.kill()); + }); + } catch (e) { + spinner.fail(); + reject(e); + } + }, + ); + } +} + +export class GoogleAuthProvider extends BaseAuthProvider { + constructor(scopes?: string[], email?: string) { + super(); + this.scopes = scopes || DEFAULT_GOOGLE_SCOPES; + this.email = email; + } + async generateToken(code: string): Promise { + const resp = await axios.post( + `${(await getApiConfig()).OAUTH_ENDPOINT}/token`, + { + code: code, + }, + ); + return resp.data as PersistedTokens; + } + + async refreshToken(refreshToken: string): Promise { + const resp = await axios.post( + `${(await getApiConfig()).OAUTH_ENDPOINT}/refresh`, + { + refreshToken, + }, + ); + return resp.data as PersistedTokens; + } + async readFile(): Promise { + try { + return JSON.parse( + readFileSync(GOOGLE_AUTH_FILE_PATH).toString(), + ) as PersistedTokens[]; + } catch (_err) { + return null; + } + } + + async getTokens(): Promise { + const credentialArr = await this.readFile(); + if (!credentialArr) return null; + + // Return null if required given email + const credIndex = (credentialArr || []).findIndex((acc) => { + const payload = parseJwt(acc.id_token as string); + return (payload.email.split("@")[1] || "").toLowerCase() === this.email; + }); + if (credIndex === -1) return null; + const credentials = credentialArr[credIndex]; + + // Return null if required scope is not present + const grantedScopes = new Set(credentials.scope?.split(" ") || []); + if ( + this.scopes && + this.scopes.length > 0 && + !this.scopes.every((i) => grantedScopes.has(i)) + ) { + return null; + } + + const tokenPayload = parseJwt(credentials.id_token as string); + // Check if token is expired + if (tokenPayload.exp) { + const currentTime = await AddOnApiHelper.getCurrentTime(); + + if (currentTime < tokenPayload.exp * 1000) { + return credentials; + } + } + + try { + const newCred = await this.refreshToken( + credentials.refresh_token as string, + ); + credentialArr[credIndex] = newCred; + this.persist(credentialArr); + return newCred; + } catch (_err) { + return null; + } + } + + async persist(cred: PersistedTokens[]) { + await persistDetailsToFile(cred, GOOGLE_AUTH_FILE_PATH); + } + + login(): Promise { + return new Promise( + // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor + async (resolve, reject) => { + const spinner = ora("Logging you in...").start(); + try { + const authData = await this.getTokens(); + if (authData) { + const tokenPayload = parseJwt(authData.id_token as string); + spinner.succeed( + `You are already logged in as ${tokenPayload.email}.`, + ); + return resolve(); + } + + const apiConfig = await getApiConfig(); + const oAuth2Client = new OAuth2Client({ + clientId: apiConfig.googleClientId, + redirectUri: apiConfig.googleRedirectUri, + }); + + // Generate the url that will be used for the consent dialog. + const authorizeUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: this.scopes, + }); + + const existingCredentials = (await this.readFile()) || []; + + const server = http.createServer(async (req, res) => { + try { + if (!req.url) { + throw new Error("No URL path provided"); + } + + if (req.url.indexOf("/oauth-redirect") > -1) { + const qs = new url.URL(req.url, "http://localhost:3030") + .searchParams; + const code = qs.get("code"); + const currDir = dirname(fileURLToPath(import.meta.url)); + const content = readFileSync( + join(currDir, "../templates/accountConnectSuccess.html"), + ); + const credentials = await this.generateToken(code as string); + const tokenPayload = parseJwt(credentials.id_token as string); + existingCredentials.push(credentials); + await this.persist(existingCredentials); + + res.end( + nunjucks.renderString(content.toString(), { + email: tokenPayload.email, + }), + ); + server.destroy(); + if (this.email && this.email !== tokenPayload.email) { + throw new IncorrectAccount(); + } + + spinner.succeed( + `You are successfully logged in as ${tokenPayload["pcc/email"]}`, + ); + resolve(); + } + } catch (e) { + spinner.fail(); + reject(e); + } + }); + + destroyer(server); + + server.listen(3030, () => { + open(authorizeUrl, { wait: true }).then((cp) => cp.kill()); + }); + } catch (e) { + spinner.fail(); + reject(e); + } + }, + ); + } +} + +export function getAuthProvider( + authType: "auth0" | "google", + scope?: string[], + email?: string, +): Auth0Provider | GoogleAuthProvider { + if (authType === "auth0") return new Auth0Provider(scope, email); + else return new GoogleAuthProvider(scope, email); +} diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index 7f72db65..244f34e0 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -1,71 +1,13 @@ import { readFileSync, writeFileSync } from "fs"; import path from "path"; -import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import { ensureFile, remove } from "fs-extra"; -import { Credentials as GoogleCredentials } from "google-auth-library"; import { PCC_ROOT_DIR } from "../constants"; import { Config } from "../types/config"; -import AddOnApiHelper, { PersistedTokens } from "./addonApiHelper"; export const AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); export const GOOGLE_AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "google.json"); export const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); -export const getLocalAuthDetails = async ( - requiredScopes?: string[], -): Promise => { - let credentials: PersistedTokens; - try { - credentials = JSON.parse( - readFileSync(AUTH_FILE_PATH).toString(), - ) as PersistedTokens; - } catch (_err) { - return null; - } - - // Return null if required scope is not present - const grantedScopes = new Set(credentials.scope?.split(" ") || []); - if ( - requiredScopes && - requiredScopes.length > 0 && - !requiredScopes.every((i) => grantedScopes.has(i)) - ) { - return null; - } - - const tokenPayload = parseJwt(credentials.access_token as string); - // Check if token is expired - if (tokenPayload.exp) { - const currentTime = await AddOnApiHelper.getCurrentTime(); - - if (currentTime < tokenPayload.exp * 1000) { - return credentials; - } - } - - try { - const newCred = await AddOnApiHelper.refreshToken( - credentials.refresh_token as string, - ); - persistAuthDetails(newCred); - return newCred; - } catch (_err) { - return null; - } -}; - -export const getGoogleAuthDetails = async (): Promise< - GoogleCredentials[] | undefined -> => { - try { - return JSON.parse( - readFileSync(GOOGLE_AUTH_FILE_PATH).toString(), - ) as GoogleCredentials[]; - } catch (_err) { - return; - } -}; - export const getLocalConfigDetails = async (): Promise => { try { return JSON.parse(readFileSync(CONFIG_FILE_PATH).toString()); @@ -74,28 +16,16 @@ export const getLocalConfigDetails = async (): Promise => { } }; -export const persistAuthDetails = async ( - payload: PersistedTokens, -): Promise => { - await persistDetailsToFile(payload, AUTH_FILE_PATH); -}; -export const persistGoogleAuthDetails = async ( - payload: PersistedTokens, -): Promise => { - const existingAccounts = await getGoogleAuthDetails(); - const newAccounts = []; - if (existingAccounts) newAccounts.push(...existingAccounts); - newAccounts.push(payload); - await persistDetailsToFile(newAccounts, GOOGLE_AUTH_FILE_PATH); -}; - export const persistConfigDetails = async (payload: Config): Promise => { await persistDetailsToFile(payload, CONFIG_FILE_PATH); }; export const deleteConfigDetails = async () => remove(CONFIG_FILE_PATH); -const persistDetailsToFile = async (payload: unknown, filePath: string) => { +export const persistDetailsToFile = async ( + payload: unknown, + filePath: string, +) => { await new Promise((resolve, reject) => ensureFile(filePath, (err: unknown) => { if (err) { From 9f41889fb5cd8019e943740d972b06cbc71f5fb0 Mon Sep 17 00:00:00 2001 From: Omkar Date: Mon, 27 Jan 2025 12:12:43 +0530 Subject: [PATCH 07/19] PCC-1882: Refactored localStorage module for CLI --- packages/cli/src/cli/commands/config.ts | 12 ++--- packages/cli/src/lib/apiConfig.ts | 4 +- packages/cli/src/lib/auth.ts | 55 +++++----------------- packages/cli/src/lib/checkEnvironment.ts | 4 +- packages/cli/src/lib/localStorage.ts | 59 ++++++++++++++++-------- 5 files changed, 60 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/cli/commands/config.ts b/packages/cli/src/cli/commands/config.ts index 5e2f0224..f5711ae8 100644 --- a/packages/cli/src/cli/commands/config.ts +++ b/packages/cli/src/cli/commands/config.ts @@ -1,11 +1,7 @@ import nunjucks from "nunjucks"; import ora from "ora"; import { getApiConfig } from "../../lib/apiConfig"; -import { - deleteConfigDetails, - getLocalConfigDetails, - persistConfigDetails, -} from "../../lib/localStorage"; +import * as LocalStorage from "../../lib/localStorage"; import { errorHandler } from "../exceptions"; nunjucks.configure({ autoescape: true }); @@ -17,7 +13,7 @@ export const setTargetEnvironment = errorHandler<"production" | "staging">( async (resolve, reject) => { const spinner = ora("Updating config file").start(); try { - await persistConfigDetails({ + await LocalStorage.persistConfigDetails({ targetEnvironment: target, }); @@ -38,7 +34,7 @@ export const resetTargetEnvironment = errorHandler((): Promise => { async (resolve, reject) => { const spinner = ora("Deleting config file").start(); try { - await deleteConfigDetails(); + await LocalStorage.deleteConfigDetails(); spinner.succeed(`Successfully deleted config file`); resolve(); } catch (e) { @@ -56,7 +52,7 @@ export const printConfigurationData = errorHandler( async (resolve, reject) => { const spinner = ora("Retrieving configuration data").start(); try { - const localConfig = await getLocalConfigDetails(); + const localConfig = await LocalStorage.getConfigDetails(); const apiConfig = await getApiConfig(); console.log(JSON.stringify({ localConfig, apiConfig }, null, 4)); spinner.succeed(`Successfully retrieved configuration data`); diff --git a/packages/cli/src/lib/apiConfig.ts b/packages/cli/src/lib/apiConfig.ts index eea44078..5b1318a8 100644 --- a/packages/cli/src/lib/apiConfig.ts +++ b/packages/cli/src/lib/apiConfig.ts @@ -1,4 +1,4 @@ -import { getLocalConfigDetails } from "./localStorage"; +import { getConfigDetails } from "./localStorage"; export enum TargetEnvironment { production = "production", @@ -55,7 +55,7 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { }; export const getApiConfig = async () => { - const config = await getLocalConfigDetails(); + const config = await getConfigDetails(); const apiConfig = apiConfigMap[ config?.targetEnvironment || diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 0f8a8b9c..88217836 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -1,6 +1,6 @@ import { readFileSync } from "fs"; import http from "http"; -import path, { dirname, join } from "path"; +import { dirname, join } from "path"; import url, { fileURLToPath } from "url"; import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import axios from "axios"; @@ -11,10 +11,9 @@ import ora from "ora"; import queryString from "query-string"; import destroyer from "server-destroy"; import { IncorrectAccount } from "../cli/exceptions"; -import { PCC_ROOT_DIR } from "../constants"; import AddOnApiHelper from "./addonApiHelper"; import { getApiConfig } from "./apiConfig"; -import { persistDetailsToFile } from "./localStorage"; +import * as LocalStorage from "./localStorage"; const DEFAULT_AUTH0_SCOPES = [ "openid", @@ -26,10 +25,6 @@ const DEFAULT_GOOGLE_SCOPES = [ "https://www.googleapis.com/auth/userinfo.email", ]; -export const AUTH0_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); -export const GOOGLE_AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "google.json"); -export const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); - export interface PersistedTokens { id_token: string; refresh_token: string; @@ -48,8 +43,6 @@ abstract class BaseAuthProvider { abstract generateToken(code: string): Promise; abstract refreshToken(refreshToken: string): Promise; abstract getTokens(): Promise; - abstract readFile(): Promise; - abstract persist(cred: PersistedTokens | PersistedTokens[]): Promise; abstract login(): Promise; } @@ -83,18 +76,8 @@ export class Auth0Provider extends BaseAuthProvider { } as PersistedTokens; } - async readFile(): Promise { - try { - return JSON.parse( - readFileSync(AUTH0_FILE_PATH).toString(), - ) as PersistedTokens; - } catch (_err) { - return null; - } - } - async getTokens(): Promise { - const credentials = await this.readFile(); + const credentials = await LocalStorage.getAuthDetails(); if (!credentials) return null; // Return null if required scope is not present @@ -117,17 +100,13 @@ export class Auth0Provider extends BaseAuthProvider { const newCred = await this.refreshToken( credentials.refresh_token as string, ); - this.persist(newCred); + await LocalStorage.persistAuthDetails(newCred); return newCred; } catch (_err) { return null; } } - async persist(cred: PersistedTokens) { - await persistDetailsToFile(cred, AUTH0_FILE_PATH); - } - async login(): Promise { return new Promise( // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor @@ -172,7 +151,7 @@ export class Auth0Provider extends BaseAuthProvider { const tokenPayload = parseJwt( credentials.access_token as string, ); - await this.persist(credentials); + await LocalStorage.persistAuthDetails(credentials); res.end( nunjucks.renderString(content.toString(), { @@ -231,18 +210,9 @@ export class GoogleAuthProvider extends BaseAuthProvider { ); return resp.data as PersistedTokens; } - async readFile(): Promise { - try { - return JSON.parse( - readFileSync(GOOGLE_AUTH_FILE_PATH).toString(), - ) as PersistedTokens[]; - } catch (_err) { - return null; - } - } async getTokens(): Promise { - const credentialArr = await this.readFile(); + const credentialArr = await LocalStorage.getGoogleAuthDetails(); if (!credentialArr) return null; // Return null if required given email @@ -278,17 +248,13 @@ export class GoogleAuthProvider extends BaseAuthProvider { credentials.refresh_token as string, ); credentialArr[credIndex] = newCred; - this.persist(credentialArr); + await LocalStorage.persistGoogleAuthDetails(credentialArr); return newCred; } catch (_err) { return null; } } - async persist(cred: PersistedTokens[]) { - await persistDetailsToFile(cred, GOOGLE_AUTH_FILE_PATH); - } - login(): Promise { return new Promise( // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor @@ -316,7 +282,8 @@ export class GoogleAuthProvider extends BaseAuthProvider { scope: this.scopes, }); - const existingCredentials = (await this.readFile()) || []; + const existingCredentials = + (await LocalStorage.getGoogleAuthDetails()) || []; const server = http.createServer(async (req, res) => { try { @@ -335,7 +302,9 @@ export class GoogleAuthProvider extends BaseAuthProvider { const credentials = await this.generateToken(code as string); const tokenPayload = parseJwt(credentials.id_token as string); existingCredentials.push(credentials); - await this.persist(existingCredentials); + await LocalStorage.persistGoogleAuthDetails( + existingCredentials, + ); res.end( nunjucks.renderString(content.toString(), { diff --git a/packages/cli/src/lib/checkEnvironment.ts b/packages/cli/src/lib/checkEnvironment.ts index 9c18243f..e8d29c95 100644 --- a/packages/cli/src/lib/checkEnvironment.ts +++ b/packages/cli/src/lib/checkEnvironment.ts @@ -1,9 +1,9 @@ import chalk from "chalk"; import { TargetEnvironment } from "./apiConfig"; -import { getLocalConfigDetails } from "./localStorage"; +import { getConfigDetails } from "./localStorage"; export const checkEnvironment = async () => { - const config = await getLocalConfigDetails(); + const config = await getConfigDetails(); const env = config?.targetEnvironment || (process.env.NODE_ENV as TargetEnvironment) || diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index 244f34e0..85b7a331 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -3,38 +3,59 @@ import path from "path"; import { ensureFile, remove } from "fs-extra"; import { PCC_ROOT_DIR } from "../constants"; import { Config } from "../types/config"; +import { PersistedTokens } from "./auth"; -export const AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); -export const GOOGLE_AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "google.json"); -export const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); +const AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); +const GOOGLE_AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "google.json"); +const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); -export const getLocalConfigDetails = async (): Promise => { +const persistDetailsToFile = async (payload: unknown, filePath: string) => { + await new Promise((resolve, reject) => + ensureFile(filePath, (err: unknown) => { + if (err) { + reject(err); + } else { + resolve(); + } + }), + ); + + writeFileSync(filePath, JSON.stringify(payload, null, 2)); +}; +const readFile = async (path: string): Promise => { try { - return JSON.parse(readFileSync(CONFIG_FILE_PATH).toString()); + return JSON.parse(readFileSync(path).toString()) as T; } catch (_err) { return null; } }; +export const getConfigDetails = async (): Promise => { + return readFile(CONFIG_FILE_PATH); +}; + export const persistConfigDetails = async (payload: Config): Promise => { await persistDetailsToFile(payload, CONFIG_FILE_PATH); }; export const deleteConfigDetails = async () => remove(CONFIG_FILE_PATH); -export const persistDetailsToFile = async ( - payload: unknown, - filePath: string, -) => { - await new Promise((resolve, reject) => - ensureFile(filePath, (err: unknown) => { - if (err) { - reject(err); - } else { - resolve(); - } - }), - ); +export const getAuthDetails = async (): Promise => { + return readFile(AUTH_FILE_PATH); +}; +export const persistAuthDetails = async ( + payload: PersistedTokens, +): Promise => { + await persistDetailsToFile(payload, AUTH_FILE_PATH); +}; - writeFileSync(filePath, JSON.stringify(payload, null, 2)); +export const getGoogleAuthDetails = async (): Promise< + PersistedTokens[] | null +> => { + return readFile(GOOGLE_AUTH_FILE_PATH); +}; +export const persistGoogleAuthDetails = async ( + payload: PersistedTokens[], +): Promise => { + await persistDetailsToFile(payload, GOOGLE_AUTH_FILE_PATH); }; From 83025f2cc2df093dd220dfddc0f80512f40674fe Mon Sep 17 00:00:00 2001 From: Omkar Date: Mon, 27 Jan 2025 16:22:16 +0530 Subject: [PATCH 08/19] PCC-1882: Fixed messaging for CLI errors --- packages/cli/src/cli/commands/import/markdown.ts | 10 +--------- packages/cli/src/cli/exceptions.ts | 2 +- packages/cli/src/lib/apiConfig.ts | 2 ++ packages/cli/src/lib/auth.ts | 6 +++--- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/cli/commands/import/markdown.ts b/packages/cli/src/cli/commands/import/markdown.ts index 149317d9..09f0503e 100644 --- a/packages/cli/src/cli/commands/import/markdown.ts +++ b/packages/cli/src/cli/commands/import/markdown.ts @@ -38,17 +38,9 @@ export const importFromMarkdown = errorHandler( const content = fs.readFileSync(filePath).toString(); // Check user has required permission to create drive file - await AddOnApiHelper.getGoogleTokens([ + const tokens = await AddOnApiHelper.getGoogleTokens([ "https://www.googleapis.com/auth/drive.file", ]); - const provider = new GoogleAuthProvider([ - "https://www.googleapis.com/auth/drive.file", - ]); - const tokens = await provider.getTokens(); - if (!tokens) { - logger.error(chalk.red(`ERROR: Failed to retrieve login details.`)); - exit(1); - } // Create Google Doc const spinner = ora("Creating document on the Google Drive...").start(); diff --git a/packages/cli/src/cli/exceptions.ts b/packages/cli/src/cli/exceptions.ts index 8dd44acd..739f779f 100644 --- a/packages/cli/src/cli/exceptions.ts +++ b/packages/cli/src/cli/exceptions.ts @@ -18,7 +18,7 @@ export class UserNotLoggedIn extends Error { export class IncorrectAccount extends Error { constructor() { - super("Selected account doesn't match with account provided in the CLI."); + super("Selected account is not valid"); this.name = this.constructor.name; } } diff --git a/packages/cli/src/lib/apiConfig.ts b/packages/cli/src/lib/apiConfig.ts index 5b1318a8..1d27261d 100644 --- a/packages/cli/src/lib/apiConfig.ts +++ b/packages/cli/src/lib/apiConfig.ts @@ -32,6 +32,8 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { playgroundUrl: "https://live-collabcms-fe-demo.appa.pantheon.site", }, [TargetEnvironment.staging]: { + // addOnApiEndpoint: + // "https://us-central1-pantheon-content-cloud-staging.cloudfunctions.net/addOnApi", addOnApiEndpoint: "http://localhost:8080", auth0ClientId: "RAHxEbc251zD529hByapcv6Dcp3pmv4P", auth0RedirectUri: "http://localhost:3030/oauth-redirect", diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 88217836..5af03ccb 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -259,13 +259,13 @@ export class GoogleAuthProvider extends BaseAuthProvider { return new Promise( // eslint-disable-next-line no-async-promise-executor -- Handling promise rejection in the executor async (resolve, reject) => { - const spinner = ora("Logging you in...").start(); + const spinner = ora("Connecting Google account...").start(); try { const authData = await this.getTokens(); if (authData) { const tokenPayload = parseJwt(authData.id_token as string); spinner.succeed( - `You are already logged in as ${tokenPayload.email}.`, + `"${tokenPayload.email}" Google account is already connected.`, ); return resolve(); } @@ -317,7 +317,7 @@ export class GoogleAuthProvider extends BaseAuthProvider { } spinner.succeed( - `You are successfully logged in as ${tokenPayload["pcc/email"]}`, + `Successfully connected "${tokenPayload.email}" Google account.`, ); resolve(); } From 60da27310515ce841cdd8aa579b1c87176df52ba Mon Sep 17 00:00:00 2001 From: Omkar Date: Mon, 27 Jan 2025 16:37:59 +0530 Subject: [PATCH 09/19] PCC-1882: Added comment --- packages/cli/src/lib/addonApiHelper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index bb757914..499531bc 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -53,6 +53,7 @@ class AddOnApiHelper { title?: string, ): Promise
{ // TODO: Add required scopes and domain + // Use Auth0 tokens as Authorization and accessToken from Google const { id_token: idToken, access_token: oauthToken } = await this.getGoogleTokens(); From cc3d28b1df827eaaaa60c60ad86aa30d045403e5 Mon Sep 17 00:00:00 2001 From: Omkar Date: Mon, 27 Jan 2025 23:42:00 +0530 Subject: [PATCH 10/19] PCC-1882: Fixed site creation logic --- packages/cli/src/cli/commands/logout.ts | 10 ++++++---- packages/cli/src/cli/commands/sites/site.ts | 2 +- packages/cli/src/cli/exceptions.ts | 2 +- packages/cli/src/lib/addonApiHelper.ts | 8 ++++---- packages/cli/src/lib/auth.ts | 2 +- packages/cli/src/lib/localStorage.ts | 4 ++++ 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/commands/logout.ts b/packages/cli/src/cli/commands/logout.ts index 00e00a53..42430037 100644 --- a/packages/cli/src/cli/commands/logout.ts +++ b/packages/cli/src/cli/commands/logout.ts @@ -1,13 +1,15 @@ -import { existsSync, rmSync } from "fs"; import ora from "ora"; -import { AUTH_FILE_PATH, GOOGLE_AUTH_FILE_PATH } from "../../lib/localStorage"; +import { + deleteAuthDetails, + deleteGoogleAuthDetails, +} from "../../lib/localStorage"; import { errorHandler } from "../exceptions"; const logout = async () => { const spinner = ora("Logging you out...").start(); try { - if (existsSync(AUTH_FILE_PATH)) rmSync(AUTH_FILE_PATH); - if (existsSync(GOOGLE_AUTH_FILE_PATH)) rmSync(GOOGLE_AUTH_FILE_PATH); + deleteAuthDetails(); + deleteGoogleAuthDetails(); spinner.succeed("Successfully logged you out from PPC client!"); } catch (e) { spinner.fail(); diff --git a/packages/cli/src/cli/commands/sites/site.ts b/packages/cli/src/cli/commands/sites/site.ts index cada533b..874cf4b9 100644 --- a/packages/cli/src/cli/commands/sites/site.ts +++ b/packages/cli/src/cli/commands/sites/site.ts @@ -21,7 +21,7 @@ export const createSite = errorHandler<{ url: string; googleAccount: string }>( } catch (e) { if (e instanceof IncorrectAccount) { spinner.fail( - "Selected account doesn't match with account provided in the CLI.", + "Selected account doesn't match with the account provided in the CLI.", ); return; } diff --git a/packages/cli/src/cli/exceptions.ts b/packages/cli/src/cli/exceptions.ts index 739f779f..7ae650ee 100644 --- a/packages/cli/src/cli/exceptions.ts +++ b/packages/cli/src/cli/exceptions.ts @@ -18,7 +18,7 @@ export class UserNotLoggedIn extends Error { export class IncorrectAccount extends Error { constructor() { - super("Selected account is not valid"); + super("Selected account is not"); this.name = this.constructor.name; } } diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 499531bc..80e934af 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -265,19 +265,19 @@ class AddOnApiHelper { } static async createSite(url: string, googleAccount: string): Promise { - // Add domain - const { id_token: idToken } = await this.getGoogleTokens( + const { access_token: googleAccessToken } = await this.getGoogleTokens( undefined, googleAccount, ); + const { access_token: auth0AccessToken } = await this.getAuth0Tokens(); const resp = await axios.post( (await getApiConfig()).SITE_ENDPOINT, { name: "", url, emailList: "" }, - // Add domain { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${auth0AccessToken}`, + "oauth-token": googleAccessToken, }, }, ); diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 5af03ccb..960ba4b4 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -218,7 +218,7 @@ export class GoogleAuthProvider extends BaseAuthProvider { // Return null if required given email const credIndex = (credentialArr || []).findIndex((acc) => { const payload = parseJwt(acc.id_token as string); - return (payload.email.split("@")[1] || "").toLowerCase() === this.email; + return payload.email === this.email; }); if (credIndex === -1) return null; const credentials = credentialArr[credIndex]; diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index 85b7a331..76061857 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -59,3 +59,7 @@ export const persistGoogleAuthDetails = async ( ): Promise => { await persistDetailsToFile(payload, GOOGLE_AUTH_FILE_PATH); }; + +export const deleteAuthDetails = async () => remove(AUTH_FILE_PATH); +export const deleteGoogleAuthDetails = async () => + remove(GOOGLE_AUTH_FILE_PATH); From 2d871723c1195090c8b350ae3661312cbc983473 Mon Sep 17 00:00:00 2001 From: Omkar Date: Tue, 28 Jan 2025 11:55:00 +0530 Subject: [PATCH 11/19] PCC-1882: Added google account integration with import commands --- .../cli/src/cli/commands/import/drupal.ts | 17 ++- .../cli/src/cli/commands/import/markdown.ts | 35 ++--- packages/cli/src/cli/commands/import/utils.ts | 18 +-- .../cli/src/cli/commands/import/wordpress.ts | 15 +- packages/cli/src/cli/exceptions.ts | 2 +- packages/cli/src/cli/index.ts | 130 +++++++++--------- packages/cli/src/lib/addonApiHelper.ts | 54 +++++--- packages/cli/src/lib/auth.ts | 26 ++-- packages/cli/src/types/index.d.ts | 1 + 9 files changed, 153 insertions(+), 145 deletions(-) diff --git a/packages/cli/src/cli/commands/import/drupal.ts b/packages/cli/src/cli/commands/import/drupal.ts index e4b41c85..c0fdffe2 100644 --- a/packages/cli/src/cli/commands/import/drupal.ts +++ b/packages/cli/src/cli/commands/import/drupal.ts @@ -85,7 +85,16 @@ export const importFromDrupal = errorHandler( exit(1); } - const drive = await getAuthedDrive(logger); + // Get site details + const site = await AddOnApiHelper.getSite(siteId); + + const tokens = await AddOnApiHelper.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain: site.domain, + }); + + const drive = getAuthedDrive(tokens); + const folder = await createFolder( drive, `PCC Import from Drupal on ${new Date().toLocaleDateString()} unique id: ${randomUUID()}`, @@ -166,12 +175,12 @@ export const importFromDrupal = errorHandler( } // Add it to the PCC site. - await AddOnApiHelper.getDocument(fileId, true); + await AddOnApiHelper.getDocument(fileId, true, site.domain); try { await AddOnApiHelper.updateDocument( fileId, - siteId, + site, post.attributes.title, post.relationships.field_topics?.data ?.map( @@ -188,7 +197,7 @@ export const importFromDrupal = errorHandler( ); if (publish) { - await AddOnApiHelper.publishDocument(fileId); + await AddOnApiHelper.publishDocument(fileId, site.domain); } } catch (e) { console.error(e instanceof AxiosError ? e.response?.data : e); diff --git a/packages/cli/src/cli/commands/import/markdown.ts b/packages/cli/src/cli/commands/import/markdown.ts index 09f0503e..a52c85fd 100644 --- a/packages/cli/src/cli/commands/import/markdown.ts +++ b/packages/cli/src/cli/commands/import/markdown.ts @@ -11,6 +11,7 @@ import AddOnApiHelper from "../../../lib/addonApiHelper"; import { GoogleAuthProvider } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; import { errorHandler } from "../../exceptions"; +import { getAuthedDrive } from "./utils"; const HEADING_TAGS = ["h1", "h2", "h3", "title"]; @@ -37,19 +38,20 @@ export const importFromMarkdown = errorHandler( // Prepare article content and title const content = fs.readFileSync(filePath).toString(); + // Get site details + const site = await AddOnApiHelper.getSite(siteId); + // Check user has required permission to create drive file - const tokens = await AddOnApiHelper.getGoogleTokens([ - "https://www.googleapis.com/auth/drive.file", - ]); + const tokens = await AddOnApiHelper.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain: site.domain, + }); // Create Google Doc const spinner = ora("Creating document on the Google Drive...").start(); - const oauth2Client = new OAuth2Client(); - oauth2Client.setCredentials(tokens); - const drive = google.drive({ - version: "v3", - auth: oauth2Client, - }); + + const drive = getAuthedDrive(tokens); + const converter = new showdown.Converter(); const html = converter.makeHtml(content); const dom = parseFromString(html); @@ -84,22 +86,15 @@ export const importFromMarkdown = errorHandler( } // Create PCC document - await AddOnApiHelper.getDocument(fileId, true, title); + await AddOnApiHelper.getDocument(fileId, true, site.domain, title); // Cannot set metadataFields(title,slug) in the same request since we reset metadataFields // when changing the siteId. - await AddOnApiHelper.updateDocument( - fileId, - siteId, - title, - [], - null, - verbose, - ); - await AddOnApiHelper.getDocument(fileId, false, title); + await AddOnApiHelper.updateDocument(fileId, site, title, [], null, verbose); + await AddOnApiHelper.getDocument(fileId, false, site.domain, title); // Publish PCC document if (publish) { - await AddOnApiHelper.publishDocument(fileId); + await AddOnApiHelper.publishDocument(fileId, site.domain); } spinner.succeed( `Successfully created document at below path${ diff --git a/packages/cli/src/cli/commands/import/utils.ts b/packages/cli/src/cli/commands/import/utils.ts index 0d803d28..e88cb7e1 100644 --- a/packages/cli/src/cli/commands/import/utils.ts +++ b/packages/cli/src/cli/commands/import/utils.ts @@ -4,7 +4,7 @@ import type { GaxiosResponse } from "gaxios"; import { OAuth2Client } from "google-auth-library"; import { drive_v3, google } from "googleapis"; import AddOnApiHelper from "../../../lib/addonApiHelper"; -import { GoogleAuthProvider } from "../../../lib/auth"; +import { GoogleAuthProvider, PersistedTokens } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; export function preprocessBaseURL(originalBaseURL: string) { @@ -32,21 +32,7 @@ export function preprocessBaseURL(originalBaseURL: string) { } } -export async function getAuthedDrive(logger: Logger) { - await AddOnApiHelper.getGoogleTokens([ - "https://www.googleapis.com/auth/drive.file", - ]); - - // TODO: Add domain - const provider = new GoogleAuthProvider([ - "https://www.googleapis.com/auth/drive.file", - ]); - const tokens = await provider.getTokens(); - if (!tokens) { - logger.error(chalk.red(`ERROR: Failed to retrieve login details. `)); - exit(1); - } - +export function getAuthedDrive(tokens: PersistedTokens) { const oauth2Client = new OAuth2Client(); oauth2Client.setCredentials(tokens); return google.drive({ diff --git a/packages/cli/src/cli/commands/import/wordpress.ts b/packages/cli/src/cli/commands/import/wordpress.ts index 5de34beb..ba521939 100644 --- a/packages/cli/src/cli/commands/import/wordpress.ts +++ b/packages/cli/src/cli/commands/import/wordpress.ts @@ -128,7 +128,14 @@ export const importFromWordPress = errorHandler( exit(1); } - const drive = await getAuthedDrive(logger); + // Get site details + const site = await AddOnApiHelper.getSite(siteId); + + const tokens = await AddOnApiHelper.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain: site.domain, + }); + const drive = getAuthedDrive(tokens); const folder = await createFolder( drive, `PCC Import from WordPress on ${new Date().toLocaleDateString()} unique id: ${randomUUID()}`, @@ -204,12 +211,12 @@ export const importFromWordPress = errorHandler( } // Add it to the PCC site. - await AddOnApiHelper.getDocument(fileId, true); + await AddOnApiHelper.getDocument(fileId, true, site.domain); try { await AddOnApiHelper.updateDocument( fileId, - siteId, + site, post.title.rendered, (await getTagInfo(processedBaseURL, post.tags)).map((x) => x.name), { @@ -220,7 +227,7 @@ export const importFromWordPress = errorHandler( ); if (publish) { - await AddOnApiHelper.publishDocument(fileId); + await AddOnApiHelper.publishDocument(fileId, site.domain); } } catch (e) { console.error(e instanceof AxiosError ? e.response?.data : e); diff --git a/packages/cli/src/cli/exceptions.ts b/packages/cli/src/cli/exceptions.ts index 7ae650ee..739f779f 100644 --- a/packages/cli/src/cli/exceptions.ts +++ b/packages/cli/src/cli/exceptions.ts @@ -18,7 +18,7 @@ export class UserNotLoggedIn extends Error { export class IncorrectAccount extends Error { constructor() { - super("Selected account is not"); + super("Selected account is not valid"); this.name = this.constructor.name; } } diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index ff7b4197..18f61e73 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -316,75 +316,70 @@ yargs(hideBin(process.argv)) yargs .strictCommands() .demandCommand() - .command( - "admins [options]", - "CRUD admins for a site", - (yargs) => { - yargs - .strictCommands() - .demandCommand() - .command( - "list [options]", - "List admins for a site", - (yargs) => { - yargs.option("siteId", { - describe: "Site id", - type: "string", - demandOption: true, - }); - }, - async (args) => - await listAdminsSchema({ - siteId: args.siteId as string, - }), - ) - .command( - "remove [options]", - "Remove admin for a site", - (yargs) => { - yargs.option("siteId", { - describe: "Site id", - type: "string", - demandOption: true, - }); + .command("admins [options]", "CRUD admins for a site", (yargs) => { + yargs + .strictCommands() + .demandCommand() + .command( + "list [options]", + "List admins for a site", + (yargs) => { + yargs.option("siteId", { + describe: "Site id", + type: "string", + demandOption: true, + }); + }, + async (args) => + await listAdminsSchema({ + siteId: args.siteId as string, + }), + ) + .command( + "remove [options]", + "Remove admin for a site", + (yargs) => { + yargs.option("siteId", { + describe: "Site id", + type: "string", + demandOption: true, + }); - yargs.option("email", { - describe: "Email of admin to remove", - type: "string", - demandOption: true, - }); - }, - async (args) => - await removeAdminSchema({ - siteId: args.siteId as string, - email: args.email as string, - }), - ) - .command( - "add [options]", - "Add admin to a site", - (yargs) => { - yargs.option("siteId", { - describe: "Site id", - type: "string", - demandOption: true, - }); + yargs.option("email", { + describe: "Email of admin to remove", + type: "string", + demandOption: true, + }); + }, + async (args) => + await removeAdminSchema({ + siteId: args.siteId as string, + email: args.email as string, + }), + ) + .command( + "add [options]", + "Add admin to a site", + (yargs) => { + yargs.option("siteId", { + describe: "Site id", + type: "string", + demandOption: true, + }); - yargs.option("email", { - describe: "Email of admin to add", - type: "string", - demandOption: true, - }); - }, - async (args) => - await addAdminSchema({ - siteId: args.siteId as string, - email: args.email as string, - }), - ); - }, - async (args) => await createSite(args.url as string), - ) + yargs.option("email", { + describe: "Email of admin to add", + type: "string", + demandOption: true, + }); + }, + async (args) => + await addAdminSchema({ + siteId: args.siteId as string, + email: args.email as string, + }), + ); + }) .command( "create [options]", "Creates new site.", @@ -718,7 +713,6 @@ yargs(hideBin(process.argv)) }), ); }, - async (args) => await createSite(args.url as string), ) .example(formatExamples(SITE_EXAMPLES)); }, diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 80e934af..128632f1 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -31,11 +31,13 @@ class AddOnApiHelper { throw new UserNotLoggedIn(); } - static async getGoogleTokens( - scopes?: string[], - email?: string, - ): Promise { - const provider = new GoogleAuthProvider(scopes, email); + static async getGoogleTokens(args?: { + scopes?: string[]; + email?: string | undefined; + domain?: string | undefined; + }): Promise { + const { scopes, email, domain } = args || {}; + const provider = new GoogleAuthProvider(scopes, email, domain); let tokens = await provider.getTokens(); if (tokens) return tokens; @@ -50,12 +52,13 @@ class AddOnApiHelper { static async getDocument( documentId: string, insertIfMissing = false, + domain: string, title?: string, ): Promise
{ - // TODO: Add required scopes and domain - // Use Auth0 tokens as Authorization and accessToken from Google const { id_token: idToken, access_token: oauthToken } = - await this.getGoogleTokens(); + await this.getGoogleTokens({ + domain, + }); const resp = await axios.get( `${(await getApiConfig()).DOCUMENT_ENDPOINT}/${documentId}`, @@ -104,7 +107,7 @@ class AddOnApiHelper { static async updateDocument( documentId: string, - siteId: string, + site: Site, title: string, tags: string[], metadataFields: { @@ -113,12 +116,15 @@ class AddOnApiHelper { verbose?: boolean, ): Promise
{ - const { access_token: accessToken } = await this.getAuth0Tokens(); + const { access_token: auth0AccessToken } = await this.getAuth0Tokens(); + const { access_token: googleAccessToken } = await this.getGoogleTokens({ + domain: site.domain, + }); if (verbose) { console.log("update document", { documentId, - siteId, + siteId: site.id, title, tags, metadataFields, @@ -128,7 +134,7 @@ class AddOnApiHelper { const resp = await axios.patch( `${(await getApiConfig()).DOCUMENT_ENDPOINT}/${documentId}`, { - siteId, + siteId: site.id, tags, title, ...(metadataFields && { @@ -137,7 +143,8 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: `Bearer ${auth0AccessToken}`, + "oauth-token": googleAccessToken, "Content-Type": "application/json", }, }, @@ -146,10 +153,12 @@ class AddOnApiHelper { return resp.data as Article; } - static async publishDocument(documentId: string) { - // TODO: Add domain + static async publishDocument(documentId: string, domain: string) { const { id_token: idToken, access_token: oauthToken } = - await this.getGoogleTokens(["https://www.googleapis.com/auth/drive"]); + await this.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain, + }); if (!idToken || !oauthToken) { throw new UserNotLoggedIn(); @@ -157,7 +166,7 @@ class AddOnApiHelper { const resp = await axios.post<{ url: string }>( `${(await getApiConfig()).DOCUMENT_ENDPOINT}/${documentId}/publish`, - null, + {}, { headers: { Authorization: `Bearer ${idToken}`, @@ -190,7 +199,9 @@ class AddOnApiHelper { ): Promise { // TODO: Add domain const { id_token: idToken, access_token: oauthToken } = - await this.getGoogleTokens(["https://www.googleapis.com/auth/drive"]); + await this.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive"], + }); if (!idToken || !oauthToken) { throw new UserNotLoggedIn(); @@ -265,10 +276,9 @@ class AddOnApiHelper { } static async createSite(url: string, googleAccount: string): Promise { - const { access_token: googleAccessToken } = await this.getGoogleTokens( - undefined, - googleAccount, - ); + const { access_token: googleAccessToken } = await this.getGoogleTokens({ + email: googleAccount, + }); const { access_token: auth0AccessToken } = await this.getAuth0Tokens(); const resp = await axios.post( diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 960ba4b4..79a4c88b 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -47,11 +47,6 @@ abstract class BaseAuthProvider { } export class Auth0Provider extends BaseAuthProvider { - constructor(scopes?: string[], email?: string) { - super(); - this.scopes = scopes || DEFAULT_AUTH0_SCOPES; - this.email = email; - } async generateToken(code: string): Promise { const resp = await axios.post( `${(await getApiConfig()).AUTH0_ENDPOINT}/token`, @@ -186,10 +181,15 @@ export class Auth0Provider extends BaseAuthProvider { } export class GoogleAuthProvider extends BaseAuthProvider { - constructor(scopes?: string[], email?: string) { + private domain: string | undefined; + constructor(scopes?: string[], email?: string, domain?: string) { + if (!email && !domain) + throw new Error("Either email or domain should be provided"); + super(); - this.scopes = scopes || DEFAULT_GOOGLE_SCOPES; + this.scopes = [...DEFAULT_GOOGLE_SCOPES, ...(scopes || [])]; this.email = email; + this.domain = domain; } async generateToken(code: string): Promise { const resp = await axios.post( @@ -218,8 +218,10 @@ export class GoogleAuthProvider extends BaseAuthProvider { // Return null if required given email const credIndex = (credentialArr || []).findIndex((acc) => { const payload = parseJwt(acc.id_token as string); - return payload.email === this.email; + if (this.email) return payload.email === this.email; + else return (payload.email.split("@")[1] || "") === this.domain; }); + if (credIndex === -1) return null; const credentials = credentialArr[credIndex]; @@ -312,9 +314,13 @@ export class GoogleAuthProvider extends BaseAuthProvider { }), ); server.destroy(); - if (this.email && this.email !== tokenPayload.email) { + + if ( + (this.email && this.email !== tokenPayload.email) || + (this.domain && + this.domain !== tokenPayload.email.split("@")[1]) + ) throw new IncorrectAccount(); - } spinner.succeed( `Successfully connected "${tokenPayload.email}" Google account.`, diff --git a/packages/cli/src/types/index.d.ts b/packages/cli/src/types/index.d.ts index 519639c6..3cdf3985 100644 --- a/packages/cli/src/types/index.d.ts +++ b/packages/cli/src/types/index.d.ts @@ -15,6 +15,7 @@ declare type ApiKey = { declare type Site = { id: string; url: string; + domain: string; created?: number; __isPlayground: boolean; connectionStatus?: { From e6b489b875d04bb1656c595b724476ba6832e757 Mon Sep 17 00:00:00 2001 From: Omkar Date: Tue, 28 Jan 2025 12:48:13 +0530 Subject: [PATCH 12/19] PCC-1882: Handled case when site domain is different than account selected --- .../cli/src/cli/commands/import/markdown.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cli/commands/import/markdown.ts b/packages/cli/src/cli/commands/import/markdown.ts index a52c85fd..0c4734d5 100644 --- a/packages/cli/src/cli/commands/import/markdown.ts +++ b/packages/cli/src/cli/commands/import/markdown.ts @@ -8,9 +8,9 @@ import { drive_v3, google } from "googleapis"; import ora from "ora"; import showdown from "showdown"; import AddOnApiHelper from "../../../lib/addonApiHelper"; -import { GoogleAuthProvider } from "../../../lib/auth"; +import { GoogleAuthProvider, PersistedTokens } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; -import { errorHandler } from "../../exceptions"; +import { errorHandler, IncorrectAccount } from "../../exceptions"; import { getAuthedDrive } from "./utils"; const HEADING_TAGS = ["h1", "h2", "h3", "title"]; @@ -42,10 +42,23 @@ export const importFromMarkdown = errorHandler( const site = await AddOnApiHelper.getSite(siteId); // Check user has required permission to create drive file - const tokens = await AddOnApiHelper.getGoogleTokens({ - scopes: ["https://www.googleapis.com/auth/drive.file"], - domain: site.domain, - }); + let tokens: PersistedTokens; + try { + tokens = await AddOnApiHelper.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain: site.domain, + }); + } catch (e) { + if (e instanceof IncorrectAccount) { + logger.error( + chalk.red( + "ERROR: Selected account doesn't belong to domain of the site.", + ), + ); + return; + } + throw e; + } // Create Google Doc const spinner = ora("Creating document on the Google Drive...").start(); From 45139794658f01ba6d9a3ff72a519b8ce71f3fdb Mon Sep 17 00:00:00 2001 From: Omkar Date: Tue, 28 Jan 2025 13:04:25 +0530 Subject: [PATCH 13/19] PCC-1882: Update preview command to accepet domain param --- packages/cli/src/cli/commands/documents.ts | 5 +++-- packages/cli/src/cli/index.ts | 6 ++++++ packages/cli/src/lib/addonApiHelper.ts | 19 ++++++++----------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/cli/commands/documents.ts b/packages/cli/src/cli/commands/documents.ts index a97be848..98bc3c0c 100644 --- a/packages/cli/src/cli/commands/documents.ts +++ b/packages/cli/src/cli/commands/documents.ts @@ -7,13 +7,14 @@ import { errorHandler } from "../exceptions"; type GeneratePreviewParam = { documentId: string; baseUrl: string; + domain: string; }; const GDOCS_URL_REGEX = /^(https|http):\/\/(www.)?docs.google.com\/document\/d\/(?[^/]+).*$/; export const generatePreviewLink = errorHandler( - async ({ documentId, baseUrl }: GeneratePreviewParam) => { + async ({ documentId, baseUrl, domain }: GeneratePreviewParam) => { const logger = new Logger(); let cleanedId = documentId.trim(); @@ -45,7 +46,7 @@ export const generatePreviewLink = errorHandler( const generateLinkLogger = new SpinnerLogger("Generating preview link"); generateLinkLogger.start(); - const previewLink = await AddOnApiHelper.previewFile(cleanedId, { + const previewLink = await AddOnApiHelper.previewFile(cleanedId, domain, { baseUrl, }); diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 18f61e73..086d6c9b 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -742,12 +742,18 @@ yargs(hideBin(process.argv)) describe: "Base URL for the generated preview link.", type: "string", demandOption: false, + }) + .option("domain", { + describe: "Domain of the document's site", + type: "string", + demandOption: true, }); }, async (args) => await generatePreviewLink({ documentId: args.id as string, baseUrl: args.baseUrl as string, + domain: args.domain as string, }), ) .example(formatExamples(DOCUMENT_EXAMPLES)); diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 128632f1..7f2c5425 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -191,21 +191,18 @@ class AddOnApiHelper { static async previewFile( docId: string, + domain: string, { baseUrl, }: { baseUrl?: string; }, ): Promise { - // TODO: Add domain - const { id_token: idToken, access_token: oauthToken } = - await this.getGoogleTokens({ - scopes: ["https://www.googleapis.com/auth/drive"], - }); - - if (!idToken || !oauthToken) { - throw new UserNotLoggedIn(); - } + const { access_token: auth0AccessToken } = await this.getAuth0Tokens(); + const { access_token: googleAccessToken } = await this.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive"], + domain, + }); const resp = await axios.post<{ url: string }>( `${(await getApiConfig()).DOCUMENT_ENDPOINT}/${docId}/preview`, @@ -214,9 +211,9 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${auth0AccessToken}`, "Content-Type": "application/json", - "oauth-token": oauthToken, + "oauth-token": googleAccessToken, }, }, ); From aeef0802bce7c01cb3081d77b279e0958792744c Mon Sep 17 00:00:00 2001 From: Omkar Date: Tue, 28 Jan 2025 13:27:25 +0530 Subject: [PATCH 14/19] PCC-1882: Added error handling and tested preview command --- packages/cli/src/cli/commands/documents.ts | 19 +++++++++++--- .../cli/src/cli/commands/import/drupal.ts | 24 ++++++++++++++---- .../cli/src/cli/commands/import/wordpress.ts | 25 +++++++++++++++---- packages/cli/src/cli/index.ts | 10 ++++---- packages/cli/src/lib/logger.ts | 3 +++ 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/cli/commands/documents.ts b/packages/cli/src/cli/commands/documents.ts index 98bc3c0c..a5097be0 100644 --- a/packages/cli/src/cli/commands/documents.ts +++ b/packages/cli/src/cli/commands/documents.ts @@ -2,7 +2,7 @@ import { exit } from "process"; import chalk from "chalk"; import AddOnApiHelper from "../../lib/addonApiHelper"; import { Logger, SpinnerLogger } from "../../lib/logger"; -import { errorHandler } from "../exceptions"; +import { errorHandler, IncorrectAccount } from "../exceptions"; type GeneratePreviewParam = { documentId: string; @@ -46,9 +46,20 @@ export const generatePreviewLink = errorHandler( const generateLinkLogger = new SpinnerLogger("Generating preview link"); generateLinkLogger.start(); - const previewLink = await AddOnApiHelper.previewFile(cleanedId, domain, { - baseUrl, - }); + let previewLink: string; + try { + previewLink = await AddOnApiHelper.previewFile(cleanedId, domain, { + baseUrl, + }); + } catch (e) { + if (e instanceof IncorrectAccount) { + generateLinkLogger.fail( + "Selected account doesn't belong to domain of the site.", + ); + return; + } + throw e; + } generateLinkLogger.succeed( "Successfully generated preview link. Please copy it below:", diff --git a/packages/cli/src/cli/commands/import/drupal.ts b/packages/cli/src/cli/commands/import/drupal.ts index c0fdffe2..cd4c0f06 100644 --- a/packages/cli/src/cli/commands/import/drupal.ts +++ b/packages/cli/src/cli/commands/import/drupal.ts @@ -7,8 +7,9 @@ import type { GaxiosResponse } from "gaxios"; import type { drive_v3 } from "googleapis"; import queryString from "query-string"; import AddOnApiHelper from "../../../lib/addonApiHelper"; +import { PersistedTokens } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; -import { errorHandler } from "../../exceptions"; +import { errorHandler, IncorrectAccount } from "../../exceptions"; import { createFolder, getAuthedDrive, preprocessBaseURL } from "./utils"; type DrupalImportParams = { @@ -88,10 +89,23 @@ export const importFromDrupal = errorHandler( // Get site details const site = await AddOnApiHelper.getSite(siteId); - const tokens = await AddOnApiHelper.getGoogleTokens({ - scopes: ["https://www.googleapis.com/auth/drive.file"], - domain: site.domain, - }); + let tokens: PersistedTokens; + try { + tokens = await AddOnApiHelper.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain: site.domain, + }); + } catch (e) { + if (e instanceof IncorrectAccount) { + logger.error( + chalk.red( + "ERROR: Selected account doesn't belong to domain of the site.", + ), + ); + return; + } + throw e; + } const drive = getAuthedDrive(tokens); diff --git a/packages/cli/src/cli/commands/import/wordpress.ts b/packages/cli/src/cli/commands/import/wordpress.ts index ba521939..8f4c42f9 100644 --- a/packages/cli/src/cli/commands/import/wordpress.ts +++ b/packages/cli/src/cli/commands/import/wordpress.ts @@ -7,8 +7,9 @@ import type { GaxiosResponse } from "gaxios"; import { drive_v3 } from "googleapis"; import queryString from "query-string"; import AddOnApiHelper from "../../../lib/addonApiHelper"; +import { PersistedTokens } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; -import { errorHandler } from "../../exceptions"; +import { errorHandler, IncorrectAccount } from "../../exceptions"; import { createFolder, getAuthedDrive, preprocessBaseURL } from "./utils"; const DEFAULT_PAGE_SIZE = 50; @@ -131,10 +132,24 @@ export const importFromWordPress = errorHandler( // Get site details const site = await AddOnApiHelper.getSite(siteId); - const tokens = await AddOnApiHelper.getGoogleTokens({ - scopes: ["https://www.googleapis.com/auth/drive.file"], - domain: site.domain, - }); + let tokens: PersistedTokens; + try { + tokens = await AddOnApiHelper.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain: site.domain, + }); + } catch (e) { + if (e instanceof IncorrectAccount) { + logger.error( + chalk.red( + "ERROR: Selected account doesn't belong to domain of the site.", + ), + ); + return; + } + throw e; + } + const drive = getAuthedDrive(tokens); const folder = await createFolder( drive, diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 086d6c9b..19a523d4 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -738,15 +738,15 @@ yargs(hideBin(process.argv)) demandOption: true, type: "string", }) - .option("baseUrl", { - describe: "Base URL for the generated preview link.", - type: "string", - demandOption: false, - }) .option("domain", { describe: "Domain of the document's site", type: "string", demandOption: true, + }) + .option("baseUrl", { + describe: "Base URL for the generated preview link.", + type: "string", + demandOption: false, }); }, async (args) => diff --git a/packages/cli/src/lib/logger.ts b/packages/cli/src/lib/logger.ts index 4d84c8e0..2dcd2708 100644 --- a/packages/cli/src/lib/logger.ts +++ b/packages/cli/src/lib/logger.ts @@ -20,6 +20,9 @@ export class SpinnerLogger { succeed(message: string) { if (this.logger) this.logger.succeed(message); } + fail(message: string) { + if (this.logger) this.logger.fail(message); + } } export class Logger { From 1149f7794b9161b5ddcdb51b1e61cc9053653428 Mon Sep 17 00:00:00 2001 From: Omkar Date: Tue, 28 Jan 2025 13:33:22 +0530 Subject: [PATCH 15/19] PCC-1882: Updated site creation command --- packages/cli/src/cli/commands/sites/site.ts | 10 +++------- packages/cli/src/cli/index.ts | 8 ++++---- packages/cli/src/lib/addonApiHelper.ts | 4 ++-- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/cli/commands/sites/site.ts b/packages/cli/src/cli/commands/sites/site.ts index 874cf4b9..1e56c4da 100644 --- a/packages/cli/src/cli/commands/sites/site.ts +++ b/packages/cli/src/cli/commands/sites/site.ts @@ -5,16 +5,12 @@ import AddOnApiHelper from "../../../lib/addonApiHelper"; import { printTable } from "../../../lib/cliDisplay"; import { errorHandler, IncorrectAccount } from "../../exceptions"; -export const createSite = errorHandler<{ url: string; googleAccount: string }>( - async ({ url, googleAccount }) => { +export const createSite = errorHandler<{ url: string; domain: string }>( + async ({ url, domain }) => { const spinner = ora("Creating site...").start(); - if (!googleAccount) { - spinner.fail("You must provide Google workspace account"); - return; - } try { - const siteId = await AddOnApiHelper.createSite(url, googleAccount); + const siteId = await AddOnApiHelper.createSite(url, domain); spinner.succeed( `Successfully created the site with given details. Id: ${siteId}`, ); diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 19a523d4..327fa911 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -389,16 +389,16 @@ yargs(hideBin(process.argv)) type: "string", demandOption: true, }); - yargs.option("googleAccount", { - describe: "Google workspace account email", + yargs.option("domain", { + describe: "Domain of the site", type: "string", - demandOption: false, + demandOption: true, }); }, async (args) => await createSite({ url: args.url as string, - googleAccount: args.googleAccount as string, + domain: args.domain as string, }), ) .command( diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 7f2c5425..c17f76d1 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -272,9 +272,9 @@ class AddOnApiHelper { } } - static async createSite(url: string, googleAccount: string): Promise { + static async createSite(url: string, domain: string): Promise { const { access_token: googleAccessToken } = await this.getGoogleTokens({ - email: googleAccount, + domain, }); const { access_token: auth0AccessToken } = await this.getAuth0Tokens(); From 27641334019b1b5ad14cff6f8383150e66f04b4d Mon Sep 17 00:00:00 2001 From: Omkar Date: Tue, 28 Jan 2025 20:35:16 +0530 Subject: [PATCH 16/19] PCC-1882: Fixed issues --- packages/cli/src/cli/commands/import/markdown.ts | 2 +- packages/cli/src/lib/addonApiHelper.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/cli/commands/import/markdown.ts b/packages/cli/src/cli/commands/import/markdown.ts index 0c4734d5..2c7b438d 100644 --- a/packages/cli/src/cli/commands/import/markdown.ts +++ b/packages/cli/src/cli/commands/import/markdown.ts @@ -45,7 +45,7 @@ export const importFromMarkdown = errorHandler( let tokens: PersistedTokens; try { tokens = await AddOnApiHelper.getGoogleTokens({ - scopes: ["https://www.googleapis.com/auth/drive.file"], + scopes: ["https://www.googleapis.com/auth/drive"], domain: site.domain, }); } catch (e) { diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index c17f76d1..9e4b1a0f 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -55,10 +55,10 @@ class AddOnApiHelper { domain: string, title?: string, ): Promise
{ - const { id_token: idToken, access_token: oauthToken } = - await this.getGoogleTokens({ - domain, - }); + const { access_token: auth0AccessToken } = await this.getAuth0Tokens(); + const { access_token: googleAccessToken } = await this.getGoogleTokens({ + domain, + }); const resp = await axios.get( `${(await getApiConfig()).DOCUMENT_ENDPOINT}/${documentId}`, @@ -70,8 +70,8 @@ class AddOnApiHelper { }), }, headers: { - Authorization: `Bearer ${idToken}`, - "oauth-token": oauthToken, + Authorization: `Bearer ${auth0AccessToken}`, + "oauth-token": googleAccessToken, }, }, ); From 41cb64bc5c456c364bc98823d7c655b70296d47c Mon Sep 17 00:00:00 2001 From: Omkar Date: Tue, 28 Jan 2025 20:47:31 +0530 Subject: [PATCH 17/19] PCC-1882: Fixed local auth management --- packages/cli/src/cli/commands/logout.ts | 4 ++-- packages/cli/src/lib/auth.ts | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/cli/commands/logout.ts b/packages/cli/src/cli/commands/logout.ts index 42430037..76731e45 100644 --- a/packages/cli/src/cli/commands/logout.ts +++ b/packages/cli/src/cli/commands/logout.ts @@ -8,8 +8,8 @@ import { errorHandler } from "../exceptions"; const logout = async () => { const spinner = ora("Logging you out...").start(); try { - deleteAuthDetails(); - deleteGoogleAuthDetails(); + await deleteAuthDetails(); + await deleteGoogleAuthDetails(); spinner.succeed("Successfully logged you out from PPC client!"); } catch (e) { spinner.fail(); diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 79a4c88b..a2830259 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -303,7 +303,22 @@ export class GoogleAuthProvider extends BaseAuthProvider { ); const credentials = await this.generateToken(code as string); const tokenPayload = parseJwt(credentials.id_token as string); - existingCredentials.push(credentials); + + if ( + (this.email && this.email !== tokenPayload.email) || + (this.domain && + this.domain !== tokenPayload.email.split("@")[1]) + ) + throw new IncorrectAccount(); + + const matchIndex = existingCredentials.findIndex((acc) => { + const currentPayload = parseJwt(acc.id_token); + return currentPayload.email === tokenPayload.email; + }); + if (matchIndex !== -1) + existingCredentials[matchIndex] = credentials; + else existingCredentials.push(credentials); + await LocalStorage.persistGoogleAuthDetails( existingCredentials, ); @@ -315,13 +330,6 @@ export class GoogleAuthProvider extends BaseAuthProvider { ); server.destroy(); - if ( - (this.email && this.email !== tokenPayload.email) || - (this.domain && - this.domain !== tokenPayload.email.split("@")[1]) - ) - throw new IncorrectAccount(); - spinner.succeed( `Successfully connected "${tokenPayload.email}" Google account.`, ); From e80cc96db0b7503de2400394668f3f262c371dea Mon Sep 17 00:00:00 2001 From: Omkar Date: Wed, 29 Jan 2025 19:16:09 +0530 Subject: [PATCH 18/19] PCC-1822: fixed linting issues --- packages/cli/src/cli/commands/import/markdown.ts | 5 ++--- packages/cli/src/cli/commands/import/utils.ts | 6 +----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/cli/commands/import/markdown.ts b/packages/cli/src/cli/commands/import/markdown.ts index 2c7b438d..39b09863 100644 --- a/packages/cli/src/cli/commands/import/markdown.ts +++ b/packages/cli/src/cli/commands/import/markdown.ts @@ -3,12 +3,11 @@ import { exit } from "process"; import chalk from "chalk"; import { parseFromString } from "dom-parser"; import type { GaxiosResponse } from "gaxios"; -import { OAuth2Client } from "google-auth-library"; -import { drive_v3, google } from "googleapis"; +import { drive_v3 } from "googleapis"; import ora from "ora"; import showdown from "showdown"; import AddOnApiHelper from "../../../lib/addonApiHelper"; -import { GoogleAuthProvider, PersistedTokens } from "../../../lib/auth"; +import { PersistedTokens } from "../../../lib/auth"; import { Logger } from "../../../lib/logger"; import { errorHandler, IncorrectAccount } from "../../exceptions"; import { getAuthedDrive } from "./utils"; diff --git a/packages/cli/src/cli/commands/import/utils.ts b/packages/cli/src/cli/commands/import/utils.ts index e88cb7e1..e152579d 100644 --- a/packages/cli/src/cli/commands/import/utils.ts +++ b/packages/cli/src/cli/commands/import/utils.ts @@ -1,11 +1,7 @@ -import { exit } from "process"; -import chalk from "chalk"; import type { GaxiosResponse } from "gaxios"; import { OAuth2Client } from "google-auth-library"; import { drive_v3, google } from "googleapis"; -import AddOnApiHelper from "../../../lib/addonApiHelper"; -import { GoogleAuthProvider, PersistedTokens } from "../../../lib/auth"; -import { Logger } from "../../../lib/logger"; +import { PersistedTokens } from "../../../lib/auth"; export function preprocessBaseURL(originalBaseURL: string) { let baseURL: string | null = originalBaseURL; From 748ffcc9c280ca8d6cd6f813d90db8af734cb0e9 Mon Sep 17 00:00:00 2001 From: Omkar Date: Wed, 29 Jan 2025 19:34:18 +0530 Subject: [PATCH 19/19] PCC-1882: Fixed minor issues and added comments --- packages/cli/src/cli/commands/documents.ts | 1 + packages/cli/src/cli/commands/login.ts | 17 ++++------------- packages/cli/src/cli/index.ts | 2 +- packages/cli/src/lib/addonApiHelper.ts | 18 +++++++----------- packages/cli/src/lib/apiConfig.ts | 3 +++ packages/cli/src/lib/localStorage.ts | 21 +++++++++------------ 6 files changed, 25 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/cli/commands/documents.ts b/packages/cli/src/cli/commands/documents.ts index a5097be0..30ef6770 100644 --- a/packages/cli/src/cli/commands/documents.ts +++ b/packages/cli/src/cli/commands/documents.ts @@ -48,6 +48,7 @@ export const generatePreviewLink = errorHandler( let previewLink: string; try { + // TODO: Check if we can derive domain from the document previewLink = await AddOnApiHelper.previewFile(cleanedId, domain, { baseUrl, }); diff --git a/packages/cli/src/cli/commands/login.ts b/packages/cli/src/cli/commands/login.ts index e58eef78..dc621228 100644 --- a/packages/cli/src/cli/commands/login.ts +++ b/packages/cli/src/cli/commands/login.ts @@ -1,23 +1,14 @@ import nunjucks from "nunjucks"; -import { getAuthProvider } from "../../lib/auth"; +import { Auth0Provider } from "../../lib/auth"; import { errorHandler } from "../exceptions"; nunjucks.configure({ autoescape: true }); -async function login({ - authType, - scopes, -}: { - authType: "auth0" | "google"; - scopes?: string[]; -}): Promise { - const provider = getAuthProvider(authType, scopes); +async function login(): Promise { + const provider = new Auth0Provider(); await provider.login(); } -export default errorHandler<{ - authType: "auth0" | "google"; - scopes?: string[]; -}>(login); +export default errorHandler(login); export const LOGIN_EXAMPLES = [ { description: "Login the user", command: "$0 login" }, ]; diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 327fa911..21d5ef1c 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -896,7 +896,7 @@ yargs(hideBin(process.argv)) () => { // noop }, - async () => await login({ authType: "auth0" }), + async () => await login(), ) .command( "logout", diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 9e4b1a0f..4fd03558 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -154,24 +154,20 @@ class AddOnApiHelper { } static async publishDocument(documentId: string, domain: string) { - const { id_token: idToken, access_token: oauthToken } = - await this.getGoogleTokens({ - scopes: ["https://www.googleapis.com/auth/drive.file"], - domain, - }); - - if (!idToken || !oauthToken) { - throw new UserNotLoggedIn(); - } + const { access_token: auth0AccessToken } = await this.getAuth0Tokens(); + const { access_token: googleAccessToken } = await this.getGoogleTokens({ + scopes: ["https://www.googleapis.com/auth/drive.file"], + domain, + }); const resp = await axios.post<{ url: string }>( `${(await getApiConfig()).DOCUMENT_ENDPOINT}/${documentId}/publish`, {}, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${auth0AccessToken}`, "Content-Type": "application/json", - "oauth-token": oauthToken, + "oauth-token": googleAccessToken, }, }, ); diff --git a/packages/cli/src/lib/apiConfig.ts b/packages/cli/src/lib/apiConfig.ts index 1d27261d..de48d75b 100644 --- a/packages/cli/src/lib/apiConfig.ts +++ b/packages/cli/src/lib/apiConfig.ts @@ -21,6 +21,7 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { [TargetEnvironment.production]: { addOnApiEndpoint: "https://us-central1-pantheon-content-cloud.cloudfunctions.net/addOnApi", + // TODO: Update with the Auth0 prod tenant auth0ClientId: "432998952749-6eurouamlt7mvacb6u4e913m3kg4774c.apps.googleusercontent.com", auth0RedirectUri: "http://localhost:3030/oauth-redirect", @@ -32,9 +33,11 @@ const apiConfigMap: { [key in TargetEnvironment]: ApiConfig } = { playgroundUrl: "https://live-collabcms-fe-demo.appa.pantheon.site", }, [TargetEnvironment.staging]: { + // TODO: Uncomment the correct one // addOnApiEndpoint: // "https://us-central1-pantheon-content-cloud-staging.cloudfunctions.net/addOnApi", addOnApiEndpoint: "http://localhost:8080", + // TODO: Update with the Auth0 staging tenant auth0ClientId: "RAHxEbc251zD529hByapcv6Dcp3pmv4P", auth0RedirectUri: "http://localhost:3030/oauth-redirect", auth0Audience: "https://addonapi-cxog5ytt4a-uc.a.run.app", diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index 76061857..0ded324e 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -33,33 +33,30 @@ const readFile = async (path: string): Promise => { export const getConfigDetails = async (): Promise => { return readFile(CONFIG_FILE_PATH); }; +export const getAuthDetails = async (): Promise => { + return readFile(AUTH_FILE_PATH); +}; +export const getGoogleAuthDetails = async (): Promise< + PersistedTokens[] | null +> => { + return readFile(GOOGLE_AUTH_FILE_PATH); +}; export const persistConfigDetails = async (payload: Config): Promise => { await persistDetailsToFile(payload, CONFIG_FILE_PATH); }; - -export const deleteConfigDetails = async () => remove(CONFIG_FILE_PATH); - -export const getAuthDetails = async (): Promise => { - return readFile(AUTH_FILE_PATH); -}; export const persistAuthDetails = async ( payload: PersistedTokens, ): Promise => { await persistDetailsToFile(payload, AUTH_FILE_PATH); }; - -export const getGoogleAuthDetails = async (): Promise< - PersistedTokens[] | null -> => { - return readFile(GOOGLE_AUTH_FILE_PATH); -}; export const persistGoogleAuthDetails = async ( payload: PersistedTokens[], ): Promise => { await persistDetailsToFile(payload, GOOGLE_AUTH_FILE_PATH); }; +export const deleteConfigDetails = async () => remove(CONFIG_FILE_PATH); export const deleteAuthDetails = async () => remove(AUTH_FILE_PATH); export const deleteGoogleAuthDetails = async () => remove(GOOGLE_AUTH_FILE_PATH);