Skip to content

Commit 1a223d7

Browse files
wildematcleve-faunaecho-bravo-yahoo
committed
login poc (#370)
* poc for new login auth * remove unused code from poc * no need client secret for auth code * some fixups * better port selection * avoid race condition * Update src/commands/login.ts Co-authored-by: Cleve Stuart <[email protected]> * Update src/commands/login.ts Co-authored-by: Cleve Stuart <[email protected]> * surface error_description and leverage open() to for oauth prompt * move login command to yargs --------- Co-authored-by: Cleve Stuart <[email protected]> Co-authored-by: Ashton Eby <[email protected]>
1 parent 5d2fba8 commit 1a223d7

File tree

5 files changed

+264
-1
lines changed

5 files changed

+264
-1
lines changed

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"inquirer": "^8.1.1",
3030
"moment": "^2.29.1",
3131
"object-sizeof": "^1.6.1",
32+
"open": "8.4.2",
3233
"prettier": "^2.3.0",
3334
"rate-limiter-flexible": "^2.3.6",
3435
"sinon-called-with-diff": "^3.1.1",
@@ -111,6 +112,7 @@
111112
"postpack": "rm -f oclif.manifest.json",
112113
"prepack": "yarn build && oclif manifest",
113114
"pretest": "yarn fixlint",
115+
"local": "export $(cat .env | xargs); node bin/run",
114116
"local-test": "export $(cat .env | xargs); mocha \"test/**/*.test.{js,ts}\"",
115117
"test": "c8 -r html mocha --forbid-only \"test/**/*.test.{js,ts}\"",
116118
"lint": "eslint .",

src/cli.mjs

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import yargs from 'yargs'
22

33
import evalCommand from './yargs-commands/eval.mjs'
4+
import loginCommand from './yargs-commands/login.mjs'
45
// import { prefix } from './lib/completion.js'
56

67
export let container
@@ -14,6 +15,7 @@ export function run(argvInput, _container) {
1415
return yargs(argvInput)
1516
.scriptName("fauna")
1617
.command("eval", "Evaluate the given query.", evalCommand)
18+
.command("login", "Login via website", loginCommand)
1719
.demandCommand()
1820
.strict()
1921
// .completion('completion', function(currentWord, argv, defaultCompletions, done) {

src/lib/auth/oauth-client.mjs

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import http, { IncomingMessage, ServerResponse } from "http";
2+
import { randomBytes, createHash } from "crypto";
3+
import url from "url";
4+
5+
export const ACCOUNT_URL =
6+
process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com/api/v1";
7+
8+
// Default to prod client id and secret
9+
const clientId = process.env.FAUNA_CLIENT_ID ?? "Aq4_G0mOtm_F1fK3PuzE0k-i9F0";
10+
// Native public clients are not confidential. The client secret is not used beyond
11+
// client identification. https://datatracker.ietf.org/doc/html/rfc8252#section-8.5
12+
const clientSecret =
13+
process.env.FAUNA_CLIENT_SECRET ??
14+
"2W9eZYlyN5XwnpvaP3AwOfclrtAjTXncH6k-bdFq1ZV0hZMFPzRIfg";
15+
const REDIRECT_URI = `http://127.0.0.1`;
16+
17+
class OAuthClient {
18+
server; //: http.Server;
19+
port; //: number;
20+
code_verifier; //: string;
21+
code_challenge; //: string;
22+
auth_code; //: string;
23+
state; //: string;
24+
25+
constructor() {
26+
this.server = http.createServer(this._handleRequest.bind(this));
27+
this.code_verifier = Buffer.from(randomBytes(20)).toString("base64url");
28+
this.code_challenge = createHash("sha256")
29+
.update(this.code_verifier)
30+
.digest("base64url");
31+
this.port = 0;
32+
this.auth_code = "";
33+
this.state = this._generateCSRFToken();
34+
}
35+
36+
getRequestUrl() {
37+
const params = {
38+
client_id: clientId,
39+
redirect_uri: `${REDIRECT_URI}:${this.port}`,
40+
code_challenge: this.code_challenge,
41+
code_challenge_method: "S256",
42+
response_type: "code",
43+
scope: "create_session",
44+
state: this.state,
45+
};
46+
return `${ACCOUNT_URL}/api/v1/oauth/authorize?${new URLSearchParams(
47+
params
48+
)}`;
49+
}
50+
51+
getToken() {
52+
const params = {
53+
grant_type: "authorization_code",
54+
client_id: clientId,
55+
client_secret: clientSecret,
56+
code: this.auth_code,
57+
redirect_uri: `${REDIRECT_URI}:${this.port}`,
58+
code_verifier: this.code_verifier,
59+
};
60+
return fetch(`${ACCOUNT_URL}/api/v1/oauth/token`, {
61+
method: "POST",
62+
headers: {
63+
"Content-Type": "application/x-www-form-urlencoded",
64+
},
65+
body: new URLSearchParams(params),
66+
});
67+
}
68+
69+
_generateCSRFToken() {
70+
return Buffer.from(randomBytes(20)).toString("base64url");
71+
}
72+
73+
// req: IncomingMessage, res: ServerResponse
74+
_handleRequest(req, res) {
75+
const allowedOrigins = [
76+
"http://localhost:3005",
77+
"http://127.0.0.1:3005",
78+
"http://dashboard.fauna.com",
79+
"http://dashboard.fauna-dev.com",
80+
"http://dashboard.fauna-preview.com",
81+
];
82+
const origin = req.headers.origin || "";
83+
84+
if (allowedOrigins.includes(origin)) {
85+
res.setHeader("Access-Control-Allow-Origin", origin);
86+
res.setHeader("Access-Control-Allow-Methods", "GET");
87+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
88+
}
89+
90+
let errorMessage = "";
91+
92+
if (req.method === "GET") {
93+
const parsedUrl = url.parse(req.url || "", true);
94+
if (parsedUrl.pathname === "/success") {
95+
console.log("Received success response");
96+
res.write(`
97+
<body>
98+
<h1>Success</h1>
99+
<p>Authentication successful. You can close this window and return to the terminal.</p>
100+
</body>
101+
`);
102+
res.end();
103+
this.closeServer();
104+
} else if (parsedUrl.pathname !== "/") {
105+
errorMessage = "Invalid redirect uri";
106+
this.closeServer();
107+
}
108+
const query = parsedUrl.query;
109+
if (query.error) {
110+
errorMessage = `${query.error.toString()} - ${query.error_description}`;
111+
this.closeServer();
112+
}
113+
if (query.code) {
114+
const authCode = query.code;
115+
if (!authCode || typeof authCode !== "string") {
116+
errorMessage = "Invalid authorization code received";
117+
this.server.close();
118+
} else {
119+
this.auth_code = authCode;
120+
if (query.state !== this.state) {
121+
errorMessage = "Invalid state received";
122+
this.closeServer();
123+
}
124+
res.writeHead(302, { Location: "/success" });
125+
res.end();
126+
this.server.emit("auth_code_received");
127+
}
128+
}
129+
} else {
130+
errorMessage = "Invalid request method";
131+
this.closeServer();
132+
}
133+
if (errorMessage) {
134+
console.error("Error during authentication:", errorMessage);
135+
}
136+
}
137+
138+
async start() {
139+
try {
140+
this.server.on("listening", () => {
141+
this.port = (this.server.address()).port;
142+
this.server.emit("ready");
143+
});
144+
this.server.listen(0);
145+
} catch (e) {
146+
console.error("Error starting loopback server:", e.message);
147+
}
148+
}
149+
150+
closeServer() {
151+
this.server.closeAllConnections();
152+
this.server.close();
153+
}
154+
}
155+
156+
export default OAuthClient;

src/yargs-commands/login.mjs

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import OAuthServer, { ACCOUNT_URL } from "../lib/auth/oauth-client.mjs";
2+
import open from "open";
3+
import { container } from '../cli.mjs'
4+
5+
async function run() {
6+
await this.execute();
7+
}
8+
9+
async function getSession(access_token) {
10+
const myHeaders = new Headers();
11+
myHeaders.append("Authorization", `Bearer ${access_token}`);
12+
13+
const requestOptions = {
14+
method: "POST",
15+
headers: myHeaders,
16+
};
17+
const response = await fetch(`${ACCOUNT_URL}/session`, requestOptions);
18+
if (response.status >= 400) {
19+
throw new Error(`Error creating session: ${response.statusText}`);
20+
}
21+
const session = await response.json();
22+
return session;
23+
}
24+
25+
async function listDatabases(account_key) {
26+
const myHeaders = new Headers();
27+
myHeaders.append("Authorization", `Bearer ${account_key}`);
28+
29+
const requestOptions = {
30+
method: "GET",
31+
headers: myHeaders,
32+
};
33+
const response = await fetch(`${ACCOUNT_URL}/databases`, requestOptions);
34+
if (response.status >= 400) {
35+
throw new Error(`Error listing databases: ${response.statusText}`);
36+
}
37+
const databases = await response.json();
38+
console.log(databases);
39+
return databases;
40+
}
41+
42+
async function execute(argv) {
43+
const { user } = argv;
44+
const logger = await container.resolve("logger")
45+
46+
47+
const oAuth = new OAuthServer();
48+
await oAuth.start();
49+
oAuth.server.on("ready", async () => {
50+
const dashboardOAuthURL = (await fetch(oAuth.getRequestUrl())).url;
51+
const error = new URL(dashboardOAuthURL).searchParams.get("error");
52+
if (error) {
53+
throw new Error(`Error during login: ${error}`);
54+
}
55+
open(dashboardOAuthURL);
56+
logger.stdout(`To login, open your browser to:\n ${dashboardOAuthURL}`);
57+
});
58+
oAuth.server.on("auth_code_received", async () => {
59+
try {
60+
const tokenResponse = await oAuth.getToken();
61+
const token = await tokenResponse.json();
62+
logger.stdout("Authentication successful!");
63+
const { state, access_token } = token;
64+
if (state !== oAuth.state) {
65+
throw new Error("Error during login: invalid state.");
66+
}
67+
const session = await this.getSession(access_token);
68+
logger.stdout("Listing Databases...");
69+
await this.listDatabases(session.account_key);
70+
} catch (err) {
71+
console.error(err);
72+
}
73+
});
74+
}
75+
76+
function buildLoginCommand(yargs) {
77+
return yargs.options({
78+
user: {
79+
type: "string",
80+
description: "a user profile",
81+
default: "default",
82+
},
83+
});
84+
}
85+
86+
export default {
87+
builder: buildLoginCommand,
88+
handler: execute,
89+
};

yarn.lock

+15-1
Original file line numberDiff line numberDiff line change
@@ -2763,6 +2763,11 @@ define-data-property@^1.1.4:
27632763
es-errors "^1.3.0"
27642764
gopd "^1.0.1"
27652765

2766+
define-lazy-prop@^2.0.0:
2767+
version "2.0.0"
2768+
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
2769+
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
2770+
27662771
delegates@^1.0.0:
27672772
version "1.0.0"
27682773
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
@@ -3973,7 +3978,7 @@ is-core-module@^2.13.0, is-core-module@^2.5.0, is-core-module@^2.8.1:
39733978
dependencies:
39743979
hasown "^2.0.2"
39753980

3976-
is-docker@^2.0.0:
3981+
is-docker@^2.0.0, is-docker@^2.1.1:
39773982
version "2.2.1"
39783983
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
39793984
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
@@ -5607,6 +5612,15 @@ onetime@^5.1.0, onetime@^5.1.2:
56075612
dependencies:
56085613
mimic-fn "^2.1.0"
56095614

5615+
5616+
version "8.4.2"
5617+
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
5618+
integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
5619+
dependencies:
5620+
define-lazy-prop "^2.0.0"
5621+
is-docker "^2.1.1"
5622+
is-wsl "^2.2.0"
5623+
56105624
optionator@^0.8.1:
56115625
version "0.8.3"
56125626
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"

0 commit comments

Comments
 (0)