From 3e75f86769c19507df17e2e2a82bb05b469d6f91 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Wed, 28 Oct 2026 16:49:32 +0200 Subject: [PATCH 1/6] feat: allow optional password and Slack code admin sign-in Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 3 + FEATURES.md | 14 +- TEST-PLAN.md | 24 ++++ public/admin-access.js | 112 +++++++++++++++- public/app.js | 4 +- public/login.html | 18 +++ public/login.js | 55 +++++++- src/config/settings.js | 4 + src/web/admin-auth.js | 108 +++++++++++++-- src/web/auth.js | 12 +- src/web/routes/settings.js | 10 +- test/admin-password-slack-browser.test.js | 76 +++++++++++ test/admin-password-slack.test.js | 153 ++++++++++++++++++++++ test/authorization.test.js | 27 ++++ 14 files changed, 591 insertions(+), 29 deletions(-) create mode 100644 test/admin-password-slack-browser.test.js create mode 100644 test/admin-password-slack.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a087d0..15c90f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ product overview. ## Unreleased +- Add an opt-in admin sign-in alternative using the admin password and a private, expiring Slack + verification code for existing administrators, including sensitive-action reauthentication. + - Version login-page scripts with their HTML so cached pre-upgrade code cannot reference removed fields. - Use the existing Users list and admin role for dashboard passkeys and invitations. Existing diff --git a/FEATURES.md b/FEATURES.md index 61d61146..025ea3f7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -10,6 +10,18 @@ Slack’s native running/Stop label shows the resolved model and explicitly configured effort, plus compact live phases (Thinking, Using tools, Writing, Waiting, Queued, Coordinating agents). Engine fallback replaces both model and effort; unset effort is omitted rather than guessed. +## Optional password and Slack code sign-in + +- Settings → Admin access can enable an alternative to passkeys: the configured admin password + followed by an eight-digit code delivered privately to an existing Slack administrator. + The option is off by default; password alone never grants dashboard access. Existing passkeys + remain available. A recently verified, identity-linked initial setup can enable this option + without first registering a passkey. +- Codes expire after five minutes, are bound to the requesting browser, accept at most five + guesses and are single-use. Delivery failures, role revocation, password changes and disabling + the option invalidate pending authority. Sensitive actions can repeat password and Slack + verification. Sessions retain the normal idle/absolute expiry and current-role checks. + ## User-linked dashboard passkeys - The existing **Users** directory and its admin role govern dashboard access. Each credential @@ -27,7 +39,7 @@ Slack’s native running/Stop label shows the resolved model and explicitly conf session, with five attempts maximum. Matching names never assign ownership. Unlinked accounts can complete this verification but cannot use ordinary admin APIs once known admins exist. Initial password setup follows the same proof before enrollment; fresh installs without known - admins retain temporary operator setup. First enrollment permanently retires password login. + admins retain temporary operator setup. First enrollment retires the password-only bootstrap path; the optional password plus Slack code flow requires explicit enablement. - Host operators recover a known admin with `npm run admin:recover -- --user `. Recovery revokes its keys/sessions and prints a short-lived registration-only link; it never restores password login. Private keys remain with authenticators; SQLite stores public keys diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 3e7a46d5..262dde7e 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,29 @@ # ChannelGate — Test Plan +## Optional password and Slack code authentication (2026-10-28) + +- Automated scratch HTTP fixture: `test/admin-password-slack.test.js`. Use fake private delivery, + disposable passwords and existing Slack Admin/non-admin users. Verify disabled-by-default, + password-only denial, correct browser-bound code login after passkey enrollment, replay/expiry/ + guess limits, private delivery failure, role and credential/settings revocation. Never send + fixture codes to deployed users. +- Browser fixture: `test/admin-password-slack-browser.test.js` exercises Settings → Admin access enablement, alternate sign-in and sensitive + reauthentication and sign-out on disable without a WebAuthn device. Chromium verification: + all 15 combined passkey/initial-setup/alternate-login assertions passed, zero skips; the + extended disable/sign-out browser fixture also passed. Private delivery is simulated. +- Live acceptance pending, engine-independent: this HTTP/browser authentication path never calls + either engine. On a disposable gateway with an existing Slack administrator and working bot DM + delivery, enable the checkbox using recent admin verification, sign out, enter the password + and own Slack member ID, then enter the privately received code. Pass only when the dashboard + opens as that exact existing user with no passkey prompt. Repeat a sensitive action with code + reauthentication. Confirm a wrong/replayed code and a non-admin are refused; disabling the + checkbox removes alternate login and invalidates its sessions. Check from the RustDesk browser + through a localhost SSH tunnel. Record screenshots/HTTP evidence without codes/passwords. +- Gate maintenance: linked-workspace authorization cases in `test/authorization.test.js` + restore the existing 95% branch floor (measured 96.81%); no production policy or floor changed. +- Private QA registry update pending: the requesting user's personal Airtable connection is not + connected in this run. Do not treat the missing registry or unexecuted live case as passed. + ## Linked working-folder setup acceptance Re-verification (2026-10-26): regression coverage now checks source `excludedUsers` and restricted-channel policy through linked DMs, explicit-unlink/source-departure section-skill retention, DM template preservation, established promoted-owner selection, and immediate follower SSH disconnection after source revocation (20-minute timer disabled as a fallback). `test/skills-workspace-sync.test.js` and `test/ssh-broker.test.js` cover these gaps. Updated full suite: 4,117 passed, 37 skipped, zero failures; Chromium linked-folder fixture passed. Actual Claude and Codex CLI smoke checks read a source-scoped skill from a generated linked workspace and returned its unique marker. Codex used the gateway's Worker/container mode; an initial nested read-only sandbox attempt failed with a bubblewrap capability error. These are CLI smoke checks, not a Slack end-to-end acceptance pass. diff --git a/public/admin-access.js b/public/admin-access.js index e3a00466..4a210ec9 100644 --- a/public/admin-access.js +++ b/public/admin-access.js @@ -2,12 +2,89 @@ import { api } from "./admin-api.js"; const passkeys = () => window.ChannelGatePasskeys; +// Passwords and codes stay only in this temporary form; never browser storage. +async function passwordSlackReauth(me) { + const previousFocus = document.activeElement; + const modal = document.createElement("div"); + modal.className = "modal"; + modal.innerHTML = ``; + document.body.append(modal); + const at = (name) => modal.querySelector(`[data-auth="${name}"]`); + at("passkey").hidden = !me.passkeys?.length; + let requestId = ""; + let busy = false; + return new Promise((resolve, reject) => { + function finish(value, error) { + at("password").value = ""; + at("code").value = ""; + document.removeEventListener("keydown", onKey); + modal.remove(); + previousFocus?.focus(); + if (error) reject(error); else resolve(value); + } + function onKey(event) { + if (event.key === "Escape" && !busy) { event.preventDefault(); finish(false, new Error("Identity verification cancelled.")); } + if (event.key === "Tab") { + const controls = [...modal.querySelectorAll("input, button")].filter((item) => !item.disabled && !item.closest("[hidden]")); + const first = controls[0]; + const last = controls.at(-1); + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + } + } + document.addEventListener("keydown", onKey); + at("cancel").addEventListener("click", () => finish(false, new Error("Identity verification cancelled."))); + at("passkey").addEventListener("click", () => finish(false)); + modal.querySelector("form").addEventListener("submit", async (event) => { + event.preventDefault(); + if (busy) return; + busy = true; + for (const button of modal.querySelectorAll("button")) button.disabled = true; + at("error").textContent = ""; + try { + if (!requestId) { + let result; + try { + result = await api("/api/auth/password-slack/options", { method: "POST", body: JSON.stringify({ password: at("password").value, userId: me.account.userId }) }); + } finally { at("password").value = ""; } + requestId = result.requestId; + at("password").required = false; + at("password-label").hidden = true; + at("code-label").hidden = false; + at("code").required = true; + at("status").textContent = "Check your private Slack messages. The code expires in 5 minutes."; + at("submit").textContent = "Verify code"; + at("code").focus(); + } else { + try { + await api("/api/auth/password-slack/verify", { method: "POST", body: JSON.stringify({ requestId, code: at("code").value.trim() }) }); + } finally { at("code").value = ""; } + finish(true); + } + } catch (error) { at("error").textContent = error.message; } + finally { + busy = false; + for (const button of modal.querySelectorAll("button")) button.disabled = false; + } + }); + at("password").focus(); + }); +} + // Called from a user action (settings, secret reveal, account/key management). The server is // authoritative about freshness; never cache a password or an assertion in browser storage. export async function freshAdminAuth({ force = false } = {}) { const me = await api("/api/auth/me", { cache: "no-store" }); if (me.bootstrap) throw new Error("Create your first passkey under Settings → Admin access before continuing."); if (!force && me.fresh) return true; + if (me.passwordSlackEnabled && /^[UW][A-Z0-9]{8,}$/.test(me.account?.userId || "") && await passwordSlackReauth(me)) return true; try { const { options } = await api("/api/auth/reauth/options", { method: "POST", body: "{}" }); const response = await passkeys().get(options); @@ -67,7 +144,13 @@ export async function mountAdminAccess(container, { confirm = async () => false,

