grain-serve logs the web in, holding its OAuth session
MergedThis pull makes grain-serve log the web in as a confidential OAuth client with hatk's client ID, key ID and scope. It keeps sessions in the index (V7), sets a signed cookie, requires Origin on cookie-authenticated writes, and refreshes tokens under a per-account lock.
I could only read login.rs and index/sessions.rs in full. The diff is about 1,770 lines and I could not open the saved copy, so I did not read viewer.rs, the migration, the harness changes or the tests. I found nothing wrong in the parts I read.
- Cookie validity:
heldrejects bad signatures and expired cookies (verify_jwtchecksexp). It also rejects cookies signed before the account'ssince, cookies whose session is gone, and takedowns. - Login binding: the
grain_logincookie ties a callback to the browser that started it. back_to: it accepts only same-site paths.- Refresh: it re-reads the row after taking the lock and ends the session only on
Refused. since: a repeat login keeps the oldsince, so logging in from a second browser does not kill the first browser's cookie.
Must fix: the check workflow failed. vp check reports a formatting issue in harness/authorize.mjs, which this pull changes (the new --stop flag). Run vp check --fix and push. The rust, harness and test checks had not finished when I looked, so I have not seen their results. Per AGENTS.md the harness coverage gate must also pass.
Minor, not blocking: in session, a refresh that is in flight when the user logs out finishes with keep_web_session. That re-inserts the row after end_web_session deleted it. Old cookies stay dead because the new since is later than their iat. The refreshed grant is still left stored for an account that logged out, and a fresh login would pick it up. Consider skipping the write, or ending the grant, if the row was deleted while the refresh ran.
This pull makes grain-serve log the web in as hatk's OAuth client. It keeps sessions in the index (V7), sets a signed cookie, requires Origin on cookie-authenticated writes, and refreshes tokens under a per-account lock.
Round 1 asked for the harness/authorize.mjs formatting fix. The check workflow now passes on d7d2ab1. The new commit also adds tests for failed logins and failed refreshes. rust, test and harness were still queued or running when I looked, so I have not seen their results. They need to pass before merging, including the harness coverage gate.
Not addressed, and not blocking: a refresh that is in flight when the user logs out still re-inserts the row. login.rs around line 575 calls keep_web_session after end_web_session deleted it, and the upsert recreates the row with a new since. Old cookies stay dead, but the refreshed grant remains stored and a later login would pick it up. Consider skipping the write when the row is gone after the refresh.
This pull makes grain-serve log the web in as hatk's OAuth client. It keeps sessions in the index (V7), sets a signed cookie, requires Origin on cookie-authenticated writes, and refreshes tokens under a per-account lock.
Round 2 left one thing open: a refresh in flight when the user logs out re-inserted the session row. That is fixed. session now calls the new Index::refresh_web_session, which updates only a row that still exists. If the row is gone, the call returns ended() and a logout stays a logout. The store test covers both cases: a refresh keeps since, and a refresh over a missing row writes nothing. The other changes in this commit only reword comments.
Before merging, check, rust, test and harness must all pass. All four were still queued when I looked, so I have not seen any result for ea052f5. The harness coverage gate is one of them.
Carried forward: the change this pull makes at 801cc65fbf716b46620e7200aa9a7ae17b19e7f6 is the one reviewed at ea052f5405c429fe38308d14edd42edbefe9b008, so that review stands. What is new comes from its base.
This pull makes grain-serve log the web in as hatk's OAuth client. It keeps sessions in the index (V7), sets a signed cookie, requires Origin on cookie-authenticated writes, and refreshes tokens under a per-account lock.
The only change since round 3 is that cookies are now signed with their own key, web-cookie. It is made on first start beside pds-client-auth-key, never put in the JWKS, and never sent to a PDS. Web::new loads both keys from oauth_key. The test checks that the two keys differ and that the cookie key's public part is not in the JWKS. This fits the design: a token the client key signs is no cookie, and either key can be rotated alone. Nothing I read is wrong.
On 8042129, check and test pass. rust and harness were still running when I looked, so I have not seen their results. Both must pass before merging, including the harness coverage gate.
- grain-serve logs the web in, holding its OAuth session
- main

