convex
Version:
Client for the Convex Cloud
260 lines (245 loc) • 8.64 kB
text/typescript
import { errors, BaseClient, custom } from "openid-client";
import {
globalConfigPath,
rootDirectory,
GlobalConfig,
getAuthHeader,
bigBrainAPI,
} from "./utils.js";
import open from "open";
import chalk from "chalk";
import { provisionHost } from "./config.js";
import { version } from "../../index.js";
import axios, { AxiosRequestConfig } from "axios";
import { Context } from "./context.js";
import { Issuer } from "openid-client";
import inquirer from "inquirer";
import { hostname } from "os";
import { execSync } from "child_process";
const SCOPE = "openid email profile";
// Per https://github.com/panva/node-openid-client/tree/main/docs#customizing
custom.setHttpOptionsDefaults({
timeout: 10000,
});
async function writeGlobalConfig(ctx: Context, config: GlobalConfig) {
const dirName = rootDirectory();
ctx.fs.mkdir(dirName, { allowExisting: true });
const path = globalConfigPath();
try {
ctx.fs.writeUtf8File(path, JSON.stringify(config));
} catch (err) {
console.log(
chalk.red(`Failed to write auth config to ${path} with error: ${err}`)
);
return await ctx.fatalError(1, "fs", err);
}
console.log(
chalk.green(`Successfully wrote your auth credentials to ${path}!`)
);
}
export async function checkAuthorization(ctx: Context): Promise<boolean> {
const header = await getAuthHeader(ctx);
if (!header) {
return false;
}
try {
const resp = await axios.head(`${provisionHost}/api/${version}/authorize`, {
headers: { Authorization: header },
// Don't throw an error if this request returns a non-200 status.
// Big Brain responds with a variety of error codes -- 401 if the token is correctly-formed but not valid, and either 400 or 500 if the token is ill-formed.
// We only care if this check returns a 200 code (so we can skip logging in again) -- any other errors should be silently skipped and we'll run the whole login flow again.
validateStatus: _ => true,
});
return resp.status === 200;
} catch (e: any) {
// This `catch` block should only be hit if a network error was encountered and axios didn't receive any sort of response.
console.log(chalk.gray(`Unexpected error when authorizing: ${e}`));
return false;
}
}
async function performDeviceAuthorization(
ctx: Context,
auth0Client: BaseClient,
shouldOpen: boolean
): Promise<string> {
// Device authorization flow follows this guide: https://github.com/auth0/auth0-device-flow-cli-sample/blob/9f0f3b76a6cd56ea8d99e76769187ea5102d519d/cli.js
// Device Authorization Request - https://tools.ietf.org/html/rfc8628#section-3.1
// Get authentication URL
const handle = await auth0Client.deviceAuthorization({
scope: SCOPE,
audience: "https://console.convex.dev/api/",
});
// Device Authorization Response - https://tools.ietf.org/html/rfc8628#section-3.2
// Open authentication URL
const { verification_uri_complete, user_code, expires_in } = handle;
console.log(
`Visit ${verification_uri_complete} to finish logging in. You should see the following code which expires in ${
expires_in % 60 === 0
? `${expires_in / 60} minutes`
: `${expires_in} seconds`
}: ${user_code}`
);
if (shouldOpen) {
shouldOpen = (
await inquirer.prompt([
{
name: "openBrowser",
message: `Open in browser?`,
type: "confirm",
default: true,
},
])
).openBrowser;
}
if (shouldOpen) {
console.log(
`Opening ${verification_uri_complete} in your browser to log in...`
);
try {
await open(verification_uri_complete);
} catch (err: any) {
console.log(chalk.red(`Unable to open browser.`));
console.log(
`Manually open ${verification_uri_complete} in your browser to log in.`
);
}
} else {
console.log(`Open ${verification_uri_complete} in your browser to log in.`);
}
// Device Access Token Request - https://tools.ietf.org/html/rfc8628#section-3.4
// Device Access Token Response - https://tools.ietf.org/html/rfc8628#section-3.5
try {
const tokens = await handle.poll();
if (typeof tokens.access_token === "string") {
return tokens.access_token;
} else {
throw Error("Access token is missing");
}
} catch (err: any) {
switch (err.error) {
case "access_denied": // end-user declined the device confirmation prompt, consent or rules failed
console.error("Access denied.");
return await ctx.fatalError(1, err);
case "expired_token": // end-user did not complete the interaction in time
console.error("Device flow expired.");
return await ctx.fatalError(1, err);
default:
if (err instanceof errors.OPError) {
console.error(
`Error = ${err.error}; error_description = ${err.error_description}`
);
} else {
console.error(`Login failed with error: ${err}`);
}
return await ctx.fatalError(1, err);
}
}
}
async function performPasswordAuthentication(
ctx: Context,
issuer: string,
clientId: string,
username: string,
password: string
): Promise<string> {
// Unfortunately, `openid-client` doesn't support the resource owner password credentials flow so we need to manually send the requests.
const options: AxiosRequestConfig = {
method: "POST",
url: new URL("/oauth/token", issuer).href,
headers: { "content-type": "application/x-www-form-urlencoded" },
data: new URLSearchParams({
grant_type: "password",
username: username,
password: password,
scope: SCOPE,
client_id: clientId,
audience: "https://console.convex.dev/api/",
// Note that there is no client secret provided, as Auth0 refuses to require it for untrusted apps.
}),
};
try {
const response = await axios.request(options);
if (typeof response.data.access_token === "string") {
return response.data.access_token;
} else {
throw Error("Access token is missing");
}
} catch (err: any) {
console.log(`Password flow failed: ${err}`);
if (err.response) {
console.log(`${JSON.stringify(err.response.data)}`);
}
return await ctx.fatalError(1, err);
}
}
export async function performLogin(
ctx: Context,
overrideAuthUrl?: string,
overrideAuthClient?: string,
overrideAuthUsername?: string,
overrideAuthPassword?: string,
open = true,
deviceNameOverride?: string
) {
// Get access token from big-brain
// Default the device name to the hostname, but allow the user to change this if the terminal is interactive.
// On Macs, the `hostname()` may be a weirdly-truncated form of the computer name. Attempt to read the "real" name before falling back to hostname.
let deviceName = deviceNameOverride ?? "";
if (!deviceName && process.platform === "darwin") {
try {
deviceName = execSync("scutil --get ComputerName").toString().trim();
} catch {
// Just fall back to the hostname default below.
}
}
if (!deviceName) {
deviceName = hostname();
}
if (process.stdin.isTTY && !deviceNameOverride) {
const answers = await inquirer.prompt([
{
type: "input",
name: "deviceName",
message: "Enter a name for the device being authorized:",
default: deviceName,
},
]);
deviceName = answers.deviceName;
}
const issuer = overrideAuthUrl ?? "https://auth.convex.dev";
const auth0 = await Issuer.discover(issuer);
const clientId = overrideAuthClient ?? "HFtA247jp9iNs08NTLIB7JsNPMmRIyfi";
const auth0Client = new auth0.Client({
client_id: clientId,
token_endpoint_auth_method: "none",
id_token_signed_response_alg: "RS256",
});
let accessToken: string;
if (overrideAuthUsername && overrideAuthPassword) {
accessToken = await performPasswordAuthentication(
ctx,
issuer,
clientId,
overrideAuthUsername,
overrideAuthPassword
);
} else {
accessToken = await performDeviceAuthorization(ctx, auth0Client, open);
}
interface AuthorizeArgs {
authnToken: string;
deviceName: string;
}
const authorizeArgs: AuthorizeArgs = {
authnToken: accessToken,
deviceName: deviceName,
};
const data = await bigBrainAPI(ctx, "POST", "authorize", authorizeArgs);
const globalConfig = { accessToken: data.accessToken };
try {
await writeGlobalConfig(ctx, globalConfig);
} catch (err: any) {
return await ctx.fatalError(1, "fs", err);
}
console.log(chalk.green("Successfully logged in and authorized device"));
}