From c32b3577274b8c680530e5c37766f273b6394003 Mon Sep 17 00:00:00 2001 From: webdevtodayjason Date: Mon, 21 Sep 2026 18:39:44 -0500 Subject: [PATCH 1/3] Dashboard: a real front door, so nobody pastes a key to read a page (#261) A person signs in and stays signed in until they sign out. A program sends an API key. Those were the same credential before this, which meant the only way to open a dashboard on a node that required auth was to paste a key meant for a script into a browser. The browser side: * app.js::init reads GET /api/auth/me and nothing else before deciding. AINodeAuth.bootDecision turns that one payload into the shell or the sign-in screen. A node that never answers the route (an older release) reads as "carry on on the API key", never as "signed out", so this is safe to ship ahead of the backend. * static/js/signin.js is the screen: a full page, not a modal. Name, password, Enter submits, the browser's password manager sees a real login (autocomplete username / current-password). One error line for a wrong password, for the 429 (it says how long to wait) and for the 409, which shows the exact command that creates the first account. Made in Texas on the footer. * Every request the dashboard makes now carries X-AINode-Client: dashboard and credentials: same-origin, stamped once in AINodeAuth.headers/fetch. The server ignores the session cookie without that header, so a call site that built its own headers would be a call site whose cookie is dropped. * A 401 is routed by WHICH credential failed: no credential goes back to the front door with a one-line reason ("This node ended your session. Sign in again."), a rejected stored key still goes to Config > API access, which is where a key is fixed. * Header: a user chip with the name, the role, and Sign out. Signing out ends the session and leaves a stored key alone, because that key belongs to a program and API access has the button that forgets it. * Config > Account: who you are, change password, your sessions with created and last-seen times and a Revoke per session (this browser is marked), and a pointer to keys for programs. * Config > Users: admins add, reset, disable, enable and remove accounts. The page renders the server's answer rather than a role it read once, so a member gets one line and the last-admin refusal is the node's own words. * Config > API access is now about keys for programs and the auth switch. The copy names both new open paths (/api/auth/login, /api/auth/me) and says who sends what. "Paste a key" still works, collapsed under "Use a key in this browser instead", for a browser driving a node it has no account on. "Stop requiring a key" only shows to a caller this node has authenticated (#262). * The chip copy is written from the user's side: "Signed in as ", "Using an API key", "Open, no key required", "Sign-in required". The node's own wording for the port moved to apiStateText, which is what the API access Status card prints, so /api/status, the installer summary and this panel still read the same. tests/test_auth_usable.py gains the boot decision table (auth off, auth on plus session, auth on plus key, auth on plus nothing, auth on with no accounts), the CSRF header on GET and non-GET, that a wrong password is answered on the screen instead of firing the session-ended handler, and that sign out does not forget a program's key. All of it runs under node against the real auth.js and signin.js. Backend for this is a separate PR; this half was driven against a stubbed server in headless Chrome, every boot row and the whole sign-in, account, users and sign-out flow, with no console errors. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 1 + ainode/web/static/css/style.css | 323 ++++++++++++++ ainode/web/static/js/app.js | 731 +++++++++++++++++++++++++++++--- ainode/web/static/js/auth.js | 224 +++++++++- ainode/web/static/js/signin.js | 180 ++++++++ ainode/web/templates/index.html | 22 +- tests/test_auth_usable.py | 346 ++++++++++++++- 7 files changed, 1751 insertions(+), 76 deletions(-) create mode 100644 ainode/web/static/js/signin.js diff --git a/AGENTS.md b/AGENTS.md index 619dcfa3..aa5642b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ State / architecture / decisions / "why": Obsidian Vault, `AINode` (cluster ops: - **A join writes its own keys into `config.json` and touches nothing else** (`cluster/join.py::merge_config_keys`). It merges into the raw JSON; a `NodeConfig.load(); setattr; save()` round trip rewrites the file from the dataclass, so it ADDS every key this release happens to default differently and DROPS every key the dataclass does not declare. A joiner is `cluster_role: "worker"` AND `distributed_mode: "member"`: those are the two spellings of one decision, and `member` is not a value `cluster_role` accepts (the `PATCH /api/config` validator rejects it). - **The installer generates a `cluster_secret`, and the join flow is how the value reaches a second node** (#169's follow-up). Nothing else distributes it: a node whose secret differs from the rest is dark to them once discovery is signed, so anything that MINTS a secret on an existing cluster has to say so out loud. `ainode config` masks it and `GET /api/config` scrubs it. - **There is no browser onboarding wizard, and reviving one is not the fix for a first-run gap** (#208). The 19 KB template, its three `/api/onboarding/*` routes and `handle_index`'s redirect to them are deleted: the installer writes `"onboarded": true` and every non-TTY start sets it before the server binds, so `/onboarding` only ever answered a redirect back to `/`, and its complete handler never wrote a cluster key. The TTY wizard (`onboarding/setup.py`) stays. Browser-side joining is one card in `web/static/js/join.js` on a key-protected `POST /api/cluster/join-self`, which restarts nothing (the restart would kill its own response) and `app.js` holds exactly one line that mounts it. +- **A PERSON signs in at the front door and a PROGRAM sends a key, and the browser decides which before it fetches anything else** (#261). `app.js::init` reads `GET /api/auth/me` (open, like `/api/auth/status`) and nothing else: `AINodeAuth.bootDecision` turns that one payload into the shell or the full-page sign-in screen (`web/static/js/signin.js`, never a modal, never a shell of panels that all 401), and a payload that never arrives (a release older than the route) reads as "carry on on the API key" rather than as "signed out". Every request from the dashboard carries `X-AINode-Client: dashboard` and `credentials: 'same-origin'`, both stamped once in `AINodeAuth.headers`/`fetch`, because the server ignores the session cookie on a request that does not carry the header: a call site that builds its own headers is a call site whose cookie is dropped. A 401 is routed by WHICH credential failed: no credential goes back to the front door with a one-line reason, a rejected stored key goes to Config > API access, which is where a key is fixed. Pasting a key into a browser still works and is collapsed, because a browser driving a node it has no account on is a real case. The decisions stay in `auth.js` (DOM-free, so `tests/test_auth_usable.py` runs the whole boot table under node); `signin.js` is markup and focus. - **An address this node hands to a client is never a loopback spelling, and there is ONE derivation of it** (`api/server_routes.py`, the `own_addresses` / `lan_address` / `peer_host` block). `GET /api/cluster/endpoint` and `/api/status`'s `endpoint_hint` exist so a client stranded by the node it holds can reach another one, so "localhost" (which `/api/nodes` still reports for the local row, deliberately, for the browser on that node) would be the one answer guaranteed wrong on the caller's machine: a node with nothing routable reports an empty host and a null url instead. The local address comes from the request's `Host` header ONLY when it names an address this node really answers on, because that header is caller-controlled and would otherwise let one request set what the node advertises to the next reader; a peer's comes from `peer_host`, which `api/server.py::_node_host` also calls, so the endpoint and the node rows cannot drift apart. Deriving it reads the host (an `ip` subprocess, a routing lookup) and `/api/status` is polled constantly, so it is cached for `ADDRESS_CACHE_SECONDS` keyed on the config fields that feed it: never put a name resolution or a subprocess on that path uncached. `/api/cluster/endpoint` is one of the paths exempt from the API key rule (`auth/middleware.py::SKIP_PATHS`, beside `/api/health`, `/api/auth/status` and `POST /api/cluster/join`) and may carry nothing but names, addresses, ports, schemes, roles and versions: a client that cannot reach its node has no key to present for a route that only tells it where to knock. - Handoffs use the threadmaster-handoff runbook; ops state lives in `ops/` (runbooks under `ops/runbooks/`). - Distribution is `docker pull` only, from GHCR (there is no Docker Hub mirror), and end users never hand-edit vLLM commands: the engine emits the flags (see `engine/AGENTS.md`). diff --git a/ainode/web/static/css/style.css b/ainode/web/static/css/style.css index c9e8c3ac..e956da4c 100644 --- a/ainode/web/static/css/style.css +++ b/ainode/web/static/css/style.css @@ -6520,3 +6520,326 @@ select { .metrics-store-warn { color: var(--amber); } + +/* ============================================================ + THE FRONT DOOR (#261): the sign-in screen, the header's user chip, + and the account / user rows under Config. + ============================================================ */ + +/* A full page, not a modal: while this is up the shell is display:none and + nothing else has been fetched. */ +.signin-screen { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--bg-primary); + background-image: radial-gradient(circle, #151515 1px, transparent 1px); + background-size: 20px 20px; + overflow-y: auto; +} + +body.signin-mode { overflow: hidden; } + +.signin-card { + width: 100%; + max-width: 380px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 28px 26px 20px; +} + +.signin-brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; +} + +.signin-logo { width: 30px; height: 30px; } + +.signin-wordmark { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.4px; +} +.signin-wordmark-ai { color: var(--nvidia-green); } +.signin-wordmark-node { color: var(--text-primary); } + +.signin-title { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.signin-lead { + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + margin-bottom: 18px; +} + +.signin-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.signin-input { + width: 100%; + background: var(--bg-surface); + border: 1px solid var(--border-hover); + border-radius: var(--radius-sm); + padding: 11px 13px; + margin-bottom: 14px; + color: var(--text-primary); + font-size: 14px; + font-family: var(--font-sans); +} +.signin-input:focus { + outline: none; + border-color: var(--nvidia-green); + box-shadow: 0 0 0 2px var(--nvidia-green-glow); +} + +.signin-submit { + width: 100%; + background: var(--nvidia-green); + border: 1px solid var(--nvidia-green); + border-radius: var(--radius-sm); + color: #000; + font-family: var(--font-sans); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 11px 14px; + margin-top: 4px; + cursor: pointer; +} +.signin-submit:hover:not(:disabled) { background: var(--nvidia-green-bright); } +.signin-submit:disabled { opacity: 0.6; cursor: default; } + +/* The one line that says what went wrong, and what to do about it. */ +.signin-error { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--red); + border-radius: var(--radius-sm); + background: var(--red-glow); + color: var(--text-primary); + font-size: 12px; + line-height: 1.5; +} + +.signin-command { + background: var(--bg-surface); + border: 1px solid var(--border-hover); + border-radius: var(--radius-sm); + padding: 11px 12px; + margin-bottom: 12px; + overflow-x: auto; +} +.signin-command code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--nvidia-green); + white-space: nowrap; +} + +.signin-note { + font-size: 11px; + line-height: 1.6; + color: var(--text-secondary); + margin-bottom: 16px; +} +.signin-note code { font-family: var(--font-mono); color: var(--text-primary); } + +.signin-programs { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--border); + font-size: 11px; + line-height: 1.6; + color: var(--text-muted); +} + +.signin-footer { + margin-top: 12px; + font-size: 11px; + color: var(--text-muted); + display: flex; + align-items: center; +} + +/* -- the header chip: who is signed in, and the way out -------------------- */ + +.user-chip { + display: inline-flex; + align-items: center; + gap: 2px; + border: 1px solid var(--border); + border-radius: 20px; + padding: 2px 3px 2px 2px; + white-space: nowrap; +} + +.user-chip-name { + background: transparent; + border: none; + border-radius: 20px; + padding: 3px 9px; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; + max-width: 20ch; + overflow: hidden; + text-overflow: ellipsis; +} +.user-chip-name:hover { color: var(--nvidia-green); } +.user-chip[data-role="admin"] .user-chip-name::after { + content: "admin"; + margin-left: 6px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--nvidia-green); +} + +.user-chip-signout { + background: transparent; + border: none; + border-left: 1px solid var(--border); + padding: 3px 9px; + font-family: var(--font-sans); + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; +} +.user-chip-signout:hover { color: var(--red); } + +/* -- account and user rows ------------------------------------------------- */ + +.auth-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 13px 0; + border-bottom: 1px solid var(--border); +} +.auth-row:last-child { border-bottom: none; } + +.auth-row-main { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.auth-row-name { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.auth-row-meta { + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + color: var(--text-secondary); +} + +.auth-row-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.auth-badge { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 10px; + border: 1px solid var(--border-hover); + color: var(--text-secondary); +} +.auth-badge-admin { border-color: var(--nvidia-green); color: var(--nvidia-green); } +.auth-badge-member { border-color: var(--border-hover); color: var(--text-secondary); } +.auth-badge-current { border-color: var(--cyan); color: var(--cyan); } +.auth-badge-off { border-color: var(--amber); color: var(--amber); } + +.auth-form { display: block; margin-top: 4px; } +.auth-form .form-input { margin-bottom: 12px; } + +.auth-form-add { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; +} +.auth-form-add .form-input { margin-bottom: 0; } + +.auth-inline-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + margin-top: 6px; +} + +@media (max-width: 720px) { + .auth-form-add { grid-template-columns: minmax(0, 1fr); } + .auth-row { grid-template-columns: minmax(0, 1fr); } + .auth-row-actions { justify-content: flex-start; } +} + +/* -- the collapsed "paste a key" block in API access ----------------------- */ + +.config-details { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-top: 10px; +} +.config-details > summary { + cursor: pointer; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + list-style: none; +} +.config-details > summary::-webkit-details-marker { display: none; } +.config-details > summary::before { + content: "\25B8"; + margin-right: 8px; + color: var(--text-muted); +} +.config-details[open] > summary::before { content: "\25BE"; } +.config-details > summary:hover { color: var(--text-primary); } + +/* The person, under the port's own wording, in the API access Status card. */ +.api-access-who { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); +} diff --git a/ainode/web/static/js/app.js b/ainode/web/static/js/app.js index 3f8412ea..710e3d10 100644 --- a/ainode/web/static/js/app.js +++ b/ainode/web/static/js/app.js @@ -33,10 +33,15 @@ const AINode = { modelsSearch: '', modelsSort: 'recommended', configSection: 'credentials', - // /api/auth/status, polled on its own because it is the one API route that - // still answers when the key is missing (#167). + // /api/auth/status, polled on its own because it answers when the key is + // missing (#167). Who is signed in lives in AINodeAuth.me, from + // /api/auth/me: one home for the credential, same as the key (#261). authStatus: null, authBlocked: null, + // Config > Account and Config > Users, per render. null means "not read yet", + // so an empty list is never drawn as an answer nobody asked for. + authSessions: null, + authUsers: null, configData: { secrets: null, cluster: null, @@ -64,8 +69,35 @@ const AINode = { // ======================================================================== init() { - // First: a node that wants a key must be able to say so before any panel - // tries to render (#167). + var self = this; + // The front door comes before everything else (#261). /api/auth/me is open, + // so it answers on a node that wants a sign-in, and a release older than the + // route answers nothing at all: bootDecision reads that as "carry on as + // before, on the API key". Until it resolves, nothing is fetched and nothing + // is rendered, because a shell of panels that all 401 is the bug this fixes. + AINodeAuth.loadMe().then(function (me) { + var decision = AINodeAuth.bootDecision(me); + if (decision.show === 'signin') { + AINodeSignIn.show({ + state: decision.state, + onSignedIn: function () { self.start(); }, + }); + return; + } + self.start(); + }); + }, + + /** + * The dashboard itself: bindings, polling, first render. Called once per page + * load, after the front door has let this browser through, and again when a + * person signs back in after a session ended mid-visit (which only restarts + * the clocks, because the listeners and the panels are still there). + */ + start() { + if (this._started) { this.resume(); return; } + this._started = true; + // A node that wants a key must be able to say so before any panel renders. this.initAuth(); this.loadChatSettings(); this.loadConversations(); @@ -87,19 +119,44 @@ const AINode = { setTimeout(() => this.reconcileActiveDownloads(), 500); }, + /** Back from the sign-in screen: restart the clocks, redraw what is on screen. */ + resume() { + this.startPolling(); + this.refreshAuthStatus(); + this.renderUserChip(); + if (this.state.currentView === 'config') this.renderConfigSection(); + }, + + /** Every interval this page runs, stopped. The front door polls nothing. */ + stopPolling() { + var self = this; + ['pollInterval', 'metricsInterval', 'loadTickInterval', 'versionInterval', + 'authInterval'].forEach(function (key) { + if (self.state[key]) { + clearInterval(self.state[key]); + self.state[key] = null; + } + }); + if (typeof this.stopServerLogPolling === 'function') this.stopServerLogPolling(); + }, + // ======================================================================== // API KEY (see static/js/auth.js for the wrapper every fetch goes through) // ======================================================================== initAuth() { var self = this; - // One handler for the whole UI: any 401, from any panel, opens the panel - // that fixes it instead of leaving an empty shell behind. + // One handler for the whole UI: any 401, from any panel, lands on the one + // screen that fixes it instead of leaving an empty shell behind. AINodeAuth.onUnauthorized(function (info) { self.onUnauthorized(info); }); var chip = document.getElementById('api-access-chip'); if (chip) chip.addEventListener('click', function () { self.openApiAccess(); }); + var who = document.getElementById('user-chip-name'); + if (who) who.addEventListener('click', function () { self.openConfigSection('account'); }); + var out = document.getElementById('sign-out'); + if (out) out.addEventListener('click', function () { self.signOut(); }); this.refreshAuthStatus(); - this.state.authInterval = setInterval(function () { self.refreshAuthStatus(); }, 15000); + this.renderUserChip(); }, initTopology() { @@ -526,6 +583,9 @@ const AINode = { // Version check every 30 minutes this.checkVersion(); this.state.versionInterval = setInterval(function () { self.checkVersion(); }, 30 * 60 * 1000); + // How this port is protected, on its own cadence: /api/auth/status answers + // when nothing else does (#167), and the chips read it. + this.state.authInterval = setInterval(function () { self.refreshAuthStatus(); }, 15000); // Initial fetch this.refresh(); }, @@ -5825,6 +5885,8 @@ const AINode = { renderConfigSection() { switch (this.state.configSection) { case 'credentials': return this.renderConfigCredentials(); + case 'account': return this.renderConfigAccount(); + case 'users': return this.renderConfigUsers(); case 'api': return this.renderConfigApiAccess(); case 'cluster': return this.renderConfigCluster(); case 'node': return this.renderConfigNode(); @@ -6047,10 +6109,447 @@ const AINode = { }); }, + // ----- Account ------------------------------------------------------------ + // Who you are on this node, your password, and the browsers signed in as you. + // Everything here is about a PERSON; keys for programs stay in API access. + + /** A server timestamp as local text. Never invents one: absent reads absent. */ + _authWhen(value) { + if (value === null || value === undefined || value === '') return 'unknown'; + var when = null; + if (typeof value === 'number') { + // Seconds or milliseconds; a node writes seconds, so scale the small ones. + when = new Date(value < 1e12 ? value * 1000 : value); + } else { + when = new Date(String(value)); + } + if (isNaN(when.getTime())) return String(value); + return when.toLocaleString(); + }, + + /** A response body, or null when there is no JSON in it. */ + async _authBody(resp) { + try { return await resp.json(); } catch (e) { return null; } + }, + + /** The server's own message for a refusal, or the fallback. */ + _authMessage(body, fallback) { + if (body && body.error && body.error.message) return String(body.error.message); + if (body && body.message) return String(body.message); + return fallback; + }, + + async renderConfigAccount() { + var mount = this._configMount(); + if (!mount) return; + var self = this; + mount.innerHTML = '
Loading your account…
'; + await AINodeAuth.loadMe(); + this.renderUserChip(); + var me = AINodeAuth.me || {}; + var user = AINodeAuth.user(); + + var html = ''; + html += '

Account

'; + html += '

You sign in once and stay signed in until you sign out. A program is different: it sends an API key, and those live in API access.

'; + + if (!user) { + // No session: say which of the two reasons it is, and what to do next. + html += '
'; + html += '

You are not signed in

'; + if (!AINodeAuth.me) { + html += '

This node is running a release without accounts, so there is nobody to sign in as. Update it to sign in with a name and password (the version is in Config > About).

'; + } else if (!me.auth_enabled) { + html += '

This node is open, so nobody signs in and there are no accounts. Turn sign-in on in API access, then create the first account on the node: ' + this.esc(AINodeAuth.userAddCommand()) + '

'; + } else if (AINodeAuth.hasKey()) { + html += '

This browser is driving the node with a stored API key, which belongs to a program rather than a person, so there is no account to show. Forget the key in API access and reload to sign in with a name and password.

'; + } else { + html += '

Reload this page to sign in.

'; + } + html += ''; + html += '
'; + mount.innerHTML = html; + var open = document.getElementById('account-open-api'); + if (open) open.addEventListener('click', function () { self.openApiAccess(); }); + return; + } + + // -- who you are --------------------------------------------------------- + html += '
'; + html += '

You

'; + html += '
'; + html += '
'; + html += '
' + this.esc(user.name) + ''; + html += ' ' + this.esc(user.role || 'member') + ''; + html += '
'; + html += '
' + (user.role === 'admin' + ? 'An admin manages accounts and keys, and can turn sign-in on and off.' + : 'A member signs in and uses the dashboard. Account and key management need an admin.') + '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + // -- change password ----------------------------------------------------- + html += '
'; + html += '

Change password

'; + html += '

Your current password proves it is you. The new one takes effect at once and your other sessions stay signed in.

'; + html += '
'; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += '
'; + html += ''; + html += '
'; + + // -- your sessions ------------------------------------------------------- + var currentId = (user.session && user.session.id) ? String(user.session.id) : ''; + var resp = await AINodeAuth.fetch('/api/auth/sessions'); + var body = await this._authBody(resp); + html += '
'; + html += '

Your sessions

'; + if (resp.status === 404) { + html += '
This node is running a release that does not list sessions. Update it to manage them here.
'; + } else if (!resp.ok) { + html += '
' + this.esc(this._authMessage(body, + 'This node did not list your sessions (HTTP ' + resp.status + ').')) + '
'; + } else { + var sessions = Array.isArray(body) ? body : ((body && body.sessions) || []); + this.state.authSessions = sessions; + if (!sessions.length) { + html += '
No sessions listed.
'; + } else { + html += '

One row per browser signed in as you. Revoke one and it signs out on its next request.

'; + sessions.forEach(function (s) { + var mine = currentId && String(s.id) === currentId; + html += '
'; + html += '
'; + html += '
Session ' + self.esc(s.id); + if (mine) html += ' this browser'; + html += '
'; + html += '
Signed in ' + self.esc(self._authWhen(s.created_at)) + + ' · last seen ' + self.esc(self._authWhen(s.last_seen)) + '
'; + html += '
'; + html += '
'; + html += '
'; + }); + } + } + html += '
'; + + // -- the pointer to keys ------------------------------------------------- + html += '
'; + html += '

API keys for your programs

'; + html += '

A script, the desktop app or another node sends Authorization: Bearer <key> instead of signing in. Create and revoke those in API access.

'; + html += ''; + html += '
'; + + mount.innerHTML = html; + + var signout = document.getElementById('account-signout'); + if (signout) signout.addEventListener('click', function () { self.signOut(); }); + var openApi = document.getElementById('account-open-api'); + if (openApi) openApi.addEventListener('click', function () { self.openApiAccess(); }); + + var form = document.getElementById('account-password-form'); + if (form) { + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + self.accountChangePassword(); + }); + } + mount.querySelectorAll('[data-revoke-session]').forEach(function (btn) { + btn.addEventListener('click', function () { + self.accountRevokeSession(btn.dataset.revokeSession, btn.dataset.sessionCurrent === '1'); + }); + }); + }, + + async accountChangePassword() { + var self = this; + var cur = document.getElementById('account-pw-current'); + var next = document.getElementById('account-pw-new'); + var again = document.getElementById('account-pw-confirm'); + var result = document.getElementById('account-pw-result'); + var show = function (ok, message) { + if (!result) { self.toast(message, ok ? 'success' : 'error'); return; } + result.style.display = ''; + result.className = 'config-test-result ' + (ok ? 'ok' : 'err'); + result.textContent = message; + }; + var current = cur ? cur.value : ''; + var wanted = next ? next.value : ''; + var confirmed = again ? again.value : ''; + if (!current || !wanted) { show(false, 'Fill in your current password and the new one.'); return; } + if (wanted !== confirmed) { show(false, 'The two new passwords are different. Type the new one again.'); return; } + var btn = document.getElementById('account-pw-save'); + if (btn) { btn.disabled = true; btn.textContent = 'Changing...'; } + var resp = await AINodeAuth.fetch('/api/auth/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current: current, new: wanted }), + }); + var body = await this._authBody(resp); + if (btn) { btn.disabled = false; btn.textContent = 'Change password'; } + if (!resp.ok) { + show(false, resp.status === 403 + ? this._authMessage(body, 'That is not your current password.') + : this._authMessage(body, 'This node did not change your password (HTTP ' + resp.status + ').')); + return; + } + if (cur) cur.value = ''; + if (next) next.value = ''; + if (again) again.value = ''; + show(true, 'Password changed. You stay signed in here.'); + }, + + async accountRevokeSession(sessionId, isCurrent) { + if (!sessionId) return; + if (isCurrent && !confirm('Revoke this session?\n\nIt is the one this browser is using, so you will be signed out.')) return; + if (!isCurrent && !confirm('Revoke session ' + sessionId + '?\n\nThat browser signs out on its next request.')) return; + var resp = await AINodeAuth.fetch('/api/auth/sessions/' + encodeURIComponent(sessionId), { method: 'DELETE' }); + if (!resp.ok) { + var body = await this._authBody(resp); + this.toast(this._authMessage(body, 'Could not revoke session ' + sessionId + '.'), 'error'); + return; + } + if (isCurrent) { + AINodeAuth.me = null; + this.renderUserChip(); + this.requireSignIn('You signed this browser out.'); + return; + } + this.toast('Session ' + sessionId + ' revoked.', 'success'); + this.renderConfigAccount(); + }, + + // ----- Users -------------------------------------------------------------- + // Accounts on this node. Admin only: the server decides, and this panel + // renders the answer it gives rather than guessing from a role it read once. + + async renderConfigUsers() { + var mount = this._configMount(); + if (!mount) return; + var self = this; + mount.innerHTML = '
Loading accounts…
'; + var resp = await AINodeAuth.fetch('/api/auth/users'); + var body = await this._authBody(resp); + + var html = ''; + html += '

