From a5489c311a87580e96068ca38921ec5245096bb3 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 2 Nov 2023 20:43:41 -0400 Subject: [PATCH 1/7] Add NodeJS example using EdgeDB Auth --- nodejs-auth/dbschema/default.esdl | 29 ++ nodejs-auth/dbschema/migrations/00001.edgeql | 23 ++ nodejs-auth/edgedb.toml | 2 + nodejs-auth/flake.lock | 61 +++ nodejs-auth/flake.nix | 53 +++ nodejs-auth/index.js | 412 +++++++++++++++++++ nodejs-auth/package-lock.json | 45 ++ nodejs-auth/package.json | 17 + 8 files changed, 642 insertions(+) create mode 100644 nodejs-auth/dbschema/default.esdl create mode 100644 nodejs-auth/dbschema/migrations/00001.edgeql create mode 100644 nodejs-auth/edgedb.toml create mode 100644 nodejs-auth/flake.lock create mode 100644 nodejs-auth/flake.nix create mode 100644 nodejs-auth/index.js create mode 100644 nodejs-auth/package-lock.json create mode 100644 nodejs-auth/package.json diff --git a/nodejs-auth/dbschema/default.esdl b/nodejs-auth/dbschema/default.esdl new file mode 100644 index 0000000..136ac16 --- /dev/null +++ b/nodejs-auth/dbschema/default.esdl @@ -0,0 +1,29 @@ +using extension auth; + +module default { + global current_user := ( + assert_single(( + select User + filter .identity = + global ext::auth::ClientTokenIdentity + )) + ); + + type User { + name: str; + required identity: ext::auth::Identity; + } + + type Post { + required content: str; + required author: User; + access policy author_full_access + allow all + using ( + .author ?= global current_user + ); + + access policy others_read_only + allow select; + } +} \ No newline at end of file diff --git a/nodejs-auth/dbschema/migrations/00001.edgeql b/nodejs-auth/dbschema/migrations/00001.edgeql new file mode 100644 index 0000000..07730c1 --- /dev/null +++ b/nodejs-auth/dbschema/migrations/00001.edgeql @@ -0,0 +1,23 @@ +CREATE MIGRATION m13bcrrwlp5jdlmorad6tuv3uubb5jkbgd5f6dbhehhsbrw7jardvq + ONTO initial +{ + CREATE EXTENSION pgcrypto VERSION '1.3'; + CREATE EXTENSION auth VERSION '1.0'; + CREATE TYPE default::User { + CREATE REQUIRED LINK identity: ext::auth::Identity; + CREATE PROPERTY name: std::str; + }; + CREATE GLOBAL default::current_user := (std::assert_single((SELECT + default::User + FILTER + (.identity = GLOBAL ext::auth::ClientTokenIdentity) + ))); + CREATE TYPE default::Post { + CREATE REQUIRED LINK author: default::User; + CREATE ACCESS POLICY author_full_access + ALLOW ALL USING ((.author ?= GLOBAL default::current_user)); + CREATE ACCESS POLICY others_read_only + ALLOW SELECT ; + CREATE REQUIRED PROPERTY content: std::str; + }; +}; diff --git a/nodejs-auth/edgedb.toml b/nodejs-auth/edgedb.toml new file mode 100644 index 0000000..53cecc8 --- /dev/null +++ b/nodejs-auth/edgedb.toml @@ -0,0 +1,2 @@ +[edgedb] +server-version = "5.0" diff --git a/nodejs-auth/flake.lock b/nodejs-auth/flake.lock new file mode 100644 index 0000000..fba0a5b --- /dev/null +++ b/nodejs-auth/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1698869283, + "narHash": "sha256-EO0WZngDV6Q9xaFeHHMxuii2ayyKX4RzRrTlGF8hBiE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "747e3067461e130d9540c6a284b4e5c33fc0dde7", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "master", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nodejs-auth/flake.nix b/nodejs-auth/flake.nix new file mode 100644 index 0000000..51e9a80 --- /dev/null +++ b/nodejs-auth/flake.nix @@ -0,0 +1,53 @@ +{ + description = "A Nix-flake-based Node.js development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/master"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs {inherit system;}; + edgedb-dev = pkgs.edgedb.overrideAttrs (oldAttrs: rec { + version = "4.0.0-alpha.1+ddfbe70"; + src = pkgs.fetchFromGitHub { + owner = "edgedb"; + repo = "edgedb-cli"; + rev = "243f510575f7d3fce8d1964c964dd5fdbf510299"; + sha256 = "sha256-1G7Ci7iI8IbgWRUDPBhW9hVDR6l/VbrLJEkKzyQpIkw="; + fetchSubmodules = true; + }; + cargoDeps = pkgs.rustPlatform.importCargoLock { + lockFile = src + "/Cargo.lock"; + outputHashes = { + "edgedb-derive-0.5.1" = "sha256-9NhfmtuZcDG+ouDeUKM8HpboJYU8rT8Own5M13PrDU8="; + "edgeql-parser-0.1.0" = "sha256-c5xBuW47xXgy8VLR/P7DvVhLBd0rvI6P9w82IPPsTwo="; + "rexpect-0.5.0" = "sha256-vstAL/fJWWx7WbmRxNItKpzvgGF3SvJDs5isq9ym/OA="; + "rustyline-8.0.0" = "sha256-CrICwQbHPzS4QdVIEHxt2euX+g+0pFYe84NfMp1daEc="; + "serde_str-1.0.0" = "sha256-CMBh5lxdQb2085y0jc/DrV6B8iiXvVO2aoZH/lFFjak="; + "indexmap-2.0.0-pre" = "sha256-QMOmoUHE1F/sp+NeDpgRGqqacWLHWG02YgZc5vAdXZY="; + }; + }; + }); + in { + devShell = pkgs.mkShell { + packages = [ + (pkgs.nodejs_20.override {enableNpm = false;}) + pkgs.nodePackages.npm + edgedb-dev + pkgs.httpie + ]; + + shellHook = '' + echo "`${edgedb-dev}/bin/edgedb --version`" + echo "npm `${pkgs.nodePackages.npm}/bin/npm --version`" + echo "node `${pkgs.nodejs_20}/bin/node --version`" + ''; + }; + }); +} diff --git a/nodejs-auth/index.js b/nodejs-auth/index.js new file mode 100644 index 0000000..22462e9 --- /dev/null +++ b/nodejs-auth/index.js @@ -0,0 +1,412 @@ +import http from "node:http"; +import { URL } from "node:url"; +import crypto from "node:crypto"; + +/** + * You can get this value by running `edgedb instance credentials`. + * Value should be: `${protocol}://${host}:${port}/db/${database}/ext/auth/ + */ +const EDGEDB_AUTH_BASE_URL = process.env.EDGEDB_AUTH_BASE_URL; +const SERVER_PORT = 3000; + +/** + * Generate a random Base64 url-encoded string, and derive a "challenge" + * string from that string to use as proof that the request for a token + * later is made from the same user agent that made the original request + * + * @returns {Object} The verifier and challenge strings + */ +const generatePKCE = () => { + const verifier = crypto.randomBytes(32).toString("base64url"); + + const challenge = crypto + .createHash("sha256") + .update(verifier) + .digest("base64url"); + + return { verifier, challenge }; +}; + +/** + * In Node, the `req.url` is only the `pathname` portion of a URL. In order + * to generate a full URL, we need to build the protocol and host from other + * parts of the request. + * + * One reason we like to use `URL` objects here is to easily parse the + * `URLSearchParams` from the request, and rather than do more error prone + * string manipulation, we build a `URL`. + * + * @param {Request} req + * @returns {URL} + */ +const getRequestUrl = (req) => { + const protocol = req.connection.encrypted ? "https" : "http"; + return new URL(req.url, `${protocol}://${req.headers.host}`); +}; + +const server = http.createServer(async (req, res) => { + const requestUrl = getRequestUrl(req); + + switch (requestUrl.pathname) { + case "/auth/ui/signin": { + await handleUiSignIn(req, res); + break; + } + + case "/auth/ui/signup": { + await handleUiSignUp(req, res); + break; + } + + case "/auth/authorize": { + await handleAuthorize(req, res); + break; + } + + case "/auth/callback": { + await handleCallback(req, res); + break; + } + + case "/auth/signup": { + await handleSignUp(req, res); + break; + } + + case "/auth/signin": { + await handleSignIn(req, res); + break; + } + + case "/auth/verify": { + await handleVerify(req, res); + break; + } + + default: { + res.writeHead(404); + res.end("Not found"); + break; + } + } +}); + +/** + * Redirects browser requests to EdgeDB Auth UI sign in page with the + * PKCE challenge, and saves PKCE verifier in an HttpOnly cookie. + * + * @param {Request} req + * @param {Response} res + */ +const handleUiSignIn = async (req, res) => { + const pkce = generatePKCE(); + + const redirectUrl = new URL("ui/signin", EDGEDB_AUTH_BASE_URL); + redirectUrl.searchParams.set("challenge", pkce.challenge); + + res.writeHead(301, { + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + Location: redirectUrl.href, + }); + res.end(); +}; + +/** + * Redirects browser requests to EdgeDB Auth UI sign up page with the + * PKCE challenge, and saves PKCE verifier in an HttpOnly cookie. + * + * @param {Request} req + * @param {Response} res + */ +const handleUiSignUp = async (req, res) => { + const pkce = generatePKCE(); + + const redirectUrl = new URL("ui/signup", EDGEDB_AUTH_BASE_URL); + redirectUrl.searchParams.set("challenge", pkce.challenge); + + res.writeHead(301, { + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + Location: redirectUrl.href, + }); + res.end(); +}; + +/** + * Redirects OAuth requests to EdgeDB Auth OAuth authorize redirect + * with the PKCE challenge, and saves PKCE verifier in an HttpOnly + * cookie for later retrieval. + * + * @param {Request} req + * @param {Response} res + */ +const handleAuthorize = async (req, res) => { + const requestUrl = getRequestUrl(req); + const provider = requestUrl.searchParams.get("provider"); + + if (!provider) { + res.status = 400; + res.end("Must provider a 'provider' value in search parameters"); + return; + } + + const pkce = generatePKCE(); + const redirectUrl = new URL("authorize", EDGEDB_AUTH_BASE_URL); + redirectUrl.searchParams.set("provider", provider); + redirectUrl.searchParams.set("challenge", pkce.challenge); + redirectUrl.searchParams.set( + "redirect_to", + `http://localhost:{PORT}/auth/callack`, + ); + + res.writeHead(301, { + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + Location: redirectUrl.href, + }); + res.end(); +}; + +/** + * Handles the PKCE callback and exchanges the `code` and `verifier + * for an auth_token, setting the auth_token as an HttpOnly cookie. + * + * @param {Request} req + * @param {Response} res + */ +const handleCallback = async (req, res) => { + const requestUrl = getRequestUrl(req); + + const code = requestUrl.searchParams.get("code"); + if (!code) { + const error = requestUrl.searchParams.get("error"); + res.status = 400; + res.end( + `OAuth callback is missing 'code'. OAuth provider responded with error: ${error}`, + ); + return; + } + + const cookies = req.headers.cookie?.split("; "); + const verifier = cookies + .find((cookie) => cookie.startsWith("edgedb-pkce-verifier=")) + ?.split("=")[1]; + if (!verifier) { + res.status = 400; + res.end( + `Could not find 'verifier' in the cookie store. Is this the same user agent/browser that started the authorization flow?`, + ); + return; + } + + const codeExchangeUrl = new URL("token", EDGEDB_AUTH_BASE_URL); + codeExchangeUrl.searchParams.set("code", code); + codeExchangeUrl.searchParams.set("verifier", verifier); + const codeExchangeResponse = await fetch(codeExchangeUrl.href, { + method: "GET", + }); + + if (!codeExchangeResponse.ok) { + const text = await codeExchangeResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + + const { auth_token } = await codeExchangeResponse.json(); + res.writeHead(204, { + "Set-Cookie": `edgedb-auth-token=${auth_token}; Path=/; HttpOnly`, + }); + res.end(); +}; + +/** + * Handles sign up with email and password. + * + * @param {Request} req + * @param {Response} res + */ +const handleSignUp = async (req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", async () => { + const pkce = generatePKCE(); + const { email, password, provider } = JSON.parse(body); + if (!email || !password || !provider) { + res.status = 400; + res.end( + `Request body malformed. Expected JSON body with 'email', 'password', and 'provider' keys, but got: ${body}`, + ); + return; + } + + const registerUrl = new URL("register", EDGEDB_AUTH_BASE_URL); + const registerResponse = await fetch(registerUrl.href, { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + challenge: pkce.challenge, + email, + password, + provider, + verify_url: `http://localhost:${SERVER_PORT}/auth/verify`, + }), + }); + + if (!registerResponse.ok) { + const text = await registerResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + + res.writeHead(204, { + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + }); + res.end(); + }); +}; +/** + * Handles sign in with email and password. + * + * @param {Request} req + * @param {Response} res + */ +const handleSignIn = async (req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", async () => { + const pkce = generatePKCE(); + const { email, password, provider } = JSON.parse(body); + if (!email || !password || !provider) { + res.status = 400; + res.end( + `Request body malformed. Expected JSON body with 'email', 'password', and 'provider' keys, but got: ${body}`, + ); + return; + } + + const authenticateUrl = new URL("authenticate", EDGEDB_AUTH_BASE_URL); + const authenticateResponse = await fetch(authenticateUrl.href, { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + challenge: pkce.challenge, + email, + password, + provider, + }), + }); + + if (!authenticateResponse.ok) { + const text = await authenticateResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + + const { code } = await authenticateResponse.json(); + + const tokenUrl = new URL("token", EDGEDB_AUTH_BASE_URL); + tokenUrl.searchParams.set("code", code); + tokenUrl.searchParams.set("verifier", pkce.verifier); + const tokenResponse = await fetch(tokenUrl.href, { + method: "get", + }); + + if (!tokenResponse.ok) { + const text = await authenticateResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + + const { auth_token } = await tokenResponse.json(); + res.writeHead(204, { + "Set-Cookie": `edgedb-auth-token=${auth_token}; Path=/; HttpOnly`, + }); + res.end(); + }); +}; + +/** + * Handles the link in the email verification flow. + * + * @param {Request} req + * @param {Response} res + */ +const handleVerify = async (req, res) => { + const requestUrl = getRequestUrl(req); + const verification_token = requestUrl.searchParams.get("verification_token"); + if (!verification_token) { + res.status = 400; + res.end( + `Verify request is missing 'verification_token' search param. The verification email is malformed.`, + ); + return; + } + + const cookies = req.headers.cookie?.split("; "); + const verifier = cookies + .find((cookie) => cookie.startsWith("edgedb-pkce-verifier=")) + ?.split("=")[1]; + if (!verifier) { + res.status = 400; + res.end( + `Could not find 'verifier' in the cookie store. Is this the same user agent/browser that started the authorization flow?`, + ); + return; + } + + const verifyUrl = new URL("verify", EDGEDB_AUTH_BASE_URL); + const verifyResponse = await fetch(verifyUrl.href, { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + verification_token, + verifier, + provider: "builtin::local_emailpassword", + }), + }); + + if (!verifyResponse.ok) { + const text = await verifyResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + + const { code } = await verifyResponse.json(); + + const tokenUrl = new URL("token", EDGEDB_AUTH_BASE_URL); + tokenUrl.searchParams.set("code", code); + tokenUrl.searchParams.set("verifier", verifier); + const tokenResponse = await fetch(tokenUrl.href, { + method: "get", + }); + + if (!tokenResponse.ok) { + const text = await authenticateResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + + const { auth_token } = await tokenResponse.json(); + res.writeHead(204, { + "Set-Cookie": `edgedb-auth-token=${auth_token}; Path=/; HttpOnly`, + }); + res.end(); +}; + +server.listen(SERVER_PORT, () => { + console.log(`HTTP server listening on port ${SERVER_PORT}...`); +}); diff --git a/nodejs-auth/package-lock.json b/nodejs-auth/package-lock.json new file mode 100644 index 0000000..ce6099f --- /dev/null +++ b/nodejs-auth/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "auth-nodejs-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "auth-nodejs-example", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "edgedb": "^1.4.0" + }, + "devDependencies": { + "prettier": "^3.0.3" + } + }, + "node_modules/edgedb": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/edgedb/-/edgedb-1.4.0.tgz", + "integrity": "sha512-KYH0DDaOlOe0EZEy9TYbVIM7LHZo5+wAsvmtFT7QoYm8ukaTQhEGSVQ8STMvGLRGPrUUJyEqS3C+P2gO0NvdCA==", + "bin": { + "edgeql-js": "dist/cli.js" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/nodejs-auth/package.json b/nodejs-auth/package.json new file mode 100644 index 0000000..8e2774b --- /dev/null +++ b/nodejs-auth/package.json @@ -0,0 +1,17 @@ +{ + "name": "auth-nodejs-example", + "version": "1.0.0", + "private": true, + "description": "", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js", + }, + "dependencies": { + "edgedb": "^1.4.0" + }, + "devDependencies": { + "prettier": "^3.0.3" + } +} From 0fb03cd12a66085d124c16eb270545dd1d5453f7 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 2 Nov 2023 21:32:05 -0400 Subject: [PATCH 2/7] Fix a few small typos and more secure cookies --- nodejs-auth/index.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/nodejs-auth/index.js b/nodejs-auth/index.js index 22462e9..8501553 100644 --- a/nodejs-auth/index.js +++ b/nodejs-auth/index.js @@ -105,7 +105,7 @@ const handleUiSignIn = async (req, res) => { redirectUrl.searchParams.set("challenge", pkce.challenge); res.writeHead(301, { - "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, Location: redirectUrl.href, }); res.end(); @@ -125,7 +125,7 @@ const handleUiSignUp = async (req, res) => { redirectUrl.searchParams.set("challenge", pkce.challenge); res.writeHead(301, { - "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, Location: redirectUrl.href, }); res.end(); @@ -155,11 +155,11 @@ const handleAuthorize = async (req, res) => { redirectUrl.searchParams.set("challenge", pkce.challenge); redirectUrl.searchParams.set( "redirect_to", - `http://localhost:{PORT}/auth/callack`, + `http://localhost:${SERVER_PORT}/auth/callack`, ); res.writeHead(301, { - "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, Location: redirectUrl.href, }); res.end(); @@ -187,7 +187,7 @@ const handleCallback = async (req, res) => { const cookies = req.headers.cookie?.split("; "); const verifier = cookies - .find((cookie) => cookie.startsWith("edgedb-pkce-verifier=")) + ?.find((cookie) => cookie.startsWith("edgedb-pkce-verifier=")) ?.split("=")[1]; if (!verifier) { res.status = 400; @@ -213,7 +213,7 @@ const handleCallback = async (req, res) => { const { auth_token } = await codeExchangeResponse.json(); res.writeHead(204, { - "Set-Cookie": `edgedb-auth-token=${auth_token}; Path=/; HttpOnly`, + "Set-Cookie": `edgedb-auth-token=${auth_token}; HttpOnly; Path=/; Secure; SameSite=Strict`, }); res.end(); }; @@ -263,7 +263,7 @@ const handleSignUp = async (req, res) => { } res.writeHead(204, { - "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; Path=/; HttpOnly`, + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, }); res.end(); }); @@ -329,7 +329,7 @@ const handleSignIn = async (req, res) => { const { auth_token } = await tokenResponse.json(); res.writeHead(204, { - "Set-Cookie": `edgedb-auth-token=${auth_token}; Path=/; HttpOnly`, + "Set-Cookie": `edgedb-auth-token=${auth_token}; HttpOnly; Path=/; Secure; SameSite=Strict`, }); res.end(); }); @@ -354,7 +354,7 @@ const handleVerify = async (req, res) => { const cookies = req.headers.cookie?.split("; "); const verifier = cookies - .find((cookie) => cookie.startsWith("edgedb-pkce-verifier=")) + ?.find((cookie) => cookie.startsWith("edgedb-pkce-verifier=")) ?.split("=")[1]; if (!verifier) { res.status = 400; @@ -394,7 +394,7 @@ const handleVerify = async (req, res) => { }); if (!tokenResponse.ok) { - const text = await authenticateResponse.text(); + const text = await tokenResponse.text(); res.status = 400; res.end(`Error from the auth server: ${text}`); return; @@ -402,7 +402,7 @@ const handleVerify = async (req, res) => { const { auth_token } = await tokenResponse.json(); res.writeHead(204, { - "Set-Cookie": `edgedb-auth-token=${auth_token}; Path=/; HttpOnly`, + "Set-Cookie": `edgedb-auth-token=${auth_token}; HttpOnly; Path=/; Secure; SameSite=Strict`, }); res.end(); }; From 03df12098766a4534e586ee9ad0f247a34dfd188 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Thu, 2 Nov 2023 22:25:16 -0400 Subject: [PATCH 3/7] Use correct redirect status, and fix callback URL --- nodejs-auth/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nodejs-auth/index.js b/nodejs-auth/index.js index 8501553..9f6279b 100644 --- a/nodejs-auth/index.js +++ b/nodejs-auth/index.js @@ -104,7 +104,7 @@ const handleUiSignIn = async (req, res) => { const redirectUrl = new URL("ui/signin", EDGEDB_AUTH_BASE_URL); redirectUrl.searchParams.set("challenge", pkce.challenge); - res.writeHead(301, { + res.writeHead(302, { "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, Location: redirectUrl.href, }); @@ -124,7 +124,7 @@ const handleUiSignUp = async (req, res) => { const redirectUrl = new URL("ui/signup", EDGEDB_AUTH_BASE_URL); redirectUrl.searchParams.set("challenge", pkce.challenge); - res.writeHead(301, { + res.writeHead(302, { "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, Location: redirectUrl.href, }); @@ -155,10 +155,10 @@ const handleAuthorize = async (req, res) => { redirectUrl.searchParams.set("challenge", pkce.challenge); redirectUrl.searchParams.set( "redirect_to", - `http://localhost:${SERVER_PORT}/auth/callack`, + `http://localhost:${SERVER_PORT}/auth/callback`, ); - res.writeHead(301, { + res.writeHead(302, { "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, Location: redirectUrl.href, }); From 70f7ed5dc8b470c50eee6b6e6d32b55ae72061c1 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Mon, 13 Nov 2023 10:14:26 -0500 Subject: [PATCH 4/7] Updates for password reset --- nodejs-auth/index.js | 173 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 7 deletions(-) diff --git a/nodejs-auth/index.js b/nodejs-auth/index.js index 9f6279b..7d7e649 100644 --- a/nodejs-auth/index.js +++ b/nodejs-auth/index.js @@ -58,6 +58,11 @@ const server = http.createServer(async (req, res) => { break; } + case "/auth/ui/reset-password": { + await handleUiResetPassword(req, res); + break; + } + case "/auth/authorize": { await handleAuthorize(req, res); break; @@ -83,6 +88,16 @@ const server = http.createServer(async (req, res) => { break; } + case "/auth/send-password-reset-email": { + await handleSendPasswordResetEmail(req, res); + break; + } + + case "/auth/reset-password": { + await handleResetPassword(req, res); + break; + } + default: { res.writeHead(404); res.end("Not found"); @@ -155,7 +170,7 @@ const handleAuthorize = async (req, res) => { redirectUrl.searchParams.set("challenge", pkce.challenge); redirectUrl.searchParams.set( "redirect_to", - `http://localhost:${SERVER_PORT}/auth/callback`, + `http://localhost:${SERVER_PORT}/auth/callback` ); res.writeHead(302, { @@ -180,7 +195,7 @@ const handleCallback = async (req, res) => { const error = requestUrl.searchParams.get("error"); res.status = 400; res.end( - `OAuth callback is missing 'code'. OAuth provider responded with error: ${error}`, + `OAuth callback is missing 'code'. OAuth provider responded with error: ${error}` ); return; } @@ -192,7 +207,7 @@ const handleCallback = async (req, res) => { if (!verifier) { res.status = 400; res.end( - `Could not find 'verifier' in the cookie store. Is this the same user agent/browser that started the authorization flow?`, + `Could not find 'verifier' in the cookie store. Is this the same user agent/browser that started the authorization flow?` ); return; } @@ -235,7 +250,7 @@ const handleSignUp = async (req, res) => { if (!email || !password || !provider) { res.status = 400; res.end( - `Request body malformed. Expected JSON body with 'email', 'password', and 'provider' keys, but got: ${body}`, + `Request body malformed. Expected JSON body with 'email', 'password', and 'provider' keys, but got: ${body}` ); return; } @@ -285,7 +300,7 @@ const handleSignIn = async (req, res) => { if (!email || !password || !provider) { res.status = 400; res.end( - `Request body malformed. Expected JSON body with 'email', 'password', and 'provider' keys, but got: ${body}`, + `Request body malformed. Expected JSON body with 'email', 'password', and 'provider' keys, but got: ${body}` ); return; } @@ -347,7 +362,7 @@ const handleVerify = async (req, res) => { if (!verification_token) { res.status = 400; res.end( - `Verify request is missing 'verification_token' search param. The verification email is malformed.`, + `Verify request is missing 'verification_token' search param. The verification email is malformed.` ); return; } @@ -359,7 +374,7 @@ const handleVerify = async (req, res) => { if (!verifier) { res.status = 400; res.end( - `Could not find 'verifier' in the cookie store. Is this the same user agent/browser that started the authorization flow?`, + `Could not find 'verifier' in the cookie store. Is this the same user agent/browser that started the authorization flow?` ); return; } @@ -407,6 +422,150 @@ const handleVerify = async (req, res) => { res.end(); }; +/** + * Request a password reset for an email. + * + * @param {Request} req + * @param {Response} res + */ +const handleSendPasswordResetEmail = async (req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", async () => { + const { email } = JSON.parse(body); + const reset_url = `http://localhost:${SERVER_PORT}/auth/ui/reset-password`; + const provider = "builtin::local_emailpassword"; + const pkce = generatePKCE(); + + const sendResetUrl = new URL("send-reset-email", EDGEDB_AUTH_BASE_URL); + const sendResetResponse = await fetch(sendResetUrl.href, { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + provider, + reset_url, + challenge: pkce.challenge, + }), + }); + + if (!sendResetResponse.ok) { + const text = await sendResetResponse.text(); + res.status = 400; + res.end(`Error from auth server: ${text}`); + return; + } + + const { email_sent } = await sendResetResponse.json(); + + res.writeHead(200, { + "Set-Cookie": `edgedb-pkce-verifier=${pkce.verifier}; HttpOnly; Path=/; Secure; SameSite=Strict`, + }); + res.end(`Reset email sent to '${email_sent}'`); + }); +}; + +/** + * Render a simple reset password UI + * + * @param {Request} req + * @param {Response} res + */ +const handleUiResetPassword = async (req, res) => { + const url = new URL(req.url); + const reset_token = url.searchParams.get("reset_token"); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + +
+ + + +
+ + + `); +}; + +/** + * Send new password with reset token to EdgeDB Auth. + * + * @param {Request} req + * @param {Response} res + */ +const handleResetPassword = async (req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", async () => { + const { reset_token, password } = JSON.parse(body); + if (!reset_token || !password) { + res.status = 400; + res.end( + `Request body malformed. Expected JSON body with 'reset_token' and 'password' keys, but got: ${body}` + ); + return; + } + const provider = "builtin::local_emailpassword"; + const cookies = req.headers.cookie.split("; "); + const verifier = cookies + .find((cookie) => cookie.startsWith("edgedb-pkce-verifier=")) + .split("=")[1]; + if (!verifier) { + res.status = 400; + res.end( + `Could not find 'verifier' in the cookie store. Is this the same user agent/browser that started the authorization flow?` + ); + return; + } + const resetUrl = new URL("reset-password", EDGEDB_AUTH_BASE_URL); + const resetResponse = await fetch(resetUrl.href, { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + reset_token, + provider, + password, + }), + }); + if (!resetResponse.ok) { + const text = await resetResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + const { code } = await resetResponse.json(); + const tokenUrl = new URL("token", EDGEDB_AUTH_BASE_URL); + tokenUrl.searchParams.set("code", code); + tokenUrl.searchParams.set("verifier", verifier); + const tokenResponse = await fetch(tokenUrl.href, { + method: "get", + }); + if (!tokenResponse.ok) { + const text = await tokenResponse.text(); + res.status = 400; + res.end(`Error from the auth server: ${text}`); + return; + } + const { auth_token } = await tokenResponse.json(); + res.writeHead(204, { + "Set-Cookie": `edgedb-auth-token=${auth_token}; HttpOnly; Path=/; Secure; SameSite=Strict`, + }); + res.end(); + }); +}; + server.listen(SERVER_PORT, () => { console.log(`HTTP server listening on port ${SERVER_PORT}...`); }); From c5670a21be35f85e281bec1bdf50fb3f5e6969b2 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Mon, 13 Nov 2023 10:23:30 -0500 Subject: [PATCH 5/7] Add very basic README --- nodejs-auth/README.md | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 nodejs-auth/README.md diff --git a/nodejs-auth/README.md b/nodejs-auth/README.md new file mode 100644 index 0000000..3b3fcd1 --- /dev/null +++ b/nodejs-auth/README.md @@ -0,0 +1,48 @@ +This is a sample project demonstrating how to use [EdgeDB +Auth](https://www.edgedb.com/docs/guides/auth/index) from a vanilla NodeJS HTTP server. It serves as +a blueprint for building all of the low-level pieces of interacting with the EdgeDB authentication +server. + +Please see the [EdgeDB Auth guide](https://www.edgedb.com/docs/guides/auth/index) for more +information on how these different piece fit into the various available authentication flows. + +## Getting Started + +### Requirements + +- Node.js v20 +- npm v10 +- EdgeDB CLI v4 + +#### Nix + +You can use the included Nix flake to manage the binary dependencies of this project (Node, npm, and +the EdgeDB CLI). It also includes `httpie` which can be useful for testing endpoints. + +### Install dependencies + +```bash +npm install +``` + +### Initialize project + +```bash +edgedb project init +``` + +### Configure Auth Extension + +Follow the configuration guidance in [the +docs](https://www.edgedb.com/docs/guides/auth/index#extension-configuration). You can launch the +built-in UI using the EdgeDB CLI: + +```bash +edgedb ui +``` + +### Start server + +```bash +node index.js +``` From a9051fda03677239ca68f15a603d35cc07a8f18c Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Mon, 13 Nov 2023 10:24:09 -0500 Subject: [PATCH 6/7] Remove stray trailing comma --- nodejs-auth/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs-auth/package.json b/nodejs-auth/package.json index 8e2774b..0ada7da 100644 --- a/nodejs-auth/package.json +++ b/nodejs-auth/package.json @@ -6,7 +6,7 @@ "type": "module", "main": "index.js", "scripts": { - "start": "node index.js", + "start": "node index.js" }, "dependencies": { "edgedb": "^1.4.0" From e9847b9b7c18430128ed3ea88b0c1538120edcc2 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Mon, 13 Nov 2023 10:24:39 -0500 Subject: [PATCH 7/7] Use 4.x version for project --- nodejs-auth/edgedb.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs-auth/edgedb.toml b/nodejs-auth/edgedb.toml index 53cecc8..15bedf8 100644 --- a/nodejs-auth/edgedb.toml +++ b/nodejs-auth/edgedb.toml @@ -1,2 +1,2 @@ [edgedb] -server-version = "5.0" +server-version = "4.x"