From dbd60860e0da317f95c7537f4e909f5513de4772 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 2 May 2026 01:38:00 +0100 Subject: [PATCH 01/16] feat: SCRAM-SHA-256, TOTP 2FA + biometric (WebAuthn) login Adds three new SASL paths plus an enrolment UI for second-factor credentials, all behind a single feature branch. * `src/lib/sasl/scram.ts` - SCRAM-SHA-256 (RFC 7677) using Web Crypto for PBKDF2/HMAC/SHA-256. Picked automatically when the server advertises it; falls back to PLAIN. * `src/lib/sasl/webauthn.ts` - thin `navigator.credentials` wrapper for the DRAFT-WEBAUTHN-BIO mechanism and `2FA ADD webauthn` enrolment. * `src/store/handlers/auth.ts` - per-server SASL session state machine that dispatches AUTHENTICATE messages by mechanism and routes `AUTHENTICATE 2FA-REQUIRED` to the step-up modal. * `2FA` IRC command + `TWOFA` / `TWOFA_NOTE` events for status, listing, enrolment, removal, enable/disable replies from the server. * `TotpStepUpModal` - prompts for the 6-digit code mid-SASL. * `TwoFactorSettingsModal` - status, credential list, TOTP enrol with QR (qrcode dep), WebAuthn biometric enrol via `navigator.credentials. create`, removal, and password-free disable via TOTP proof. * Wired into `EditServerModal` (button visible when the server advertises `draft/account-2fa`). --- package-lock.json | 281 ++++++++++- package.json | 2 + src/App.tsx | 12 + src/components/ui/EditServerModal.tsx | 63 ++- src/components/ui/TotpStepUpModal.tsx | 84 ++++ src/components/ui/TwoFactorSettingsModal.tsx | 371 ++++++++++++++ src/lib/irc/IRCClient.ts | 13 + src/lib/irc/handlers/auth.ts | 33 ++ src/lib/irc/handlers/index.ts | 3 + src/lib/sasl/scram.ts | 191 ++++++++ src/lib/sasl/webauthn.ts | 147 ++++++ src/store/handlers/auth.ts | 490 +++++++++++++------ src/store/index.ts | 86 ++++ src/types/index.ts | 3 + tests/fixtures/uiState.ts | 2 + 15 files changed, 1600 insertions(+), 181 deletions(-) create mode 100644 src/components/ui/TotpStepUpModal.tsx create mode 100644 src/components/ui/TwoFactorSettingsModal.tsx create mode 100644 src/lib/sasl/scram.ts create mode 100644 src/lib/sasl/webauthn.ts diff --git a/package-lock.json b/package-lock.json index 18319fd7..d7fa8785 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@tauri-apps/plugin-notification": "^2.2.2", "@tauri-apps/plugin-opener": "^2.0.0", "@tauri-apps/plugin-os": "^2.2.1", + "@types/qrcode": "^1.5.6", "@types/uuid": "^10.0.0", "buffer": "^6.0.3", "clsx": "^2.1.1", @@ -29,6 +30,7 @@ "html-react-parser": "^5.2.7", "immer": "^11.1.4", "marked": "^16.4.0", + "qrcode": "^1.5.4", "react": "^19.2.5", "react-color": "^2.19.3", "react-dom": "^19.2.5", @@ -2842,12 +2844,20 @@ "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3728,6 +3738,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -3857,6 +3876,63 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cloudflare-video-element": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/cloudflare-video-element/-/cloudflare-video-element-1.3.5.tgz", @@ -4074,6 +4150,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -4114,6 +4199,12 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -4827,6 +4918,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/gh-pages": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", @@ -6424,6 +6524,15 @@ "ce-la-react": "^0.3.2" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -6751,6 +6860,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6986,6 +7112,21 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7147,6 +7288,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -7879,7 +8026,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -8270,6 +8416,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -8425,6 +8577,12 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8432,6 +8590,125 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 95b0eabe..6defab93 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tauri-apps/plugin-notification": "^2.2.2", "@tauri-apps/plugin-opener": "^2.0.0", "@tauri-apps/plugin-os": "^2.2.1", + "@types/qrcode": "^1.5.6", "@types/uuid": "^10.0.0", "buffer": "^6.0.3", "clsx": "^2.1.1", @@ -52,6 +53,7 @@ "html-react-parser": "^5.2.7", "immer": "^11.1.4", "marked": "^16.4.0", + "qrcode": "^1.5.4", "react": "^19.2.5", "react-color": "^2.19.3", "react-dom": "^19.2.5", diff --git a/src/App.tsx b/src/App.tsx index a33a508a..2610c090 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,8 @@ import { EditServerModal } from "./components/ui/EditServerModal"; import LinkSecurityWarningModal from "./components/ui/LinkSecurityWarningModal"; import LoadingOverlay from "./components/ui/LoadingOverlay"; import QuickActions from "./components/ui/QuickActions"; +import { TotpStepUpModal } from "./components/ui/TotpStepUpModal"; +import { TwoFactorSettingsModal } from "./components/ui/TwoFactorSettingsModal"; import UserProfileModal from "./components/ui/UserProfileModal"; import UserSettings from "./components/ui/UserSettings"; import { useChannelTabSwitching } from "./hooks/useChannelTabSwitching"; @@ -80,6 +82,7 @@ const App: React.FC = () => { toggleAddServerModal, toggleEditServerModal, toggleQuickActions, + toggleTwoFactorSettings, ui: { isAddServerModalOpen, isChannelListModalOpen, @@ -88,7 +91,9 @@ const App: React.FC = () => { isSettingsModalOpen, isQuickActionsOpen, isUserProfileModalOpen, + isTwoFactorSettingsOpen, editServerId, + twoFactorSettingsServerId, linkSecurityWarnings, profileViewRequest, prefillServerDetails, @@ -306,6 +311,13 @@ const App: React.FC = () => { onClose={() => toggleEditServerModal(false)} /> )} + {isTwoFactorSettingsOpen && twoFactorSettingsServerId && ( + toggleTwoFactorSettings(false)} + /> + )} + {isSettingsModalOpen && } {isQuickActionsOpen && } {isChannelListModalOpen && } diff --git a/src/components/ui/EditServerModal.tsx b/src/components/ui/EditServerModal.tsx index e824a786..1f8a0c16 100644 --- a/src/components/ui/EditServerModal.tsx +++ b/src/components/ui/EditServerModal.tsx @@ -247,29 +247,48 @@ export const EditServerModal: React.FC = ({ )} {showAccount && ( -
-
- - setSaslAccountName(e.target.value)} - placeholder="SASL Account Name" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" - /> -
-
-
+ {server?.capabilities?.some((c) => + c.startsWith("draft/account-2fa"), + ) && ( +
+ +
+ )} + )} {/* IRC Operator Section */} diff --git a/src/components/ui/TotpStepUpModal.tsx b/src/components/ui/TotpStepUpModal.tsx new file mode 100644 index 00000000..da0acb0f --- /dev/null +++ b/src/components/ui/TotpStepUpModal.tsx @@ -0,0 +1,84 @@ +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import useStore from "../../store"; + +export const TotpStepUpModal: React.FC = () => { + const pending = useStore((s) => s.pendingTotpStepUp); + const submitTotpStepUp = useStore((s) => s.submitTotpStepUp); + const cancelTotpStepUp = useStore((s) => s.cancelTotpStepUp); + + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (pending) { + setCode(""); + setError(null); + // Autofocus once the modal mounts. + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [pending]); + + if (!pending) return null; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = code.trim(); + if (!/^\d{6}$/.test(trimmed)) { + setError("Enter the 6-digit code from your authenticator app."); + return; + } + submitTotpStepUp(pending.serverId, trimmed); + }; + + return ( +
+
+

+ Two-factor authentication required +

+

+ Enter the 6-digit code from your authenticator app to finish signing + in + {pending.account ? ` as ${pending.account}` : ""}. +

+
+ { + setCode(e.target.value.replace(/\D/g, "")); + setError(null); + }} + className="w-full px-3 py-2 rounded bg-discord-dark-300 text-white tracking-[0.4em] text-center text-xl font-mono focus:outline-none focus:ring-2 focus:ring-discord-blue" + placeholder="000000" + /> + {error &&

{error}

} +
+ + +
+
+
+
+ ); +}; + +export default TotpStepUpModal; diff --git a/src/components/ui/TwoFactorSettingsModal.tsx b/src/components/ui/TwoFactorSettingsModal.tsx new file mode 100644 index 00000000..1cd65e01 --- /dev/null +++ b/src/components/ui/TwoFactorSettingsModal.tsx @@ -0,0 +1,371 @@ +import QRCode from "qrcode"; +import type React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { + b64StdDecode, + bytesToB64Std, + isWebAuthnAvailable, + webauthnRegister, +} from "../../lib/sasl/webauthn"; +import useStore from "../../store"; + +const TYPE_LABELS: Record = { + totp: "Authenticator app (TOTP)", + webauthn: "Biometric / security key", +}; + +function decodeChallenge(blob: string): unknown { + try { + return JSON.parse(new TextDecoder().decode(b64StdDecode(blob))); + } catch { + return null; + } +} + +interface Props { + serverId: string; + onClose: () => void; +} + +export const TwoFactorSettingsModal: React.FC = ({ + serverId, + onClose, +}) => { + const status = useStore((s) => s.twofaStatus[serverId] ?? "unknown") as + | "enabled" + | "disabled" + | "unknown"; + const credentials = useStore((s) => s.twofaCredentials[serverId] ?? []); + const challenge = useStore((s) => s.pendingTwofaChallenge); + const server = useStore((s) => s.servers.find((srv) => srv.id === serverId)); + + const twofaStatusQuery = useStore((s) => s.twofaStatusQuery); + const twofaListQuery = useStore((s) => s.twofaListQuery); + const twofaChallenge = useStore((s) => s.twofaChallenge); + const twofaAdd = useStore((s) => s.twofaAdd); + const twofaRemove = useStore((s) => s.twofaRemove); + const twofaEnable = useStore((s) => s.twofaEnable); + const twofaDisable = useStore((s) => s.twofaDisable); + + const supportsWebAuthn = + server?.capabilities?.some( + (c) => c.startsWith("draft/account-2fa") && c.includes("webauthn"), + ) ?? false; + + const [enrollName, setEnrollName] = useState(""); + const [enrollCode, setEnrollCode] = useState(""); + const [enrollError, setEnrollError] = useState(null); + const [enrollBusy, setEnrollBusy] = useState(false); + const [disableCode, setDisableCode] = useState(""); + const [qrSvg, setQrSvg] = useState(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store actions have unstable refs + useEffect(() => { + twofaStatusQuery(serverId); + twofaListQuery(serverId); + }, [serverId]); + + // Render QR for the active TOTP enrolment challenge. + useEffect(() => { + if ( + !challenge || + challenge.serverId !== serverId || + challenge.type !== "totp" + ) { + setQrSvg(null); + return; + } + const decoded = decodeChallenge(challenge.blob); + const uri = + decoded && + typeof decoded === "object" && + decoded !== null && + "uri" in decoded && + typeof (decoded as { uri: unknown }).uri === "string" + ? (decoded as { uri: string }).uri + : null; + if (!uri) { + setQrSvg(null); + return; + } + QRCode.toString(uri, { type: "svg", margin: 2, width: 220 }) + .then(setQrSvg) + .catch(() => setQrSvg(null)); + }, [challenge, serverId]); + + const onAddTotp = () => { + setEnrollError(null); + setEnrollName(""); + setEnrollCode(""); + twofaChallenge(serverId, "totp"); + }; + + const onAddWebAuthn = async () => { + if (!isWebAuthnAvailable()) { + setEnrollError("WebAuthn is not available in this environment."); + return; + } + setEnrollError(null); + twofaChallenge(serverId, "webauthn"); + }; + + // When a webauthn challenge arrives, run the create() ceremony immediately. + // biome-ignore lint/correctness/useExhaustiveDependencies: store actions have unstable refs + useEffect(() => { + if ( + !challenge || + challenge.serverId !== serverId || + challenge.type !== "webauthn" || + enrollBusy + ) + return; + const decoded = decodeChallenge(challenge.blob); + if (!decoded || typeof decoded !== "object") { + setEnrollError("Server returned an invalid WebAuthn challenge."); + return; + } + const name = enrollName.trim() || `Device-${Date.now()}`; + setEnrollBusy(true); + webauthnRegister(decoded as Parameters[0]) + .then((result) => { + const payload = bytesToB64Std( + new TextEncoder().encode(JSON.stringify(result)), + ); + twofaAdd(serverId, "webauthn", name, payload); + setEnrollBusy(false); + }) + .catch((err) => { + setEnrollError(err instanceof Error ? err.message : String(err)); + setEnrollBusy(false); + }); + }, [challenge?.blob, challenge?.type, challenge?.serverId, serverId]); + + const onSubmitTotp = (e: React.FormEvent) => { + e.preventDefault(); + setEnrollError(null); + const name = enrollName.trim(); + if (!name || /\s/.test(name)) { + setEnrollError("Pick a single-word name (no spaces)."); + return; + } + if (!/^\d{6}$/.test(enrollCode.trim())) { + setEnrollError("Enter the 6-digit code from your authenticator app."); + return; + } + twofaAdd(serverId, "totp", name, enrollCode.trim()); + setEnrollName(""); + setEnrollCode(""); + }; + + const onDisable = () => { + setEnrollError(null); + if (!/^\d{6}$/.test(disableCode.trim())) { + setEnrollError( + "Enter a 6-digit code from any of your authenticator apps to disable 2FA.", + ); + return; + } + twofaDisable(serverId, "totp", disableCode.trim()); + setDisableCode(""); + }; + + const inflightTotp = useMemo( + () => + challenge?.serverId === serverId && challenge?.type === "totp" && !!qrSvg, + [challenge, qrSvg, serverId], + ); + + return ( +
+
+
+

+ Two-factor authentication +

+ +
+ +
+
+ Status:{" "} + + {status === "enabled" + ? "Enabled" + : status === "disabled" + ? "Disabled" + : "Loading…"} + +
+
+ +

+ Registered credentials +

+ {credentials.length === 0 ? ( +

+ You have no 2FA credentials registered. +

+ ) : ( +
    + {credentials.map((c) => ( +
  • +
    +
    {c.name}
    +
    + {TYPE_LABELS[c.type] ?? c.type} + {c.createdAt ? ` · added ${c.createdAt}` : ""} +
    +
    + +
  • + ))} +
+ )} + +
+ + +
+ + {inflightTotp && qrSvg && ( +
+

+ Scan this QR code with your authenticator app, then confirm with + the 6-digit code it shows. +

+
+
+ setEnrollName(e.target.value)} + className="w-full px-3 py-2 rounded bg-discord-dark-200 text-white" + /> + + setEnrollCode(e.target.value.replace(/\D/g, "")) + } + className="w-full px-3 py-2 rounded bg-discord-dark-200 text-white tracking-[0.4em] text-center font-mono" + /> + +
+ + )} + + {enrollError && ( +

{enrollError}

+ )} + {enrollBusy && ( +

+ Waiting for biometric prompt… +

+ )} + +
+ + {status === "enabled" ? ( +
+

+ Disable 2FA. Enter a 6-digit code from any of your registered + authenticator apps to confirm. +

+
+ + setDisableCode(e.target.value.replace(/\D/g, "")) + } + className="flex-1 px-3 py-2 rounded bg-discord-dark-200 text-white tracking-[0.4em] text-center font-mono" + /> + +
+
+ ) : ( + + )} +
+
+ ); +}; + +export default TwoFactorSettingsModal; diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index 5b1d500e..aa6a0173 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -245,6 +245,15 @@ export interface EventMap { serviceName: string; jwtToken: string; }; + TWOFA: EventWithTags & { + subcommand: string; + status: string; + args: string[]; + }; + TWOFA_NOTE: EventWithTags & { + code: string; + args: string[]; + }; WHOIS_BOT: { serverId: string; nick: string; @@ -1356,6 +1365,10 @@ export class IRCClient implements IRCClientContext { return this.capNegotiationComplete.get(serverId) ?? false; } + getSaslMechanisms(serverId: string): string[] { + return this.saslMechanisms.get(serverId) ?? []; + } + getNick(serverId: string): string | undefined { return this.nicks.get(serverId); } diff --git a/src/lib/irc/handlers/auth.ts b/src/lib/irc/handlers/auth.ts index 2d493236..4fa9e892 100644 --- a/src/lib/irc/handlers/auth.ts +++ b/src/lib/irc/handlers/auth.ts @@ -72,6 +72,14 @@ export function handleNote( target, message, }); + if (cmd === "2FA") { + ctx.triggerEvent("TWOFA_NOTE", { + serverId, + mtags, + code, + args: parv.slice(2), + }); + } } export function handleSuccess( @@ -135,6 +143,31 @@ export function handleVerify( // original code only handles VERIFY SUCCESS with local variables, fires no event } +// `:server 2FA [SUCCESS] [arg ...] :description` +// Examples: +// :server 2FA ADD SUCCESS totp cred-1 :Credential 'Phone' registered. +// :server 2FA REMOVE SUCCESS cred-1 :... +// :server 2FA ENABLE SUCCESS :... +// :server 2FA DISABLE SUCCESS :... +export function handleTwoFA( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + mtags: Record | undefined, +): void { + const subcommand = parv[0]; + const status = parv[1]; + const args = parv.slice(2); + ctx.triggerEvent("TWOFA", { + serverId, + mtags, + subcommand, + status, + args, + }); +} + export function handleExtjwt( ctx: IRCClientContext, serverId: string, diff --git a/src/lib/irc/handlers/index.ts b/src/lib/irc/handlers/index.ts index 15aaf834..48e69c1f 100644 --- a/src/lib/irc/handlers/index.ts +++ b/src/lib/irc/handlers/index.ts @@ -6,6 +6,7 @@ import { handleNote, handleRegister, handleSuccess, + handleTwoFA, handleVerify, handleWarn, } from "./auth"; @@ -286,6 +287,8 @@ export const IRC_DISPATCH: Record = { handleRegister(ctx, serverId, source, parv, mtags), VERIFY: (ctx, serverId, source, parv, mtags) => handleVerify(ctx, serverId, source, parv, mtags), + "2FA": (ctx, serverId, source, parv, mtags) => + handleTwoFA(ctx, serverId, source, parv, mtags), EXTJWT: (ctx, serverId, source, parv, mtags) => handleExtjwt(ctx, serverId, source, parv, mtags), diff --git a/src/lib/sasl/scram.ts b/src/lib/sasl/scram.ts new file mode 100644 index 00000000..df1c036d --- /dev/null +++ b/src/lib/sasl/scram.ts @@ -0,0 +1,191 @@ +// SCRAM-SHA-256 (RFC 7677) client. Uses Web Crypto: works in browsers, +// Tauri WebView (WebKit/WebView2), and node (vitest provides crypto.subtle). + +import { Buffer } from "buffer"; + +const ENC = new TextEncoder(); +const DEC = new TextDecoder(); + +function bytesToB64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} + +function b64ToBytes(b64: string): Uint8Array { + return new Uint8Array(Buffer.from(b64, "base64")); +} + +function strToBytes(s: string): Uint8Array { + return ENC.encode(s); +} + +function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length); + for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i]; + return out; +} + +// SCRAM allows '=' and ',' inside usernames only when escaped as =3D / =2C. +function escapeUsername(u: string): string { + return u.replace(/=/g, "=3D").replace(/,/g, "=2C"); +} + +// Random nonce: alnum-only to keep it printable & comma-free. +function randomNonce(bytes = 18): string { + const buf = new Uint8Array(bytes); + crypto.getRandomValues(buf); + const alpha = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let s = ""; + for (const b of buf) s += alpha[b % alpha.length]; + return s; +} + +async function hmacSha256( + key: Uint8Array, + data: Uint8Array, +): Promise { + const k = await crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", k, data); + return new Uint8Array(sig); +} + +async function sha256(data: Uint8Array): Promise { + const out = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(out); +} + +async function pbkdf2Sha256( + password: string, + salt: Uint8Array, + iterations: number, +): Promise { + const k = await crypto.subtle.importKey( + "raw", + strToBytes(password), + "PBKDF2", + false, + ["deriveBits"], + ); + const bits = await crypto.subtle.deriveBits( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + k, + 256, + ); + return new Uint8Array(bits); +} + +export interface ScramState { + username: string; + password: string; + clientNonce: string; + clientFirstBare: string; + serverFirst?: string; + combinedNonce?: string; + serverSignature?: Uint8Array; +} + +// Build the GS2 header + client-first-message-bare and remember the bare part +// so we can build AuthMessage in step 2. +export function scramStart( + username: string, + password: string, +): { + state: ScramState; + message: string; +} { + const cnonce = randomNonce(); + const bare = `n=${escapeUsername(username)},r=${cnonce}`; + const message = `n,,${bare}`; + return { + state: { + username, + password, + clientNonce: cnonce, + clientFirstBare: bare, + }, + message, + }; +} + +function parseAttrs(s: string): Record { + const out: Record = {}; + for (const pair of s.split(",")) { + const eq = pair.indexOf("="); + if (eq <= 0) continue; + const k = pair.slice(0, eq); + const v = pair.slice(eq + 1); + out[k] = v; + } + return out; +} + +// Consume server-first, produce client-final. Stores ServerSignature in +// state so the caller can verify server-final later. +export async function scramFinal( + state: ScramState, + serverFirst: string, +): Promise { + const attrs = parseAttrs(serverFirst); + const r = attrs.r; + const s = attrs.s; + const i = attrs.i; + if (!r || !s || !i) throw new Error("SCRAM: malformed server-first"); + if (!r.startsWith(state.clientNonce)) + throw new Error("SCRAM: server nonce does not extend client nonce"); + + const salt = b64ToBytes(s); + const iterations = Number.parseInt(i, 10); + if (!Number.isFinite(iterations) || iterations < 1) + throw new Error("SCRAM: bad iteration count"); + + state.serverFirst = serverFirst; + state.combinedNonce = r; + + // c=biws is base64("n,,") -- the GS2 header we sent in client-first. + const clientFinalNoProof = `c=biws,r=${r}`; + const authMessage = `${state.clientFirstBare},${serverFirst},${clientFinalNoProof}`; + const authMessageBytes = strToBytes(authMessage); + + const saltedPassword = await pbkdf2Sha256(state.password, salt, iterations); + const clientKey = await hmacSha256(saltedPassword, strToBytes("Client Key")); + const storedKey = await sha256(clientKey); + const clientSignature = await hmacSha256(storedKey, authMessageBytes); + const clientProof = xorBytes(clientKey, clientSignature); + + const serverKey = await hmacSha256(saltedPassword, strToBytes("Server Key")); + state.serverSignature = await hmacSha256(serverKey, authMessageBytes); + + return `${clientFinalNoProof},p=${bytesToB64(clientProof)}`; +} + +// Verify server-final's v= against our stored ServerSignature. +export function scramVerifyServerFinal( + state: ScramState, + serverFinal: string, +): boolean { + const attrs = parseAttrs(serverFinal); + const v = attrs.v; + if (!v || !state.serverSignature) return false; + const got = b64ToBytes(v); + if (got.length !== state.serverSignature.length) return false; + let diff = 0; + for (let i = 0; i < got.length; i++) + diff |= got[i] ^ state.serverSignature[i]; + return diff === 0; +} + +// Encode/decode the SASL chunk wire format (Base64 standard, padded). +export const sasl = { + encodeUtf8(s: string): string { + return Buffer.from(s, "utf8").toString("base64"); + }, + decodeUtf8(b64: string): string { + return DEC.decode(b64ToBytes(b64)); + }, +}; diff --git a/src/lib/sasl/webauthn.ts b/src/lib/sasl/webauthn.ts new file mode 100644 index 00000000..10745da1 --- /dev/null +++ b/src/lib/sasl/webauthn.ts @@ -0,0 +1,147 @@ +// WebAuthn helpers for the DRAFT-WEBAUTHN-BIO mechanism and 2FA enrolment. +// Uses the platform `navigator.credentials` API; works in modern browsers +// and Tauri's WebView wrappers (WebKit2GTK, WKWebView, WebView2). + +import { Buffer } from "buffer"; + +// SASL transport always uses standard Base64 (padded); the byte fields +// inside our JSON payloads use Base64url (no padding) to match what +// browser-side WebAuthn returns. +export function b64uEncode(buf: ArrayBuffer | Uint8Array): string { + const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + return Buffer.from(bytes) + .toString("base64") + .replace(/=+$/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +export function b64uDecode(s: string): Uint8Array { + const padded = s.replace(/-/g, "+").replace(/_/g, "/"); + const pad = padded.length % 4 ? 4 - (padded.length % 4) : 0; + return new Uint8Array(Buffer.from(padded + "=".repeat(pad), "base64")); +} + +export function bytesToB64Std(buf: ArrayBuffer | Uint8Array): string { + const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + return Buffer.from(bytes).toString("base64"); +} + +export function b64StdDecode(s: string): Uint8Array { + return new Uint8Array(Buffer.from(s, "base64")); +} + +// True if WebAuthn is usable in this runtime. +export function isWebAuthnAvailable(): boolean { + return ( + typeof navigator !== "undefined" && + typeof navigator.credentials !== "undefined" && + typeof PublicKeyCredential !== "undefined" && + typeof navigator.credentials.create === "function" && + typeof navigator.credentials.get === "function" + ); +} + +// Server-issued enrolment challenge (decoded JSON from +// NOTE 2FA REGISTRATION_CHALLENGE webauthn ). +interface CreationOptionsBlob { + challenge: string; // base64url + rpId: string; + rpName?: string; + userId: string; // base64url, opaque per-account handle + userName: string; + userVerification: "required" | "preferred" | "discouraged"; + pubKeyCredParams?: PublicKeyCredentialParameters[]; +} + +// Run the WebAuthn create() ceremony with the server-issued options and +// return the JSON the server expects in `2FA ADD webauthn `. +export async function webauthnRegister( + blob: CreationOptionsBlob, +): Promise<{ clientDataJSON: string; attestationObject: string }> { + if (!isWebAuthnAvailable()) + throw new Error("WebAuthn is not available in this environment"); + + const params: PublicKeyCredentialParameters[] = + blob.pubKeyCredParams && blob.pubKeyCredParams.length > 0 + ? blob.pubKeyCredParams + : [{ type: "public-key", alg: -7 }]; + + const cred = (await navigator.credentials.create({ + publicKey: { + challenge: b64uDecode(blob.challenge), + rp: { id: blob.rpId, name: blob.rpName ?? blob.rpId }, + user: { + id: b64uDecode(blob.userId), + name: blob.userName, + displayName: blob.userName, + }, + pubKeyCredParams: params, + authenticatorSelection: { + userVerification: blob.userVerification, + residentKey: "preferred", + requireResidentKey: false, + }, + timeout: 60_000, + attestation: "none", + }, + })) as PublicKeyCredential | null; + + if (!cred) throw new Error("WebAuthn create() returned null"); + + const r = cred.response as AuthenticatorAttestationResponse; + return { + clientDataJSON: b64uEncode(r.clientDataJSON), + attestationObject: b64uEncode(r.attestationObject), + }; +} + +// Server-issued challenge for SASL login (decoded from the AUTHENTICATE +// step-4 message), as PublicKeyCredentialRequestOptions-shaped JSON. +interface RequestOptionsBlob { + version?: number; + challenge: string; // base64url + rpId: string; + timeout?: number; + userVerification: "required" | "preferred" | "discouraged"; + allowCredentials?: { type: "public-key"; id: string }[]; +} + +// Run the WebAuthn get() ceremony and return the assertion JSON the +// server expects on the wire. +export async function webauthnAssert(blob: RequestOptionsBlob): Promise<{ + credentialId: string; + authenticatorData: string; + clientDataJSON: string; + signature: string; + userHandle?: string; +}> { + if (!isWebAuthnAvailable()) + throw new Error("WebAuthn is not available in this environment"); + + const allow = (blob.allowCredentials ?? []).map((c) => ({ + type: "public-key" as const, + id: b64uDecode(c.id), + })); + + const cred = (await navigator.credentials.get({ + publicKey: { + challenge: b64uDecode(blob.challenge), + rpId: blob.rpId, + timeout: blob.timeout ?? 60_000, + userVerification: blob.userVerification, + allowCredentials: allow.length > 0 ? allow : undefined, + }, + })) as PublicKeyCredential | null; + + if (!cred) throw new Error("WebAuthn get() returned null"); + + const r = cred.response as AuthenticatorAssertionResponse; + return { + credentialId: b64uEncode(cred.rawId), + authenticatorData: b64uEncode(r.authenticatorData), + clientDataJSON: b64uEncode(r.clientDataJSON), + signature: b64uEncode(r.signature), + userHandle: r.userHandle ? b64uEncode(r.userHandle) : undefined, + }; +} diff --git a/src/store/handlers/auth.ts b/src/store/handlers/auth.ts index d383166c..b1062885 100644 --- a/src/store/handlers/auth.ts +++ b/src/store/handlers/auth.ts @@ -1,11 +1,70 @@ import { v4 as uuidv4 } from "uuid"; import type { StoreApi } from "zustand"; import ircClient from "../../lib/ircClient"; +import { + type ScramState, + sasl as saslChunk, + scramFinal, + scramStart, + scramVerifyServerFinal, +} from "../../lib/sasl/scram"; +import { + b64StdDecode, + bytesToB64Std, + isWebAuthnAvailable, + webauthnAssert, +} from "../../lib/sasl/webauthn"; import type { Message } from "../../types"; import { normalizeHost } from "../helpers"; import type { AppState } from "../index"; import * as storage from "../localStorage"; +type SaslMech = "PLAIN" | "SCRAM-SHA-256" | "DRAFT-WEBAUTHN-BIO"; + +interface SaslSession { + mech: SaslMech; + username: string; + password?: string; + scram?: ScramState; + step: number; +} + +const sessions = new Map(); + +function chooseMechanism( + available: string[], + pref: "auto" | "PLAIN" | "SCRAM-SHA-256" | "DRAFT-WEBAUTHN-BIO" | undefined, +): SaslMech { + if (pref === "DRAFT-WEBAUTHN-BIO" && available.includes("DRAFT-WEBAUTHN-BIO")) + return "DRAFT-WEBAUTHN-BIO"; + if (pref === "PLAIN") return "PLAIN"; + if (pref === "SCRAM-SHA-256" && available.includes("SCRAM-SHA-256")) + return "SCRAM-SHA-256"; + // auto: prefer SCRAM-SHA-256 over PLAIN. + if (available.includes("SCRAM-SHA-256")) return "SCRAM-SHA-256"; + return "PLAIN"; +} + +function loadCreds( + serverId: string, +): { user: string; pass: string; mech: SaslMech } | null { + const servers = storage.servers.load(); + const serv = servers.find((s) => s.id === serverId); + if (!serv?.saslEnabled) return null; + const user = serv.saslAccountName?.length + ? serv.saslAccountName + : serv.nickname; + const pass = serv.saslPassword ? atob(serv.saslPassword) : undefined; + if (!user || !pass) return null; + const available = ircClient.getSaslMechanisms(serverId); + const mech = chooseMechanism(available, serv.saslMechanism); + return { user, pass, mech }; +} + +function clearSession(serverId: string) { + sessions.delete(serverId); +} + export function registerAuthHandlers(store: StoreApi): void { ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { if (capabilities?.startsWith("draft/metadata")) { @@ -24,84 +83,235 @@ export function registerAuthHandlers(store: StoreApi): void { ]; store.getState().metadataSub(serverId, defaultKeys); } - - // Note: Metadata restoration/sending is now handled in the "ready" event - // to ensure the server is ready to receive METADATA commands } - if (key === "sasl") { - const servers = storage.servers.load(); - for (const serv of servers) { - if (serv.id !== serverId) continue; + if (key !== "sasl") return; - if (!serv.saslEnabled) return; - } - ircClient.sendRaw(serverId, "AUTHENTICATE PLAIN"); - } + // Pick mechanism up-front so the AUTHENTICATE event handler knows what + // to do when the server says "+". + const servers = storage.servers.load(); + const serv = servers.find((s) => s.id === serverId); + if (!serv?.saslEnabled) return; + + const available = ircClient.getSaslMechanisms(serverId); + const mech = chooseMechanism(available, serv.saslMechanism); + const username = serv.saslAccountName?.length + ? serv.saslAccountName + : serv.nickname; + const password = serv.saslPassword ? atob(serv.saslPassword) : undefined; + + sessions.set(serverId, { + mech, + username, + password, + step: 0, + }); + ircClient.sendRaw(serverId, `AUTHENTICATE ${mech}`); }); - ircClient.on("AUTHENTICATE", ({ serverId, param }) => { - if (param !== "+") return; - - // Don't respond to AUTHENTICATE if CAP negotiation is already complete + ircClient.on("AUTHENTICATE", async ({ serverId, param }) => { if (ircClient.isCapNegotiationComplete(serverId)) return; - let user: string | undefined; - let pass: string | undefined; - const servers = storage.servers.load(); - for (const serv of servers) { - if (serv.id !== serverId) continue; + // Synthetic step-up signal from the server (draft/account-2fa). + if (param === "2FA-REQUIRED") { + const session = sessions.get(serverId); + const acct = session?.username ?? ""; + store.setState({ pendingTotpStepUp: { serverId, account: acct } }); + return; + } + + const session = sessions.get(serverId); + if (!session) { + // Either no SASL is in flight or a fresh PLAIN exchange started before + // our session was set up. Fall back to the legacy PLAIN behaviour so + // older test fixtures still work. + if (param !== "+") return; + const creds = loadCreds(serverId); + if (!creds || creds.mech !== "PLAIN") return; + ircClient.sendRaw( + serverId, + `AUTHENTICATE ${btoa(`${creds.user}\x00${creds.user}\x00${creds.pass}`)}`, + ); + return; + } + + try { + if (session.mech === "PLAIN") { + if (param !== "+") return; + if (!session.password) return; + ircClient.sendRaw( + serverId, + `AUTHENTICATE ${btoa(`${session.username}\x00${session.username}\x00${session.password}`)}`, + ); + return; + } + + if (session.mech === "SCRAM-SHA-256") { + if (session.step === 0 && param === "+") { + if (!session.password) return; + const { state, message } = scramStart( + session.username, + session.password, + ); + session.scram = state; + session.step = 1; + ircClient.sendRaw( + serverId, + `AUTHENTICATE ${saslChunk.encodeUtf8(message)}`, + ); + return; + } + if (session.step === 1 && session.scram) { + const serverFirst = saslChunk.decodeUtf8(param); + const clientFinal = await scramFinal(session.scram, serverFirst); + session.step = 2; + ircClient.sendRaw( + serverId, + `AUTHENTICATE ${saslChunk.encodeUtf8(clientFinal)}`, + ); + return; + } + if (session.step === 2 && session.scram) { + const serverFinal = saslChunk.decodeUtf8(param); + const ok = scramVerifyServerFinal(session.scram, serverFinal); + if (!ok) { + ircClient.sendRaw(serverId, "AUTHENTICATE *"); + } else { + // Acknowledge by sending an empty client message; the server + // will then issue 900/903 (or AUTHENTICATE 2FA-REQUIRED). + ircClient.sendRaw(serverId, "AUTHENTICATE +"); + } + session.step = 3; + return; + } + return; + } - if (!serv.saslEnabled) return; + if (session.mech === "DRAFT-WEBAUTHN-BIO") { + if (session.step === 0 && param === "+") { + // Send hello identifying the account; the server will reply with + // a challenge JSON in the next AUTHENTICATE message. + const hello = JSON.stringify({ username: session.username }); + session.step = 1; + ircClient.sendRaw(serverId, `AUTHENTICATE ${btoa(hello)}`); + return; + } + if (session.step === 1) { + if (!isWebAuthnAvailable()) { + ircClient.sendRaw(serverId, "AUTHENTICATE *"); + return; + } + const challengeJson = JSON.parse( + new TextDecoder().decode(b64StdDecode(param)), + ); + const assertion = await webauthnAssert(challengeJson); + const reply = JSON.stringify(assertion); + session.step = 2; + ircClient.sendRaw( + serverId, + `AUTHENTICATE ${bytesToB64Std(new TextEncoder().encode(reply))}`, + ); + return; + } + return; + } + } catch (err) { + console.error("[SASL] error:", err); + ircClient.sendRaw(serverId, "AUTHENTICATE *"); + clearSession(serverId); + } + }); - user = serv.saslAccountName?.length - ? serv.saslAccountName - : serv.nickname; - pass = serv.saslPassword ? atob(serv.saslPassword) : undefined; + // 2FA replies: server uses NOTE 2FA :. + // `args` here is everything after the code; the trailing description is + // the LAST entry (parsed by the IRC layer as a single trailing param). + ircClient.on("TWOFA_NOTE", ({ serverId, code, args }) => { + // args[0..len-2] are positional, args[len-1] is the description. + const positional = args.slice(0, Math.max(0, args.length - 1)); + if (code === "ENABLED") { + store.setState((s) => ({ + twofaStatus: { ...s.twofaStatus, [serverId]: "enabled" }, + })); + } else if (code === "DISABLED") { + store.setState((s) => ({ + twofaStatus: { ...s.twofaStatus, [serverId]: "disabled" }, + })); + } else if (code === "REGISTRATION_CHALLENGE") { + const type = positional[0] ?? ""; + const blob = positional[1] ?? ""; + store.setState({ + pendingTwofaChallenge: { serverId, type, blob }, + }); + } else if (code === "CREDENTIAL") { + const id = positional[0] ?? ""; + const credType = positional[1] ?? ""; + const name = positional[2] ?? ""; + const ts = positional[3] ?? ""; + store.setState((s) => ({ + twofaCredentials: { + ...s.twofaCredentials, + [serverId]: [ + ...(s.twofaCredentials[serverId] ?? []), + { id, type: credType, name, createdAt: ts }, + ], + }, + })); + } else if (code === "NO_CREDENTIALS") { + store.setState((s) => ({ + twofaCredentials: { ...s.twofaCredentials, [serverId]: [] }, + })); } - if (!user || !pass) - // wtf happened lol - return; + }); - ircClient.sendRaw( - serverId, - `AUTHENTICATE ${btoa(`${user}\x00${user}\x00${pass}`)}`, - ); - // Note: CAP END will be sent by the IRC client when SASL authentication completes (903/904-907 responses) - // ircClient.sendRaw(serverId, "CAP END"); - // ircClient.userOnConnect(serverId); + // `2FA SUCCESS ...` lands in the dedicated TWOFA event. + ircClient.on("TWOFA", ({ serverId, subcommand, status, args }) => { + if (status !== "SUCCESS") return; + if (subcommand === "ADD") { + // Args: : + // We don't know the name from the success line alone; the LIST query + // below refreshes the table. + store.getState().twofaListQuery(serverId); + store.setState({ pendingTwofaChallenge: null }); + } else if (subcommand === "REMOVE") { + const id = args[0] ?? ""; + store.setState((s) => ({ + twofaCredentials: { + ...s.twofaCredentials, + [serverId]: (s.twofaCredentials[serverId] ?? []).filter( + (c) => c.id !== id, + ), + }, + })); + } else if (subcommand === "ENABLE") { + store.setState((s) => ({ + twofaStatus: { ...s.twofaStatus, [serverId]: "enabled" }, + })); + } else if (subcommand === "DISABLE") { + store.setState((s) => ({ + twofaStatus: { ...s.twofaStatus, [serverId]: "disabled" }, + })); + } }); // Handle CAP LS to get informational capabilities like unrealircd.org/link-security ircClient.on("CAP LS", ({ serverId, cliCaps }) => { - // Parse link-security from CAP LS (informational capability) if (cliCaps.includes("unrealircd.org/link-security=")) { const match = cliCaps.match(/unrealircd\.org\/link-security=(\d+)/); if (match) { const linkSecurityValue = Number.parseInt(match[1], 10) || 0; - - // Update server with link security value store.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - linkSecurity: linkSecurityValue, - }; - } - return server; - }); - + const updatedServers = state.servers.map((server) => + server.id === serverId + ? { ...server, linkSecurity: linkSecurityValue } + : server, + ); return { servers: updatedServers }; }); - // Show warning modal for low UnrealIRCd link-security value const currentState = store.getState(); const currentServer = currentState.servers.find( (s) => s.id === serverId, ); const hasLowLinkSecurity = linkSecurityValue < 2; - - // Check if we should show warning based on individual skip preferences const savedServers = storage.servers.load(); const serverConfig = currentServer ? savedServers.find( @@ -110,20 +320,15 @@ export function registerAuthHandlers(store: StoreApi): void { s.port === currentServer.port, ) : undefined; - const shouldWarnLinkSecurity = hasLowLinkSecurity && !serverConfig?.skipLinkSecurityWarning; if (shouldWarnLinkSecurity) { store.setState((state) => { - // Check if warning already exists for this server const existingWarning = state.ui.linkSecurityWarnings.find( (w) => w.serverId === serverId, ); - if (existingWarning) { - return state; // Don't add duplicate warning - } - + if (existingWarning) return state; return { ui: { ...state.ui, @@ -146,11 +351,9 @@ export function registerAuthHandlers(store: StoreApi): void { const tok = cap.split("="); const capName = tok[0]; const capValue = tok[1]; - ircClient.capAck(serverId, capName, capValue ?? null); } - // Update server capabilities in store (merge, don't overwrite) store.setState((state) => { const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -158,28 +361,20 @@ export function registerAuthHandlers(store: StoreApi): void { const newCaps = cliCaps.split(" "); const merged = [...existing]; for (const cap of newCaps) { - if (!merged.includes(cap)) { - merged.push(cap); - } + if (!merged.includes(cap)) merged.push(cap); } - return { - ...server, - capabilities: merged, - }; + return { ...server, capabilities: merged }; } return server; }); return { servers: updatedServers }; }); - // Check if we should prevent CAP END (for SASL, account registration, or link security warning) const state = store.getState(); const server = state.servers.find((s) => s.id === serverId); let preventCapEnd = false; - // Check if SASL was requested and acknowledged, AND we have credentials if (caps.some((cap) => cap.startsWith("sasl"))) { - // Only prevent CAP END if we actually have SASL credentials const servers = storage.servers.load(); const savedServer = servers.find((s) => s.id === serverId); if (savedServer?.saslEnabled && savedServer?.saslPassword) { @@ -187,11 +382,9 @@ export function registerAuthHandlers(store: StoreApi): void { } } - // Check if there's pending account registration const pendingReg = state.pendingRegistration; if (pendingReg && pendingReg.serverId === serverId) { preventCapEnd = true; - // Check if server supports account registration if (server?.capabilities?.includes("draft/account-registration")) { store .getState() @@ -201,17 +394,13 @@ export function registerAuthHandlers(store: StoreApi): void { pendingReg.email, pendingReg.password, ); - // Clear the pending registration store.setState({ pendingRegistration: null }); } else { - // Clear the pending registration store.setState({ pendingRegistration: null }); - // Send CAP END since registration is not possible preventCapEnd = false; } } - // Check if link security warning modal is showing - prevent CAP END until user responds if (state.ui.linkSecurityWarnings.some((w) => w.serverId === serverId)) { preventCapEnd = true; } @@ -219,7 +408,6 @@ export function registerAuthHandlers(store: StoreApi): void { if (!preventCapEnd) { ircClient.sendRaw(serverId, "CAP END"); ircClient.userOnConnect(serverId); - } else { } }); @@ -227,31 +415,28 @@ export function registerAuthHandlers(store: StoreApi): void { ircClient.on("REGISTER_SUCCESS", ({ serverId, account, message }) => { const state = store.getState(); const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels[0]; - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "system", - content: `Account registration successful for ${account}: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - store.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } + if (!server) return; + const channel = server.channels[0]; + if (!channel) return; + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account registration successful for ${account}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + const key = `${serverId}-${channel.id}`; + store.setState((s) => ({ + messages: { + ...s.messages, + [key]: [...(s.messages[key] || []), notificationMessage], + }, + })); }); ircClient.on( @@ -259,62 +444,56 @@ export function registerAuthHandlers(store: StoreApi): void { ({ serverId, account, message }) => { const state = store.getState(); const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels[0]; - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "system", - content: `Account registration for ${account} requires verification: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - store.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } + if (!server) return; + const channel = server.channels[0]; + if (!channel) return; + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account registration for ${account} requires verification: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + const key = `${serverId}-${channel.id}`; + store.setState((s) => ({ + messages: { + ...s.messages, + [key]: [...(s.messages[key] || []), notificationMessage], + }, + })); }, ); ircClient.on("VERIFY_SUCCESS", ({ serverId, account, message }) => { const state = store.getState(); const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels[0]; - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "system", - content: `Account verification successful for ${account}: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - store.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } + if (!server) return; + const channel = server.channels[0]; + if (!channel) return; + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account verification successful for ${account}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + const key = `${serverId}-${channel.id}`; + store.setState((s) => ({ + messages: { + ...s.messages, + [key]: [...(s.messages[key] || []), notificationMessage], + }, + })); }); ircClient.on( @@ -327,12 +506,9 @@ export function registerAuthHandlers(store: StoreApi): void { jwtToken: jwtToken ? "present" : "missing", }); store.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { ...server, jwtToken }; - } - return server; - }); + const updatedServers = state.servers.map((server) => + server.id === serverId ? { ...server, jwtToken } : server, + ); return { servers: updatedServers }; }); }, diff --git a/src/store/index.ts b/src/store/index.ts index 7ef1cff3..67e9c5f5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -372,6 +372,8 @@ interface UIState { isAddServerModalOpen: boolean | undefined; isEditServerModalOpen: boolean; editServerId: string | null; + isTwoFactorSettingsOpen: boolean; + twoFactorSettingsServerId: string | null; isSettingsModalOpen: boolean; isQuickActionsOpen: boolean; isDarkMode: boolean; @@ -524,6 +526,23 @@ export interface AppState { email: string; password: string; } | null; + // 2FA: SASL step-up modal trigger. Set when the server replies + // `AUTHENTICATE 2FA-REQUIRED`; the modal observes this and prompts the + // user for a TOTP code. Cleared on submit / cancel / SASL completion. + pendingTotpStepUp: { serverId: string; account: string } | null; + // 2FA management state per server (populated by `2FA LIST` / `2FA STATUS`). + twofaStatus: Record; + twofaCredentials: Record< + string, + Array<{ id: string; type: string; name: string; createdAt: string }> + >; + // Active enrolment challenge (server reply to `2FA CHALLENGE `), + // base64-encoded JSON. Consumed by the enrolment dialogs. + pendingTwofaChallenge: { + serverId: string; + type: string; + blob: string; + } | null; // Channel order persistence channelOrder: ChannelOrderMap; // serverId -> ordered array of channel names // Message deduplication tracking @@ -565,6 +584,22 @@ export interface AppState { password: string, ) => void; verifyAccount: (serverId: string, account: string, code: string) => void; + // 2FA actions + twofaStatusQuery: (serverId: string) => void; + twofaListQuery: (serverId: string) => void; + twofaChallenge: (serverId: string, type: string) => void; + twofaAdd: ( + serverId: string, + type: string, + name: string, + data: string, + ) => void; + twofaRemove: (serverId: string, id: string) => void; + twofaEnable: (serverId: string) => void; + twofaDisable: (serverId: string, type: string, data: string) => void; + submitTotpStepUp: (serverId: string, code: string) => void; + cancelTotpStepUp: (serverId: string) => void; + toggleTwoFactorSettings: (isOpen?: boolean, serverId?: string | null) => void; setAway: (serverId: string, message?: string) => void; clearAway: (serverId: string) => void; warnUser: ( @@ -845,6 +880,10 @@ const useStore = create((set, get) => ({ metadataChangeCounter: 0, whoisData: {}, pendingRegistration: null, + pendingTotpStepUp: null, + twofaStatus: {}, + twofaCredentials: {}, + pendingTwofaChallenge: null, channelOrder: loadChannelOrder(), processedMessageIds: new Set(), hasConnectedToSavedServers: false, @@ -860,6 +899,8 @@ const useStore = create((set, get) => ({ }, isAddServerModalOpen: false, isEditServerModalOpen: false, + isTwoFactorSettingsOpen: false, + twoFactorSettingsServerId: null, editServerId: null, isSettingsModalOpen: false, isQuickActionsOpen: false, @@ -1365,6 +1406,41 @@ const useStore = create((set, get) => ({ ircClient.verifyAccount(serverId, account, code); }, + twofaStatusQuery: (serverId) => { + ircClient.sendRaw(serverId, "2FA STATUS"); + }, + twofaListQuery: (serverId) => { + set((state) => ({ + twofaCredentials: { ...state.twofaCredentials, [serverId]: [] }, + })); + ircClient.sendRaw(serverId, "2FA LIST"); + }, + twofaChallenge: (serverId, type) => { + ircClient.sendRaw(serverId, `2FA CHALLENGE ${type}`); + }, + twofaAdd: (serverId, type, name, data) => { + ircClient.sendRaw(serverId, `2FA ADD ${type} ${name} ${data}`); + }, + twofaRemove: (serverId, id) => { + ircClient.sendRaw(serverId, `2FA REMOVE ${id}`); + }, + twofaEnable: (serverId) => { + ircClient.sendRaw(serverId, "2FA ENABLE"); + }, + twofaDisable: (serverId, type, data) => { + ircClient.sendRaw(serverId, `2FA DISABLE ${type} ${data}`); + }, + submitTotpStepUp: (serverId, code) => { + ircClient.sendRaw(serverId, "AUTHENTICATE TOTP"); + const b64 = btoa(code.trim()); + ircClient.sendRaw(serverId, `AUTHENTICATE ${b64}`); + set({ pendingTotpStepUp: null }); + }, + cancelTotpStepUp: (serverId) => { + ircClient.sendRaw(serverId, "AUTHENTICATE *"); + set({ pendingTotpStepUp: null }); + }, + setAway: (serverId, message) => { const awayMsg = message || get().globalSettings.awayMessage || "Away"; ircClient.setAway(serverId, awayMsg); @@ -2540,6 +2616,16 @@ const useStore = create((set, get) => ({ })); }, + toggleTwoFactorSettings: (isOpen, serverId = null) => { + set((state) => ({ + ui: { + ...state.ui, + isTwoFactorSettingsOpen: isOpen ?? false, + twoFactorSettingsServerId: isOpen ? serverId : null, + }, + })); + }, + toggleSettingsModal: (isOpen) => { set((state) => ({ ui: { diff --git a/src/types/index.ts b/src/types/index.ts index d5694eff..e2835f05 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -59,6 +59,9 @@ export interface ServerConfig { saslAccountName?: string; saslPassword?: string; saslEnabled: boolean; + // "auto" prefers SCRAM-SHA-256 when the server advertises it and falls + // back to PLAIN, "webauthn" uses DRAFT-WEBAUTHN-BIO directly. + saslMechanism?: "auto" | "PLAIN" | "SCRAM-SHA-256" | "DRAFT-WEBAUTHN-BIO"; skipLinkSecurityWarning?: boolean; skipLocalhostWarning?: boolean; operUsername?: string; diff --git a/tests/fixtures/uiState.ts b/tests/fixtures/uiState.ts index ed3698c8..68ad1ece 100644 --- a/tests/fixtures/uiState.ts +++ b/tests/fixtures/uiState.ts @@ -5,4 +5,6 @@ export const defaultUIExtensions = { inviteUserRequest: null, openedMedia: null, activeMedia: null, + isTwoFactorSettingsOpen: false, + twoFactorSettingsServerId: null, }; From af2064bc00fab8db2b83003e9b77abae206ccdf1 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 2 May 2026 14:22:51 +0100 Subject: [PATCH 02/16] feat: tic-tac-toe (compatible with KiwiIRC plugin) Implements the same TAGMSG protocol used by ItsOnlyBinary/kiwiirc-plugin-tictactoe so KiwiIRC and ObsidianIRC users can play each other. * `src/lib/games/tictactoe.ts` -- pure game logic: board, turn bookkeeping, win/draw detection, marker assignment. * `src/lib/games/tictactoeProtocol.ts` -- IRC message-tag escape and unescape per IRCv3 message-tags. * `src/store/handlers/tictactoeActions.ts` -- store-side actions (invite/accept/decline/move/terminate) that mutate state and emit TAGMSGs over `+kiwiirc.com/ttt`. * `src/store/handlers/tictactoe.ts` -- inbound TAGMSG dispatch. Auto-opens the modal on incoming invites, replays moves into the local board, detects out-of-sync turns, handles forfeit / decline. * `src/components/ui/TicTacToeModal.tsx` -- minimal 3x3 board UI with invite accept/decline, in-game forfeit, and game-over close. * "Play Tic-Tac-Toe" entry in the chat-header overflow menu when a private chat is selected. --- src/App.tsx | 2 + src/components/layout/ChatHeader.tsx | 12 ++ src/components/ui/TicTacToeModal.tsx | 154 ++++++++++++++++++ src/lib/games/tictactoe.ts | 176 ++++++++++++++++++++ src/lib/games/tictactoeProtocol.ts | 30 ++++ src/store/handlers/index.ts | 2 + src/store/handlers/tictactoe.ts | 215 +++++++++++++++++++++++++ src/store/handlers/tictactoeActions.ts | 201 +++++++++++++++++++++++ src/store/index.ts | 45 ++++++ 9 files changed, 837 insertions(+) create mode 100644 src/components/ui/TicTacToeModal.tsx create mode 100644 src/lib/games/tictactoe.ts create mode 100644 src/lib/games/tictactoeProtocol.ts create mode 100644 src/store/handlers/tictactoe.ts create mode 100644 src/store/handlers/tictactoeActions.ts diff --git a/src/App.tsx b/src/App.tsx index 2610c090..586a6a9a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { EditServerModal } from "./components/ui/EditServerModal"; import LinkSecurityWarningModal from "./components/ui/LinkSecurityWarningModal"; import LoadingOverlay from "./components/ui/LoadingOverlay"; import QuickActions from "./components/ui/QuickActions"; +import { TicTacToeModal } from "./components/ui/TicTacToeModal"; import { TotpStepUpModal } from "./components/ui/TotpStepUpModal"; import { TwoFactorSettingsModal } from "./components/ui/TwoFactorSettingsModal"; import UserProfileModal from "./components/ui/UserProfileModal"; @@ -318,6 +319,7 @@ const App: React.FC = () => { /> )} + {isSettingsModalOpen && } {isQuickActionsOpen && } {isChannelListModalOpen && } diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index 24cb6bda..18a7b3c3 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -316,6 +316,18 @@ export const ChatHeader: React.FC = ({ onClick: onOpenInviteUser, show: !!selectedChannel, }, + { + label: "Play Tic-Tac-Toe", + icon: , + onClick: () => { + if (selectedServerId && selectedPrivateChat) { + useStore + .getState() + .tictactoeInvite(selectedServerId, selectedPrivateChat.username); + } + }, + show: !!selectedPrivateChat, + }, { label: "Server Channels", icon: , diff --git a/src/components/ui/TicTacToeModal.tsx b/src/components/ui/TicTacToeModal.tsx new file mode 100644 index 00000000..4a6823c7 --- /dev/null +++ b/src/components/ui/TicTacToeModal.tsx @@ -0,0 +1,154 @@ +import type React from "react"; +import { useMemo } from "react"; +import { isLocalTurn, isWinningCell } from "../../lib/games/tictactoe"; +import useStore from "../../store"; + +export const TicTacToeModal: React.FC = () => { + const open = useStore((s) => s.tictactoe?.open ?? null); + const games = useStore((s) => s.tictactoe?.games ?? {}); + const accept = useStore((s) => s.tictactoeAccept); + const decline = useStore((s) => s.tictactoeDecline); + const move = useStore((s) => s.tictactoeMove); + const terminate = useStore((s) => s.tictactoeTerminate); + const closeModal = useStore((s) => s.tictactoeCloseModal); + + const game = useMemo(() => { + if (!open) return null; + return games[open.serverId]?.[open.opponent.toLowerCase()] ?? null; + }, [open, games]); + + if (!open || !game) return null; + + const myTurn = game.active && !game.gameOver && isLocalTurn(game); + const onClose = () => { + terminate(open.serverId, open.opponent); + closeModal(); + }; + + return ( +
+
+
+

+ Tic-Tac-Toe vs {open.opponent} +

+ +
+ + {game.inviteIncoming && ( +
+

+ {open.opponent} has invited you to play. +

+
+ + +
+
+ )} + + {game.invitePending && !game.active && ( +
+ Waiting for {open.opponent} to accept… +
+ )} + + {(game.active || game.gameOver) && ( +
+ + + {game.board.map((row, r) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: 3x3 fixed grid + + {row.map((cell, c) => { + const playable = myTurn && cell === "" && !game.gameOver; + const win = isWinningCell(game, r, c); + return ( + + ); + })} + + ))} + +
move(open.serverId, open.opponent, r, c) + : undefined + } + onKeyDown={ + playable + ? (e) => { + if (e.key === "Enter" || e.key === " ") + move(open.serverId, open.opponent, r, c); + } + : undefined + } + tabIndex={playable ? 0 : -1} + className={`w-20 h-20 text-4xl font-bold text-center border-4 border-discord-dark-100 select-none ${ + playable + ? "cursor-pointer hover:bg-discord-dark-300" + : "" + } ${ + win ? "bg-discord-green text-white" : "text-white" + }`} + > + {cell} +
+
+ )} + +
+ {game.statusMessage} +
+ + {game.gameOver && ( +
+ +
+ )} + + {!game.gameOver && game.active && ( +
+ +
+ )} +
+
+ ); +}; + +export default TicTacToeModal; diff --git a/src/lib/games/tictactoe.ts b/src/lib/games/tictactoe.ts new file mode 100644 index 00000000..816daf26 --- /dev/null +++ b/src/lib/games/tictactoe.ts @@ -0,0 +1,176 @@ +// Compatible-by-design with the KiwiIRC tic-tac-toe plugin +// (https://github.com/ItsOnlyBinary/kiwiirc-plugin-tictactoe). +// +// All exchanges are IRC TAGMSGs to the opponent's nick carrying a single +// client-only message tag, `+kiwiirc.com/ttt`, whose value is a JSON object. + +export const TTT_TAG = "+kiwiirc.com/ttt"; + +export type Cell = "" | "X" | "O"; +export type Board = Cell[][]; // 3x3 + +// Wire commands. +export type TttMessage = + | { cmd: "invite" } + | { cmd: "invite_received" } + | { cmd: "invite_accepted"; startPlayer: string } + | { cmd: "invite_declined" } + | { cmd: "action"; clicked: [number, number]; turn: number } + | { cmd: "error"; message: string } + | { cmd: "terminate" }; + +export interface GameSnapshot { + serverId: string; + localPlayer: string; + remotePlayer: string; + startPlayer: string | null; + board: Board; + turn: number; // increments each move, starts at 1 + gameOver: boolean; + winner: "X" | "O" | "draw" | null; + winLine: Array<[number, number]> | null; + // UI state + invitePending: boolean; // we sent invite, awaiting their response + inviteIncoming: boolean; // they sent invite, awaiting our response + active: boolean; // game accepted and in progress + terminated: boolean; // explicitly ended (by them or us) + statusMessage: string; +} + +const WIN_LINES: Array> = [ + // rows + [ + [0, 0], + [0, 1], + [0, 2], + ], + [ + [1, 0], + [1, 1], + [1, 2], + ], + [ + [2, 0], + [2, 1], + [2, 2], + ], + // columns + [ + [0, 0], + [1, 0], + [2, 0], + ], + [ + [0, 1], + [1, 1], + [2, 1], + ], + [ + [0, 2], + [1, 2], + [2, 2], + ], + // diagonals + [ + [0, 0], + [1, 1], + [2, 2], + ], + [ + [0, 2], + [1, 1], + [2, 0], + ], +]; + +export function emptyBoard(): Board { + return [ + ["", "", ""], + ["", "", ""], + ["", "", ""], + ]; +} + +export function makeGame( + serverId: string, + localPlayer: string, + remotePlayer: string, +): GameSnapshot { + return { + serverId, + localPlayer, + remotePlayer, + startPlayer: null, + board: emptyBoard(), + turn: 1, + gameOver: false, + winner: null, + winLine: null, + invitePending: false, + inviteIncoming: false, + active: false, + terminated: false, + statusMessage: "", + }; +} + +export function markerForTurn(turn: number): "X" | "O" { + return turn % 2 === 1 ? "X" : "O"; +} + +// The starter is X. Whoever the accepter chose as startPlayer takes the +// first move; subsequent turns alternate. +export function isLocalTurn(g: GameSnapshot): boolean { + if (!g.active || g.gameOver) return false; + // turn 1 is the start player. start === local => local plays on odd turns. + const odd = g.turn % 2 === 1; + return g.startPlayer === g.localPlayer ? odd : !odd; +} + +// Apply a move (idempotent against an already-occupied cell -> returns false). +// Caller is expected to already have validated whose turn it is. +export function applyMove(g: GameSnapshot, row: number, col: number): boolean { + if (row < 0 || row > 2 || col < 0 || col > 2) return false; + if (g.board[row][col] !== "") return false; + g.board[row][col] = markerForTurn(g.turn); + g.turn += 1; + evaluate(g); + return true; +} + +// Check for win or draw and update game state accordingly. +export function evaluate(g: GameSnapshot): void { + for (const line of WIN_LINES) { + const [a, b, c] = line; + const va = g.board[a[0]][a[1]]; + const vb = g.board[b[0]][b[1]]; + const vc = g.board[c[0]][c[1]]; + if (va !== "" && va === vb && vb === vc) { + g.gameOver = true; + g.winner = va; + g.winLine = line; + return; + } + } + if (g.board.every((row) => row.every((c) => c !== ""))) { + g.gameOver = true; + g.winner = "draw"; + g.winLine = null; + } +} + +export function isWinningCell( + g: GameSnapshot, + row: number, + col: number, +): boolean { + if (!g.winLine) return false; + return g.winLine.some(([r, c]) => r === row && c === col); +} + +// Whose nick goes first when both peers agree on a flip. Either side is +// fine -- we randomise on the accepter side and tell the inviter via +// `invite_accepted`. +export function pickStartPlayer(local: string, remote: string): string { + return Math.random() < 0.5 ? local : remote; +} diff --git a/src/lib/games/tictactoeProtocol.ts b/src/lib/games/tictactoeProtocol.ts new file mode 100644 index 00000000..cafd9b33 --- /dev/null +++ b/src/lib/games/tictactoeProtocol.ts @@ -0,0 +1,30 @@ +// IRC message-tag value escape per IRCv3 message-tags spec. Used to safely +// embed JSON payloads inside the `+kiwiirc.com/ttt` tag. + +export function escapeTagValue(v: string): string { + return v + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\:") + .replace(/ /g, "\\s") + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n"); +} + +export function unescapeTagValue(v: string): string { + let out = ""; + for (let i = 0; i < v.length; i++) { + const c = v[i]; + if (c !== "\\" || i + 1 >= v.length) { + out += c; + continue; + } + const next = v[++i]; + if (next === ":") out += ";"; + else if (next === "s") out += " "; + else if (next === "r") out += "\r"; + else if (next === "n") out += "\n"; + else if (next === "\\") out += "\\"; + else out += next; + } + return out; +} diff --git a/src/store/handlers/index.ts b/src/store/handlers/index.ts index f6c59eb1..40c67902 100644 --- a/src/store/handlers/index.ts +++ b/src/store/handlers/index.ts @@ -6,6 +6,7 @@ import { registerChannelHandlers } from "./channels"; import { registerConnectionHandlers } from "./connection"; import { registerMessageHandlers } from "./messages"; import { registerMetadataHandlers } from "./metadata"; +import { registerTicTacToeHandlers } from "./tictactoe"; import { registerUserHandlers } from "./users"; import { registerWhoisHandlers } from "./whois"; @@ -18,4 +19,5 @@ export function registerAllHandlers(store: StoreApi): void { registerMetadataHandlers(store); registerBatchHandlers(store); registerAuthHandlers(store); + registerTicTacToeHandlers(store); } diff --git a/src/store/handlers/tictactoe.ts b/src/store/handlers/tictactoe.ts new file mode 100644 index 00000000..6232c453 --- /dev/null +++ b/src/store/handlers/tictactoe.ts @@ -0,0 +1,215 @@ +// TAGMSG receiver for tic-tac-toe games. Listens on the existing TAGMSG +// event the IRC layer fires and reacts to messages tagged with +// `+kiwiirc.com/ttt`. + +import type { StoreApi } from "zustand"; +import { + applyMove, + type GameSnapshot, + isLocalTurn, + makeGame, + TTT_TAG, + type TttMessage, +} from "../../lib/games/tictactoe"; +import { unescapeTagValue } from "../../lib/games/tictactoeProtocol"; +import ircClient from "../../lib/ircClient"; +import type { AppState } from "../index"; +import * as actions from "./tictactoeActions"; + +function key(opponent: string): string { + return opponent.toLowerCase(); +} + +function getGame( + state: AppState, + serverId: string, + opponent: string, +): GameSnapshot | undefined { + return state.tictactoe.games[serverId]?.[key(opponent)]; +} + +function putGame( + store: StoreApi, + serverId: string, + opponent: string, + g: GameSnapshot, +) { + store.setState((state) => ({ + tictactoe: { + ...state.tictactoe, + games: { + ...state.tictactoe.games, + [serverId]: { + ...(state.tictactoe.games[serverId] ?? {}), + [key(opponent)]: g, + }, + }, + }, + })); +} + +export function registerTicTacToeHandlers(store: StoreApi) { + ircClient.on("TAGMSG", ({ serverId, mtags, sender, channelName }) => { + const me = ircClient.getNick(serverId); + if (!me || !mtags) return; + // Only direct messages from another nick to us. + if (channelName !== me) return; + if (!sender || sender === me) return; + + const raw = mtags[TTT_TAG]; + if (!raw) return; + const value = unescapeTagValue(raw); + if (!value || value[0] !== "{") return; + + let data: TttMessage | null = null; + try { + data = JSON.parse(value) as TttMessage; + } catch { + return; + } + if (!data || typeof data !== "object" || !("cmd" in data)) return; + + const opponent = sender; + const state = store.getState(); + let game = getGame(state, serverId, opponent); + + switch (data.cmd) { + case "invite": { + if (!game) { + game = makeGame(serverId, me, opponent); + } + game = { + ...game, + inviteIncoming: true, + invitePending: false, + statusMessage: `${opponent} invited you to play.`, + }; + putGame(store, serverId, opponent, game); + store.setState((state) => ({ + tictactoe: { + ...state.tictactoe, + open: state.tictactoe.open ?? { serverId, opponent }, + }, + })); + // Acknowledge. + ircClient.sendRaw( + serverId, + `@${TTT_TAG}={"cmd"\\:"invite_received"} TAGMSG ${opponent}`, + ); + break; + } + case "invite_received": { + // Inviter side: the other side has UI for the invite now -- we + // don't need to do anything but stop any "still waiting" timer + // (we don't have one). Update status for clarity. + if (game) { + game = { + ...game, + statusMessage: `${opponent} sees the invite…`, + }; + putGame(store, serverId, opponent, game); + } + break; + } + case "invite_accepted": { + if (!game) return; + const startPlayer = data.startPlayer; + const next: GameSnapshot = { + ...game, + startPlayer, + active: true, + gameOver: false, + winner: null, + winLine: null, + turn: 1, + board: [ + ["", "", ""], + ["", "", ""], + ["", "", ""], + ], + invitePending: false, + inviteIncoming: false, + terminated: false, + statusMessage: "", + }; + next.statusMessage = isLocalTurn(next) + ? "Your turn!" + : `Waiting for ${opponent}…`; + putGame(store, serverId, opponent, next); + // Auto-open the modal so the inviter sees the game. + store.setState((state) => ({ + tictactoe: { + ...state.tictactoe, + open: { serverId, opponent }, + }, + })); + break; + } + case "invite_declined": { + if (!game) return; + actions.terminate(store.setState, store.getState, serverId, opponent); + break; + } + case "action": { + if (!game?.active || game.gameOver) return; + const [row, col] = data.clicked ?? [-1, -1]; + // The remote turn number is the turn they had BEFORE making the + // move; if it doesn't match our current turn we've gone out of + // sync and the safe thing to do is end the game. + if (data.turn !== game.turn) { + const broken: GameSnapshot = { + ...game, + gameOver: true, + statusMessage: "Out of sync — game ended.", + }; + putGame(store, serverId, opponent, broken); + ircClient.sendRaw( + serverId, + `@${TTT_TAG}={"cmd"\\:"error"\\,"message"\\:"out of sync"} TAGMSG ${opponent}`, + ); + return; + } + const next: GameSnapshot = { + ...game, + board: game.board.map((r) => [...r]), + }; + if (!applyMove(next, row, col)) { + return; + } + if (!next.gameOver) { + next.statusMessage = isLocalTurn(next) + ? "Your turn!" + : `Waiting for ${opponent}…`; + } else if (next.winner === "draw") { + next.statusMessage = "Draw!"; + } else { + next.statusMessage = `${opponent} wins! (${next.winner})`; + } + putGame(store, serverId, opponent, next); + break; + } + case "error": { + if (!game) return; + putGame(store, serverId, opponent, { + ...game, + gameOver: true, + statusMessage: + "message" in data + ? `Error from ${opponent}: ${data.message}` + : `Error from ${opponent}`, + }); + break; + } + case "terminate": { + if (!game) return; + putGame(store, serverId, opponent, { + ...game, + gameOver: true, + terminated: true, + statusMessage: `${opponent} ended the game.`, + }); + break; + } + } + }); +} diff --git a/src/store/handlers/tictactoeActions.ts b/src/store/handlers/tictactoeActions.ts new file mode 100644 index 00000000..66ed2e45 --- /dev/null +++ b/src/store/handlers/tictactoeActions.ts @@ -0,0 +1,201 @@ +// Store-side actions for tic-tac-toe. These both mutate the Zustand store +// and send TAGMSG packets via ircClient. Imported from store/index.ts. + +import { + applyMove, + type GameSnapshot, + isLocalTurn, + makeGame, + pickStartPlayer, + TTT_TAG, + type TttMessage, +} from "../../lib/games/tictactoe"; +import { escapeTagValue } from "../../lib/games/tictactoeProtocol"; +import ircClient from "../../lib/ircClient"; +import type { AppState } from "../index"; + +type SetFn = ( + partial: Partial | ((state: AppState) => Partial), +) => void; +type GetFn = () => AppState; + +function key(opponent: string): string { + return opponent.toLowerCase(); +} + +function getGame( + state: AppState, + serverId: string, + opponent: string, +): GameSnapshot | undefined { + return state.tictactoe.games[serverId]?.[key(opponent)]; +} + +function setGame( + set: SetFn, + serverId: string, + opponent: string, + g: GameSnapshot, +) { + set((state) => ({ + tictactoe: { + ...state.tictactoe, + games: { + ...state.tictactoe.games, + [serverId]: { + ...(state.tictactoe.games[serverId] ?? {}), + [key(opponent)]: g, + }, + }, + }, + })); +} + +function deleteGame(set: SetFn, serverId: string, opponent: string) { + set((state) => { + const games = { ...state.tictactoe.games }; + if (games[serverId]) { + const inner = { ...games[serverId] }; + delete inner[key(opponent)]; + games[serverId] = inner; + } + const open = + state.tictactoe.open && + state.tictactoe.open.serverId === serverId && + key(state.tictactoe.open.opponent) === key(opponent) + ? null + : state.tictactoe.open; + return { tictactoe: { ...state.tictactoe, games, open } }; + }); +} + +function sendTtt(serverId: string, target: string, msg: TttMessage) { + const json = JSON.stringify(msg); + const escaped = escapeTagValue(json); + ircClient.sendRaw(serverId, `@${TTT_TAG}=${escaped} TAGMSG ${target}`); +} + +function localNick(serverId: string): string | undefined { + return ircClient.getNick(serverId); +} + +export function invite( + set: SetFn, + get: GetFn, + serverId: string, + opponent: string, +) { + const me = localNick(serverId); + if (!me) return; + const existing = getGame(get(), serverId, opponent); + const g = existing ?? makeGame(serverId, me, opponent); + g.invitePending = true; + g.inviteIncoming = false; + g.terminated = false; + g.statusMessage = `Invite sent to ${opponent}`; + setGame(set, serverId, opponent, { ...g }); + sendTtt(serverId, opponent, { cmd: "invite" }); + set((state) => ({ + tictactoe: { ...state.tictactoe, open: { serverId, opponent } }, + })); +} + +export function accept( + set: SetFn, + get: GetFn, + serverId: string, + opponent: string, +) { + const me = localNick(serverId); + if (!me) return; + const g = + getGame(get(), serverId, opponent) ?? makeGame(serverId, me, opponent); + const startPlayer = pickStartPlayer(me, opponent); + g.startPlayer = startPlayer; + g.active = true; + g.gameOver = false; + g.winner = null; + g.winLine = null; + g.turn = 1; + g.board = [ + ["", "", ""], + ["", "", ""], + ["", "", ""], + ]; + g.inviteIncoming = false; + g.invitePending = false; + g.terminated = false; + g.statusMessage = isLocalTurn(g) ? "Your turn!" : `Waiting for ${opponent}…`; + setGame(set, serverId, opponent, { ...g }); + sendTtt(serverId, opponent, { cmd: "invite_accepted", startPlayer }); + set((state) => ({ + tictactoe: { ...state.tictactoe, open: { serverId, opponent } }, + })); +} + +export function decline( + set: SetFn, + _get: GetFn, + serverId: string, + opponent: string, +) { + sendTtt(serverId, opponent, { cmd: "invite_declined" }); + deleteGame(set, serverId, opponent); +} + +export function move( + set: SetFn, + get: GetFn, + serverId: string, + opponent: string, + row: number, + col: number, +) { + const g = getGame(get(), serverId, opponent); + if (!g?.active || g.gameOver) return; + if (!isLocalTurn(g)) return; + const turnAtSend = g.turn; + const next: GameSnapshot = { + ...g, + board: g.board.map((r) => [...r]), + }; + if (!applyMove(next, row, col)) return; + if (!next.gameOver) { + next.statusMessage = `Waiting for ${opponent}…`; + } else if (next.winner === "draw") { + next.statusMessage = "Draw!"; + } else { + next.statusMessage = isLocalTurn({ + ...next, + turn: turnAtSend, + gameOver: false, + }) + ? `You win! (${next.winner})` + : `${opponent} wins! (${next.winner})`; + } + setGame(set, serverId, opponent, next); + sendTtt(serverId, opponent, { + cmd: "action", + clicked: [row, col], + turn: turnAtSend, + }); +} + +export function terminate( + set: SetFn, + get: GetFn, + serverId: string, + opponent: string, +) { + const g = getGame(get(), serverId, opponent); + if (!g) return; + if (g.inviteIncoming) { + sendTtt(serverId, opponent, { cmd: "invite_declined" }); + deleteGame(set, serverId, opponent); + return; + } + if (g.active && !g.gameOver) { + sendTtt(serverId, opponent, { cmd: "terminate" }); + } + deleteGame(set, serverId, opponent); +} diff --git a/src/store/index.ts b/src/store/index.ts index 67e9c5f5..b23a44e0 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -15,6 +15,7 @@ import type { } from "../types"; import { registerAllHandlers } from "./handlers"; import { readyProcessedServers } from "./handlers/connection"; +import * as tictactoeActions from "./handlers/tictactoeActions"; import { MAX_MESSAGES_PER_CHANNEL } from "./helpers"; import * as storage from "./localStorage"; import { runPendingMigrations } from "./migrations"; @@ -543,6 +544,15 @@ export interface AppState { type: string; blob: string; } | null; + // Tic-tac-toe games keyed serverId -> opponent-nick-lower. Used by both + // the TAGMSG handler and the modal UI. + tictactoe: { + games: Record< + string, + Record + >; + open: { serverId: string; opponent: string } | null; + }; // Channel order persistence channelOrder: ChannelOrderMap; // serverId -> ordered array of channel names // Message deduplication tracking @@ -600,6 +610,19 @@ export interface AppState { submitTotpStepUp: (serverId: string, code: string) => void; cancelTotpStepUp: (serverId: string) => void; toggleTwoFactorSettings: (isOpen?: boolean, serverId?: string | null) => void; + // Tic-tac-toe actions + tictactoeInvite: (serverId: string, opponent: string) => void; + tictactoeAccept: (serverId: string, opponent: string) => void; + tictactoeDecline: (serverId: string, opponent: string) => void; + tictactoeMove: ( + serverId: string, + opponent: string, + row: number, + col: number, + ) => void; + tictactoeTerminate: (serverId: string, opponent: string) => void; + tictactoeOpenModal: (serverId: string, opponent: string) => void; + tictactoeCloseModal: () => void; setAway: (serverId: string, message?: string) => void; clearAway: (serverId: string) => void; warnUser: ( @@ -884,6 +907,7 @@ const useStore = create((set, get) => ({ twofaStatus: {}, twofaCredentials: {}, pendingTwofaChallenge: null, + tictactoe: { games: {}, open: null }, channelOrder: loadChannelOrder(), processedMessageIds: new Set(), hasConnectedToSavedServers: false, @@ -2616,6 +2640,27 @@ const useStore = create((set, get) => ({ })); }, + tictactoeInvite: (serverId, opponent) => + tictactoeActions.invite(set, get, serverId, opponent), + tictactoeAccept: (serverId, opponent) => + tictactoeActions.accept(set, get, serverId, opponent), + tictactoeDecline: (serverId, opponent) => + tictactoeActions.decline(set, get, serverId, opponent), + tictactoeMove: (serverId, opponent, row, col) => + tictactoeActions.move(set, get, serverId, opponent, row, col), + tictactoeTerminate: (serverId, opponent) => + tictactoeActions.terminate(set, get, serverId, opponent), + tictactoeOpenModal: (serverId, opponent) => { + set((state) => ({ + tictactoe: { ...state.tictactoe, open: { serverId, opponent } }, + })); + }, + tictactoeCloseModal: () => { + set((state) => ({ + tictactoe: { ...state.tictactoe, open: null }, + })); + }, + toggleTwoFactorSettings: (isOpen, serverId = null) => { set((state) => ({ ui: { From b6e6bd95fbe0404ed9461fe5c8fbf051b8d233c7 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 2 May 2026 18:32:58 +0100 Subject: [PATCH 03/16] feat: surface tic-tac-toe button in the PM header action row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overflow menu is only rendered in the channel header (and only on mobile), so the previous "Play Tic-Tac-Toe" entry was unreachable in private chats. Add a dedicated 🎮 button next to the existing Media / Search controls in the PM action cluster instead. --- src/components/layout/ChatHeader.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index 18a7b3c3..8f026c53 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -831,6 +831,23 @@ export const ChatHeader: React.FC = ({ )} + - - ))} +
+
+
{c.name}
+
+ {TYPE_LABELS[c.type] ?? c.type} + {c.createdAt ? ` · added ${c.createdAt}` : ""} +
+
+ {!isPendingRemoval && ( + + )} +
+ {isPendingRemoval && isLastWhileEnabled && ( +
+

+ Can't remove your last 2FA credential +

+

+ 2FA is currently enforced on this account. Removing the + only registered credential would lock you out of login. + Add another credential first, or disable 2FA below. +

+ +
+ )} + {isPendingRemoval && !isLastWhileEnabled && ( +
+

+ Remove "{c.name}"? You will no longer be able to use it + as a second factor. +

+
+ + +
+
+ )} + + ); + })} )} From d82124b79e6d57c9b9bdd2e1e47b5b42b8aade00 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 4 May 2026 05:02:43 +0100 Subject: [PATCH 05/16] Request draft/account-2fa CAP so the client merges it into server.capabilities Without this CAP REQ, the server's CAP LS advertisement of draft/account-2fa never lands on server.capabilities (the merger only writes from CAP ACK), so EditServerModal's gate server?.capabilities?.some(c => c.startsWith("draft/account-2fa")) stays false and the 'Manage two-factor authentication' button never renders. --- src/lib/irc/IRCClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index aa6a0173..e38b264f 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -469,6 +469,7 @@ export class IRCClient implements IRCClientContext { "draft/metadata-2", "draft/message-redaction", "draft/account-registration", + "draft/account-2fa", "batch", "draft/multiline", "draft/typing", From cca2abdf38493c1aa9b8583bc77c59dcc2da6d65 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 4 May 2026 05:15:50 +0100 Subject: [PATCH 06/16] =?UTF-8?q?Move=202FA=20management=20into=20UserSett?= =?UTF-8?q?ings=20=E2=86=92=20Account;=20raise=202FA=20modal=20z-index=20a?= =?UTF-8?q?bove=20LoadingOverlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button in EditServerModal was buried behind the 'Login to an account' checkbox, so users wouldn't find it. Surface it in the always-reachable Account category of UserSettings instead, gated on the currently selected server's draft/account-2fa capability. LoadingOverlay sits at z-[100001]; the 2FA / TOTP step-up modals were at z-50 and got hidden behind it whenever the server reconnected. Bump both modals to z-[100002]. --- src/components/ui/TotpStepUpModal.tsx | 2 +- src/components/ui/TwoFactorSettingsModal.tsx | 2 +- src/components/ui/UserSettings.tsx | 29 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/components/ui/TotpStepUpModal.tsx b/src/components/ui/TotpStepUpModal.tsx index da0acb0f..74d107c2 100644 --- a/src/components/ui/TotpStepUpModal.tsx +++ b/src/components/ui/TotpStepUpModal.tsx @@ -33,7 +33,7 @@ export const TotpStepUpModal: React.FC = () => { }; return ( -
+

Two-factor authentication required diff --git a/src/components/ui/TwoFactorSettingsModal.tsx b/src/components/ui/TwoFactorSettingsModal.tsx index 5298e844..9a9de773 100644 --- a/src/components/ui/TwoFactorSettingsModal.tsx +++ b/src/components/ui/TwoFactorSettingsModal.tsx @@ -177,7 +177,7 @@ export const TwoFactorSettingsModal: React.FC = ({ ); return ( -
+

diff --git a/src/components/ui/UserSettings.tsx b/src/components/ui/UserSettings.tsx index 4ad0f8c4..1dae7e62 100644 --- a/src/components/ui/UserSettings.tsx +++ b/src/components/ui/UserSettings.tsx @@ -1299,8 +1299,37 @@ export const UserSettings: React.FC = React.memo(() => { ); } + const supportsTwoFactor = + currentServer?.capabilities?.some((c) => + c.startsWith("draft/account-2fa"), + ) ?? false; + return (
+ {/* Two-Factor Authentication */} + {supportsTwoFactor && ( +
+

+ Two-Factor Authentication +

+

+ Add an authenticator app or biometric / security key to require a + second factor when logging in. +

+ +
+ )} + {/* IRC Operator Authentication */}

IRC Operator

From b096e0b025be13c210805ecd87db70b07cf3b011 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 4 May 2026 05:26:32 +0100 Subject: [PATCH 07/16] Don't send spurious AUTHENTICATE + after SCRAM server-final Per IRCv3 SASL, the server completes the exchange itself after server-final by emitting 900/903 (or AUTHENTICATE 2FA-REQUIRED for step-up). UnrealIRCd's saslserv reads our extra 'AUTHENTICATE +' as an empty/abort payload and fires 904 SASL authentication failed, which dropped the SASL session before the user could supply their TOTP code. --- src/store/handlers/auth.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/store/handlers/auth.ts b/src/store/handlers/auth.ts index b1062885..f7de43bd 100644 --- a/src/store/handlers/auth.ts +++ b/src/store/handlers/auth.ts @@ -175,11 +175,11 @@ export function registerAuthHandlers(store: StoreApi): void { const ok = scramVerifyServerFinal(session.scram, serverFinal); if (!ok) { ircClient.sendRaw(serverId, "AUTHENTICATE *"); - } else { - // Acknowledge by sending an empty client message; the server - // will then issue 900/903 (or AUTHENTICATE 2FA-REQUIRED). - ircClient.sendRaw(serverId, "AUTHENTICATE +"); } + // On success the server completes the exchange itself by + // emitting 900/903 (normal) or AUTHENTICATE 2FA-REQUIRED + // (step-up). Sending another "AUTHENTICATE +" here is read + // as an empty/abort payload by saslserv and trips 904. session.step = 3; return; } From 8fd71bb530e0aad7e0a9c6d87db0c8eda563cc7b Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 9 May 2026 19:39:29 +0100 Subject: [PATCH 08/16] feat(auth): SASL IRCV3BEARER + per-server OAuth2/OIDC sign-in Lets ObsidianIRC connect to obbyircd via OAuth2 instead of (or in addition to) PLAIN. Each server entry now carries its own provider config, so admins can point one server at Logto, another at Auth0, Keycloak, Okta, or any custom OIDC issuer. Flow 1. In Edit Server -> "OAuth2 / OIDC sign-in", admin picks a preset (Logto, Auth0, Keycloak, Custom), pastes the issuer URL + client ID, optionally tweaks scopes/redirect URI, and clicks "Sign in". 2. We discover the issuer's metadata, build an authorize URL with PKCE (S256), and open the IdP in a popup. 3. The popup redirects to /oauth/callback (a new same-origin SPA route) which postMessages the auth code back to the opener and closes itself. 4. The opener exchanges code+verifier at the token endpoint and stores the resulting access/id/refresh tokens on the server. 5. On every connect, if the server has an access token, the SASL step picks IRCV3BEARER, sends "[authzid]\0jwt\0" in 400- char AUTHENTICATE chunks, and the server validates the JWT against its configured oauth-provider/JWKS. Notes * Tokens live in localStorage with the rest of the server config; expired tokens just yield a 904 and the user re-authenticates. * IRCClient gained an oauthBearerEnabled flag on connect() so CAP requests SASL and we don't race CAP END against AUTHENTICATE. * saslFrames.ts also exports buildOauthBearerPayload for the RFC 7628 mech, even though the wired-up flow uses IRCV3BEARER. Tests cover OIDC discovery, PKCE verifier/challenge (incl. the RFC 7636 vector), authorize-URL construction, and both SASL frame builders + chunker behavior. Full suite passes (722 tests). --- src/App.tsx | 2 + src/components/OAuthCallback.tsx | 60 +++++ src/components/ui/EditServerModal.tsx | 10 +- src/components/ui/OAuthSection.tsx | 315 ++++++++++++++++++++++++++ src/lib/irc/IRCClient.ts | 24 +- src/lib/oauth.ts | 287 +++++++++++++++++++++++ src/lib/saslFrames.ts | Bin 0 -> 2079 bytes src/store/handlers/auth.ts | 72 ++++-- src/store/index.ts | 5 + src/types/index.ts | 14 ++ tests/lib/oauth.test.ts | 104 +++++++++ tests/lib/saslFrames.test.ts | 80 +++++++ 12 files changed, 947 insertions(+), 26 deletions(-) create mode 100644 src/components/OAuthCallback.tsx create mode 100644 src/components/ui/OAuthSection.tsx create mode 100644 src/lib/oauth.ts create mode 100644 src/lib/saslFrames.ts create mode 100644 tests/lib/oauth.test.ts create mode 100644 tests/lib/saslFrames.test.ts diff --git a/src/App.tsx b/src/App.tsx index a33a508a..06ae7369 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react"; import { Route, Routes } from "react-router-dom"; import AppLayout from "./components/layout/AppLayout"; import { ServerNoticesPopup } from "./components/message/ServerNoticesPopup"; +import OAuthCallback from "./components/OAuthCallback"; import PrivacyPolicy from "./components/PrivacyPolicy"; import AddServerModal from "./components/ui/AddServerModal"; import ChannelListModal from "./components/ui/ChannelListModal"; @@ -294,6 +295,7 @@ const App: React.FC = () => {
} /> + } /> { + const [message, setMessage] = useState("Completing sign-in..."); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get("code") ?? undefined; + const state = params.get("state") ?? ""; + const error = params.get("error") ?? undefined; + const errorDescription = params.get("error_description") ?? undefined; + + const payload: OAuthCallbackMessage = { + type: "obsidianirc:oauth-callback", + state, + code, + error, + errorDescription, + }; + + if (window.opener && window.opener !== window) { + try { + window.opener.postMessage(payload, window.location.origin); + setMessage("Sign-in complete. You can close this window."); + setTimeout(() => { + try { + window.close(); + } catch {} + }, 300); + return; + } catch (err) { + setMessage( + `Could not return tokens to the main window: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return; + } + } + setMessage( + "OAuth callback was loaded outside its popup. You can close this tab.", + ); + }, []); + + return ( +
+
+

OAuth sign-in

+

{message}

+
+
+ ); +}; + +export default OAuthCallback; diff --git a/src/components/ui/EditServerModal.tsx b/src/components/ui/EditServerModal.tsx index e824a786..b7eda9be 100644 --- a/src/components/ui/EditServerModal.tsx +++ b/src/components/ui/EditServerModal.tsx @@ -2,7 +2,8 @@ import type React from "react"; import { useState } from "react"; import { FaQuestionCircle, FaTimes } from "react-icons/fa"; import useStore, { loadSavedServers } from "../../store"; -import type { ServerConfig } from "../../types"; +import type { ServerConfig, ServerOAuthConfig } from "../../types"; +import { OAuthSection } from "./OAuthSection"; import { TextInput } from "./TextInput"; interface EditServerModalProps { @@ -48,6 +49,10 @@ export const EditServerModal: React.FC = ({ ); const [forgetOperCredentials, setForgetOperCredentials] = useState(false); + const [oauthConfig, setOauthConfig] = useState( + serverConfig?.oauth, + ); + const [showServerPassword, setShowServerPassword] = useState(false); const [showAccount, setShowAccount] = useState(false); const [registerAccount, setRegisterAccount] = useState(false); @@ -109,6 +114,7 @@ export const EditServerModal: React.FC = ({ } : {}), operOnConnect, + oauth: oauthConfig, }; updateServer(serverId, updatedConfig); @@ -272,6 +278,8 @@ export const EditServerModal: React.FC = ({
)} + + {/* IRC Operator Section */}

diff --git a/src/components/ui/OAuthSection.tsx b/src/components/ui/OAuthSection.tsx new file mode 100644 index 00000000..7b426c09 --- /dev/null +++ b/src/components/ui/OAuthSection.tsx @@ -0,0 +1,315 @@ +import type React from "react"; +import { useState } from "react"; +import { beginOauthLogin, OAUTH_PRESETS } from "../../lib/oauth"; +import type { ServerOAuthConfig } from "../../types"; +import { TextInput } from "./TextInput"; + +interface OAuthSectionProps { + // Initial config loaded from the saved server (may be undefined for a + // freshly added server). + initial: ServerOAuthConfig | undefined; + // Called whenever any field changes; the parent persists on submit. + onChange: (next: ServerOAuthConfig | undefined) => void; +} + +// In-modal panel: pick a provider preset, fill in issuer + clientId, hit +// "Sign in" to run the popup OAuth flow, and persist the resulting tokens +// alongside the rest of the server config. Tokens are written to the +// outer config via onChange so the parent's submit picks them up. +export const OAuthSection: React.FC = ({ + initial, + onChange, +}) => { + const [enabled, setEnabled] = useState(initial?.enabled ?? false); + const [presetId, setPresetId] = useState(() => { + if (!initial?.issuer) return "custom"; + const lower = initial.issuer.toLowerCase(); + if (lower.includes("logto")) return "logto"; + if (lower.includes("auth0")) return "auth0"; + if (lower.includes("/realms/")) return "keycloak"; + return "custom"; + }); + const preset = + OAUTH_PRESETS.find((p) => p.id === presetId) ?? OAUTH_PRESETS[0]; + + const [providerLabel, setProviderLabel] = useState( + initial?.providerLabel ?? preset.label, + ); + const [issuer, setIssuer] = useState(initial?.issuer ?? ""); + const [clientId, setClientId] = useState(initial?.clientId ?? ""); + const [scopes, setScopes] = useState(initial?.scopes ?? preset.defaultScopes); + const [redirectUri, setRedirectUri] = useState(initial?.redirectUri ?? ""); + const [accessToken, setAccessToken] = useState(initial?.accessToken ?? ""); + const [idToken, setIdToken] = useState(initial?.idToken ?? ""); + const [refreshToken, setRefreshToken] = useState(initial?.refreshToken ?? ""); + const [tokenExpiresAt, setTokenExpiresAt] = useState( + initial?.tokenExpiresAt, + ); + + const [signingIn, setSigningIn] = useState(false); + const [signInError, setSignInError] = useState(null); + + // Centralize the parent notification so every state setter goes through + // the same shape. Pass overrides for the field that just changed, since + // useState's setters won't have flushed yet. + const emit = (patch: Partial = {}) => { + if (!enabled && patch.enabled !== true) { + onChange(undefined); + return; + } + onChange({ + enabled: patch.enabled ?? enabled, + providerLabel: patch.providerLabel ?? providerLabel, + issuer: patch.issuer ?? issuer, + clientId: patch.clientId ?? clientId, + scopes: patch.scopes ?? scopes, + redirectUri: patch.redirectUri ?? redirectUri ?? undefined, + accessToken: patch.accessToken ?? accessToken ?? undefined, + idToken: patch.idToken ?? idToken ?? undefined, + refreshToken: patch.refreshToken ?? refreshToken ?? undefined, + tokenExpiresAt: patch.tokenExpiresAt ?? tokenExpiresAt, + }); + }; + + const onPresetChange = (next: string) => { + setPresetId(next); + const np = OAUTH_PRESETS.find((p) => p.id === next) ?? OAUTH_PRESETS[0]; + if (!providerLabel.trim() || providerLabel === preset.label) { + setProviderLabel(np.label); + } + if (!scopes.trim()) { + setScopes(np.defaultScopes); + } + }; + + const handleSignIn = async () => { + setSignInError(null); + if (!issuer.trim()) { + setSignInError("Issuer URL is required."); + return; + } + if (!clientId.trim()) { + setSignInError("Client ID is required."); + return; + } + setSigningIn(true); + try { + const result = await beginOauthLogin({ + issuer: issuer.trim(), + clientId: clientId.trim(), + scopes: scopes.trim() || undefined, + redirectUri: redirectUri.trim() || undefined, + }); + setAccessToken(result.accessToken); + setIdToken(result.idToken ?? ""); + setRefreshToken(result.refreshToken ?? ""); + setTokenExpiresAt(result.tokenExpiresAt); + setEnabled(true); + emit({ + enabled: true, + accessToken: result.accessToken, + idToken: result.idToken ?? undefined, + refreshToken: result.refreshToken ?? undefined, + tokenExpiresAt: result.tokenExpiresAt, + }); + } catch (err) { + setSignInError(err instanceof Error ? err.message : String(err)); + } finally { + setSigningIn(false); + } + }; + + const handleSignOut = () => { + setAccessToken(""); + setIdToken(""); + setRefreshToken(""); + setTokenExpiresAt(undefined); + emit({ + accessToken: undefined, + idToken: undefined, + refreshToken: undefined, + tokenExpiresAt: undefined, + }); + }; + + const tokenStatus = (() => { + if (!accessToken) return "Not signed in"; + if (tokenExpiresAt) { + const remaining = tokenExpiresAt - Math.floor(Date.now() / 1000); + if (remaining <= 0) return "Signed in (token expired)"; + const mins = Math.floor(remaining / 60); + return `Signed in (token expires in ~${mins}m)`; + } + return "Signed in"; + })(); + + return ( +
+

+ OAuth2 / OIDC sign-in +

+ +
+ { + setEnabled(e.target.checked); + emit({ enabled: e.target.checked }); + }} + className="accent-discord-accent rounded" + /> + +
+ + {enabled && ( + <> +
+ + + {preset.hint && ( +

+ {preset.hint} +

+ )} +
+ +
+ + { + setProviderLabel(e.target.value); + emit({ providerLabel: e.target.value }); + }} + placeholder="Logto" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + /> +
+ +
+ + { + setIssuer(e.target.value); + emit({ issuer: e.target.value }); + }} + placeholder="https://my-tenant.logto.app/oidc" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + /> +
+ +
+ + { + setClientId(e.target.value); + emit({ clientId: e.target.value }); + }} + placeholder="m0obbyircd1234" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + /> +
+ +
+ + { + setScopes(e.target.value); + emit({ scopes: e.target.value }); + }} + placeholder="openid" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + /> +
+ +
+ + { + setRedirectUri(e.target.value); + emit({ redirectUri: e.target.value }); + }} + placeholder={`${window.location.origin}/oauth/callback`} + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + /> +

+ Default: {`${window.location.origin}/oauth/callback`} + . Register this in your IdP. +

+
+ +
+ {tokenStatus} +
+ + {signInError && ( +
{signInError}
+ )} + +
+ + {accessToken && ( + + )} +
+ + )} +
+ ); +}; + +export default OAuthSection; diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index b8d7857d..7b35b3a5 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -496,6 +496,7 @@ export class IRCClient implements IRCClientContext { _saslAccountName?: string, _saslPassword?: string, serverId?: string, + oauthBearerEnabled?: boolean, ): Promise { const connectionKey = `${host}:${port}`; @@ -604,8 +605,20 @@ export class IRCClient implements IRCClientContext { saslAccountName: _saslAccountName, saslPassword: _saslPassword, }); - // Only enable SASL if we have both account name AND password - this.saslEnabled.set(server.id, !!(_saslAccountName && _saslPassword)); + // Enable SASL if we have either PLAIN credentials or an OAuth bearer + // path. OAuth path is signaled by the caller; tokens themselves are + // read from storage by the auth handler at AUTHENTICATE time. On + // internal reconnect (oauthBearerEnabled === undefined) preserve the + // previously-set value so the OAuth flag survives reconnects. + const priorSaslEnabled = this.saslEnabled.get(server.id) ?? false; + const wantOauth = + oauthBearerEnabled === undefined + ? priorSaslEnabled + : !!oauthBearerEnabled; + this.saslEnabled.set( + server.id, + !!(_saslAccountName && _saslPassword) || wantOauth, + ); // Store SASL credentials if provided if (_saslAccountName && _saslPassword) { @@ -1358,6 +1371,13 @@ export class IRCClient implements IRCClientContext { this.triggerEvent("CAP_ACKNOWLEDGED", { serverId, key, capabilities }); } + // Allow handlers (e.g. the OAuth path) to flip the SASL-pending flag + // before onCapAck's auto-send-CAP-END check runs. Called from the + // CAP_ACKNOWLEDGED listener that initiates AUTHENTICATE IRCV3BEARER. + setSaslEnabled(serverId: string, enabled: boolean): void { + this.saslEnabled.set(serverId, enabled); + } + capEnd(_serverId: string) {} isCapNegotiationComplete(serverId: string): boolean { diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts new file mode 100644 index 00000000..becfeb30 --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,287 @@ +// OAuth2 / OIDC client used to obtain a bearer token that the IRC server +// will accept via SASL IRCV3BEARER. We support any IdP that publishes an +// OIDC discovery document and issues JWT access or ID tokens (Logto, Auth0, +// Keycloak, Okta, ...). +// +// Flow: +// 1. discoverOidc(issuer) -- caches metadata +// 2. beginOauthLogin(config) -- opens a popup, waits for postMessage from +// /oauth/callback, exchanges the auth code for tokens, returns them +// 3. caller stores the result on its ServerConfig.oauth and reconnects +// +// The redirect URI must be registered with the IdP. If the caller doesn't +// override it we use `/oauth/callback`, which the SPA serves via +// the OAuthCallback component. + +import type { ServerOAuthConfig } from "../types"; + +export interface OidcMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri?: string; + end_session_endpoint?: string; + // Some providers omit this; we tolerate. + code_challenge_methods_supported?: string[]; +} + +export interface OAuthTokenResponse { + access_token: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + scope?: string; +} + +export interface OAuthLoginResult { + accessToken: string; + idToken?: string; + refreshToken?: string; + tokenExpiresAt?: number; + scope?: string; +} + +const discoveryCache = new Map(); + +export function defaultRedirectUri(): string { + return `${window.location.origin}/oauth/callback`; +} + +export async function discoverOidc(issuer: string): Promise { + const trimmed = issuer.replace(/\/+$/, ""); + const cached = discoveryCache.get(trimmed); + if (cached) return cached; + const url = `${trimmed}/.well-known/openid-configuration`; + const res = await fetch(url, { credentials: "omit" }); + if (!res.ok) { + throw new Error(`OIDC discovery failed (${res.status}) at ${url}`); + } + const meta = (await res.json()) as OidcMetadata; + if (!meta.authorization_endpoint || !meta.token_endpoint) { + throw new Error("OIDC metadata missing endpoints"); + } + discoveryCache.set(trimmed, meta); + return meta; +} + +// Crockford-safe base64url with no padding. +function base64UrlEncode(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +// 32 bytes of randomness encoded base64url -- yields 43 chars. +export function generateCodeVerifier(): string { + const buf = new Uint8Array(32); + crypto.getRandomValues(buf); + return base64UrlEncode(buf); +} + +export async function deriveCodeChallenge(verifier: string): Promise { + const enc = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", enc); + return base64UrlEncode(new Uint8Array(digest)); +} + +export function buildAuthorizeUrl( + meta: OidcMetadata, + params: { + clientId: string; + redirectUri: string; + scope: string; + state: string; + codeChallenge: string; + }, +): string { + const u = new URL(meta.authorization_endpoint); + u.searchParams.set("response_type", "code"); + u.searchParams.set("client_id", params.clientId); + u.searchParams.set("redirect_uri", params.redirectUri); + u.searchParams.set("scope", params.scope); + u.searchParams.set("state", params.state); + u.searchParams.set("code_challenge", params.codeChallenge); + u.searchParams.set("code_challenge_method", "S256"); + return u.toString(); +} + +export async function exchangeCodeForToken(args: { + meta: OidcMetadata; + clientId: string; + redirectUri: string; + code: string; + codeVerifier: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: args.code, + redirect_uri: args.redirectUri, + client_id: args.clientId, + code_verifier: args.codeVerifier, + }); + const res = await fetch(args.meta.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + credentials: "omit", + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Token exchange failed (${res.status}): ${txt}`); + } + return (await res.json()) as OAuthTokenResponse; +} + +const POPUP_W = 480; +const POPUP_H = 720; + +export interface OAuthCallbackMessage { + type: "obsidianirc:oauth-callback"; + state: string; + code?: string; + error?: string; + errorDescription?: string; +} + +// Run the full authorization-code-with-PKCE dance via a popup. Returns the +// tokens. Caller is responsible for storing them on the server config. +export async function beginOauthLogin( + cfg: Pick< + ServerOAuthConfig, + "issuer" | "clientId" | "scopes" | "redirectUri" + >, +): Promise { + const meta = await discoverOidc(cfg.issuer); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await deriveCodeChallenge(codeVerifier); + const state = generateCodeVerifier(); + const scope = cfg.scopes?.trim() || "openid"; + const redirectUri = cfg.redirectUri?.trim() || defaultRedirectUri(); + const url = buildAuthorizeUrl(meta, { + clientId: cfg.clientId, + redirectUri, + scope, + state, + codeChallenge, + }); + + const left = window.screenX + (window.outerWidth - POPUP_W) / 2; + const top = window.screenY + (window.outerHeight - POPUP_H) / 2; + const popup = window.open( + url, + "obsidianirc-oauth", + `width=${POPUP_W},height=${POPUP_H},left=${left},top=${top},popup=yes`, + ); + if (!popup) { + throw new Error( + "Popup was blocked. Allow popups for this site and try again.", + ); + } + + const code = await new Promise((resolve, reject) => { + let closed = false; + const cleanup = () => { + window.removeEventListener("message", onMessage); + clearInterval(closeTimer); + }; + const onMessage = (event: MessageEvent) => { + // Same-origin only; the callback page is served by the SPA. + if (event.origin !== window.location.origin) return; + const data = event.data as OAuthCallbackMessage | undefined; + if (!data || data.type !== "obsidianirc:oauth-callback") return; + if (data.state !== state) { + cleanup(); + reject(new Error("OAuth state mismatch (possible CSRF)")); + return; + } + cleanup(); + try { + popup.close(); + } catch {} + if (data.error) { + reject( + new Error( + data.errorDescription + ? `${data.error}: ${data.errorDescription}` + : data.error, + ), + ); + } else if (data.code) { + resolve(data.code); + } else { + reject(new Error("OAuth callback returned no code")); + } + }; + window.addEventListener("message", onMessage); + const closeTimer = setInterval(() => { + if (popup.closed && !closed) { + closed = true; + cleanup(); + reject(new Error("OAuth popup was closed before completing")); + } + }, 500); + }); + + const tokens = await exchangeCodeForToken({ + meta, + clientId: cfg.clientId, + redirectUri, + code, + codeVerifier, + }); + const expiresAt = tokens.expires_in + ? Math.floor(Date.now() / 1000) + tokens.expires_in + : undefined; + return { + accessToken: tokens.access_token, + idToken: tokens.id_token, + refreshToken: tokens.refresh_token, + tokenExpiresAt: expiresAt, + scope: tokens.scope, + }; +} + +// Provider presets surfaced in the UI dropdown. "custom" lets the admin type +// any issuer URL. +export interface OAuthProviderPreset { + id: string; + label: string; + // Either a fully-qualified issuer (e.g. "https://accounts.google.com") or + // null for providers where the admin must paste their tenant URL (Logto). + issuer: string | null; + defaultScopes: string; + // Helper text shown under the issuer field when this preset is picked. + hint?: string; +} + +export const OAUTH_PRESETS: OAuthProviderPreset[] = [ + { + id: "custom", + label: "Custom OIDC provider", + issuer: null, + defaultScopes: "openid", + hint: "Any IdP that publishes /.well-known/openid-configuration and issues JWT access tokens.", + }, + { + id: "logto", + label: "Logto", + issuer: null, + defaultScopes: "openid", + hint: "Paste your tenant URL, e.g. https://my-tenant.logto.app/oidc", + }, + { + id: "auth0", + label: "Auth0", + issuer: null, + defaultScopes: "openid profile", + hint: "Issuer is https://.us.auth0.com/", + }, + { + id: "keycloak", + label: "Keycloak", + issuer: null, + defaultScopes: "openid", + hint: "Issuer is https:///realms/", + }, +]; diff --git a/src/lib/saslFrames.ts b/src/lib/saslFrames.ts new file mode 100644 index 0000000000000000000000000000000000000000..889bbdf67f2b38845c82e85664daa5657d52b2d5 GIT binary patch literal 2079 zcma)7VQ7^N2^}hd*P; ztRe!q&y*%^S}Y02Wzx?V{e&47b~6rHqZpC6s1y>gyqB zqmWE6ggd<3=~yXN!Al1~aZ@UZ8F+#je>PJY=LuJS5OVwF>)@=jY58*rf2Z=ttIBZg zNA%nFiXmJq>ptTKA|a8N=bd4=MXtc7O@Uj3GyFTpQlX15<8oyXv(zhH%eHll~);2lzUGbCa*Q{AQ|xU+mTw zl>T{*n^UC_AYztbHTYv;D(LrZ(KJ_UEIy`6tU02b2!O?LUSes9F}b3B8|YUJb6rMr zh%+W0!w%{+SBPuM6p^B@RlI(C#hK#j4<~4!sU>EY3T53ixuv_?jT!R49D?`PlkwnR z@N(jQkA>!l?F??UnZxctw*d41rB!`mzORE1$N-tX9I1P1hg?7D&;Ie zh*jbI;H5@w-WpU+Fo|9a6LiQ<2Eg+|3@@CY+~(W@$;r@mU_@tml)Z-!#nN^C=ibJ7;}M63Cb*aR(8T90ZQ`raJ8 ztk#=-8zS7cp><#8|}f6;q0R6<^}`yb?Ph;_L_vL>c@6sBi%BcHGL#H zIc^^R_T}F{*v8^g;U-#x!J`q}KTt(}+ObcLcl{cuQRAp}6DY7gu}Rno85X5ZeGDGz rOvKz5J?!}1Hr&3rO): void { ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { if (capabilities?.startsWith("draft/metadata")) { @@ -30,11 +45,16 @@ export function registerAuthHandlers(store: StoreApi): void { } if (key === "sasl") { const servers = storage.servers.load(); - for (const serv of servers) { - if (serv.id !== serverId) continue; - - if (!serv.saslEnabled) return; + const serv = servers.find((s) => s.id === serverId); + if (!serv) return; + if (getActiveOauth(serv)) { + // Flip the SASL-pending flag so IRCClient.onCapAck's race-y CAP END + // path waits for 903/904 before tearing down the negotiation. + ircClient.setSaslEnabled(serverId, true); + ircClient.sendRaw(serverId, "AUTHENTICATE IRCV3BEARER"); + return; } + if (!serv.saslEnabled) return; ircClient.sendRaw(serverId, "AUTHENTICATE PLAIN"); } }); @@ -45,30 +65,31 @@ export function registerAuthHandlers(store: StoreApi): void { // Don't respond to AUTHENTICATE if CAP negotiation is already complete if (ircClient.isCapNegotiationComplete(serverId)) return; - let user: string | undefined; - let pass: string | undefined; const servers = storage.servers.load(); - for (const serv of servers) { - if (serv.id !== serverId) continue; - - if (!serv.saslEnabled) return; - - user = serv.saslAccountName?.length - ? serv.saslAccountName - : serv.nickname; - pass = serv.saslPassword ? atob(serv.saslPassword) : undefined; - } - if (!user || !pass) - // wtf happened lol + const serv = servers.find((s) => s.id === serverId); + if (!serv) return; + + const oauth = getActiveOauth(serv); + if (oauth?.accessToken) { + const b64 = buildIrcv3BearerPayload({ token: oauth.accessToken }); + for (const chunk of chunkSaslPayload(b64)) { + ircClient.sendRaw(serverId, `AUTHENTICATE ${chunk}`); + } return; + } + + if (!serv.saslEnabled) return; + const user = serv.saslAccountName?.length + ? serv.saslAccountName + : serv.nickname; + const pass = serv.saslPassword ? atob(serv.saslPassword) : undefined; + if (!user || !pass) return; ircClient.sendRaw( serverId, `AUTHENTICATE ${btoa(`${user}\x00${user}\x00${pass}`)}`, ); // Note: CAP END will be sent by the IRC client when SASL authentication completes (903/904-907 responses) - // ircClient.sendRaw(serverId, "CAP END"); - // ircClient.userOnConnect(serverId); }); // Handle CAP LS to get informational capabilities like unrealircd.org/link-security @@ -179,10 +200,15 @@ export function registerAuthHandlers(store: StoreApi): void { // Check if SASL was requested and acknowledged, AND we have credentials if (caps.some((cap) => cap.startsWith("sasl"))) { - // Only prevent CAP END if we actually have SASL credentials + // Only prevent CAP END if we actually have SASL credentials -- + // either a PLAIN password or an OAuth bearer token. const servers = storage.servers.load(); const savedServer = servers.find((s) => s.id === serverId); - if (savedServer?.saslEnabled && savedServer?.saslPassword) { + const hasPlain = + savedServer?.saslEnabled && Boolean(savedServer?.saslPassword); + const hasOauth = + savedServer?.oauth?.enabled && Boolean(savedServer?.oauth?.accessToken); + if (hasPlain || hasOauth) { preventCapEnd = true; } } diff --git a/src/store/index.ts b/src/store/index.ts index 04acaaa4..599b9cf5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -985,6 +985,10 @@ const useStore = create((set, get) => ({ (s) => normalizeHost(s.host) === normalizeHost(host) && s.port === port, ); + const oauthBearerEnabled = !!( + existingSavedServer?.oauth?.enabled && + existingSavedServer.oauth.accessToken + ); const server = await ircClient.connect( name, host, @@ -994,6 +998,7 @@ const useStore = create((set, get) => ({ saslAccountName, saslPassword, existingSavedServer?.id, // Pass the saved server ID if it exists + oauthBearerEnabled, ); // Save server to localStorage diff --git a/src/types/index.ts b/src/types/index.ts index 8d66cef7..617c5c35 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,6 +69,20 @@ export interface ServerConfig { operPassword?: string; operOnConnect?: boolean; addedAt?: number; // Timestamp when server was added (ms since epoch) + oauth?: ServerOAuthConfig; +} + +export interface ServerOAuthConfig { + enabled: boolean; + providerLabel: string; + issuer: string; + clientId: string; + scopes?: string; + redirectUri?: string; + accessToken?: string; + idToken?: string; + refreshToken?: string; + tokenExpiresAt?: number; } export interface Channel { diff --git a/tests/lib/oauth.test.ts b/tests/lib/oauth.test.ts new file mode 100644 index 00000000..54778aea --- /dev/null +++ b/tests/lib/oauth.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildAuthorizeUrl, + defaultRedirectUri, + deriveCodeChallenge, + discoverOidc, + generateCodeVerifier, +} from "../../src/lib/oauth"; + +describe("generateCodeVerifier / deriveCodeChallenge", () => { + it("produces a 43-char base64url string with no padding", () => { + const v = generateCodeVerifier(); + expect(v).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it("derives an S256 challenge of the same shape", async () => { + const v = generateCodeVerifier(); + const c = await deriveCodeChallenge(v); + expect(c).toMatch(/^[A-Za-z0-9_-]{43}$/); + // Must be deterministic for the same verifier. + expect(await deriveCodeChallenge(v)).toBe(c); + }); + + it("matches a known RFC 7636 vector", async () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + expect(await deriveCodeChallenge(verifier)).toBe(expected); + }); +}); + +describe("buildAuthorizeUrl", () => { + it("includes PKCE + state + scope params", () => { + const url = buildAuthorizeUrl( + { + issuer: "https://idp.example/", + authorization_endpoint: "https://idp.example/oauth/authorize", + token_endpoint: "https://idp.example/oauth/token", + }, + { + clientId: "abc", + redirectUri: "https://app.example/oauth/callback", + scope: "openid profile", + state: "STATE", + codeChallenge: "CHAL", + }, + ); + const u = new URL(url); + expect(u.searchParams.get("client_id")).toBe("abc"); + expect(u.searchParams.get("response_type")).toBe("code"); + expect(u.searchParams.get("redirect_uri")).toBe( + "https://app.example/oauth/callback", + ); + expect(u.searchParams.get("scope")).toBe("openid profile"); + expect(u.searchParams.get("state")).toBe("STATE"); + expect(u.searchParams.get("code_challenge")).toBe("CHAL"); + expect(u.searchParams.get("code_challenge_method")).toBe("S256"); + }); +}); + +describe("defaultRedirectUri", () => { + it("derives /oauth/callback from window.location", () => { + expect(defaultRedirectUri()).toBe( + `${window.location.origin}/oauth/callback`, + ); + }); +}); + +describe("discoverOidc", () => { + const fetchMock = vi.fn(); + beforeEach(() => { + fetchMock.mockReset(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + afterEach(() => { + fetchMock.mockReset(); + }); + + it("hits /.well-known/openid-configuration and returns parsed metadata", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: "https://idp.example/", + authorization_endpoint: "https://idp.example/oauth/authorize", + token_endpoint: "https://idp.example/oauth/token", + }), + }); + const meta = await discoverOidc("https://idp.example/test/"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe( + "https://idp.example/test/.well-known/openid-configuration", + ); + expect(meta.token_endpoint).toBe("https://idp.example/oauth/token"); + }); + + it("rejects when the document misses required endpoints", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ issuer: "https://idp.example/missing/" }), + }); + await expect( + discoverOidc("https://idp.example/missing/"), + ).rejects.toThrow(); + }); +}); diff --git a/tests/lib/saslFrames.test.ts b/tests/lib/saslFrames.test.ts new file mode 100644 index 00000000..a5d6caf8 --- /dev/null +++ b/tests/lib/saslFrames.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + buildIrcv3BearerPayload, + buildOauthBearerPayload, + chunkSaslPayload, +} from "../../src/lib/saslFrames"; + +function decodeB64(b64: string): string { + const bin = atob(b64); + let out = ""; + for (let i = 0; i < bin.length; i++) { + const ch = bin.charCodeAt(i); + out += ch === 0 ? "\\0" : String.fromCharCode(ch); + } + return out; +} + +describe("buildIrcv3BearerPayload", () => { + it("formats [authzid]\\0type\\0token with empty authzid by default", () => { + const b64 = buildIrcv3BearerPayload({ token: "TOK" }); + expect(decodeB64(b64)).toBe("\\0jwt\\0TOK"); + }); + + it("respects an explicit authzid", () => { + const b64 = buildIrcv3BearerPayload({ + token: "TOK", + authzid: "alice", + tokenType: "oauth2", + }); + expect(decodeB64(b64)).toBe("alice\\0oauth2\\0TOK"); + }); + + it("preserves multibyte UTF-8 in the token", () => { + const b64 = buildIrcv3BearerPayload({ token: "héllo" }); + const bin = atob(b64); + // 0x00 j w t 0x00 h é(2 bytes utf-8: c3 a9) l l o + expect(bin.length).toBe(1 + 3 + 1 + 6); + }); +}); + +describe("buildOauthBearerPayload", () => { + it("emits the RFC 7628 GS2 frame", () => { + const b64 = buildOauthBearerPayload({ token: "TOK", authzid: "alice" }); + const decoded = atob(b64); + expect(decoded).toBe("n,a=alice,\x01auth=Bearer TOK\x01\x01"); + }); + + it("includes optional host/port hints", () => { + const b64 = buildOauthBearerPayload({ + token: "TOK", + host: "irc.example.com", + port: 6697, + }); + const decoded = atob(b64); + expect(decoded).toBe( + "n,a=,\x01port=6697\x01host=irc.example.com\x01auth=Bearer TOK\x01\x01", + ); + }); +}); + +describe("chunkSaslPayload", () => { + it("returns the input as a single chunk when shorter than 400", () => { + expect(chunkSaslPayload("abc")).toEqual(["abc"]); + }); + + it("splits at every 400-character boundary", () => { + const s = "x".repeat(950); + const chunks = chunkSaslPayload(s); + expect(chunks.length).toBe(3); + expect(chunks[0].length).toBe(400); + expect(chunks[1].length).toBe(400); + expect(chunks[2].length).toBe(150); + }); + + it("appends a + sentinel when the final chunk is exactly 400 chars", () => { + const s = "x".repeat(800); + const chunks = chunkSaslPayload(s); + expect(chunks).toEqual(["x".repeat(400), "x".repeat(400), "+"]); + }); +}); From 68140b41eb73f0a9b72bbe0576169b8c03cf7d5d Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 9 May 2026 20:30:29 +0100 Subject: [PATCH 09/16] feat(auth): bake OAuth config into the build for lock-mode deployments When the client is built with VITE_HIDE_SERVER_LIST=true (single-server deploy where users can't add their own servers), admins can now bake the OAuth provider settings into the build via VITE_DEFAULT_OAUTH_* env vars. Users see a "Sign in with " button instead of an editable issuer/client_id form -- they don't need to know what Logto, Auth0 or Keycloak even is. VITE_DEFAULT_OAUTH_PROVIDER_LABEL e.g. "Logto" VITE_DEFAULT_OAUTH_ISSUER e.g. https://my-tenant.logto.app/oidc VITE_DEFAULT_OAUTH_CLIENT_ID VITE_DEFAULT_OAUTH_SCOPES optional, defaults "openid" VITE_DEFAULT_OAUTH_REDIRECT_URI optional, defaults /oauth/callback Both the welcome AddServerModal and EditServerModal pull getBuiltinOAuthConfig() and pass it as `locked` to OAuthSection, which in locked mode renders only the Sign-in / Sign-out actions plus a token status line. The provider fields are no longer editable. In multi-server builds (VITE_HIDE_SERVER_LIST unset), behavior is unchanged: the editable per-server OAuth panel still drives. Tests cover getBuiltinOAuthConfig() across all four present/missing-field combinations. Dockerfile + BUILD.md updated. --- BUILD.md | 15 +++++ Dockerfile | 10 +++ src/components/ui/AddServerModal.tsx | 22 +++++++ src/components/ui/EditServerModal.tsx | 7 ++- src/components/ui/OAuthSection.tsx | 87 +++++++++++++++++++++++++-- src/lib/oauth.ts | 29 +++++++++ src/store/index.ts | 21 ++++++- src/vite-env.d.ts | 5 ++ tests/lib/oauth.test.ts | 44 ++++++++++++++ vite.config.ts | 5 ++ 10 files changed, 235 insertions(+), 10 deletions(-) diff --git a/BUILD.md b/BUILD.md index 4c624ac4..518a4aae 100644 --- a/BUILD.md +++ b/BUILD.md @@ -30,6 +30,18 @@ VITE_HIDE_SERVER_LIST=true # Optional comma-separated list of trusted media URLs # Useful for chat bridges like Matterbridge or Matrix bridges that host media VITE_TRUSTED_MEDIA_URLS="https://matterbridge.example.com,https://matrix-media.example.com" + +# Optional OAuth2 / OIDC defaults. Only surfaced when VITE_HIDE_SERVER_LIST=true, +# i.e. single-server lock-mode. Users see a "Sign in with