Users

'; + html += '

Accounts that can sign in to this node. An admin manages accounts, API keys and the sign-in switch; a member signs in and uses the dashboard. Passwords are stored hashed in ~/.ainode/auth.json and can also be managed on the node with ainode auth user.

'; + + if (resp.status === 403) { + html += '
Only an admin can manage accounts on this node. Ask an admin to add or change one.
'; + mount.innerHTML = html; + return; + } + if (resp.status === 404) { + html += '
This node is running a release without accounts. Update it to sign in with a name and password (the version is in Config > About).
'; + mount.innerHTML = html; + return; + } + if (!resp.ok) { + html += '
' + this.esc(this._authMessage(body, + 'This node did not list accounts (HTTP ' + resp.status + ').')) + '
'; + mount.innerHTML = html; + return; + } + + var users = Array.isArray(body) ? body : ((body && body.users) || []); + this.state.authUsers = users; + var me = AINodeAuth.user(); + var admins = users.filter(function (u) { return u.role === 'admin' && !u.disabled; }).length; + + html += '
'; + html += '

Accounts

'; + if (!users.length) { + html += '
No accounts yet. Add one below, or run ' + this.esc(AINodeAuth.userAddCommand()) + ' on the node.
'; + } else { + users.forEach(function (u) { + var isMe = me && me.name === u.name; + html += '
'; + html += '
'; + html += '
' + self.esc(u.name); + html += ' ' + self.esc(u.role || 'member') + ''; + if (u.disabled) html += ' disabled'; + if (isMe) html += ' you'; + html += '
'; + html += '
Added ' + self.esc(self._authWhen(u.created_at)) + '
'; + html += ' '; + html += '
'; + html += '
'; + html += ' '; + html += u.disabled + ? ' ' + : ' '; + html += ' '; + html += '
'; + html += '
'; + }); + if (admins <= 1) { + html += '

