From ffe992241b2c252497974043fbc7c73c435fd7ae Mon Sep 17 00:00:00 2001 From: Matt Wilde Date: Mon, 16 Sep 2024 21:54:53 -0400 Subject: [PATCH 1/9] poc for new login auth --- src/commands/login.ts | 222 +++++++++++++++++++++++++++++++++++ src/lib/auth/oauth-client.ts | 161 +++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 src/commands/login.ts create mode 100644 src/lib/auth/oauth-client.ts diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 00000000..a6f041d2 --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,222 @@ +import { input, confirm, password } from "@inquirer/prompts"; +import { ShellConfig } from "../lib/config"; +import { hostname } from "os"; +import { Command } from "@oclif/core"; +import { underline, blue } from "chalk"; +import OAuthServer from "../lib/auth/oauth-client"; + +const DEFAULT_NAME = "cloud"; +const AUTH = process.env.FAUNA_AUTH ?? "https://auth.console.fauna.com"; + +type Regions = { + [key: string]: Region; +}; + +type AccessToken = { + access_token: string; + token_type: string; + ttl: string; + state: string; +}; + +class Region { + name: string; + secret: string; + + constructor(name: string, secret: string) { + this.name = name; + this.secret = secret; + } + + endpointName(base: string) { + return `${base}-${this.name}`; + } +} + +// const DASHBOARD_URL = "http://localhost:3005/login"; + +export default class LoginCommand extends Command { + static description = "Log in to a Fauna account."; + static examples = ["$ fauna login"]; + static flags = {}; + + async run() { + await this.execute(); + } + + async getSession(access_token: string) { + const myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + + const requestOptions = { + method: "POST", + headers: myHeaders, + }; + + const response = await fetch( + "http://localhost:8000/api/v1/session", + requestOptions + ); + const session = await response.json(); + return session; + } + + async execute() { + await this.parse(); + + // const { port } = await startOAuthClient(); + const oAuth = new OAuthServer(); + await oAuth.start(); + // TODO: from within our local server, do a GET to frontdoor and receive + // the redirect url to the dashboard w/ ?request= then tell them to open that. + const authUrl = (await fetch(oAuth.getRequestUrl())).url; + this.log(`To login, press Enter or open your browser to:\n ${authUrl}`); + const { default: open } = await import("open"); + open(authUrl); + oAuth.server.on("auth_code_received", async () => { + try { + const token: AccessToken = await (await oAuth.getToken()).json(); + this.log("Authentication successful!"); + const { state, ttl, access_token, token_type } = token; + if (state !== oAuth.state) { + throw new Error("Error during login: invalid state."); + } + const session = await this.getSession(access_token); + this.log("Session created:", session); + } catch (err) { + console.error(err); + } + }); + + // const base = await this.askName(config); + // const regions = await this.passwordStrategy(); + // const newDefault = await this.askDefault(config, base, regions); + + // for (const region of Object.values(regions)) { + // config.rootConfig.endpoints[region.endpointName(base)] = new Endpoint({ + // url: DB, + // secret: Secret.parse(region.secret), + // }); + // } + + // config.rootConfig.defaultEndpoint = newDefault; + // config.saveRootConfig(); + + // this.log("Configuration updated."); + } + + async askName(config: ShellConfig): Promise { + const name = await input({ + message: "Endpoint name", + default: DEFAULT_NAME, + validate: (endpoint: string) => + endpoint === "" ? "Provide an endpoint name." : true, + }); + + if (config.rootConfig.endpoints[name] !== undefined) { + const confirmed = await confirm({ + message: `The endpoint ${name} already exists. Overwrite?`, + default: false, + }); + if (!confirmed) { + return this.askName(config); + } + } + + return name; + } + + async passwordStrategy(): Promise { + return this.loginByPassword({ + email: await input({ + message: `Email address (from ${underline( + blue("https://dashboard.fauna.com/") + )})`, + validate: (email) => { + return !email || !/\S+@\S+\.\S+/.test(email) + ? "Provide a valid email address." + : true; + }, + }), + password: await password({ + message: "Password", + mask: true, + }), + }); + } + + async otp({ + email, + password, + }: { + email: string; + password: string; + }): Promise { + const otp = await input({ + message: "Enter your multi-factor authentication code", + }); + + return this.loginByPassword({ + email, + password, + otp, + }); + } + + async handlePasswordStrategyError({ + email, + password, + error, + }: { + email: string; + password: string; + error: any; + }): Promise { + if (["otp_required", "otp_invalid"].includes(error.code)) { + if (error.code === "otp_invalid") { + this.log(error.message); + } + return this.otp({ email, password }); + } + + if (error.code === "invalid_credentials") { + this.log(error.message); + return this.passwordStrategy(); + } + + throw error; + } + + async loginByPassword({ + email, + password, + otp, + }: { + email: string; + password: string; + otp?: string; + }): Promise { + const resp = await fetch(new URL("login", AUTH), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password, + type: "shell", + session: "Fauna Shell - " + hostname(), + ...(otp && { otp }), + }), + }); + const json = await resp.json(); + if (resp.ok) { + return Object.fromEntries( + Object.entries(json.regionGroups).map(([key, v]) => [ + key, + new Region(key, (v as any).secret), + ]) + ); + } else { + return this.handlePasswordStrategyError({ email, password, error: json }); + } + } +} diff --git a/src/lib/auth/oauth-client.ts b/src/lib/auth/oauth-client.ts new file mode 100644 index 00000000..5cfcf787 --- /dev/null +++ b/src/lib/auth/oauth-client.ts @@ -0,0 +1,161 @@ +import http, { IncomingMessage, ServerResponse } from "http"; +const { randomBytes, createHash } = require("node:crypto"); +import url from "url"; +import net from "net"; + +// env var +const dashboardURL = "http://localhost:3005/authorize/complete"; +// env var +const frontdoorURL = "http://localhost:8000/api/v1/oauth"; +// env var +const clientId = "Gj6wAqni5MS0U72qfcGjh9pS8+U="; +const clientSecret = "5kXhq2MrHLPF4iV5aPC5PRnGrCNhnRUsV6C8gtlj8PtkIJINR5Je2A=="; +const redirectUri = `http://127.0.0.1`; + +class OAuthClient { + public server: http.Server; + public port: number; + private code_verifier: string; + private code_challenge: string; + private auth_code: string; + public 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(); + } + + private generateCSRFToken(): string { + return Buffer.from(randomBytes(20)).toString("base64url"); + } + + public getRequestUrl() { + const params = { + client_id: clientId, + client_secret: clientSecret, + redirect_uri: `${redirectUri}:${this.port}`, + code_challenge: this.code_challenge, + code_challenge_method: "S256", + response_type: "code", + scope: "create_session", + state: this.state, + }; + return `${frontdoorURL}/authorize?${new URLSearchParams(params)}`; + } + + public getToken() { + const params = { + grant_type: "authorization_code", + client_id: clientId, + client_secret: clientSecret, + code: this.auth_code, + redirect_uri: `${redirectUri}:${this.port}`, + code_verifier: this.code_verifier, + ttl: "2024-09-17T00:00:00.00Z", + }; + return fetch(`${frontdoorURL}/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(params), + }); + } + + private handleRequest(req: IncomingMessage, res: ServerResponse) { + 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"); + } + + if (req.method === "GET") { + const parsedUrl = url.parse(req.url || "", true); + if (parsedUrl.pathname !== "/") { + console.error("Error while retrieving authorization code! Try again."); + this.closeServer(); + } + const query = parsedUrl.query; + if (query.error) { + console.error("Error returned from server:", query.error); + this.closeServer(); + } + if (query.code) { + const authCode = query.code; + if (!authCode || typeof authCode !== "string") { + console.error("Invalid authorization code returned from server"); + this.server.close(); + } else { + this.auth_code = authCode; + if (query.state !== this.state) { + console.error("Invalid state returned from server"); + this.closeServer(); + } + // Send them to a nice page that shows auth is complete and they can close the window. + res.writeHead(301, { Location: dashboardURL }); + res.end(); + this.server.emit("auth_code_received"); + this.closeServer(); + } + } + } else { + console.error("Error while retrieving authorization code! Try again."); + this.closeServer(); + } + } + + private isPortAvailable(port: number): Promise { + return new Promise((resolve, reject) => { + const tester = net + .createServer() + .once("error", (err: any) => + err.code === "EADDRINUSE" ? resolve(false) : reject(err) + ) + .once("listening", () => + tester.once("close", () => resolve(true)).close() + ) + .listen(port); + }); + } + + public async start() { + let port: number; + let isAvailable: boolean = false; + + // Loop until an available port is found + do { + port = Math.floor(Math.random() * (63000 - 62500 + 1)) + 62500; + isAvailable = await this.isPortAvailable(port); + } while (!isAvailable); + + this.port = port; + + this.server.listen(port, () => { + // console.log(`Server is listening on port ${port}`); + }); + + return { server: this.server, port }; + } + + public closeServer() { + this.server.closeAllConnections(); + this.server.close(); + } +} + +export default OAuthClient; From 934d0c02982cde8240cbd4f4f4130cc0ab47f81d Mon Sep 17 00:00:00 2001 From: Matt Wilde Date: Tue, 17 Sep 2024 08:59:33 -0400 Subject: [PATCH 2/9] remove unused code from poc --- src/commands/login.ts | 165 +---------------------------------- src/lib/auth/oauth-client.ts | 5 +- 2 files changed, 6 insertions(+), 164 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index a6f041d2..c4f09666 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,17 +1,6 @@ -import { input, confirm, password } from "@inquirer/prompts"; -import { ShellConfig } from "../lib/config"; -import { hostname } from "os"; import { Command } from "@oclif/core"; -import { underline, blue } from "chalk"; import OAuthServer from "../lib/auth/oauth-client"; -const DEFAULT_NAME = "cloud"; -const AUTH = process.env.FAUNA_AUTH ?? "https://auth.console.fauna.com"; - -type Regions = { - [key: string]: Region; -}; - type AccessToken = { access_token: string; token_type: string; @@ -19,20 +8,6 @@ type AccessToken = { state: string; }; -class Region { - name: string; - secret: string; - - constructor(name: string, secret: string) { - this.name = name; - this.secret = secret; - } - - endpointName(base: string) { - return `${base}-${this.name}`; - } -} - // const DASHBOARD_URL = "http://localhost:3005/login"; export default class LoginCommand extends Command { @@ -64,15 +39,10 @@ export default class LoginCommand extends Command { async execute() { await this.parse(); - // const { port } = await startOAuthClient(); const oAuth = new OAuthServer(); - await oAuth.start(); - // TODO: from within our local server, do a GET to frontdoor and receive - // the redirect url to the dashboard w/ ?request= then tell them to open that. const authUrl = (await fetch(oAuth.getRequestUrl())).url; - this.log(`To login, press Enter or open your browser to:\n ${authUrl}`); - const { default: open } = await import("open"); - open(authUrl); + await oAuth.start(); + this.log(`To login, open your browser to:\n ${authUrl}`); oAuth.server.on("auth_code_received", async () => { try { const token: AccessToken = await (await oAuth.getToken()).json(); @@ -87,136 +57,5 @@ export default class LoginCommand extends Command { console.error(err); } }); - - // const base = await this.askName(config); - // const regions = await this.passwordStrategy(); - // const newDefault = await this.askDefault(config, base, regions); - - // for (const region of Object.values(regions)) { - // config.rootConfig.endpoints[region.endpointName(base)] = new Endpoint({ - // url: DB, - // secret: Secret.parse(region.secret), - // }); - // } - - // config.rootConfig.defaultEndpoint = newDefault; - // config.saveRootConfig(); - - // this.log("Configuration updated."); - } - - async askName(config: ShellConfig): Promise { - const name = await input({ - message: "Endpoint name", - default: DEFAULT_NAME, - validate: (endpoint: string) => - endpoint === "" ? "Provide an endpoint name." : true, - }); - - if (config.rootConfig.endpoints[name] !== undefined) { - const confirmed = await confirm({ - message: `The endpoint ${name} already exists. Overwrite?`, - default: false, - }); - if (!confirmed) { - return this.askName(config); - } - } - - return name; - } - - async passwordStrategy(): Promise { - return this.loginByPassword({ - email: await input({ - message: `Email address (from ${underline( - blue("https://dashboard.fauna.com/") - )})`, - validate: (email) => { - return !email || !/\S+@\S+\.\S+/.test(email) - ? "Provide a valid email address." - : true; - }, - }), - password: await password({ - message: "Password", - mask: true, - }), - }); - } - - async otp({ - email, - password, - }: { - email: string; - password: string; - }): Promise { - const otp = await input({ - message: "Enter your multi-factor authentication code", - }); - - return this.loginByPassword({ - email, - password, - otp, - }); - } - - async handlePasswordStrategyError({ - email, - password, - error, - }: { - email: string; - password: string; - error: any; - }): Promise { - if (["otp_required", "otp_invalid"].includes(error.code)) { - if (error.code === "otp_invalid") { - this.log(error.message); - } - return this.otp({ email, password }); - } - - if (error.code === "invalid_credentials") { - this.log(error.message); - return this.passwordStrategy(); - } - - throw error; - } - - async loginByPassword({ - email, - password, - otp, - }: { - email: string; - password: string; - otp?: string; - }): Promise { - const resp = await fetch(new URL("login", AUTH), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email, - password, - type: "shell", - session: "Fauna Shell - " + hostname(), - ...(otp && { otp }), - }), - }); - const json = await resp.json(); - if (resp.ok) { - return Object.fromEntries( - Object.entries(json.regionGroups).map(([key, v]) => [ - key, - new Region(key, (v as any).secret), - ]) - ); - } else { - return this.handlePasswordStrategyError({ email, password, error: json }); - } } } diff --git a/src/lib/auth/oauth-client.ts b/src/lib/auth/oauth-client.ts index 5cfcf787..16c817a4 100644 --- a/src/lib/auth/oauth-client.ts +++ b/src/lib/auth/oauth-client.ts @@ -50,6 +50,9 @@ class OAuthClient { } public getToken() { + const now = new Date(); + // Short expiry for access token as it's only used to create a session + now.setUTCMinutes(now.getUTCMinutes() + 10); const params = { grant_type: "authorization_code", client_id: clientId, @@ -57,7 +60,7 @@ class OAuthClient { code: this.auth_code, redirect_uri: `${redirectUri}:${this.port}`, code_verifier: this.code_verifier, - ttl: "2024-09-17T00:00:00.00Z", + ttl: now.toISOString(), }; return fetch(`${frontdoorURL}/token`, { method: "POST", From d0010c7fc27ec998e313526de1a5d10173f7afbd Mon Sep 17 00:00:00 2001 From: Matt Wilde Date: Thu, 19 Sep 2024 12:20:13 -0400 Subject: [PATCH 3/9] no need client secret for auth code --- src/lib/auth/oauth-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/auth/oauth-client.ts b/src/lib/auth/oauth-client.ts index 16c817a4..ba4bda16 100644 --- a/src/lib/auth/oauth-client.ts +++ b/src/lib/auth/oauth-client.ts @@ -38,7 +38,6 @@ class OAuthClient { public getRequestUrl() { const params = { client_id: clientId, - client_secret: clientSecret, redirect_uri: `${redirectUri}:${this.port}`, code_challenge: this.code_challenge, code_challenge_method: "S256", From ba4648dc6fbd37695e28d47d21828c32ef9630e3 Mon Sep 17 00:00:00 2001 From: Matt Wilde Date: Mon, 23 Sep 2024 13:27:58 -0400 Subject: [PATCH 4/9] some fixups --- package.json | 1 + src/commands/login.ts | 19 ++++++---- src/lib/auth/oauth-client.ts | 71 ++++++++++++++++++++---------------- 3 files changed, 52 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index dfb8d263..3ecb81c7 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "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); c8 -r text mocha --forbid-only \"test/**/*.test.{js,ts}\"", "test": "export $(cat .env.test | xargs); c8 -r html mocha --forbid-only \"test/**/*.test.{js,ts}\"", "lint": "eslint .", diff --git a/src/commands/login.ts b/src/commands/login.ts index c4f09666..c6601ef6 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -8,8 +8,6 @@ type AccessToken = { state: string; }; -// const DASHBOARD_URL = "http://localhost:3005/login"; - export default class LoginCommand extends Command { static description = "Log in to a Fauna account."; static examples = ["$ fauna login"]; @@ -27,11 +25,13 @@ export default class LoginCommand extends Command { method: "POST", headers: myHeaders, }; - const response = await fetch( "http://localhost:8000/api/v1/session", requestOptions ); + if (response.status >= 400) { + throw new Error(`Error creating session: ${response.statusText}`); + } const session = await response.json(); return session; } @@ -40,14 +40,19 @@ export default class LoginCommand extends Command { await this.parse(); const oAuth = new OAuthServer(); - const authUrl = (await fetch(oAuth.getRequestUrl())).url; await oAuth.start(); - this.log(`To login, open your browser to:\n ${authUrl}`); + const dashboardOAuthURL = (await fetch(oAuth.getRequestUrl())).url; + const error = new URL(dashboardOAuthURL).searchParams.get("error"); + if (error) { + throw new Error(`Error during login: ${error}`); + } + this.log(`To login, open your browser to:\n ${dashboardOAuthURL}`); oAuth.server.on("auth_code_received", async () => { try { - const token: AccessToken = await (await oAuth.getToken()).json(); + const tokenResponse = await oAuth.getToken(); + const token: AccessToken = await tokenResponse.json(); this.log("Authentication successful!"); - const { state, ttl, access_token, token_type } = token; + const { state, access_token } = token; if (state !== oAuth.state) { throw new Error("Error during login: invalid state."); } diff --git a/src/lib/auth/oauth-client.ts b/src/lib/auth/oauth-client.ts index ba4bda16..c439acc6 100644 --- a/src/lib/auth/oauth-client.ts +++ b/src/lib/auth/oauth-client.ts @@ -3,14 +3,16 @@ const { randomBytes, createHash } = require("node:crypto"); import url from "url"; import net from "net"; -// env var -const dashboardURL = "http://localhost:3005/authorize/complete"; -// env var -const frontdoorURL = "http://localhost:8000/api/v1/oauth"; -// env var -const clientId = "Gj6wAqni5MS0U72qfcGjh9pS8+U="; -const clientSecret = "5kXhq2MrHLPF4iV5aPC5PRnGrCNhnRUsV6C8gtlj8PtkIJINR5Je2A=="; -const redirectUri = `http://127.0.0.1`; +const accountURL = process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com"; + +// 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 { public server: http.Server; @@ -21,47 +23,45 @@ class OAuthClient { public state: string; constructor() { - this.server = http.createServer(this.handleRequest.bind(this)); + 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(); - } - - private generateCSRFToken(): string { - return Buffer.from(randomBytes(20)).toString("base64url"); + this.state = this._generateCSRFToken(); } public getRequestUrl() { const params = { client_id: clientId, - redirect_uri: `${redirectUri}:${this.port}`, + 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 `${frontdoorURL}/authorize?${new URLSearchParams(params)}`; + return `${accountURL}/api/v1/oauth/authorize?${new URLSearchParams( + params + )}`; } public getToken() { const now = new Date(); // Short expiry for access token as it's only used to create a session - now.setUTCMinutes(now.getUTCMinutes() + 10); + now.setUTCMinutes(now.getUTCMinutes() + 5); const params = { grant_type: "authorization_code", client_id: clientId, client_secret: clientSecret, code: this.auth_code, - redirect_uri: `${redirectUri}:${this.port}`, + redirect_uri: `${REDIRECT_URI}:${this.port}`, code_verifier: this.code_verifier, ttl: now.toISOString(), }; - return fetch(`${frontdoorURL}/token`, { + return fetch(`${accountURL}/api/v1/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -70,7 +70,11 @@ class OAuthClient { }); } - private handleRequest(req: IncomingMessage, res: ServerResponse) { + private _generateCSRFToken(): string { + return Buffer.from(randomBytes(20)).toString("base64url"); + } + + private _handleRequest(req: IncomingMessage, res: ServerResponse) { const allowedOrigins = [ "http://localhost:3005", "http://127.0.0.1:3005", @@ -86,42 +90,47 @@ class OAuthClient { res.setHeader("Access-Control-Allow-Headers", "Content-Type"); } + let errorMessage = ""; + if (req.method === "GET") { const parsedUrl = url.parse(req.url || "", true); if (parsedUrl.pathname !== "/") { - console.error("Error while retrieving authorization code! Try again."); + errorMessage = "Invalid redirect uri"; this.closeServer(); } const query = parsedUrl.query; if (query.error) { - console.error("Error returned from server:", query.error); + errorMessage = query.error.toString(); this.closeServer(); } if (query.code) { const authCode = query.code; if (!authCode || typeof authCode !== "string") { - console.error("Invalid authorization code returned from server"); + errorMessage = "Invalid authorization code received"; this.server.close(); } else { this.auth_code = authCode; if (query.state !== this.state) { - console.error("Invalid state returned from server"); + errorMessage = "Invalid state received"; this.closeServer(); } - // Send them to a nice page that shows auth is complete and they can close the window. - res.writeHead(301, { Location: dashboardURL }); + // TODO: Send them to a nice page that shows auth is complete and they can close the window. + res.writeHead(200, { "Content-Type": "text/html" }); res.end(); this.server.emit("auth_code_received"); this.closeServer(); } } } else { - console.error("Error while retrieving authorization code! Try again."); + errorMessage = "Invalid request method"; this.closeServer(); } + if (errorMessage) { + console.error("Error during authentication:", errorMessage); + } } - private isPortAvailable(port: number): Promise { + private _isPortAvailable(port: number): Promise { return new Promise((resolve, reject) => { const tester = net .createServer() @@ -142,14 +151,12 @@ class OAuthClient { // Loop until an available port is found do { port = Math.floor(Math.random() * (63000 - 62500 + 1)) + 62500; - isAvailable = await this.isPortAvailable(port); + isAvailable = await this._isPortAvailable(port); } while (!isAvailable); this.port = port; - this.server.listen(port, () => { - // console.log(`Server is listening on port ${port}`); - }); + this.server.listen(port); return { server: this.server, port }; } From 16c262cc3e0390e533649b7230268b882b9fe020 Mon Sep 17 00:00:00 2001 From: Matt Wilde Date: Mon, 23 Sep 2024 15:11:40 -0400 Subject: [PATCH 5/9] better port selection --- src/lib/auth/oauth-client.ts | 43 ++++++++++++++---------------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/src/lib/auth/oauth-client.ts b/src/lib/auth/oauth-client.ts index c439acc6..90c06680 100644 --- a/src/lib/auth/oauth-client.ts +++ b/src/lib/auth/oauth-client.ts @@ -1,7 +1,7 @@ import http, { IncomingMessage, ServerResponse } from "http"; const { randomBytes, createHash } = require("node:crypto"); import url from "url"; -import net from "net"; +import net, { AddressInfo } from "net"; const accountURL = process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com"; @@ -130,35 +130,26 @@ class OAuthClient { } } - private _isPortAvailable(port: number): Promise { - return new Promise((resolve, reject) => { - const tester = net - .createServer() - .once("error", (err: any) => - err.code === "EADDRINUSE" ? resolve(false) : reject(err) - ) - .once("listening", () => - tester.once("close", () => resolve(true)).close() - ) - .listen(port); + private _getFreePort(): Promise { + return new Promise((res, rej) => { + const srv = net.createServer(); + srv.listen(0, () => { + const port = srv.address() as AddressInfo; + srv.close((err) => { + if (err) rej(err); + res(port.port); + }); + }); }); } public async start() { - let port: number; - let isAvailable: boolean = false; - - // Loop until an available port is found - do { - port = Math.floor(Math.random() * (63000 - 62500 + 1)) + 62500; - isAvailable = await this._isPortAvailable(port); - } while (!isAvailable); - - this.port = port; - - this.server.listen(port); - - return { server: this.server, port }; + try { + this.port = await this._getFreePort(); + this.server.listen(this.port); + } catch (e: any) { + console.error("Error starting loopback server:", e.message); + } } public closeServer() { From 1f91256655f87ed5fde1247346942fa1858f11d5 Mon Sep 17 00:00:00 2001 From: Matt Wilde Date: Mon, 23 Sep 2024 16:13:35 -0400 Subject: [PATCH 6/9] avoid race condition --- src/commands/login.ts | 14 ++++++++------ src/lib/auth/oauth-client.ts | 22 ++++++---------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index c6601ef6..6da6ecc1 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -41,12 +41,14 @@ export default class LoginCommand extends Command { const oAuth = new OAuthServer(); await oAuth.start(); - const dashboardOAuthURL = (await fetch(oAuth.getRequestUrl())).url; - const error = new URL(dashboardOAuthURL).searchParams.get("error"); - if (error) { - throw new Error(`Error during login: ${error}`); - } - this.log(`To login, open your browser to:\n ${dashboardOAuthURL}`); + 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}`); + } + this.log(`To login, open your browser to:\n ${dashboardOAuthURL}`); + }); oAuth.server.on("auth_code_received", async () => { try { const tokenResponse = await oAuth.getToken(); diff --git a/src/lib/auth/oauth-client.ts b/src/lib/auth/oauth-client.ts index 90c06680..2f61197f 100644 --- a/src/lib/auth/oauth-client.ts +++ b/src/lib/auth/oauth-client.ts @@ -1,7 +1,7 @@ import http, { IncomingMessage, ServerResponse } from "http"; const { randomBytes, createHash } = require("node:crypto"); import url from "url"; -import net, { AddressInfo } from "net"; +import { AddressInfo } from "net"; const accountURL = process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com"; @@ -130,23 +130,13 @@ class OAuthClient { } } - private _getFreePort(): Promise { - return new Promise((res, rej) => { - const srv = net.createServer(); - srv.listen(0, () => { - const port = srv.address() as AddressInfo; - srv.close((err) => { - if (err) rej(err); - res(port.port); - }); - }); - }); - } - public async start() { try { - this.port = await this._getFreePort(); - this.server.listen(this.port); + this.server.on("listening", () => { + this.port = (this.server.address() as AddressInfo).port; + this.server.emit("ready"); + }); + this.server.listen(0); } catch (e: any) { console.error("Error starting loopback server:", e.message); } From af4ad7c58850a368aefc43e75c8c0ee828016797 Mon Sep 17 00:00:00 2001 From: Matthew Wilde Date: Tue, 24 Sep 2024 11:22:04 -0400 Subject: [PATCH 7/9] Update src/commands/login.ts Co-authored-by: Cleve Stuart <90649124+cleve-fauna@users.noreply.github.com> --- src/commands/login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 6da6ecc1..373177f7 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -9,7 +9,7 @@ type AccessToken = { }; export default class LoginCommand extends Command { - static description = "Log in to a Fauna account."; + static description = "Login to your Fauna account."; static examples = ["$ fauna login"]; static flags = {}; From 0c3c13c364be2e542758b632ffece94638c5356f Mon Sep 17 00:00:00 2001 From: Matthew Wilde Date: Tue, 24 Sep 2024 11:40:52 -0400 Subject: [PATCH 8/9] Update src/commands/login.ts Co-authored-by: Cleve Stuart <90649124+cleve-fauna@users.noreply.github.com> --- src/commands/login.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 373177f7..1f8bc5eb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -37,7 +37,6 @@ export default class LoginCommand extends Command { } async execute() { - await this.parse(); const oAuth = new OAuthServer(); await oAuth.start(); From 914dfae3e63a4d699854da273a0742748ccdd58d Mon Sep 17 00:00:00 2001 From: Matt Wilde Date: Wed, 25 Sep 2024 18:05:42 -0700 Subject: [PATCH 9/9] surface error_description and leverage open() to for oauth prompt --- package.json | 1 + src/commands/login.ts | 29 +++++++++++++++++++++++------ src/lib/auth/oauth-client.ts | 29 +++++++++++++++++------------ yarn.lock | 16 +++++++++++++++- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 3ecb81c7..601039f5 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,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", "stream-json": "^1.7.3" diff --git a/src/commands/login.ts b/src/commands/login.ts index 1f8bc5eb..ad60ba70 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,5 +1,6 @@ import { Command } from "@oclif/core"; -import OAuthServer from "../lib/auth/oauth-client"; +import OAuthServer, { ACCOUNT_URL } from "../lib/auth/oauth-client"; +import open from "open"; type AccessToken = { access_token: string; @@ -25,10 +26,7 @@ export default class LoginCommand extends Command { method: "POST", headers: myHeaders, }; - const response = await fetch( - "http://localhost:8000/api/v1/session", - requestOptions - ); + const response = await fetch(`${ACCOUNT_URL}/session`, requestOptions); if (response.status >= 400) { throw new Error(`Error creating session: ${response.statusText}`); } @@ -36,6 +34,23 @@ export default class LoginCommand extends Command { return session; } + async listDatabases(account_key: string) { + 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 execute() { const oAuth = new OAuthServer(); @@ -46,6 +61,7 @@ export default class LoginCommand extends Command { if (error) { throw new Error(`Error during login: ${error}`); } + open(dashboardOAuthURL); this.log(`To login, open your browser to:\n ${dashboardOAuthURL}`); }); oAuth.server.on("auth_code_received", async () => { @@ -58,7 +74,8 @@ export default class LoginCommand extends Command { throw new Error("Error during login: invalid state."); } const session = await this.getSession(access_token); - this.log("Session created:", session); + this.log("Listing databases..."); + await this.listDatabases(session.account_key); } catch (err) { console.error(err); } diff --git a/src/lib/auth/oauth-client.ts b/src/lib/auth/oauth-client.ts index 2f61197f..5fe9d4f3 100644 --- a/src/lib/auth/oauth-client.ts +++ b/src/lib/auth/oauth-client.ts @@ -3,7 +3,8 @@ const { randomBytes, createHash } = require("node:crypto"); import url from "url"; import { AddressInfo } from "net"; -const accountURL = process.env.FAUNA_ACCOUNT_URL ?? "https://account.fauna.com"; +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"; @@ -43,15 +44,12 @@ class OAuthClient { scope: "create_session", state: this.state, }; - return `${accountURL}/api/v1/oauth/authorize?${new URLSearchParams( + return `${ACCOUNT_URL}/api/v1/oauth/authorize?${new URLSearchParams( params )}`; } public getToken() { - const now = new Date(); - // Short expiry for access token as it's only used to create a session - now.setUTCMinutes(now.getUTCMinutes() + 5); const params = { grant_type: "authorization_code", client_id: clientId, @@ -59,9 +57,8 @@ class OAuthClient { code: this.auth_code, redirect_uri: `${REDIRECT_URI}:${this.port}`, code_verifier: this.code_verifier, - ttl: now.toISOString(), }; - return fetch(`${accountURL}/api/v1/oauth/token`, { + return fetch(`${ACCOUNT_URL}/api/v1/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -94,13 +91,23 @@ class OAuthClient { if (req.method === "GET") { const parsedUrl = url.parse(req.url || "", true); - if (parsedUrl.pathname !== "/") { + if (parsedUrl.pathname === "/success") { + console.log("Received success response"); + res.write(` + +

Success

+

Authentication successful. You can close this window and return to the terminal.

+ + `); + 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(); + errorMessage = `${query.error.toString()} - ${query.error_description}`; this.closeServer(); } if (query.code) { @@ -114,11 +121,9 @@ class OAuthClient { errorMessage = "Invalid state received"; this.closeServer(); } - // TODO: Send them to a nice page that shows auth is complete and they can close the window. - res.writeHead(200, { "Content-Type": "text/html" }); + res.writeHead(302, { Location: "/success" }); res.end(); this.server.emit("auth_code_received"); - this.closeServer(); } } } else { diff --git a/yarn.lock b/yarn.lock index b5aa389d..76abcede 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2703,6 +2703,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.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" @@ -3898,7 +3903,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.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== @@ -5473,6 +5478,15 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@8.4.2: + 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.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"