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

login poc #370

Merged
merged 12 commits into from
Oct 1, 2024
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"inquirer": "^8.1.1",
"moment": "^2.29.1",
"object-sizeof": "^1.6.1",
"open": "8.4.2",
"prettier": "^2.3.0",
"rate-limiter-flexible": "^2.3.6",
"sinon-called-with-diff": "^3.1.1",
Expand Down Expand Up @@ -104,6 +105,8 @@
"build": "rm -rf dist && tsc -b",
"postpack": "rm -f oclif.manifest.json",
"prepack": "yarn build && oclif manifest",
"pretest": "yarn fixlint",
"local": "export $(cat .env | xargs); node bin/run",
"local-test": "export $(cat .env | xargs); mocha \"test/**/*.test.{js,ts}\"",
"test": "c8 -r html mocha --forbid-only \"test/**/*.test.{js,ts}\"",
"version": "oclif-dev readme && git add README.md",
Expand Down
2 changes: 2 additions & 0 deletions src/cli.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import yargs from 'yargs'

import evalCommand from './yargs-commands/eval.mjs'
import loginCommand from './yargs-commands/login.mjs'
// import { prefix } from './lib/completion.js'

export let container
Expand All @@ -14,6 +15,7 @@ export function run(argvInput, _container) {
return yargs(argvInput)
.scriptName("fauna")
.command("eval", "Evaluate the given query.", evalCommand)
.command("login", "Login via website", loginCommand)
.demandCommand()
.strict()
// .completion('completion', function(currentWord, argv, defaultCompletions, done) {
Expand Down
156 changes: 156 additions & 0 deletions src/lib/auth/oauth-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import http, { IncomingMessage, ServerResponse } from "http";
import { randomBytes, createHash } from "crypto";
import url from "url";

export const ACCOUNT_URL =
process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com/api/v1";

// Default to prod client id and secret
const clientId = process.env.FAUNA_CLIENT_ID ?? "Aq4_G0mOtm_F1fK3PuzE0k-i9F0";
// Native public clients are not confidential. The client secret is not used beyond
// client identification. https://datatracker.ietf.org/doc/html/rfc8252#section-8.5
const clientSecret =
process.env.FAUNA_CLIENT_SECRET ??
"2W9eZYlyN5XwnpvaP3AwOfclrtAjTXncH6k-bdFq1ZV0hZMFPzRIfg";
const REDIRECT_URI = `http://127.0.0.1`;

class OAuthClient {
server; //: http.Server;
port; //: number;
code_verifier; //: string;
code_challenge; //: string;
auth_code; //: string;
state; //: string;

constructor() {
this.server = http.createServer(this._handleRequest.bind(this));
this.code_verifier = Buffer.from(randomBytes(20)).toString("base64url");
this.code_challenge = createHash("sha256")
.update(this.code_verifier)
.digest("base64url");
this.port = 0;
this.auth_code = "";
this.state = this._generateCSRFToken();
}

getRequestUrl() {
const params = {
client_id: clientId,
redirect_uri: `${REDIRECT_URI}:${this.port}`,
code_challenge: this.code_challenge,
code_challenge_method: "S256",
response_type: "code",
scope: "create_session",
state: this.state,
};
return `${ACCOUNT_URL}/api/v1/oauth/authorize?${new URLSearchParams(
params
)}`;
}

getToken() {
const params = {
grant_type: "authorization_code",
client_id: clientId,
client_secret: clientSecret,
code: this.auth_code,
redirect_uri: `${REDIRECT_URI}:${this.port}`,
code_verifier: this.code_verifier,
};
return fetch(`${ACCOUNT_URL}/api/v1/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params),
});
}

_generateCSRFToken() {
return Buffer.from(randomBytes(20)).toString("base64url");
}

// req: IncomingMessage, res: ServerResponse
_handleRequest(req, res) {
const allowedOrigins = [
"http://localhost:3005",
"http://127.0.0.1:3005",
"http://dashboard.fauna.com",
"http://dashboard.fauna-dev.com",
"http://dashboard.fauna-preview.com",
];
const origin = req.headers.origin || "";

if (allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}

let errorMessage = "";

if (req.method === "GET") {
const parsedUrl = url.parse(req.url || "", true);
if (parsedUrl.pathname === "/success") {
console.log("Received success response");
res.write(`
<body>
<h1>Success</h1>
<p>Authentication successful. You can close this window and return to the terminal.</p>
</body>
`);
res.end();
this.closeServer();
} else if (parsedUrl.pathname !== "/") {
errorMessage = "Invalid redirect uri";
this.closeServer();
}
const query = parsedUrl.query;
if (query.error) {
errorMessage = `${query.error.toString()} - ${query.error_description}`;
this.closeServer();
}
if (query.code) {
const authCode = query.code;
if (!authCode || typeof authCode !== "string") {
errorMessage = "Invalid authorization code received";
this.server.close();
} else {
this.auth_code = authCode;
if (query.state !== this.state) {
errorMessage = "Invalid state received";
this.closeServer();
}
res.writeHead(302, { Location: "/success" });
res.end();
this.server.emit("auth_code_received");
}
}
} else {
errorMessage = "Invalid request method";
this.closeServer();
}
if (errorMessage) {
console.error("Error during authentication:", errorMessage);
}
}

async start() {
try {
this.server.on("listening", () => {
this.port = (this.server.address()).port;
this.server.emit("ready");
});
this.server.listen(0);
} catch (e) {
console.error("Error starting loopback server:", e.message);
}
}

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

export default OAuthClient;
89 changes: 89 additions & 0 deletions src/yargs-commands/login.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import OAuthServer, { ACCOUNT_URL } from "../lib/auth/oauth-client.mjs";
import open from "open";
import { container } from '../cli.mjs'

async function run() {
await this.execute();
}

async function getSession(access_token) {
const myHeaders = new Headers();
myHeaders.append("Authorization", `Bearer ${access_token}`);

const requestOptions = {
method: "POST",
headers: myHeaders,
};
const response = await fetch(`${ACCOUNT_URL}/session`, requestOptions);
if (response.status >= 400) {
throw new Error(`Error creating session: ${response.statusText}`);
}
const session = await response.json();
return session;
}

async function listDatabases(account_key) {
const myHeaders = new Headers();
myHeaders.append("Authorization", `Bearer ${account_key}`);

const requestOptions = {
method: "GET",
headers: myHeaders,
};
const response = await fetch(`${ACCOUNT_URL}/databases`, requestOptions);
if (response.status >= 400) {
throw new Error(`Error listing databases: ${response.statusText}`);
}
const databases = await response.json();
console.log(databases);
return databases;
}

async function execute(argv) {
const { user } = argv;
const logger = await container.resolve("logger")


const oAuth = new OAuthServer();
await oAuth.start();
oAuth.server.on("ready", async () => {
const dashboardOAuthURL = (await fetch(oAuth.getRequestUrl())).url;
const error = new URL(dashboardOAuthURL).searchParams.get("error");
if (error) {
throw new Error(`Error during login: ${error}`);
}
open(dashboardOAuthURL);
logger.stdout(`To login, open your browser to:\n ${dashboardOAuthURL}`);
});
oAuth.server.on("auth_code_received", async () => {
try {
const tokenResponse = await oAuth.getToken();
const token = await tokenResponse.json();
logger.stdout("Authentication successful!");
const { state, access_token } = token;
if (state !== oAuth.state) {
throw new Error("Error during login: invalid state.");
}
const session = await this.getSession(access_token);
logger.stdout("Listing Databases...");
await this.listDatabases(session.account_key);
} catch (err) {
console.error(err);
}
});
}

function buildLoginCommand(yargs) {
return yargs.options({
user: {
type: "string",
description: "a user profile",
default: "default",
},
});
}

export default {
builder: buildLoginCommand,
handler: execute,
};
16 changes: 15 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2693,6 +2693,11 @@ define-data-property@^1.1.4:
es-errors "^1.3.0"
gopd "^1.0.1"

define-lazy-prop@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==

delegates@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz"
Expand Down Expand Up @@ -3681,7 +3686,7 @@ is-core-module@^2.13.0, is-core-module@^2.5.0, is-core-module@^2.8.1:
dependencies:
hasown "^2.0.2"

is-docker@^2.0.0:
is-docker@^2.0.0, is-docker@^2.1.1:
version "2.2.1"
resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
Expand Down Expand Up @@ -5287,6 +5292,15 @@ onetime@^5.1.0, onetime@^5.1.2:
dependencies:
mimic-fn "^2.1.0"

[email protected]:
version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
dependencies:
define-lazy-prop "^2.0.0"
is-docker "^2.1.1"
is-wsl "^2.2.0"

optionator@^0.8.1:
version "0.8.3"
resolved "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz"
Expand Down