This node has one admin. It will refuse to remove or disable the last one, so nobody can lock everybody out.

'; + } + } + html += '
'; + + // -- add an account ------------------------------------------------------ + html += '
'; + html += '

Add an account

'; + html += '

The person signs in with this name and password, and can change the password from their own Account page.

'; + html += '
'; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += '
'; + html += ''; + html += '
'; + + mount.innerHTML = html; + + var form = document.getElementById('user-add-form'); + if (form) { + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + self.usersAdd(); + }); + } + mount.querySelectorAll('[data-reset-password]').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = document.getElementById('user-pw-' + btn.dataset.resetPassword); + if (!row) return; + var open = row.style.display !== 'none'; + row.style.display = open ? 'none' : ''; + if (!open) { + var input = row.querySelector('[data-new-password]'); + if (input) input.focus(); + } + }); + }); + mount.querySelectorAll('[data-save-password]').forEach(function (btn) { + btn.addEventListener('click', function () { + // By element, not by a selector built from a name: a name is the user's + // text and has no business inside a CSS selector. + var name = btn.dataset.savePassword; + var row = document.getElementById('user-pw-' + name); + var input = row ? row.querySelector('[data-new-password]') : null; + self.usersSetPassword(name, input ? input.value : ''); + }); + }); + mount.querySelectorAll('[data-disable-user]').forEach(function (btn) { + btn.addEventListener('click', function () { self.usersSetEnabled(btn.dataset.disableUser, false); }); + }); + mount.querySelectorAll('[data-enable-user]').forEach(function (btn) { + btn.addEventListener('click', function () { self.usersSetEnabled(btn.dataset.enableUser, true); }); + }); + mount.querySelectorAll('[data-remove-user]').forEach(function (btn) { + btn.addEventListener('click', function () { self.usersRemove(btn.dataset.removeUser); }); + }); + }, + + async usersAdd() { + var self = this; + var nameEl = document.getElementById('user-add-name'); + var passEl = document.getElementById('user-add-password'); + var roleEl = document.getElementById('user-add-role'); + var result = document.getElementById('user-add-result'); + var show = function (ok, message) { + if (!result) { self.toast(message, ok ? 'success' : 'error'); return; } + result.style.display = ''; + result.className = 'config-test-result ' + (ok ? 'ok' : 'err'); + result.textContent = message; + }; + var name = (nameEl && nameEl.value || '').trim(); + var password = passEl ? passEl.value : ''; + var role = (roleEl && roleEl.value) || 'member'; + if (!name || !password) { show(false, 'Enter a name and a password.'); return; } + var btn = document.getElementById('user-add-btn'); + if (btn) btn.disabled = true; + var resp = await AINodeAuth.fetch('/api/auth/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name, password: password, role: role }), + }); + var body = await this._authBody(resp); + if (btn) btn.disabled = false; + if (!resp.ok) { + show(false, this._authMessage(body, 'Could not add ' + name + ' (HTTP ' + resp.status + ').')); + return; + } + this.toast('Added ' + name + ' as ' + role + '. Tell them to sign in and change the password.', 'success'); + this.renderConfigUsers(); + }, + + async usersSetPassword(name, password) { + if (!name) return; + if (!password) { this.toast('Type the new password first.', 'error'); return; } + var resp = await AINodeAuth.fetch('/api/auth/users/' + encodeURIComponent(name) + '/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: password }), + }); + if (!resp.ok) { + var body = await this._authBody(resp); + this.toast(this._authMessage(body, 'Could not set a password for ' + name + '.'), 'error'); + return; + } + this.toast('Password set for ' + name + '. Tell them what it is, and to change it.', 'success'); + this.renderConfigUsers(); + }, + + async usersSetEnabled(name, enabled) { + if (!name) return; + if (!enabled && !confirm('Disable ' + name + '?\n\nTheir sessions end and they cannot sign in until you enable them again.')) return; + var path = '/api/auth/users/' + encodeURIComponent(name) + (enabled ? '/enable' : '/disable'); + var resp = await AINodeAuth.fetch(path, { method: 'POST' }); + if (!resp.ok) { + var body = await this._authBody(resp); + this.toast(this._authMessage(body, + 'Could not ' + (enabled ? 'enable ' : 'disable ') + name + '.'), 'error'); + return; + } + this.toast(name + (enabled ? ' can sign in again.' : ' is disabled.'), 'success'); + this.renderConfigUsers(); + }, + + async usersRemove(name) { + if (!name) return; + if (!confirm('Remove ' + name + '?\n\nTheir sessions end at once. API keys are separate and are not touched.')) return; + var resp = await AINodeAuth.fetch('/api/auth/users/' + encodeURIComponent(name), { method: 'DELETE' }); + if (!resp.ok) { + var body = await this._authBody(resp); + // A 409 is the node refusing to remove its last admin. Show its own words. + this.toast(this._authMessage(body, 'Could not remove ' + name + '.'), 'error'); + return; + } + this.toast(name + ' removed.', 'success'); + var me = AINodeAuth.user(); + if (me && me.name === name) { + AINodeAuth.me = null; + this.renderUserChip(); + this.requireSignIn('You removed your own account.'); + return; + } + this.renderConfigUsers(); + }, + // ----- API access --------------------------------------------------------- - // The panel that makes auth usable: whether this port wants a key, the switch - // that turns it on, a key shown once, the keys that exist, and a box to paste - // one into on a fresh browser. A 401 anywhere in the UI lands here (#167). + // Keys for programs, and the switch that closes this port. People sign in at + // the front door (static/js/signin.js): the one hand-pasted key left here is + // for a browser driving a node it has no account on. A 401 with a stored key + // lands here (#167); a 401 with no credential lands on the front door (#261). async refreshAuthStatus() { // /api/auth/status answers with no key on purpose, so this stays truthful @@ -6061,14 +6560,29 @@ const AINode = { return st; }, + /** + * The chip, written from the user's side: who they are, or what this port is. + * The node's own wording for the port lives in apiStateText(), which is what + * the API access panel shows. + */ authChipText() { var st = this.state.authStatus; + var user = AINodeAuth.user(); + if (user) return 'Signed in as ' + user.name; if (!st) return 'API access'; - if (st.enabled) { - return st.authenticated ? 'API key required, key set' : 'API key required, no key'; - } - // Must read the same as /api/status's auth.label (api/server.py - // ::auth_status_fields) -- one fact, one wording. + if (!st.enabled) return 'Open, no key required'; + return AINodeAuth.hasKey() ? 'Using an API key' : 'Sign-in required'; + }, + + /** + * How this port is protected, in the node's words: it must read the same as + * /api/status's auth.label (api/server.py::auth_status_fields), because the + * installer summary and the API answer quote it too. One fact, one wording. + */ + apiStateText() { + var st = this.state.authStatus; + if (!st) return 'API access'; + if (st.enabled) return 'API key required'; return st.key_count ? 'API open, key set but not required' : 'API open, no key set'; }, @@ -6079,38 +6593,121 @@ const AINode = { var st = this.state.authStatus || {}; label.textContent = this.authChipText(); var state = 'open'; - if (st.enabled) state = st.authenticated ? 'keyed' : 'locked'; + if (st.enabled) state = (AINodeAuth.user() || AINodeAuth.hasKey()) ? 'keyed' : 'locked'; chip.dataset.state = state; chip.title = st.enabled - ? 'This node requires an API key. Click to manage keys.' - : 'Anyone who can reach this port can load models and change config. Click to require a key.'; - }, - - openApiAccess() { - this.state.configSection = 'api'; + ? 'This node requires a sign-in, or an API key for a program. Click to manage keys.' + : 'Anyone who can reach this port can load models and change config. Click to require a sign-in.'; + this.renderUserChip(); + }, + + /** The name in the header, and the way out. Hidden when nobody is signed in. */ + renderUserChip() { + var wrap = document.getElementById('user-chip'); + var name = document.getElementById('user-chip-name'); + if (!wrap || !name) return; + var user = AINodeAuth.user(); + if (!user) { wrap.style.display = 'none'; return; } + wrap.style.display = ''; + name.textContent = user.name; + wrap.dataset.role = user.role || 'member'; + name.title = 'Account: your name, password and sessions' + + (user.role ? ' (' + user.role + ')' : ''); + }, + + /** Land on one Config section, sidebar in step with it. */ + openConfigSection(section) { + this.state.configSection = section; if (this.state.currentView !== 'config') { this.navigate('config'); } var nav = document.getElementById('config-nav'); if (nav) { nav.querySelectorAll('.config-nav-item').forEach(function (b) { - b.classList.toggle('active', b.dataset.section === 'api'); + b.classList.toggle('active', b.dataset.section === section); }); } this.renderConfigSection(); }, + openApiAccess() { + this.openConfigSection('api'); + }, + + /** + * Put the front door back up, with the reason on it. Stops every poll first: + * a page behind the sign-in screen must not be firing requests that 401. + */ + async requireSignIn(reason) { + if (AINodeSignIn.isUp()) return; + this.stopPolling(); + var self = this; + var me = await AINodeAuth.loadMe(); + if (!AINodeAuth.needsSignIn(me)) { + // The credential is good after all (a node that is open, or a browser + // holding a key): carry on instead of demanding a password for nothing. + this.start(); + return; + } + AINodeSignIn.show({ + state: AINodeAuth.bootDecision(me).state, + reason: reason || '', + onSignedIn: function () { self.start(); }, + }); + }, + onUnauthorized(info) { - // A blank dashboard is the bug. Say what happened and open the one panel - // that fixes it. + // A blank dashboard is the bug, and there are two credentials to be refused + // on. A person whose session ended goes back to the front door with the + // reason on it; a browser sending a key this node rejects goes to the panel + // that holds the key box, because that is where a key is fixed. A release + // with no front door (/api/auth/me unanswered) keeps the old behaviour. this.state.authBlocked = info || { hadKey: AINodeAuth.hasKey() }; - this.toast(info && info.hadKey + var hadKey = !!(info && info.hadKey); + if (!hadKey && AINodeAuth.me) { + this.requireSignIn(info && info.hadSession + ? 'This node ended your session. Sign in again.' + : 'This node requires a sign-in.'); + return; + } + this.toast(hadKey ? 'This node rejected the stored API key. Paste a current one.' : 'This node requires an API key.', 'error'); this.refreshAuthStatus(); this.openApiAccess(); }, + /** + * Sign out: end the session, then show the front door again. The stored API + * key is left alone on purpose. It belongs to a program, and Config > API + * access has the button that forgets it. + */ + async signOut() { + await AINodeAuth.signOut(); + this.state.authBlocked = null; + this.state.authSessions = null; + this.state.authUsers = null; + this.stopPolling(); + var self = this; + var me = await AINodeAuth.loadMe(); + this.renderUserChip(); + if (!AINodeAuth.needsSignIn(me)) { + // A key in this browser still opens every route, so claiming to be signed + // out behind a sign-in screen would be a lie. Say what is still true. + this.start(); + this.toast(AINodeAuth.hasKey() + ? 'Signed out. This browser still sends a stored API key, so the dashboard keeps working. Forget it in Config > API access.' + : 'Signed out. This node is open, so the dashboard keeps working.', 'info'); + this.refreshAuthStatus(); + return; + } + AINodeSignIn.show({ + state: AINodeAuth.bootDecision(me).state, + reason: '', + onSignedIn: function () { self.start(); }, + }); + }, + // The TLS sentence in the API access panel, from /api/status's tls block // (api/server.py::tls_status_fields). Three states, because "I could not read // it" is not the same answer as "there is none": a browser with no key gets a @@ -6150,56 +6747,81 @@ const AINode = { keys = await this.fetchJSON('/api/auth/keys'); } var stored = AINodeAuth.getKey(); + var me = AINodeAuth.me || {}; + var signedIn = AINodeAuth.user(); var html = ''; html += '

