A login for people: accounts, sessions and a cookie beside the API key (#261) - #266
Merged
Merged
Conversation
#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
curlall hold a key. It is the wrong credential for a person. One key answers for everybody, it gets pasted into a browser and kept inlocalStorage, 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).UsersStoreover~/.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 asAuthConfig.hashlib.scrypt(password, salt=16 random bytes, n=2**14, r=8, p=1, dklen=32), hex, per-password salt, compared withhmac.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.secrets.token_urlsafe(32), returned once, stored assha256(token)only. No expiry: in until you log out. 20 per account, oldest evicted.last_seenis refreshed at most once a minute, because this is on the hot path of every cookie 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.ainode_sessioncookie stampsrequest["authenticated"] = True,request["api_key_id"] = "user:<name>",request["user"],request["user_role"].localStoragewould otherwise 401 on every request with a valid session in the jar.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_PATHSgrows 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_MESSAGEnow 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 toregister_auth_routesincreate_app.app["users_store"]is created whereauth_configis.ainode/auth/api_routes.py./api/auth/keys(all three methods) and/api/auth/enable|disablenow 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.POST /api/auth/loginSKIP_PATHS){"user": {"name", "role"}, "session_id"}+Set-Cookie: ainode_session=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=34560000(plus; Securewhenrequest.secureorX-Forwarded-Proto: https). Body{"name", "password"}, optional `"client": "dashboard"POST /api/auth/logout{"ok": true, "revoked": bool}+Set-Cookie ... Max-Age=0, always, even with no session to revokeGET /api/auth/meSKIP_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": nullGET /api/auth/sessions[?user=<name>]?user=needs admin (or your own name)DELETE /api/auth/sessions/{id}{"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 allPOST /api/auth/password{"current", "new"}. 200{"ok": true, "session_id"}+ a FRESH cookie: every other session of the account is revoked and this browser is kept. 403forbidden"That is not the current password."; 403 for an API key, naming the admin route instead; 400 for a short new passwordGET /api/auth/users{"users": [{"name", "role", "created_at", "disabled", "sessions": <count>}], "admin_count": n}, never a hash or a saltPOST /api/auth/users{"name", "password", "role"}-> 201{"user": {...}}. 400 carrying the rule it broke (name, password length, role, duplicate), the same string the CLI printsDELETE /api/auth/users/{name}{"removed": true, "name"}; 404 unknown; 409conflict"This is the only admin on this node. Add another admin before removing this one."POST /api/auth/users/{name}/password{"password"}-> 200{"ok": true, "name"}; 404 unknown; 400 too short. Revokes that account's sessionsPOST /api/auth/users/{name}/disable{"user": {...}}, sessions revoked; 404 unknown; 409 on the last enabled adminPOST /api/auth/users/{name}/enable{"user": {...}}; 404 unknownGET /api/auth/users/export{"users": [{"name", "password_hash", "salt", "role", "created_at", "disabled"}], "stamp": "<16 hex>"}, sorted by name; 403 for anybody else, an admin includedPOST /api/auth/users/sync{"users": [...]}-> 200{"changed": bool, "count": n}. All or nothing: a malformed record is a 400 and this node is untouched.changedis false for a list it already has. Sessions whose account survives are keptEvery 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.requestkeys 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)
user.sessionIS on the signed-in/api/auth/mepayload, withid,created_at,last_seen(plususer,client,agent). Nevertoken_hash.GET /api/auth/sessionsanswers{"sessions": [...]}, each row withid,created_at,last_seen. Timestamps are ISOYYYY-MM-DDTHH:MM:SSZ, the same spellingauth.jsonuses.GET /api/auth/usersanswers{"users": [...]}withname,role,created_at,disabled(plus asessionscount). A member gets 403, never an empty 200. A key-authenticated browser with no account gets the list.Retry-After: 300.Plus the behaviour question: yes,
GET /api/auth/statusreportsauthenticated: truefor a session cookie with no Bearer key. It readsis_authenticated(request), which reads the stamp the middleware sets for all three credentials. Pinned bytest_auth_status_counts_a_session_as_authenticated.Where I diverged, and why
SameSite=Strictand "sessions expire". The brief I was given saysLaxand no expiry, and I followed the brief.Strictdrops 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;Laxstill withholds it from cross-site POSTs, and theX-AINode-Clientrule 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.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 acurluser needs the command rather than a guess. The auth-on case behaves exactly as specified.disable, not justDELETE. 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.POST /api/auth/logoutis NOT inSKIP_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 bytest_logout_is_not_keylessso the choice is on the record.tests/test_fleet_auth.py, nottests/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 touchainode/web/static/js/*, the two new paths sit in an explicitCOPY_PENDING_IN_THE_PANELset 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) andainode/api/cluster_join.py's module docstring name the two new keyless paths, becausetests/test_join_flow.pyandtests/test_fleet_auth.pyassert everySKIP_PATHSentry appears in both. This is the whole doc change; it was not optional.tests/conftest.pygains an autouseisolate_users_store, so the suite cannot read or write the operator's own~/.ainode/users.json, the same reasonisolate_metrics_storeexists.Proof
pytest tests/is 2891 passed, 2 skipped, 1 xfailed (from 2761 passed onmain: 130 new tests, 63 intests/test_accounts.pyand 67 intests/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_seenrefreshed 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,mein 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), andSKIP_PATHSasserted as a literal set of seven.Changelog text for the release PR
Added
~/.ainode/users.json(0600, hashed withhashlib.scryptover a per-password salt), andPOST /api/auth/loginhands a browser anHttpOnly,SameSite=Laxsession 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 ofusers.jsonlogs nobody in.adminmay manage accounts, API keys and the auth switch; amembermay 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), andGET /api/auth/users/export/POST /api/auth/users/syncare the fleet key's alone, because they move password hashes.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/loginand/api/auth/meanswer 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
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.Retry-After. Per address and not per name, because a name-keyed lockout is one an attacker can aim at somebody else's account.