Skip to content

Commit

Permalink
Alternate login method (#555)
Browse files Browse the repository at this point in the history
* no browser login option

* update name of flag

* add a test

* get dashboard url from account url

* test fix
  • Loading branch information
mwilde345 authored Jan 14, 2025
1 parent 94bb56a commit 6db388a
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 26 deletions.
56 changes: 48 additions & 8 deletions src/commands/login.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.");
}
}
}
}

/**
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/config/setup-container.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
20 changes: 20 additions & 0 deletions src/lib/account-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
42 changes: 27 additions & 15 deletions src/lib/auth/oauth-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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",
Expand All @@ -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");
}
Expand All @@ -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
Expand Down Expand Up @@ -162,8 +172,10 @@ class OAuthClient {
}

closeServer() {
this.server.closeAllConnections();
this.server.close();
if (this.server.listening) {
this.server.closeAllConnections();
this.server.close();
}
}
}

Expand Down
29 changes: 27 additions & 2 deletions test/commands/login.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,6 +40,7 @@ describe("login", function () {
codeVerifier: "code-verifier",
};
},
validateAuthorizationCode: stub(),
server: {
on: (eventName, handler) => {
handlers[eventName] = handler;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
});
});

0 comments on commit 6db388a

Please sign in to comment.