diff --git a/packages/cli/src/cli/commands/login.ts b/packages/cli/src/cli/commands/login.ts index a239fa88..f7bc16a8 100644 --- a/packages/cli/src/cli/commands/login.ts +++ b/packages/cli/src/cli/commands/login.ts @@ -3,7 +3,7 @@ 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 { Credentials, OAuth2Client } from "google-auth-library"; import nunjucks from "nunjucks"; import open from "open"; import ora from "ora"; @@ -11,7 +11,9 @@ import destroyer from "server-destroy"; import AddOnApiHelper from "../../lib/addonApiHelper"; import { getApiConfig } from "../../lib/apiConfig"; import { + CREDENTIAL_TYPE, getLocalAuthDetails, + NextJwtCredentials, persistAuthDetails, } from "../../lib/localStorage"; import { errorHandler } from "../exceptions"; @@ -20,13 +22,17 @@ nunjucks.configure({ autoescape: true }); const OAUTH_SCOPES = ["https://www.googleapis.com/auth/userinfo.email"]; -function login(extraScopes: string[]): Promise { +export function loginOAuth(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); + const authData = (await getLocalAuthDetails( + CREDENTIAL_TYPE.OAUTH, + extraScopes, + )) as Credentials | null; + if (authData) { const scopes = authData.scope?.split(" "); @@ -70,7 +76,7 @@ function login(extraScopes: string[]): Promise { ); const credentials = await AddOnApiHelper.getToken(code as string); const jwtPayload = parseJwt(credentials.id_token as string); - await persistAuthDetails(credentials); + await persistAuthDetails(credentials, CREDENTIAL_TYPE.OAUTH); res.end( nunjucks.renderString(content.toString(), { @@ -102,7 +108,86 @@ function login(extraScopes: string[]): Promise { }, ); } -export default errorHandler(login); + +function 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 getLocalAuthDetails( + CREDENTIAL_TYPE.NEXT_JWT, + )) as NextJwtCredentials | null; + if (authData) { + console.log("already exists", { authData }); + // spinner.succeed( + // `You are already logged in as ${authData.email}.`, + // ); + // return resolve(); + } + + const server = http.createServer(async (req, res) => { + try { + if (!req.url) { + throw new Error("No URL path provided"); + } + + if (req.url.indexOf("/auth-success") !== -1) { + const qs = new url.URL(req.url, "http://localhost:3030") + .searchParams; + const token = qs.get("code") as string; + const email = qs.get("email") as string; + const expiration = qs.get("expiration") as string; + const currDir = dirname(fileURLToPath(import.meta.url)); + const content = readFileSync( + join(currDir, "../templates/loginSuccess.html"), + ); + + await persistAuthDetails( + { + token, + email, + expiration, + }, + CREDENTIAL_TYPE.NEXT_JWT, + ); + + res.end( + nunjucks.renderString(content.toString(), { + email: email, + }), + ); + server.destroy(); + + spinner.succeed(`You are successfully logged in as ${email}`); + resolve(); + } else { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Hello World\n"); + return; + } + } catch (e) { + spinner.fail(); + reject(e); + } + }); + + destroyer(server); + + server.listen(3030, () => { + // const apiConfig = await getApiConfig(); + open("http://localhost:3000/auth/cli", { wait: true }).then((cp) => + cp.kill(), + ); + }); + } catch (e) { + spinner.fail(); + reject(e); + } + }, + ); +} +export default errorHandler(login); export const LOGIN_EXAMPLES = [ { description: "Login the user", command: "$0 login" }, ]; diff --git a/packages/cli/src/cli/commands/logout.ts b/packages/cli/src/cli/commands/logout.ts index 1a4feb97..4334ff69 100644 --- a/packages/cli/src/cli/commands/logout.ts +++ b/packages/cli/src/cli/commands/logout.ts @@ -1,12 +1,15 @@ import { existsSync, rmSync } from "fs"; import ora from "ora"; -import { AUTH_FILE_PATH } from "../../lib/localStorage"; +import { AUTH_FOLDER_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(AUTH_FOLDER_PATH)) + rmSync(AUTH_FOLDER_PATH, { + recursive: true, + }); spinner.succeed("Successfully logged you out from PPC client!"); } catch (e) { spinner.fail(); diff --git a/packages/cli/src/cli/commands/whoAmI.ts b/packages/cli/src/cli/commands/whoAmI.ts index 7bf6368d..9cb17198 100644 --- a/packages/cli/src/cli/commands/whoAmI.ts +++ b/packages/cli/src/cli/commands/whoAmI.ts @@ -1,16 +1,39 @@ import { parseJwt } from "@pantheon-systems/pcc-sdk-core"; import chalk from "chalk"; -import { getLocalAuthDetails } from "../../lib/localStorage"; +import { Credentials } from "google-auth-library"; +import { + CREDENTIAL_TYPE, + getLocalAuthDetails, + NextJwtCredentials, +} from "../../lib/localStorage"; import { errorHandler } from "../exceptions"; const printWhoAmI = async () => { try { - const authData = await getLocalAuthDetails(); - if (!authData) { + const nextJwt = (await getLocalAuthDetails( + CREDENTIAL_TYPE.NEXT_JWT, + )) as NextJwtCredentials | null; + if (!nextJwt) { console.log("You aren't logged in."); + } else { + console.log(`You're logged in as ${nextJwt.email}`); + } + } catch (e) { + chalk.red("Something went wrong - couldn't retrieve auth info."); + throw e; + } + + try { + const authData = (await getLocalAuthDetails( + CREDENTIAL_TYPE.OAUTH, + )) as Credentials | null; + if (!authData) { + console.log( + "You aren't logged into oauth. For some actions, the oauth connection isn't necessary. If you run a command that requires it, you will be only prompted to log in with oauth at that point.", + ); } else { const jwtPayload = parseJwt(authData.id_token as string); - console.log(`You're logged in as ${jwtPayload.email}`); + console.log(`Oauth: You're logged in as ${jwtPayload.email}`); } } catch (e) { chalk.red("Something went wrong - couldn't retrieve auth info."); diff --git a/packages/cli/src/lib/addonApiHelper.ts b/packages/cli/src/lib/addonApiHelper.ts index 968f2217..bf1f1d8f 100644 --- a/packages/cli/src/lib/addonApiHelper.ts +++ b/packages/cli/src/lib/addonApiHelper.ts @@ -3,10 +3,14 @@ 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"; +import login, { loginOAuth } from "../cli/commands/login"; import { HTTPNotFound, UserNotLoggedIn } from "../cli/exceptions"; import { getApiConfig } from "./apiConfig"; -import { getLocalAuthDetails } from "./localStorage"; +import { + CREDENTIAL_TYPE, + getLocalAuthDetails, + NextJwtCredentials, +} from "./localStorage"; import { toKebabCase } from "./utils"; class AddOnApiHelper { @@ -41,19 +45,44 @@ class AddOnApiHelper { } } + static async getNextJwt(): Promise { + let authDetails = (await getLocalAuthDetails( + CREDENTIAL_TYPE.NEXT_JWT, + )) as NextJwtCredentials | null; + + // If auth details not found, try user logging in + if (!authDetails) { + const prevOra = ora().stopAndPersist(); + await login(); + prevOra.start(); + authDetails = (await getLocalAuthDetails( + CREDENTIAL_TYPE.NEXT_JWT, + )) as NextJwtCredentials | null; + if (!authDetails) throw new UserNotLoggedIn(); + } + + return authDetails?.token; + } + static async getIdToken( requiredScopes?: string[], withAuthToken?: true, ): Promise<{ idToken: string; oauthToken: string }>; static async getIdToken(requiredScopes?: string[], withAuthToken?: boolean) { - let authDetails = await getLocalAuthDetails(requiredScopes); + let authDetails = (await getLocalAuthDetails( + CREDENTIAL_TYPE.OAUTH, + requiredScopes, + )) as Credentials | null; // If auth details not found, try user logging in if (!authDetails) { const prevOra = ora().stopAndPersist(); - await login(requiredScopes || []); + await loginOAuth(requiredScopes || []); prevOra.start(); - authDetails = await getLocalAuthDetails(requiredScopes); + authDetails = (await getLocalAuthDetails( + CREDENTIAL_TYPE.OAUTH, + requiredScopes, + )) as Credentials | null; if (!authDetails) throw new UserNotLoggedIn(); } @@ -92,7 +121,7 @@ class AddOnApiHelper { fieldTitle: string, fieldType: string, ): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); await axios.post( `${(await getApiConfig()).SITE_ENDPOINT}/${siteId}/metadata`, @@ -105,7 +134,7 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, "Content-Type": "application/json", }, }, @@ -230,7 +259,7 @@ class AddOnApiHelper { static async createApiKey({ siteId, }: { siteId?: string } = {}): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const resp = await axios.post( (await getApiConfig()).API_KEY_ENDPOINT, @@ -239,7 +268,7 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); @@ -247,11 +276,11 @@ class AddOnApiHelper { } static async listApiKeys(): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const resp = await axios.get((await getApiConfig()).API_KEY_ENDPOINT, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }); @@ -259,12 +288,12 @@ class AddOnApiHelper { } static async revokeApiKey(id: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); try { await axios.delete(`${(await getApiConfig()).API_KEY_ENDPOINT}/${id}`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }); } catch (err) { @@ -277,14 +306,14 @@ class AddOnApiHelper { } static async createSite(url: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const resp = await axios.post( (await getApiConfig()).SITE_ENDPOINT, { name: "", url, emailList: "" }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); @@ -296,7 +325,7 @@ class AddOnApiHelper { transferToSiteId: string | null | undefined, force: boolean, ): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const resp = await axios.delete( queryString.stringifyUrl({ @@ -308,7 +337,7 @@ class AddOnApiHelper { }), { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); @@ -320,11 +349,11 @@ class AddOnApiHelper { }: { withConnectionStatus?: boolean; }): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const resp = await axios.get((await getApiConfig()).SITE_ENDPOINT, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, params: { withConnectionStatus, @@ -350,27 +379,27 @@ class AddOnApiHelper { } static async updateSite(id: string, url: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); await axios.patch( `${(await getApiConfig()).SITE_ENDPOINT}/${id}`, { url }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); } static async getServersideComponentSchema(id: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/components`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); @@ -380,7 +409,7 @@ class AddOnApiHelper { id: string, componentSchema: typeof SmartComponentMapZod, ): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); await axios.post( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/components`, @@ -389,39 +418,39 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); } static async removeComponentSchema(id: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); await axios.delete( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/components`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); } static async listAdmins(id: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); return ( await axios.get(`${(await getApiConfig()).SITE_ENDPOINT}/${id}/admins`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }) ).data; } static async addAdmin(id: string, email: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); await axios.patch( `${(await getApiConfig()).SITE_ENDPOINT}/${id}/admins`, @@ -430,18 +459,18 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); } static async removeAdmin(id: string, email: string): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); await axios.delete(`${(await getApiConfig()).SITE_ENDPOINT}/${id}/admins`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, data: { email, @@ -463,7 +492,7 @@ class AddOnApiHelper { preferredEvents?: string[]; }, ): Promise { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const configuredWebhook = webhookUrl || webhookSecret || preferredEvents; @@ -481,7 +510,7 @@ class AddOnApiHelper { }, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); @@ -497,13 +526,13 @@ class AddOnApiHelper { offset?: number; }, ) { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const resp = await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${siteId}/webhookLogs`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, params: { limit, @@ -516,13 +545,13 @@ class AddOnApiHelper { } static async fetchAvailableWebhookEvents(siteId: string) { - const idToken = await this.getIdToken(); + const jwtToken = await this.getNextJwt(); const resp = await axios.get( `${(await getApiConfig()).SITE_ENDPOINT}/${siteId}/availableWebhookEvents`, { headers: { - Authorization: `Bearer ${idToken}`, + Authorization: `Bearer ${jwtToken}`, }, }, ); diff --git a/packages/cli/src/lib/localStorage.ts b/packages/cli/src/lib/localStorage.ts index 80f3724e..9f08cca1 100644 --- a/packages/cli/src/lib/localStorage.ts +++ b/packages/cli/src/lib/localStorage.ts @@ -6,48 +6,80 @@ import { PCC_ROOT_DIR } from "../constants"; import { Config } from "../types/config"; import AddOnApiHelper from "./addonApiHelper"; -export const AUTH_FILE_PATH = path.join(PCC_ROOT_DIR, "auth.json"); +export const AUTH_FOLDER_PATH = path.join(PCC_ROOT_DIR, "auth"); export const CONFIG_FILE_PATH = path.join(PCC_ROOT_DIR, "config.json"); +function getAuthFile(credentialType: CREDENTIAL_TYPE) { + return path.join(AUTH_FOLDER_PATH, `${credentialType}.json`); +} + +export enum CREDENTIAL_TYPE { + OAUTH = "OAUTH", + NEXT_JWT = "NEXT_JWT", +} + +export interface NextJwtCredentials { + token: string; + email: string; + expiration: string; +} + export const getLocalAuthDetails = async ( + credentialType: CREDENTIAL_TYPE, requiredScopes?: string[], -): Promise => { - let credentials: Credentials; +): Promise => { + let storedJSON; + try { - credentials = JSON.parse( - readFileSync(AUTH_FILE_PATH).toString(), - ) as Credentials; + storedJSON = JSON.parse( + readFileSync(getAuthFile(credentialType)).toString(), + ); } 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; - } + if (credentialType == CREDENTIAL_TYPE.OAUTH) { + const credentials: Credentials = storedJSON as Credentials; + + // 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; + } + + // Check if token is expired + if (credentials.expiry_date) { + const currentTime = await AddOnApiHelper.getCurrentTime(); - // Check if token is expired - if (credentials.expiry_date) { - const currentTime = await AddOnApiHelper.getCurrentTime(); + if (currentTime < credentials.expiry_date) { + return credentials; + } + } - if (currentTime < credentials.expiry_date) { - return credentials; + try { + const newCred = await AddOnApiHelper.refreshToken( + credentials.refresh_token as string, + ); + persistAuthDetails(newCred, CREDENTIAL_TYPE.OAUTH); + return newCred; + } catch (_err) { + return null; } - } + } else { + const credentials: NextJwtCredentials = storedJSON as NextJwtCredentials; - try { - const newCred = await AddOnApiHelper.refreshToken( - credentials.refresh_token as string, - ); - persistAuthDetails(newCred); - return newCred; - } catch (_err) { - return null; + // Check if token is expired + if (credentials.expiration) { + if (Date.now() >= Date.parse(credentials.expiration)) { + return null; + } + } + + return credentials; } }; @@ -60,9 +92,10 @@ export const getLocalConfigDetails = async (): Promise => { }; export const persistAuthDetails = async ( - payload: Credentials, + payload: Credentials | NextJwtCredentials, + credentialType: CREDENTIAL_TYPE, ): Promise => { - await persistDetailsToFile(payload, AUTH_FILE_PATH); + await persistDetailsToFile(payload, getAuthFile(credentialType)); }; export const persistConfigDetails = async (payload: Config): Promise => {