Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI: Auth0 integration #334

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 16 additions & 17 deletions packages/cli/src/cli/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void> {
return new Promise(
Expand All @@ -34,25 +34,24 @@ function login(extraScopes: string[]): Promise<void> {
!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["pcc/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 {
Expand All @@ -69,18 +68,18 @@ function login(extraScopes: string[]): Promise<void> {
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["pcc/email"]}`,
);
resolve();
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/cli/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
143 changes: 131 additions & 12 deletions packages/cli/src/cli/commands/sites/site.ts
Original file line number Diff line number Diff line change
@@ -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";

Check warning on line 12 in packages/cli/src/cli/commands/sites/site.ts

View workflow job for this annotation

GitHub Actions / lint

'queryString' is defined but never used
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<string>(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<void> => {
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) {
Fixed Show fixed Hide fixed
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);
}
},
);
};

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;
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down
59 changes: 50 additions & 9 deletions packages/cli/src/lib/addonApiHelper.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,24 +8,45 @@
import { getLocalAuthDetails } from "./localStorage";
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<Credentials> {
static async getToken(code: string): Promise<PersistedTokens> {
const resp = await axios.post(
`${(await getApiConfig()).OAUTH_ENDPOINT}/token`,
`${(await getApiConfig()).AUTH0_ENDPOINT}/token`,
{
code: code,
},
);
return resp.data as Credentials;
return resp.data as PersistedTokens;
}
static async refreshToken(refreshToken: string): Promise<Credentials> {
static async getGoogleToken(code: string): Promise<PersistedTokens> {
const resp = await axios.post(
`${(await getApiConfig()).OAUTH_ENDPOINT}/refresh`,
`${(await getApiConfig()).OAUTH_ENDPOINT}/token`,
{
refreshToken,
code: code,
},
);
return resp.data as Credentials;
return resp.data as PersistedTokens;
}
static async refreshToken(refreshToken: string): Promise<PersistedTokens> {
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,
});
Fixed Show fixed Hide fixed
return {
refresh_token: refreshToken,
...response.data,
} as PersistedTokens;
}

static async getCurrentTime(): Promise<number> {
Expand Down Expand Up @@ -58,7 +78,28 @@
}

return {
idToken: authDetails.id_token,
idToken: authDetails.access_token,
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,
};
}
Expand Down
Loading
Loading