What changes
Web login. grain-serve is a confidential atproto OAuth client of each account's PDS, as knotless-serve's pages are.
POST /oauth/login({handle}or{create: true}, andback) pushes the request with PKCE and a DPoP key and answers the approval page;GET /oauth/callbackredeems the code, keeps the session server-side and sets the cookie;POST /oauth/switch,POST /oauth/logoutandGET /oauth/sessioncover a browser logged in to several accounts (up to ten, as hatk kept). Sign-up goes toGRAIN_SIGN_UP_PDS,https://selfhosted.socialunless set, as the web's "Create one" does today.The client is hatk's. Its ID is
GRAIN_PUBLIC_URL/oauth-client-metadata.json, the redirect/oauth/callback, the key at/oauth/client-jwks.jsonunder hatk's key IDpds-client-auth-key, and the metadata registers hatk's scope string word for word (login::REGISTERED, including the space, pool and group scopes zds and blacksky hold a request to). The login asks forgrain::oauth::SCOPE, hatk's productiongrainScopes. A PDS binds each session to the client and its key ID, so this is what lets the next pull carry hatk's sessions over.Storage. Index migration V7:
oauth_key(private JWKs by key ID: the client keypds-client-auth-key, irreplaceable, as_oauth_keysis, and the cookie keyweb-cookie) andweb_session(each account'sgrain::session::Sessionas JSON, andsince, when its login was first kept). Each key is made on first start if the index has none.The cookie.
grain_session, a JWT naming the account, signed byweb-cookie, a key of its own that is never published in the JWKS and never sent to a PDS (so a JWT the client key signs, which every PDS sees, is no cookie; tested), and that the import does not carry:HttpOnly; SameSite=Lax; Path=/; Max-Age90 days,Securewhen the public URL is https. It names no one once its session is gone, if it was signed before the session'ssince(so logging out ends every cookie for the account, in every browser), or while the account is taken down. A login is answered only in the browser holding thegrain_logincookie naming its state, so a link to someone else's approval logs no one in.CSRF. Login, logout, switch, and every procedure the cookie authenticates (
viewer::writer, used byupdateSeennow) requireOriginto be the public URL; SameSite=Lax already keeps the cookie off cross-site POSTs.Viewer. Queries take the cookie as the viewer when no
Authorizationis sent; a service token still wins, and the apps' path is unchanged.Refresh.
login::session(app, did)returns a session good for the call, refreshing one that expires within a minute under a per-account lock and re-reading the row after the wait, so concurrent requests never spend the same refresh token twice. A refresh the authorization server refuses (oauth::Refused, now a typed error ingrain::oauth) ends the session; a network failure keeps it.All outbound calls of the login go through
Ctx::guarded, since the PDS and authorization server come from whatever a DID document says.Why
The plan's web model: server-held session plus signed cookie, as knotless does it, so web writes (next pulls) go through grain-serve with that session. Sessions live in the index rather than knotless's per-account files so that the nightly backups of the database carry them, as they carry hatk's today.
How it was checked
crates/grain-serve/tests/login.rsruns the round trip against the reference PDS: grain-serve behindharness/tls-proxy.mjsat an https public URL the PDS fetches the metadata and JWKS from,harness/authorize.mjsapproving (new--stopflag: stop at the redirect back and print it, so the test comes back to the callback with its own cookie jar). It covers metadata and JWKS, the origin check, an unknown handle, cookie flags, a callback from another browser and a replayed one, the cookie as viewer for a query and forupdateSeen(refused without the site'sOrigin), two accounts in one browser, switching, logout (old cookies dead, even after logging in again), a refused approval, sign-up, a takedown, concurrent refresh (one refresh, both callers get it, the session works at the PDS), and a spent refresh token ending the session. Unit tests cover the store, JWK round trip, cookie parsing and the back-path check. fmt, clippy and the harness coverage gate are left to the checks.Shortcomings
crates/reads nothing in hatk's shape at runtime (the standing rule), so hatk's__hatk_sessioncookies are not read, and with no cookie a carried session is unreachable. Every web user logs in once more after cutover; the client ID, redirect, key ID and (once imported, in the next pull) the client key stay hatk's, so PDSes see the same client.grain-native://app) is not ported, by decision: the apps move to their own PDS.Index::refresh_web_sessionupdates only a row that is there), per the review.spaceScopesand the pool/group scopes for a PDS that serves spaces) are not asked for yet; they come with private galleries and pools. They are registered, so asking for them later needs no metadata change.Vary: Cookie/Cache-Control: private; nothing caches them now, but a cache in front would need it.web-cookiesigns every browser out; rotating the client key voids every PDS session. Each can change without the other.Review
The design and the security-sensitive parts:
crates/grain-serve/src/login.rs(cookie validity inheld,same_site,session's refresh), thenviewer.rs,index/sessions.rsand the V7 migration.🤖 Generated with Claude Code