UNPKG

convex

Version:

Client for the Convex Cloud

260 lines (245 loc) 8.64 kB
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")); }