- + +
+ +

Admins can sign in without a passkey using the shared dashboard password and a private Slack code. Requires a connected Slack admin account. Disabling this option signs out password and Slack code sessions.

+ + +
@@ -83,7 +166,7 @@ export async function mountAdminAccess(container, { confirm = async () => false, let linkRequestId = ""; function syncControls() { if (!active()) return; - for (const control of container.querySelectorAll("button, select")) control.disabled = !ready || busy || control.dataset.unavailable === "true"; + for (const control of container.querySelectorAll("button, select, input[type=checkbox]")) control.disabled = !ready || busy || control.dataset.unavailable === "true"; } syncControls(); function message(text, error = false) { @@ -126,6 +209,10 @@ export async function mountAdminAccess(container, { confirm = async () => false, if (!users.length) at("link-status").textContent = "No existing admin user is available to link. Configure your chat connection and admin users first."; } at("bootstrap").hidden = !me.bootstrap; + at("password-slack-label").textContent = me.bootstrap ? "Existing setup password (required to enable sign-in)" : "Dashboard password (leave blank to keep the current password)"; + at("password-slack-enabled").checked = Boolean(me.passwordSlackEnabled); + at("password-slack-save").dataset.unavailable = String(!me.account.userId || me.linkRequired); + at("password-slack-enabled").dataset.unavailable = String(!me.account.userId || me.linkRequired); at("register").textContent = me.passkeys.length ? "Add a passkey" : "Create your first passkey"; at("register").dataset.unavailable = String(Boolean(me.linkRequired)); at("keys").replaceChildren(...me.passkeys.map((key) => accessRow( @@ -171,6 +258,27 @@ export async function mountAdminAccess(container, { confirm = async () => false, at("link-verify-form").hidden = true; at("link-status").textContent = "Send a new verification code to the selected user."; }); + async function savePasswordSlack() { + const enabled = at("password-slack-enabled").checked; + const password = at("password-slack-password").value; + at("password-slack-password").value = ""; + try { + if (!me.bootstrap) await freshAdminAuth(); + const result = await api("/api/auth/password-slack/settings", { method: "PUT", body: JSON.stringify({ enabled, ...(password ? { password } : {}) }) }); + if (result.signedOut) { location.replace("/login.html"); return; } + await refresh(); + message(enabled ? "Password and Slack code sign-in enabled." : "Password and Slack code sign-in disabled."); + if (enabled && !me.bootstrap && !me.passkeys.length) location.replace("/settings#set-admin-access"); + } catch (error) { + at("password-slack-enabled").checked = Boolean(me.passwordSlackEnabled); + throw error; + } + } + at("password-slack-form").addEventListener("submit", (event) => { + event.preventDefault(); + void run(savePasswordSlack); + }); + at("password-slack-enabled").addEventListener("change", () => { void run(savePasswordSlack); }); at("register-form").addEventListener("submit", (event) => { event.preventDefault(); void run(async () => { diff --git a/public/app.js b/public/app.js index c194f1b5..07ca6c3c 100644 --- a/public/app.js +++ b/public/app.js @@ -5239,7 +5239,7 @@ document.getElementById("dash-source").addEventListener("change", () => loadDash // ── Boot ───────────────────────────────────────────────────────────────────────── async function init() { const identity = await api("/api/auth/me", { cache: "no-store" }); - if (identity.linkRequired) { + if (identity.linkRequired || identity.bootstrap) { // Existing unlinked passkeys can authenticate, but ordinary admin APIs stay closed until // their owner proves the existing gateway identity. Keep that remedy reachable on its own. document.querySelector(".app").style.display = "none"; @@ -5248,7 +5248,7 @@ async function init() { const card = document.createElement("div"); card.className = "setcard identity-link-card"; const title = document.createElement("h2"); - title.textContent = "Link your gateway admin user"; + title.textContent = identity.linkRequired ? "Link your gateway admin user" : "Complete dashboard access setup"; const access = document.getElementById("admin-access"); card.append(title, access); gate.append(card); diff --git a/public/login.html b/public/login.html index 3d6d3204..d25efc20 100644 --- a/public/login.html +++ b/public/login.html @@ -56,6 +56,23 @@
+