Skip to content

A login for people: accounts, sessions and a cookie beside the API key (#261) - #266

Merged
webdevtodayjason merged 2 commits into
mainfrom
fable/login-backend
Sep 22, 2026
Merged

webdevtodayjason merged 2 commits into
mainfrom
fable/login-backend

Conversation

@webdevtodayjason

Copy link
Copy Markdown
Contributor

Closes the backend half of #261. The dashboard half is PR #264 (fable/login-dashboard); the CLI and fleet-replication half is somebody else's. This PR is the contract both of them code against.

API-key auth protects the port, and it is the right credential for a machine: the bench, the desktop app and curl all hold a key. It is the wrong credential for a person. One key answers for everybody, it gets pasted into a browser and kept in localStorage, revoking one person's access means rotating the key every client holds, and the first thing a new operator sees is a box asking for a machine secret. So: named accounts with passwords, and sessions that outlive a restart, sitting beside the key store rather than replacing it.

What is in it

ainode/auth/accounts.py (new). UsersStore over ~/.ainode/users.json, written 0600 through a temp file in the same directory and re-read on a stamp change, the same shape and the same reasons as AuthConfig.

  • Passwords: hashlib.scrypt(password, salt=16 random bytes, n=2**14, r=8, p=1, dklen=32), hex, per-password salt, compared with hmac.compare_digest. Standard library only, because the container is aiohttp plus pynvml plus Rich and a login page must not be the reason the image grows an argon2 wheel. An unknown or disabled name pays the same scrypt cost as a real one, so neither the message nor the clock says which accounts exist.
  • Sessions: secrets.token_urlsafe(32), returned once, stored as sha256(token) only. No expiry: in until you log out. 20 per account, oldest evicted. last_seen is refreshed at most once a minute, because this is on the hot path of every cookie request.
  • Revocation is complete in every direction it needs to be: a password change, a disable and a removal each take that account's open sessions with them, and a session whose account was removed or disabled stops authenticating on the next request.
  • require_admin(request) is the one gate: an admin session, an operator API key, or the fleet key.

ainode/auth/middleware.py. A third credential beside the operator key and the fleet key.

  • The ainode_session cookie stamps request["authenticated"] = True, request["api_key_id"] = "user:<name>", request["user"], request["user_role"].
  • A Bearer token is tried FIRST. If it matches, it wins; if it does not, the cookie is tried. That is "the key takes precedence" without the one lockout that really happens: a browser that logged in while a stale key was still in localStorage would otherwise 401 on every request with a valid session in the jar.
  • CSRF. A cookie-authenticated request whose method is not GET, HEAD or OPTIONS must carry X-AINode-Client: dashboard. A browser attaches a cookie to a cross-site request on its own; a custom header cannot be set cross-origin without a preflight this node never approves, so the header is the proof. Without it the cookie is ignored and the request proceeds unauthenticated, which with auth on is a 401 whose message names the missing header rather than blaming the password.
  • SKIP_PATHS grows by two: /api/auth/login (the door: the caller with no credential is exactly who knocks) and /api/auth/me (answers {"user": null} to a caller it does not recognise, and nothing else).
  • MISSING_KEY_MESSAGE now names both doors: "This node requires a login or an API key. Sign in on the dashboard, or send Authorization: Bearer <key>."

ainode/auth/session_routes.py (new), registered next to register_auth_routes in create_app. app["users_store"] is created where auth_config is.

ainode/auth/api_routes.py. /api/auth/keys (all three methods) and /api/auth/enable|disable now take the same gate as the account routes. A member session gets 403; an admin session, an operator key and the fleet key do not. A key is the whole access control on a node, so "log in as a member" must not be a way to mint one, revoke everybody else's, or switch the wall off.

The route contract

Error bodies are {"error": {"message": "...", "type": "..."}} everywhere in this module, and every message is a sentence meant to be shown to a person verbatim.

Route Who Answers
POST /api/auth/login anybody (in SKIP_PATHS) 200 {"user": {"name", "role"}, "session_id"} + Set-Cookie: ainode_session=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=34560000 (plus ; Secure when request.secure or X-Forwarded-Proto: https). Body {"name", "password"}, optional `"client": "dashboard"
POST /api/auth/logout a credential 200 {"ok": true, "revoked": bool} + Set-Cookie ... Max-Age=0, always, even with no session to revoke
GET /api/auth/me anybody (in SKIP_PATHS) {"user": null, "auth_enabled": bool, "has_users": bool}, or {"user": {"name", "role", "session": {"id", "user", "client", "agent", "created_at", "last_seen"}}, "auth_enabled", "has_users"}. An API key is not a person, so it reads as "user": null
GET /api/auth/sessions[?user=<name>] self; ?user= needs admin (or your own name) `{"user": ""
DELETE /api/auth/sessions/{id} own session, any for an admin 200 {"revoked": true, "session_id"}; 404 for an id outside the caller's scope (deliberately the same answer as "somebody else's", so a member cannot enumerate ids); 403 with no session at all
POST /api/auth/password a session body {"current", "new"}. 200 {"ok": true, "session_id"} + a FRESH cookie: every other session of the account is revoked and this browser is kept. 403 forbidden "That is not the current password."; 403 for an API key, naming the admin route instead; 400 for a short new password
GET /api/auth/users admin, operator key, or fleet key {"users": [{"name", "role", "created_at", "disabled", "sessions": <count>}], "admin_count": n}, never a hash or a salt
POST /api/auth/users same body {"name", "password", "role"} -> 201 {"user": {...}}. 400 carrying the rule it broke (name, password length, role, duplicate), the same string the CLI prints
DELETE /api/auth/users/{name} same 200 {"removed": true, "name"}; 404 unknown; 409 conflict "This is the only admin on this node. Add another admin before removing this one."
POST /api/auth/users/{name}/password same body {"password"} -> 200 {"ok": true, "name"}; 404 unknown; 400 too short. Revokes that account's sessions
POST /api/auth/users/{name}/disable same 200 {"user": {...}}, sessions revoked; 404 unknown; 409 on the last enabled admin
POST /api/auth/users/{name}/enable same 200 {"user": {...}}; 404 unknown
GET /api/auth/users/export fleet key only {"users": [{"name", "password_hash", "salt", "role", "created_at", "disabled"}], "stamp": "<16 hex>"}, sorted by name; 403 for anybody else, an admin included
POST /api/auth/users/sync fleet key only body {"users": [...]} -> 200 {"changed": bool, "count": n}. All or nothing: a malformed record is a 400 and this node is untouched. changed is false for a list it already has. Sessions whose account survives are kept

Every account mutation calls app["users_changed"]() when something has registered one (sync call, or an awaitable scheduled rather than awaited, so an operator's 201 does not wait on a fan-out; a hook that raises is one log line). Nothing registers one yet, which is the CLI and fleet worker's hook.

request keys for anything downstream: authenticated, api_key_id ("", a key id, "fleet", or "user:<name>"), user, user_role ("admin", "member", or "").

Answers to the dashboard worker's five questions (PR #264)

  1. user.session IS on the signed-in /api/auth/me payload, with id, created_at, last_seen (plus user, client, agent). Never token_hash.
  2. GET /api/auth/sessions answers {"sessions": [...]}, each row with id, created_at, last_seen. Timestamps are ISO YYYY-MM-DDTHH:MM:SSZ, the same spelling auth.json uses.
  3. GET /api/auth/users answers {"users": [...]} with name, role, created_at, disabled (plus a sessions count). A member gets 403, never an empty 200. A key-authenticated browser with no account gets the list.
  4. Every refusal is one sentence, written to be read.
  5. 429 carries Retry-After: 300.

Plus the behaviour question: yes, GET /api/auth/status reports authenticated: true for a session cookie with no Bearer key. It reads is_authenticated(request), which reads the stamp the middleware sets for all three credentials. Pinned by test_auth_status_counts_a_session_as_authenticated.

Where I diverged, and why

  • The issue says SameSite=Strict and "sessions expire". The brief I was given says Lax and no expiry, and I followed the brief. Strict drops the cookie on the first navigation from a link, a bookmark or the installer's printed URL, so the operator lands on the dashboard logged out every time; Lax still withholds it from cross-site POSTs, and the X-AINode-Client rule covers the rest. No expiry is Jason's rule for this feature, so revocation is the control instead, and the tests are about revocation being complete.
  • The 409 "no accounts" answer is not gated on auth.enabled. The brief conditioned it on auth being on. It fires whenever the store is empty, because on a node with no accounts there is no name for "wrong name or password" to be about, and a curl user needs the command rather than a guess. The auth-on case behaves exactly as specified.
  • The last-admin 409 also covers disable, not just DELETE. Same failure either way: a node nobody can administer. admin_count() counts ENABLED admins, and a node whose admins are all already disabled has nothing left to protect, so it refuses nothing there.
  • A self-service password change mints a fresh session instead of logging the browser doing the change out along with everybody else.
  • POST /api/auth/logout is NOT in SKIP_PATHS, per the brief's explicit list. It revokes something, so it is a write like any other; the handler answers 200 with no session, but on a node with auth on a caller presenting nothing at all still meets the middleware's 401 first. A dashboard should read that as "already logged out". Pinned by test_logout_is_not_keyless so the choice is on the record.
  • The SKIP_PATHS-against-the-panel check lives in tests/test_fleet_auth.py, not tests/test_auth_usable.py. The brief pointed at the latter. I fixed it where it lives rather than adding a second home for one fact. Because I must not touch ainode/web/static/js/*, the two new paths sit in an explicit COPY_PENDING_IN_THE_PANEL set with a comment saying to delete them once the panel names them. The panel copy is already on PR Dashboard: a real front door, so nobody pastes a key to read a page (#261) #264, so the check cannot go red for either PR: the forward assertion names any undocumented path with instructions, and there is no reverse assertion that would fail once the copy lands. Whoever merges second should delete those two lines.
  • README.md (2 sentences) and ainode/api/cluster_join.py's module docstring name the two new keyless paths, because tests/test_join_flow.py and tests/test_fleet_auth.py assert every SKIP_PATHS entry appears in both. This is the whole doc change; it was not optional.
  • tests/conftest.py gains an autouse isolate_users_store, so the suite cannot read or write the operator's own ~/.ainode/users.json, the same reason isolate_metrics_store exists.

Proof

pytest tests/ is 2891 passed, 2 skipped, 1 xfailed (from 2761 passed on main: 130 new tests, 63 in tests/test_accounts.py and 67 in tests/test_session_routes.py). ruff check . clean. No node hardware involved; a real-node pass belongs to the dashboard PR's first login.

The new tests cover: scrypt hashing and verify in both directions, the 0600 file mode and the tighten-on-load, that the file holds neither the password nor the token, session create/lookup/revoke/cap/eviction-order, last_seen refreshed at most once a minute, a session surviving a restart, live reload and the tolerant direction on a broken file, the middleware cookie path including the CSRF header rule in both directions and Bearer precedence in both directions, a revoked and a disabled account losing its session mid-flight, login success/case-folding/wrong-password/unknown-name/disabled/no-users-409/throttle-429/Secure-on-HTTPS, logout, me in all three shapes, the self password change, the account CRUD with role gating over all eleven admin routes, the last-admin rule on both routes, export and sync gated to the fleet key with an admin and an operator key both refused, a malformed sync leaving the node alone, the broadcaster hook (sync, async and raising), and SKIP_PATHS asserted as a literal set of seven.

Changelog text for the release PR

Added

  • A login for people, beside the API key for machines (A login page: username and password for the dashboard, sessions beside API keys #261). Named accounts with passwords live in ~/.ainode/users.json (0600, hashed with hashlib.scrypt over a per-password salt), and POST /api/auth/login hands a browser an HttpOnly, SameSite=Lax session cookie that the middleware accepts exactly where it accepts a key. A session does not expire: you are in until you log out, or until somebody revokes the session, changes the password or disables the account. Only a hash of the session token reaches disk, so a copy of users.json logs nobody in.
  • Roles, and a node that cannot be locked away from its operator. An admin may manage accounts, API keys and the auth switch; a member may use the node and gets a 403 from all three. The last enabled admin cannot be removed or disabled, an operator API key and the fleet key both count as an admin (which is how the CLI works and how the first admin is created on a fresh node), and GET /api/auth/users/export / POST /api/auth/users/sync are the fleet key's alone, because they move password hashes.
  • Session and account routes: POST /api/auth/login|logout|password, GET /api/auth/me|sessions|users, DELETE /api/auth/sessions/{id}, and the account CRUD under /api/auth/users. /api/auth/login and /api/auth/me answer with no credential, because the caller with none is exactly who knocks on the first and the second only ever says {"user": null} to a stranger.

Changed

  • A 401 now names both doors: "This node requires a login or an API key." A person told only about a Bearer token pastes a machine key into a browser, which is the thing this release exists to stop.
  • A cookie-authenticated write must carry X-AINode-Client: dashboard. A browser attaches a cookie to a cross-site request on its own, and a custom header cannot be set cross-origin without a preflight this node never approves, so the header is the CSRF proof. Without it the cookie is ignored and the 401 says so. A Bearer token needs nothing and keeps precedence, so the bench, the desktop app and every script behave exactly as before.
  • Failed logins are throttled per source address: ten in five minutes, then a 429 with Retry-After. Per address and not per name, because a name-keyed lockout is one an attacker can aim at somebody else's account.

webdevtodayjason and others added 2 commits September 21, 2026 18:54
#261)

API-key auth protects the port and is the right credential for a machine. It is
the wrong one for a person: one key for everybody, pasted into a browser and kept
in localStorage, and revoking one person means rotating the key every client
holds. This is the backend half of the front door.

- ainode/auth/accounts.py: UsersStore over ~/.ainode/users.json (0600, temp then
  replace, re-read on change). Passwords are hashlib.scrypt n=2**14 r=8 p=1 over a
  per-password salt, compared with compare_digest, and an unknown or disabled name
  pays the same scrypt cost so timing is not an enumeration oracle. Sessions store
  only sha256(token), have NO expiry (in until you log out), and cap at 20 per
  account. A password change, a disable and a removal all take the open sessions
  with them.
- ainode/auth/middleware.py: a third credential. The ainode_session cookie
  authenticates a request as user:<name> with request["user"] and
  request["user_role"] beside it. A Bearer token is tried first, so a stale key in
  a browser cannot cost a good session its request, and a cookie-authenticated
  WRITE must carry X-AINode-Client: dashboard, which is the CSRF proof a custom
  header gives for free. /api/auth/login and /api/auth/me join SKIP_PATHS.
- ainode/auth/session_routes.py: login (throttled at 10 failed attempts per
  address per 5 minutes), logout, me, the session list and revoke, a self-service
  password change that keeps the browser doing the changing, the account CRUD, and
  two fleet-key-only replication routes that move whole records including hashes.
- The key routes and /api/auth/enable|disable take the same gate as the account
  routes: an admin session, an operator key, or the fleet key. A member gets 403.

Nothing changes for a caller holding a key, and a node with no accounts behaves
exactly as it did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The root contract had bullets for the fleet key, the installer's first key and
the 0600 stores, and nothing for the credential a person presents. The next
person editing ainode/auth/ needs the four rules that are not obvious from the
code: users.json is re-read per request for the same reason auth.json is, the
crypto is standard library only, a session has no expiry so revocation has to be
complete, and the X-AINode-Client header is the whole CSRF defence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@webdevtodayjason
webdevtodayjason merged commit 42e583d into main Sep 22, 2026
1 check passed
@webdevtodayjason
webdevtodayjason deleted the fable/login-backend branch September 22, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant