diff --git a/shared/common/newui/modal/index.tsx b/shared/common/newui/modal/index.tsx
index 1fb12f7a..6e77177d 100644
--- a/shared/common/newui/modal/index.tsx
+++ b/shared/common/newui/modal/index.tsx
@@ -65,12 +65,11 @@ export function ModalPanel({
{subheading}
) : null}
- onClose())}
- >
-
-
+ {onClose ? (
+
+
+
+ ) : null}
) : null}
{children}
diff --git a/web/package.json b/web/package.json
index 48750763..3269b767 100644
--- a/web/package.json
+++ b/web/package.json
@@ -19,7 +19,6 @@
"@fontsource-variable/roboto-flex": "^5.1.0",
"@fontsource-variable/roboto-mono": "^5.1.0",
"edgedb": "1.6.0-canary.20250120T111308",
- "hash.js": "^1.1.7",
"mobx": "^6.5.0",
"mobx-keystone": "^1.11.0",
"mobx-react-lite": "^4.0.0",
diff --git a/web/src/components/loginPage/index.tsx b/web/src/components/loginPage/index.tsx
index 990fa7bd..9dee8565 100644
--- a/web/src/components/loginPage/index.tsx
+++ b/web/src/components/loginPage/index.tsx
@@ -1,18 +1,36 @@
import {useEffect, useState} from "react";
import {useForm} from "react-hook-form";
-import Button from "@edgedb/common/ui/button";
-import {ModalTextField} from "@edgedb/common/ui/modal";
+import {getHTTPSCRAMAuth} from "edgedb/dist/httpScram";
+import {cryptoUtils} from "edgedb/dist/browserCrypto";
+
+import {
+ ModalPanel,
+ ArrowRightIcon,
+ ModalContent,
+ TextInput,
+ SubmitButton,
+ InfoIcon,
+} from "@edgedb/common/newui";
import {serverUrl, setAuthToken} from "../../state/models/app";
-import {SCRAMAuth} from "../../utils/scram";
+import {Logo} from "../header";
import styles from "./loginPage.module.scss";
-import {ArrowRight} from "@edgedb/studio/icons";
-import {Logo} from "../header";
+
+const httpSCRAMAuth = getHTTPSCRAMAuth(cryptoUtils);
+
+const isLocalhost = (() => {
+ try {
+ return new URL(serverUrl).hostname === "localhost";
+ } catch {
+ // ignore
+ }
+ return false;
+})();
export default function LoginPage() {
- const [error, setError] = useState(null);
+ const [error, setError] = useState(null);
const {register, handleSubmit, formState, setFocus} = useForm<{
username: string;
password: string;
@@ -29,47 +47,63 @@ export default function LoginPage() {
const onSubmit = handleSubmit(async ({username, password}) => {
setError(null);
try {
- const authToken = await SCRAMAuth(serverUrl, username, password);
+ const authToken = await httpSCRAMAuth(serverUrl, username, password);
setAuthToken(username, authToken);
} catch (err) {
- setError(err as Error);
console.error(err);
+ setError("Login failed: username or password may be incorrect");
}
});
return (
-
-
+
+
+
}
+ >
+ Login
+
+ }
+ >
+
+ {isLocalhost ? (
+
+
+
+ It looks like you're running Gel locally.
+
If you created this instance using the Gel CLI, the
+ easiest way to login is by running the gel ui
{" "}
+ command from your project directory.
+
+
+ ) : null}
+
+
+
+
);
}
diff --git a/web/src/components/loginPage/loginPage.module.scss b/web/src/components/loginPage/loginPage.module.scss
index 539e1177..1e13a363 100644
--- a/web/src/components/loginPage/loginPage.module.scss
+++ b/web/src/components/loginPage/loginPage.module.scss
@@ -5,57 +5,77 @@
flex-grow: 1;
justify-content: center;
align-items: center;
+ padding: 32px;
}
-.title {
+.logo {
position: absolute;
top: 16px;
left: 24px;
}
-.loginForm {
- position: relative;
- display: flex;
- flex-direction: column;
- width: 280px;
+.loginPanel {
+ border-color: var(--Grey85);
+ box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.12),
+ 0px 2px 2px 0px rgba(0, 0, 0, 0.04);
@include darkTheme {
- input {
- background: #282828;
- }
+ box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.4),
+ 0px 2px 4px 0px rgba(0, 0, 0, 0.2);
}
+}
- .loginButton {
- --buttonBg: var(--app-accent-green);
- --buttonTextColour: #fff;
- align-self: flex-end;
- padding: 0;
+.formContent {
+ width: 380px;
+ padding-bottom: 32px;
+}
- & > div {
- padding: 6px 16px;
- border-radius: 18px;
- font-weight: 600;
- }
+.loginInfo {
+ display: flex;
+ background: #dce9ef;
+ padding: 12px 16px 12px 12px;
+ margin: 0 -8px;
+ margin-bottom: 8px;
+ border-radius: 8px;
+ gap: 8px;
+ line-height: 20px;
+ font-weight: 425;
+ color: var(--secondary_text_color);
- .loginButtonIcon {
- stroke: currentColor;
- height: 14px;
- }
+ svg {
+ flex-shrink: 0;
+ color: #1a6e9a;
}
-}
-
-.loginError {
- color: #db5246;
- background: rgba(219, 82, 70, 0.1);
- padding: 12px 16px;
- border-radius: 6px;
- position: absolute;
- top: 100%;
- margin-top: 16px;
- left: 0;
- right: 0;
span {
- font-weight: 600;
+ color: #134c6b;
+ font-weight: 475;
+ line-height: 24px;
+ }
+
+ code {
+ background: rgba(0, 0, 0, 0.05);
+ font-family: "Roboto Mono Variable", monospace;
+ padding: 2px 4px;
+ border-radius: 6px;
+ font-weight: 500;
+ font-size: 13px;
+ white-space: nowrap;
+ }
+
+ @include darkTheme {
+ background: #233945;
+
+ svg {
+ color: #65a8cb;
+ }
+
+ span {
+ color: #83bbd8;
+ }
+
+ code {
+ background: rgba(255, 255, 255, 0.1);
+ }
}
}
diff --git a/web/src/utils/scram.ts b/web/src/utils/scram.ts
deleted file mode 100644
index 2c107eea..00000000
--- a/web/src/utils/scram.ts
+++ /dev/null
@@ -1,289 +0,0 @@
-// @ts-expect-error No types available
-import * as SHA256 from "hash.js/lib/hash/sha/256";
-// @ts-expect-error No types available
-import * as Hmac from "hash.js/lib/hash/hmac";
-
-import {ProtocolError} from "edgedb";
-
-const AUTH_ENDPOINT = "/auth/token";
-const RAW_NONCE_LENGTH = 18;
-
-export async function SCRAMAuth(
- baseUrl: string,
- username: string,
- password: string
-): Promise {
- const authUrl = baseUrl + AUTH_ENDPOINT;
- const clientNonce = generateNonce();
- const [clientFirst, clientFirstBare] = buildClientFirstMessage(
- clientNonce,
- username
- );
-
- const serverFirstRes = await fetch(authUrl, {
- headers: {
- Authorization: `SCRAM-SHA-256 data=${utf8ToB64(clientFirst)}`,
- },
- });
- if (serverFirstRes.status === 403) {
- throw new Error(`Server doesn't support HTTP SCRAM authentication`);
- }
- const firstAttrs = parseHeaders(serverFirstRes.headers, "WWW-Authenticate");
- if (firstAttrs.size === 0) {
- throw new Error("Invalid credentials");
- }
- if (!firstAttrs.has("sid") || !firstAttrs.has("data")) {
- throw new ProtocolError(
- `server response doesn't contain '${
- !firstAttrs.has("sid") ? "sid" : "data"
- }' attribute`
- );
- }
- const sid = firstAttrs.get("sid")!;
- const serverFirst = b64ToUtf8(firstAttrs.get("data")!);
-
- const [serverNonce, salt, iterCount] = parseServerFirstMessage(serverFirst);
-
- const [clientFinal, expectedServerSig] = buildClientFinalMessage(
- password,
- salt,
- iterCount,
- clientFirstBare,
- serverFirst,
- serverNonce
- );
-
- const serverFinalRes = await fetch(authUrl, {
- headers: {
- Authorization: `SCRAM-SHA-256 sid=${sid}, data=${utf8ToB64(
- clientFinal
- )}`,
- },
- });
- if (!serverFinalRes.ok) {
- throw new Error("Invalid credentials");
- }
- const finalAttrs = parseHeaders(
- serverFinalRes.headers,
- "Authentication-Info",
- false
- );
- if (!firstAttrs.has("sid") || !firstAttrs.has("data")) {
- throw new ProtocolError(
- `server response doesn't contain '${
- !firstAttrs.has("sid") ? "sid" : "data"
- }' attribute`
- );
- }
- if (finalAttrs.get("sid") !== sid) {
- throw new ProtocolError("SCRAM session id does not match");
- }
- const serverFinal = b64ToUtf8(finalAttrs.get("data")!);
-
- const serverSig = parseServerFinalMessage(serverFinal);
-
- if (!serverSig.equals(expectedServerSig)) {
- throw new ProtocolError("server SCRAM proof does not match");
- }
-
- const authToken = await serverFinalRes.text();
-
- return authToken;
-}
-
-function parseHeaders(headers: Headers, headerName: string, checkAlgo = true) {
- const header = headers.get(headerName);
- if (!header) {
- throw new ProtocolError(`response doesn't contain '${headerName}' header`);
- }
- let rawAttrs: string;
- if (checkAlgo) {
- const [algo, ..._rawAttrs] = header.split(" ");
- if (algo !== "SCRAM-SHA-256") {
- throw new ProtocolError(`invalid scram algo '${algo}'`);
- }
- rawAttrs = _rawAttrs.join(" ");
- } else {
- rawAttrs = header;
- }
- return new Map(
- rawAttrs
- ? rawAttrs.split(",").map((attr) => {
- const [key, val] = attr.split("=", 2);
- return [key.trim(), val.trim()];
- })
- : []
- );
-}
-
-function utf8ToB64(str: string): string {
- return Buffer.from(str, "utf8").toString("base64");
-}
-
-function b64ToUtf8(str: string): string {
- return Buffer.from(str, "base64").toString("utf8");
-}
-
-function saslprep(str: string): string {
- return str.normalize("NFKC");
-}
-
-function randomBytes(size: number): Buffer {
- const buf = new Uint8Array(size);
- const rand = window.crypto.getRandomValues(buf);
-
- return Buffer.from(rand);
-}
-
-function H(msg: Buffer): Buffer {
- const hash = new SHA256();
- hash.update(msg);
- return Buffer.from(hash.digest());
-}
-
-function HMAC(key: Buffer, ...msgs: Buffer[]): Buffer {
- const hm = Hmac(SHA256, key);
- for (const msg of msgs) {
- hm.update(msg);
- }
- return Buffer.from(hm.digest());
-}
-
-export function generateNonce(length: number = RAW_NONCE_LENGTH): Buffer {
- return randomBytes(length);
-}
-
-function buildClientFirstMessage(
- clientNonce: Buffer,
- username: string
-): [string, string] {
- const bare = `n=${saslprep(username)},r=${clientNonce.toString("base64")}`;
- return [`n,,${bare}`, bare];
-}
-
-function parseServerFirstMessage(msg: string): [Buffer, Buffer, number] {
- const attrs = msg.split(",");
-
- if (attrs.length < 3) {
- throw new ProtocolError("malformed SCRAM message");
- }
-
- const nonceAttr = attrs[0];
- if (!nonceAttr || nonceAttr[0] !== "r") {
- throw new ProtocolError("malformed SCRAM message");
- }
- const nonceB64 = nonceAttr.split("=", 2)[1];
- if (!nonceB64) {
- throw new ProtocolError("malformed SCRAM message");
- }
- const nonce = Buffer.from(nonceB64, "base64");
-
- const saltAttr = attrs[1];
- if (!saltAttr || saltAttr[0] !== "s") {
- throw new ProtocolError("malformed SCRAM message");
- }
- const saltB64 = saltAttr.split("=", 2)[1];
- if (!saltB64) {
- throw new ProtocolError("malformed SCRAM message");
- }
- const salt = Buffer.from(saltB64, "base64");
-
- const iterAttr = attrs[2];
- if (!iterAttr || iterAttr[0] !== "i") {
- throw new ProtocolError("malformed SCRAM message");
- }
- const iter = iterAttr.split("=", 2)[1];
- if (!iter || !iter.match(/^[0-9]*$/)) {
- throw new ProtocolError("malformed SCRAM message");
- }
- const iterCount = parseInt(iter, 10);
- if (iterCount <= 0) {
- throw new ProtocolError("malformed SCRAM message");
- }
-
- return [nonce, salt, iterCount];
-}
-
-function buildClientFinalMessage(
- password: string,
- salt: Buffer,
- iterations: number,
- clientFirstBare: string,
- serverFirst: string,
- serverNonce: Buffer
-): [string, Buffer] {
- const clientFinal = `c=biws,r=${serverNonce.toString("base64")}`;
- const authMessage = Buffer.from(
- `${clientFirstBare},${serverFirst},${clientFinal}`,
- "utf8"
- );
- const saltedPassword = getSaltedPassword(
- Buffer.from(saslprep(password), "utf8"),
- salt,
- iterations
- );
- const clientKey = getClientKey(saltedPassword);
- const storedKey = H(clientKey);
- const clientSignature = HMAC(storedKey, authMessage);
- const clientProof = XOR(clientKey, clientSignature);
-
- const serverKey = getServerKey(saltedPassword);
- const serverProof = HMAC(serverKey, authMessage);
-
- return [`${clientFinal},p=${clientProof.toString("base64")}`, serverProof];
-}
-
-export function getSaltedPassword(
- password: Buffer,
- salt: Buffer,
- iterations: number
-): Buffer {
- let Hi = HMAC(password, salt, Buffer.from("00000001", "hex"));
- let Ui = Hi;
-
- for (let _ = 0; _ < iterations - 1; _++) {
- Ui = HMAC(password, Ui);
- Hi = XOR(Hi, Ui);
- }
-
- return Hi;
-}
-
-export function getClientKey(saltedPassword: Buffer): Buffer {
- return HMAC(saltedPassword, Buffer.from("Client Key", "utf8"));
-}
-
-export function getServerKey(saltedPassword: Buffer): Buffer {
- return HMAC(saltedPassword, Buffer.from("Server Key", "utf8"));
-}
-
-export function XOR(a: Buffer, b: Buffer): Buffer {
- const len = a.length;
- if (len !== b.length) {
- throw new ProtocolError("scram.XOR: buffers are of different lengths");
- }
- const res = Buffer.allocUnsafe(len);
- for (let i = 0; i < len; i++) {
- res[i] = a[i] ^ b[i];
- }
- return res;
-}
-
-function parseServerFinalMessage(msg: string): Buffer {
- const attrs = msg.split(",");
-
- if (attrs.length < 1) {
- throw new ProtocolError("malformed SCRAM message");
- }
-
- const nonceAttr = attrs[0];
- if (!nonceAttr || nonceAttr[0] !== "v") {
- throw new ProtocolError("malformed SCRAM message");
- }
- const signatureB64 = nonceAttr.split("=", 2)[1];
- if (!signatureB64) {
- throw new ProtocolError("malformed SCRAM message");
- }
- const sig = Buffer.from(signatureB64, "base64");
- return sig;
-}
diff --git a/yarn.lock b/yarn.lock
index ed218d59..f0c5489e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4955,16 +4955,6 @@ __metadata:
languageName: node
linkType: hard
-"hash.js@npm:^1.1.7":
- version: 1.1.7
- resolution: "hash.js@npm:1.1.7"
- dependencies:
- inherits: ^2.0.3
- minimalistic-assert: ^1.0.1
- checksum: e350096e659c62422b85fa508e4b3669017311aa4c49b74f19f8e1bc7f3a54a584fdfd45326d4964d6011f2b2d882e38bea775a96046f2a61b7779a979629d8f
- languageName: node
- linkType: hard
-
"hast-util-to-jsx-runtime@npm:^2.0.0":
version: 2.3.0
resolution: "hast-util-to-jsx-runtime@npm:2.3.0"
@@ -6669,13 +6659,6 @@ __metadata:
languageName: node
linkType: hard
-"minimalistic-assert@npm:^1.0.1":
- version: 1.0.1
- resolution: "minimalistic-assert@npm:1.0.1"
- checksum: cc7974a9268fbf130fb055aff76700d7e2d8be5f761fb5c60318d0ed010d839ab3661a533ad29a5d37653133385204c503bfac995aaa4236f4e847461ea32ba7
- languageName: node
- linkType: hard
-
"minimatch@npm:^3.0.4, minimatch@npm:^3.1.2":
version: 3.1.2
resolution: "minimatch@npm:3.1.2"
@@ -8748,7 +8731,6 @@ __metadata:
eslint-plugin-react-refresh: ^0.4.14
generic-names: ^4.0.0
globals: ^15.11.0
- hash.js: ^1.1.7
jest: ^29.7.0
mobx: ^6.5.0
mobx-keystone: ^1.11.0