diff --git a/src/commands/login.mjs b/src/commands/login.mjs index 45757738..17c5f7d3 100644 --- a/src/commands/login.mjs +++ b/src/commands/login.mjs @@ -12,13 +12,9 @@ async function doLogin(argv) { const open = container.resolve("open"); const credentials = container.resolve("credentials"); const oAuth = container.resolve("oauthClient"); - oAuth.server.on("ready", async () => { - const authCodeParams = oAuth.getOAuthParams({ clientId: argv.clientId }); - const dashboardOAuthURL = await startOAuthRequest(authCodeParams); - open(dashboardOAuthURL); - logger.stdout(`To login, open your browser to:\n${dashboardOAuthURL}`); - }); - oAuth.server.on("auth_code_received", async () => { + const input = container.resolve("input"); + + const loginWithToken = async () => { try { const { clientId, clientSecret, authCode, redirectURI, codeVerifier } = oAuth.getTokenParams({ @@ -37,11 +33,48 @@ async function doLogin(argv) { /* eslint-enable camelcase */ await credentials.login(accessToken); + logger.stdout("Login successful."); } catch (err) { logger.stderr(err); } + }; + const authCodeParams = oAuth.getOAuthParams({ + clientId: argv.clientId, + noRedirect: argv.noRedirect, }); - await oAuth.start(); + const dashboardOAuthURL = await startOAuthRequest(authCodeParams); + logger.stdout(`To login, open a browser to:\n${dashboardOAuthURL}`); + if (!argv.noRedirect) { + oAuth.server.on("ready", async () => { + open(dashboardOAuthURL); + }); + oAuth.server.on("auth_code_received", async () => { + await loginWithToken(); + }); + await oAuth.start(); + logger.stdout("Waiting for authentication in browser to complete..."); + } else { + try { + const userCode = await input({ + message: "Authorization Code:", + }); + try { + const jsonString = atob(userCode); + const parsed = JSON.parse(jsonString); + const { code, state } = parsed; + oAuth.validateAuthorizationCode(code, state); + await loginWithToken(); + } catch (err) { + logger.stderr( + `Error during login: ${err.message}\nPlease restart login.`, + ); + } + } catch (err) { + if (err.name === "ExitPromptError") { + logger.stdout("Login canceled."); + } + } + } } /** @@ -70,6 +103,13 @@ function buildLoginCommand(yargs) { required: false, hidden: true, }, + "no-redirect": { + alias: "n", + type: "boolean", + description: + "Login without redirecting to a local callback server. Use this option if you are unable to open a browser on your local machine.", + default: false, + }, user: { alias: "u", type: "string", diff --git a/src/config/setup-container.mjs b/src/config/setup-container.mjs index d318529e..ac518145 100644 --- a/src/config/setup-container.mjs +++ b/src/config/setup-container.mjs @@ -5,7 +5,7 @@ import os from "node:os"; import path from "node:path"; import { exit } from "node:process"; -import { confirm } from "@inquirer/prompts"; +import { confirm, input } from "@inquirer/prompts"; import * as awilix from "awilix"; import { Lifetime } from "awilix"; import Docker from "dockerode"; @@ -69,6 +69,7 @@ export const injectables = { // third-party libraries confirm: awilix.asValue(confirm), + input: awilix.asValue(input), open: awilix.asValue(open), updateNotifier: awilix.asValue(updateNotifier), fauna: awilix.asValue(fauna), diff --git a/src/lib/account-api.mjs b/src/lib/account-api.mjs index 1b76f211..9fbeca0e 100644 --- a/src/lib/account-api.mjs +++ b/src/lib/account-api.mjs @@ -31,6 +31,26 @@ export function setAccountUrl(url) { accountUrl = url; } +/** + * Infer the dashboard URL to use for login redirect URI + * @returns {string} The dashboard URL + */ +export function getDashboardUrl() { + if (process.env.FAUNA_DASHBOARD_URL) { + return process.env.FAUNA_DASHBOARD_URL; + } + switch (accountUrl) { + case "https://account.fauna-dev.com": + return "https://dashboard.fauna-dev.com"; + case "https://account.fauna-preview.com": + return "https://dashboard.fauna-preview.com"; + case "http://localhost:8000": + return "http://localhost:3005"; + default: + return "https://dashboard.fauna.com"; + } +} + /** * Builds a URL for the account API * diff --git a/src/lib/auth/oauth-client.mjs b/src/lib/auth/oauth-client.mjs index 898f8a5e..aabb302f 100644 --- a/src/lib/auth/oauth-client.mjs +++ b/src/lib/auth/oauth-client.mjs @@ -4,6 +4,7 @@ import url from "url"; import util from "util"; import { container } from "../../config/container.mjs"; +import { getDashboardUrl } from "../account-api.mjs"; import SuccessPage from "./successPage.mjs"; const ALLOWED_ORIGINS = [ @@ -39,13 +40,18 @@ class OAuthClient { * Gets the OAuth parameters for the OAuth request. * @param {Object} [overrides] - The parameters for the OAuth request * @param {string} [overrides.clientId] - The client ID + * @param {boolean} [overrides.noRedirect] - Whether to disable the redirect * @returns {Object} The OAuth parameters */ - getOAuthParams({ clientId }) { + getOAuthParams({ clientId, noRedirect }) { + const redirectURI = noRedirect + ? `${getDashboardUrl()}/auth/oauth/callback/cli` + : `${REDIRECT_URI}:${this.port}`; + return { /* eslint-disable camelcase */ client_id: clientId ?? CLIENT_ID, - redirect_uri: `${REDIRECT_URI}:${this.port}`, + redirect_uri: redirectURI, code_challenge: this.codeChallenge, code_challenge_method: "S256", response_type: "code", @@ -72,6 +78,17 @@ class OAuthClient { }; } + validateAuthorizationCode(authCode, state) { + if (!authCode || typeof authCode !== "string") { + throw new Error("Invalid authorization code received"); + } else { + this.authCode = authCode; + if (state !== this.state) { + throw new Error("Invalid state received"); + } + } + } + static _generateCSRFToken() { return Buffer.from(randomBytes(20)).toString("base64url"); } @@ -88,17 +105,10 @@ class OAuthClient { } _handleCode({ authCode, state, res }) { - if (!authCode || typeof authCode !== "string") { - throw new Error("Invalid authorization code received"); - } else { - this.authCode = authCode; - if (state !== this.state) { - throw new Error("Invalid state received"); - } - res.writeHead(302, { Location: "/success" }); - res.end(); - this.server.emit("auth_code_received"); - } + this.validateAuthorizationCode(authCode, state); + res.writeHead(302, { Location: "/success" }); + res.end(); + this.server.emit("auth_code_received"); } // req: IncomingMessage, res: ServerResponse @@ -162,8 +172,10 @@ class OAuthClient { } closeServer() { - this.server.closeAllConnections(); - this.server.close(); + if (this.server.listening) { + this.server.closeAllConnections(); + this.server.close(); + } } } diff --git a/test/commands/login.mjs b/test/commands/login.mjs index b6f6c018..e76c4449 100644 --- a/test/commands/login.mjs +++ b/test/commands/login.mjs @@ -2,7 +2,7 @@ import * as awilix from "awilix"; import { expect } from "chai"; -import sinon, { spy } from "sinon"; +import sinon, { spy, stub } from "sinon"; import { run } from "../../src/cli.mjs"; import { setupTestContainer as setupContainer } from "../../src/config/setup-test-container.mjs"; @@ -40,6 +40,7 @@ describe("login", function () { codeVerifier: "code-verifier", }; }, + validateAuthorizationCode: stub(), server: { on: (eventName, handler) => { handlers[eventName] = handler; @@ -113,7 +114,7 @@ describe("login", function () { // We open auth url in the browser and prompt user expect(container.resolve("open").calledWith("http://dashboard-url.com")); expect(logger.stdout).to.have.been.calledWith( - "To login, open your browser to:\nhttp://dashboard-url.com", + "To login, open a browser to:\nhttp://dashboard-url.com", ); // Trigger server event with mocked auth code @@ -162,4 +163,28 @@ describe("login", function () { "Using a local Fauna container does not require login.\n", ); }); + + it("doesn't run loopback server with --no-redirect flag", async function () { + const input = container.resolve("input"); + const sampleCreds = btoa(JSON.stringify({ code: "asdf", state: "state" })); + input.resolves(sampleCreds); + + await run(`login --no-redirect=true`, container); + const oauthClient = container.resolve("oauthClient"); + const logger = container.resolve("logger"); + const credentials = container.resolve("credentials"); + expect(oauthClient.start.called).to.be.false; + expect(container.resolve("open").called).to.be.false; + expect(logger.stdout).to.have.been.calledWith( + "To login, open a browser to:\nhttp://dashboard-url.com", + ); + expect(input).to.have.been.calledWith({ + message: "Authorization Code:", + }); + expect(oauthClient.validateAuthorizationCode).to.have.been.calledWith( + "asdf", + "state", + ); + expect(credentials.accountKeys.key).to.equal("login-account-key"); + }); });