API access

'; // The exempt set is auth/middleware.py::SKIP_PATHS plus SKIP_PREFIXES, spelled // out in full: this sentence listed three of the six, so an operator reading it // could not tell what an unauthenticated caller can still reach. - // tests/test_auth_usable.py checks every path in SKIP_PATHS appears here. - html += '

Who may call this node. AINode serves the dashboard, the OpenAI-compatible API and every management route on the same ports, so a key is the whole access control: with auth on, every /api and /v1 route needs Authorization: Bearer <key>. What still answers without one: / and /static/* (the shell that asks for the key), /api/health (a liveness probe has none), /api/auth/status (so this page can say a key is wanted), /api/cluster/endpoint (names, addresses and ports, so a stranded client can find another node) and /api/cluster/join (a joining node holds a one-time token instead). Peers need no key of their own: a node-to-node call carries a key derived from cluster_secret.

'; + // tests/test_fleet_auth.py checks every path in SKIP_PATHS appears here, and + // tests/test_auth_usable.py checks the two front-door paths by name. + html += '

Who may call this node. A person signs in on the dashboard and stays signed in; a program sends a key. AINode serves the dashboard, the OpenAI-compatible API and every management route on the same ports, so with sign-in on, every /api and /v1 route needs either that session or Authorization: Bearer <key>. What still answers without either: / and /static/* (the shell that asks who you are), /api/health (a liveness probe has no credential), /api/auth/status (so this page can say a key is wanted), /api/auth/me and /api/auth/login (the sign-in screen itself), /api/cluster/endpoint (names, addresses and ports, so a stranded client can find another node) and /api/cluster/join (a joining node holds a one-time token instead). Peers need no key of their own: a node-to-node call carries a key derived from cluster_secret.

'; if (this.state.authBlocked) { html += '
'; html += '

This node refused the last request

'; html += '

' + (this.state.authBlocked.hadKey ? 'The key stored in this browser is not one of this node\'s keys. Paste a current one below, or create a new key from a session that is authenticated.' - : 'Auth is on and this browser has no key. Paste one below.') + '

'; + : 'This node wants a sign-in or a key, and this browser sent neither. Reload to sign in, or paste a key below.') + '

'; html += '
'; } // -- state --------------------------------------------------------------- html += '
'; html += '

Status

'; - html += '
'; - html += '' + this.esc(this.authChipText()) + ''; + html += '
'; + html += '' + this.esc(this.apiStateText()) + ''; + html += '' + this.esc(this.authChipText()) + ''; html += '
'; if (!st.enabled) { html += '

Anyone who can reach this port can load and delete models, change config, start training and restart the cluster. That is fine on a private network and it is the default; turn it on before this node is reachable from anywhere else.

'; - html += ''; + html += '

Turning it on asks every browser for a name and password, and every program for a key. Create the first account on the node: ' + this.esc(AINodeAuth.userAddCommand()) + '

'; + html += ''; } else { - html += '

Keys are stored hashed in ~/.ainode/auth.json. This browser keeps its key in localStorage under ' + this.esc(AINodeAuth.STORAGE_KEY) + ', never in the page or a URL.

'; - html += ''; + html += '

Passwords and keys are stored hashed in ~/.ainode/auth.json. A signed-in person rides an HttpOnly ainode_session cookie, which never reaches the page; a browser using a key keeps it in localStorage under ' + this.esc(AINodeAuth.STORAGE_KEY) + ', never in the page or a URL.

'; + if (me.auth_enabled && me.has_users === false) { + html += '

No accounts exist yet, so nobody can sign in and every caller needs a key. Create the first account on the node: ' + this.esc(AINodeAuth.userAddCommand()) + '

'; + } + // The switch that opens the port is shown only to a caller this node has + // actually authenticated. An unauthenticated browser pressing it got a 401 + // and a panel that looked broken (#262). + if (st.authenticated || signedIn) { + html += ''; + } else { + html += '

Sign in, or paste a key below, to turn this back off.

'; + } } html += '
'; // -- the key this browser sends ----------------------------------------- + // Collapsed, and deliberately last in the reading order of this card: a + // person signs in at the front door and never types a key. This is for a + // browser driving a node it has no account on (a fleet node, a kiosk, the + // desktop app's embedded view), which is a real case and stays working. html += '
'; html += '

Key in this browser

'; html += '

' + (stored - ? 'Sending ' + this.esc(AINodeAuth.maskKey(stored)) + ' with every request.' - : 'No key stored. Paste one here to use this dashboard against a node that requires a key.') + '

'; - html += '
'; - html += ' '; - html += ' '; - if (stored) html += ' '; - html += '
'; + ? 'This browser is sending ' + this.esc(AINodeAuth.maskKey(stored)) + ' with every request.' + : 'Nothing stored, which is the normal case: you signed in instead.') + '

'; + var openKeyBox = !!(stored || (this.state.authBlocked && this.state.authBlocked.hadKey) + || (st.enabled && !signedIn)); + html += '
'; + html += ' Use a key in this browser instead'; + html += '

For a browser with no account on this node. The key is sent as a Bearer token on every request from this browser, exactly as a program would.

'; + html += '
'; + html += ' '; + html += ' '; + if (stored) html += ' '; + html += '
'; + html += '
'; html += '
'; // -- keys on the node ---------------------------------------------------- html += '
'; html += '

Keys on this node

'; if (keys === null) { - html += '
Paste a working key above to list and manage keys.
'; + html += '
Sign in, or paste a working key above, to list and manage keys.
'; } else { var rows = (keys.keys || []); if (!rows.length) { @@ -6306,23 +6928,34 @@ const AINode = { this.toast('Could not enable auth.', 'error'); return; } - if (data.api_key) { - // Store it straight away: the browser that flipped the switch must not - // lock itself out, which is exactly what used to happen. + // A signed-in person keeps their session across the switch, so a key does not + // need to be stored in their browser and quietly is not: the key this call + // minted is for their programs. A browser with no session is a different + // story, and storing it is what stops it locking itself out. + var keepsSession = !!AINodeAuth.user(); + if (data.api_key && !keepsSession) { AINodeAuth.setKey(data.api_key); this.state.authBlocked = null; - this.toast('Auth on. This browser is using the new key.', 'success'); + this.toast('Sign-in required from now on. This browser is using the new key.', 'success'); + } else if (data.api_key) { + this.state.authBlocked = null; + this.toast('Sign-in required from now on. You stay signed in here.', 'success'); } else { - this.toast(data.message || 'Auth on using the keys this node already has.', 'info'); + this.toast(data.message || 'Sign-in required from now on, using the keys this node already has.', 'info'); } + await AINodeAuth.loadMe(); await this.refreshAuthStatus(); await this.renderConfigApiAccess(); - if (data.api_key) this._showNewKey(data.key_id, data.api_key, 'Saved in this browser already.'); + if (data.api_key) { + this._showNewKey(data.key_id, data.api_key, keepsSession + ? 'This key is for your programs. You stay signed in here on your session.' + : 'Saved in this browser already.'); + } this.refresh(); }, async authDisable() { - if (!confirm('Stop requiring an API key?\n\nEvery /api and /v1 route on this node answers anyone who can reach the port.')) return; + if (!confirm('Stop requiring a sign-in?\n\nEvery /api and /v1 route on this node answers anyone who can reach the port, with no name and no key.')) return; var resp = await AINodeAuth.fetch('/api/auth/disable', { method: 'POST' }); if (!resp.ok) { this.toast('Could not disable auth.', 'error'); return; } this.state.authBlocked = null; diff --git a/ainode/web/static/js/auth.js b/ainode/web/static/js/auth.js index 34fe23c2..7389fd6e 100644 --- a/ainode/web/static/js/auth.js +++ b/ainode/web/static/js/auth.js @@ -1,11 +1,20 @@ /* ============================================================ - * AINode API key: the ONE place the dashboard talks about auth. + * AINode auth: the ONE place the dashboard talks about who is calling. * - * Every request the UI makes goes through AINodeAuth.fetch(), which attaches - * `Authorization: Bearer ` when a key is stored and reports a 401 to - * whoever registered onUnauthorized(). Before this existed, enabling auth left - * the shell rendering and every panel 401ing, which is why the fleet ran with - * auth off (#167). + * Two credentials, one wrapper. A PERSON signs in at the front door and rides a + * session cookie (`ainode_session`, HttpOnly, sent by the browser itself on + * same-origin requests); a PROGRAM sends `Authorization: Bearer `. Nobody + * pastes a key to use the dashboard any more (#261). Every request the UI makes + * goes through AINodeAuth.fetch(), which attaches the key when one is stored, + * stamps `X-AINode-Client: dashboard` so the server honours the cookie, and + * reports a 401 to whoever registered onUnauthorized(). Before the wrapper + * existed, enabling auth left the shell rendering and every panel 401ing, which + * is why the fleet ran with auth off (#167). + * + * The session half is decisions plus requests, no markup: loadMe() reads + * /api/auth/me, bootDecision() turns that into "show the app" or "show the + * sign-in screen", and signIn()/signOut() are the two calls the screen makes. + * static/js/signin.js draws it. * * Deliberately dependency-free and DOM-free so it can be exercised under node * with a stub fetch and a stub storage (tests/test_auth_usable.py runs it that @@ -21,6 +30,22 @@ // the chat history, and "key" on its own would not say which key. var STORAGE_KEY = 'ainode.apiKey'; + // The CSRF rule, both halves of it in one place. A session cookie is sent by + // the browser on every same-origin request, including one a third-party page + // triggered, so the server only honours the cookie when the request also + // carries this header: a form post or an from another origin cannot set + // a header, and a cross-origin fetch that tries needs a preflight this node + // does not answer. Every request the dashboard makes carries it. + var CLIENT_HEADER = 'X-AINode-Client'; + var CLIENT_NAME = 'dashboard'; + + // What an operator runs on the node when auth is on and no account exists. + // One home for the text: the sign-in screen and the 409 message both read it. + function userAddCommand(name) { + var who = String(name || '').trim(); + return 'ainode auth user add ' + (who || '') + ' --admin'; + } + function defaultStorage() { try { return global.localStorage || null; @@ -32,11 +57,18 @@ var AINodeAuth = { STORAGE_KEY: STORAGE_KEY, + CLIENT_HEADER: CLIENT_HEADER, + CLIENT_NAME: CLIENT_NAME, + userAddCommand: userAddCommand, // Injectable for tests. In a browser these are localStorage and fetch. storage: defaultStorage(), fetchImpl: (typeof global.fetch === 'function') ? global.fetch.bind(global) : null, + // The last /api/auth/me payload: {user, auth_enabled, has_users}. null means + // nobody has asked yet, or this node is old enough not to answer the route. + me: null, + // Set on the last 401 seen, so a panel opened afterwards can say what failed. lastUnauthorized: null, _handler: null, @@ -97,9 +129,13 @@ // -- headers ----------------------------------------------------------- /** - * The caller's headers plus Authorization when a key is stored. Pure: it - * returns a new object and never mutates what it was given, and it never - * overwrites an Authorization the caller set itself. + * The caller's headers plus Authorization when a key is stored, and always + * `X-AINode-Client: dashboard`. Pure: it returns a new object and never + * mutates what it was given, and it never overwrites an Authorization the + * caller set itself. + * + * The client header rides on GETs as well as writes. It costs nothing, and a + * header that is sometimes there is a header somebody has to reason about. */ headers(existing) { var out = {}; @@ -115,41 +151,203 @@ }); var key = this.getKey(); if (key && !hasAuth) out.Authorization = 'Bearer ' + key; + var hasClient = Object.keys(out).some(function (k) { + return k.toLowerCase() === CLIENT_HEADER.toLowerCase(); + }); + if (!hasClient) out[CLIENT_HEADER] = CLIENT_NAME; return out; }, // -- the wrapper every request goes through ---------------------------- /** - * fetch() with the key attached and a 401 reported once. + * fetch() with the credentials attached and a 401 reported once. * * Returns the response untouched, so callers keep their own status - * handling: this adds a header and a notification, it does not swallow + * handling: this adds headers and a notification, it does not swallow * anything. A rejected request (node down, offline) is reported as it was. + * + * `credentials: 'same-origin'` is what sends the session cookie. It is the + * browser default for same-origin requests and set anyway, because the + * dashboard's whole front door depends on it and a default is not a contract. + * + * `skip401Notify: true` keeps a 401 the caller EXPECTS from firing the + * handler: a wrong password on the sign-in screen is answered on the screen, + * not by dropping the person back onto it with a toast. */ fetch(url, options) { var self = this; var opts = {}; var src = options || {}; Object.keys(src).forEach(function (k) { opts[k] = src[k]; }); + var quiet = opts.skip401Notify === true; + delete opts.skip401Notify; opts.headers = this.headers(src.headers); + if (!opts.credentials) opts.credentials = 'same-origin'; var impl = this.fetchImpl; if (typeof impl !== 'function') { return Promise.reject(new Error('no fetch implementation')); } return impl(url, opts).then(function (resp) { - if (resp && resp.status === 401) self._unauthorized(url, resp); + if (resp && resp.status === 401 && !quiet) self._unauthorized(url, resp); return resp; }); }, + // -- the session: who is at the keyboard ------------------------------- + + /** + * Read /api/auth/me. Open on purpose, so it answers before anybody signs in. + * + * null means "this node did not answer the question": a release older than + * the front door has no such route, and the dashboard then behaves exactly + * as it did before, on the API key alone. Never treat null as "signed out". + */ + loadMe() { + var self = this; + return this.fetch('/api/auth/me', { skip401Notify: true }).then(function (resp) { + if (!resp || !resp.ok) return null; + return resp.json(); + }).then(function (body) { + self.me = (body && typeof body === 'object') ? body : null; + return self.me; + }).catch(function () { + self.me = null; + return null; + }); + }, + + /** The signed-in person, or null when it is a key or an open node. */ + user() { + return (this.me && this.me.user) ? this.me.user : null; + }, + + isAdmin() { + var u = this.user(); + return !!(u && u.role === 'admin'); + }, + + /** + * What to render on load, from one /api/auth/me payload. Pure. + * + * {show: 'app'} auth off, signed in, or a stored key + * {show: 'signin', state: 'form'} auth on, nobody signed in + * {show: 'signin', state: 'no-accounts'} auth on and this node has none + * + * A stored key still gets the app: a program's credential in a browser is + * how the desktop app and a headless-node operator drive the dashboard, and + * taking that away would strand them. + */ + bootDecision(me) { + var m = me || null; + if (!m || !m.auth_enabled) return { show: 'app', state: 'open' }; + if (m.user) return { show: 'app', state: 'session' }; + if (this.hasKey()) return { show: 'app', state: 'key' }; + if (m.has_users === false) return { show: 'signin', state: 'no-accounts' }; + return { show: 'signin', state: 'form' }; + }, + + /** True when the sign-in screen, not the app, is what this browser gets. */ + needsSignIn(me) { + return this.bootDecision(me === undefined ? this.me : me).show === 'signin'; + }, + + /** + * The one-line error for a refused sign-in. Pure, so the wording is testable. + * `retryAfter` is the Retry-After header's seconds, when the server sent one. + */ + signInError(status, body, retryAfter) { + var served = (body && body.error && body.error.message) ? String(body.error.message) : ''; + if (status === 429) { + var wait = parseInt(retryAfter, 10); + if (!isFinite(wait) || wait < 1) wait = 30; + var unit = (wait === 1) ? ' second' : ' seconds'; + return 'Too many tries. Wait ' + wait + unit + ' and sign in again.'; + } + if (status === 409) { + return served || ('This node has no accounts yet. On the node, run: ' + userAddCommand()); + } + if (status === 401 || status === 403) return served || 'Wrong name or password.'; + if (status === 400) return served || 'Enter your name and password.'; + return served || 'This node could not sign you in. Try again, and check the node is running.'; + }, + + /** + * POST /api/auth/login. Resolves to {ok: true, user} or + * {ok: false, status, message, noAccounts}: the screen renders the message + * and never has to know a status code. + */ + signIn(name, password) { + var self = this; + var body = { name: String(name || '').trim(), password: String(password || '') }; + if (!body.name || !body.password) { + return Promise.resolve({ ok: false, status: 0, message: 'Enter your name and password.' }); + } + return this.fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + skip401Notify: true, + }).then(function (resp) { + var retryAfter = ''; + try { retryAfter = (resp.headers && resp.headers.get) ? resp.headers.get('Retry-After') : ''; } catch (e) { retryAfter = ''; } + return resp.json().catch(function () { return null; }).then(function (payload) { + if (resp.ok && payload && payload.user) { + // The cookie is set. Re-read /api/auth/me rather than trusting the + // login body, so one route is the authority on who is signed in. + return self.loadMe().then(function () { + return { ok: true, user: self.user() || payload.user }; + }); + } + return { + ok: false, + status: resp.status, + message: self.signInError(resp.status, payload, retryAfter), + noAccounts: resp.status === 409, + }; + }); + }).catch(function () { + return { + ok: false, + status: 0, + message: 'Could not reach this node. Check it is running, then try again.', + }; + }); + }, + + /** + * POST /api/auth/logout. The stored API key is NOT touched: signing out ends + * a person's session, and forgetting a program's key is a separate decision + * the person makes in Config > API access. + */ + signOut() { + var self = this; + return this.fetch('/api/auth/logout', { method: 'POST', skip401Notify: true }) + .then(function (resp) { + self.me = null; + return !!(resp && resp.ok); + }) + .catch(function () { + self.me = null; + return false; + }); + }, + /** Register the one handler that opens the API access panel. */ onUnauthorized(fn) { this._handler = (typeof fn === 'function') ? fn : null; }, _unauthorized(url, resp) { - this.lastUnauthorized = { url: String(url), at: Date.now(), hadKey: this.hasKey() }; + // hadSession says WHICH credential just failed, which is the difference + // between "your session ended, sign in again" and "the key in this browser + // is not one of this node's keys". + this.lastUnauthorized = { + url: String(url), + at: Date.now(), + hadKey: this.hasKey(), + hadSession: !!this.user(), + }; if (!this._handler) return; // The dashboard fires a dozen requests per poll, so a 401 storm must open // the panel once, not a dozen times. 2s covers one poll cycle. diff --git a/ainode/web/static/js/signin.js b/ainode/web/static/js/signin.js new file mode 100644 index 00000000..af0d6bf2 --- /dev/null +++ b/ainode/web/static/js/signin.js @@ -0,0 +1,180 @@ +/* ============================================================ + * AINode sign-in: the front door (#261). + * + * A person signs in here and stays signed in until they sign out. Nobody pastes + * an API key to read a dashboard: keys are for programs, and the one place that + * still takes one by hand is a collapsed block in Config > API access. + * + * A full page, not a modal. When this screen is up the app shell is not in the + * document's flow and app.js has fetched nothing else: a node that wants a name + * and a password must not show a shell full of panels that all 401 (#167 in a + * new shape). + * + * The decisions live in AINodeAuth (bootDecision, signIn, signInError) so they + * run under node with no DOM; this file is markup, focus, Enter, and the error + * line. Same split as static/js/join.js. + * ============================================================ */ + +(function (global) { + 'use strict'; + + function esc(value) { + return String(value === null || value === undefined ? '' : value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + var AINodeSignIn = { + + // -- the markup, as one pure function ----------------------------------- + + /** + * The screen for a state. Pure, so tests can read the copy. + * + * state: 'form' name and password, the normal case + * 'no-accounts' auth is on and this node has no accounts yet, so + * there is nothing to type: it shows the one command + * that creates the first one. + * reason: a one-line explanation of why this screen is up (a revoked + * session, auth switched on mid-visit). Empty on a cold load. + */ + screenHTML(opts) { + var o = opts || {}; + var state = o.state === 'no-accounts' ? 'no-accounts' : 'form'; + var html = ''; + html += ''; + return html; + }, + + // The small Texas mark the dashboard footer already uses, so the front door + // is signed the same way the rest of the UI is. + TEXAS_MARK: '', + + // -- showing it ---------------------------------------------------------- + + _root() { return document.getElementById('signin-screen'); }, + _shell() { return document.getElementById('app-shell'); }, + + /** + * Put the screen up. `onSignedIn` is called once, after the cookie is set, + * and is where app.js starts the app it did not start on load. + */ + show(opts) { + var o = opts || {}; + var root = this._root(); + if (!root) return; + var self = this; + this.onSignedIn = (typeof o.onSignedIn === 'function') ? o.onSignedIn : this.onSignedIn; + this.state = o.state === 'no-accounts' ? 'no-accounts' : 'form'; + root.innerHTML = this.screenHTML({ state: this.state, reason: o.reason || '' }); + root.style.display = ''; + var shell = this._shell(); + if (shell) shell.style.display = 'none'; + document.body.classList.add('signin-mode'); + + var reload = document.getElementById('signin-reload'); + if (reload) reload.addEventListener('click', function () { global.location.reload(); }); + + var form = document.getElementById('signin-form'); + if (form) { + // A form submit, so Enter in either field signs in and the browser's own + // password manager sees a real login. + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + self.submit(); + }); + } + var name = document.getElementById('signin-name'); + if (name) name.focus(); + }, + + hide() { + var root = this._root(); + if (root) { + root.innerHTML = ''; + root.style.display = 'none'; + } + var shell = this._shell(); + if (shell) shell.style.display = ''; + document.body.classList.remove('signin-mode'); + }, + + /** True while the front door is the page. */ + isUp() { + var root = this._root(); + return !!(root && root.style.display !== 'none' && root.innerHTML !== ''); + }, + + error(message) { + var line = document.getElementById('signin-error'); + if (!line) return; + if (!message) { + line.hidden = true; + line.textContent = ''; + return; + } + line.hidden = false; + line.textContent = message; + }, + + submit() { + var self = this; + var nameEl = document.getElementById('signin-name'); + var passEl = document.getElementById('signin-password'); + var btn = document.getElementById('signin-submit'); + var name = nameEl ? nameEl.value : ''; + var password = passEl ? passEl.value : ''; + this.error(''); + if (btn) { btn.disabled = true; btn.textContent = 'Signing in...'; } + return AINodeAuth.signIn(name, password).then(function (result) { + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + if (result.ok) { + if (passEl) passEl.value = ''; + self.hide(); + if (typeof self.onSignedIn === 'function') self.onSignedIn(result.user); + return true; + } + if (result.noAccounts) { + // Nothing to type: swap to the state that says what to run. + self.show({ state: 'no-accounts', reason: result.message }); + return false; + } + self.error(result.message); + if (passEl) { passEl.value = ''; passEl.focus(); } + return false; + }); + }, + }; + + global.AINodeSignIn = AINodeSignIn; + // Loadable by a node test without a browser (the markup half is pure). + if (typeof module !== 'undefined' && module.exports) module.exports = AINodeSignIn; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/ainode/web/templates/index.html b/ainode/web/templates/index.html index 19ea86a6..166b0364 100644 --- a/ainode/web/templates/index.html +++ b/ainode/web/templates/index.html @@ -18,7 +18,12 @@ -
+ + + +