Core logs in with OAuth, keeps sessions and checks service auth
MergedThis pull ports knotless's oauth, session and auth modules into grain's core. It adds PAR with PKCE and DPoP, a loopback client and a confidential client, sessions kept on disk under a lock, and service-token fetching and verification.
I read auth.rs in full, plus Request::redeem, refresh, revoke and the loopback callback in oauth.rs. I did not read session.rs, the first half of oauth.rs (Client, post), or the test code beyond auth.rs's.
auth::verify: it checksaud,lxm,expand the signature. It tries the kept DID document first and then a fresh one, so a rotated key passes. A token with noexpis refused, as is one signed by a stranger.redeem: it checksstate,issandsub. For a sign-up, the PDS must log in at the same issuer.refresh: it refuses tokens for a different DID.- Checks: at the time I looked, the
rust,checkandtestworkflows had not finished. Please confirm they pass before merging.
Two small things, neither blocking:
issfallback inredeem: if the response has noiss, it falls back to the expected issuer. RFC 9207 wantsissrequired when the server advertisesauthorization_response_iss_parameter_supported. Consider enforcing that when you add grain-serve's routes.- Loopback
callback: it has no timeout, and it does one blocking read per connection. A browser's speculative empty connection could stall it until the connection closes. This is inherited from knotless, so a follow-up is fine.
The only change since my last review is in oauth::start. It now strips a leading @ from the identifier before resolving it, so a handle typed with its @ works. That's correct and small. trim_start_matches would also strip repeated @s, which doesn't matter here.
I had no blocking findings before and the two notes (requiring iss per RFC 9207, and a timeout on the loopback callback) are still fine as follow-ups. The rust, check and test workflows are still queued for this commit, so please confirm they pass before merging.
The only change since my last review is in the test helpers in oauth.rs. The stand-in authorization server now answers resolveHandle with did:plc:alice, and ctx_on points GRAIN_HANDLE_RESOLVER at it. This fits the @-stripping change from the previous round, because a handle login in the tests now resolves. It touches no production code.
I have no blocking findings. The two earlier notes are still fine as follow-ups: require iss per RFC 9207, and put a timeout on the loopback callback.
The rust and check workflows are still running and test is still queued for this commit. Please confirm all three pass before merging.
All three checks pass at 343bb7b. Both notes are left for follow-ups, as suggested. Requiring iss when the server advertises authorization_response_iss_parameter_supported belongs with grain-serve's login routes, which is where Server will first read that field. The loopback callback's lack of a timeout and its one read per connection are knotless's as they stand; the fix is a timeout on accept plus reading until the request line ends, best done in both repositories together.
- Core logs in with OAuth, keeps sessions and checks service auth
- main

What changes
Stacked on Core resolves identities, verifies atproto keys and calls XRPC (
core/identity), which is not merged yet: until it is, this pull's diff holds that one's two commits too. This pull's own commit is the last.OAuth (
oauth): authorization requests pushed (PAR) with PKCE S256 and a fresh DPoP key, thelogin_hintthe user gave orprompt=createfor a sign-up, then the code traded for DPoP-bound tokens, refreshed and revoked. DPoP proofs carryathfor an access token and the nonce each server last sent, and a request refused withuse_dpop_nonceis sent again once, at the authorization server and at a PDS alike. Two clients: the command line's loopbackhttp://localhostclient, its scope and redirect named in its ID, with the browser coming back to a listener on 127.0.0.1; and a confidential client,Client::confidential(id, key), whoseprivate_key_jwtassertion goes with every request and whose metadata document (Client::metadata, with its JWKS) grain-serve will serve.SCOPEis the base of what hatk asks for today: grain's collections,blob:image/*, and the Bluesky cross-post's create-and-update.Sessions (
session): a session from OAuth or an app password, which gives XRPC itsAuth(DPoPorBearer); for the command line, kept per DID underGRAIN_CONFIG_DIR, refreshed in place one process at a time under a file lock, and found again as the account last logged in orGRAIN_IDENTIFIER, withGRAIN_PASSWORDto log in when none is kept.Service auth (
auth): a token fromcom.atproto.server.getServiceAuthforaudandlxm(with anexpfor a longer one), andverify, which checks a token'saud,lxm,expand its signature against the#atprotokey of the issuer's DID document: as read in the last five minutes, then as it is now, so a rotated key passes.xrpcgainsAuth::Dpop;storegainsLock(knotless keeps it inmirror.rs);envgains the config directory, identifier, password and browser.Why
The second of the two pulls that bring knotless's protocol modules into grain's core: grain-serve logs people in as a confidential client and checks the apps' service tokens, and the
grainCLI logs in as a loopback client, as knotless's does.Cut from knotless and why:
oauth's scope is knotless's records, gateways, moderation, invites, secrets, notifications and spaces; grain's is its own. ItsLOGIN_BEHINDandGRANT_BEHINDtexts are knotless's words.client_keyandset_client_key, which kept a gateway's key in the config directory, are gone: a confidential client is made with the key its server holds, andrefreshandrevoketake the client granted the session rather than reading a key from disk.refreshno longer stores what it gets;sessiondoes that for the sessions it keeps on disk, so grain-serve can keep its own however it likes.refreshalso refuses tokens for an account other than the session's.session::reader(an owner's or anyone's session, to read a repository) is the forge's.auth's loopback tokens (a knotless server signing tokens to itself, for its own gateway) and space delegation tokens (verified by a space's authority) have nothing in grain to use them: grain's appview reads a space's member repos and is no space's authority.How it was checked
Nothing was built or run here beyond
cargo fmtandcargo update -p grain; therustcheck builds and tests it. knotless's unit tests come along (scopes behind, refusals for want of scope, proofs, the loopback client ID, JWT expiry). Added, against a stand-in authorization server and PDS in one (axum, in the tests), which asks once for a DPoP nonce at each and checks every proof's signature,athand nonce, and the PKCE verifier against the challenge pushed:getSessionfor the handle over DPoP, the session saved; then refreshed, refused for a spent token, and revoked;aud,lxm,exp, a stranger's key, a service-suffixed issuer, and a key that rotates.Shortcomings
Nothing here has talked to a real PDS: grain has no PDS harness yet, which is a later pull. Untested for that reason, beyond the stand-in: the reference PDS's actual PAR, token, refresh and revocation answers and its DPoP nonce rotation; app password sessions (
createSession,refreshSession);getServiceAuth's real tokens, ES256K among them (the signature check for ES256K is unit tested incrypto); verifying against a PLC-hosted DID document.SCOPEleaves out what hatk asks for only of PDSes that serve spaces (private galleries' and groups'space:andrpc:scopes) and the create-only Bluesky post scope it registers for older apps: they belong to the login and client metadata grain-serve will serve, which passes the scope it wants toauthorize. grain-serve has no OAuth routes yet; this is the library they will call.Review
Start at
oauth.rs(Client,post,Request::redeem,refresh), thenauth.rs::verify.session.rsis knotless's with the refresh moved under its own lock.🤖 Generated with Claude Code