~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

Core logs in with OAuth, keeps sessions and checks service auth

Merged
Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:core/auth

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, the login_hint the user gave or prompt=create for a sign-up, then the code traded for DPoP-bound tokens, refreshed and revoked. DPoP proofs carry ath for an access token and the nonce each server last sent, and a request refused with use_dpop_nonce is sent again once, at the authorization server and at a PDS alike. Two clients: the command line's loopback http://localhost client, 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), whose private_key_jwt assertion goes with every request and whose metadata document (Client::metadata, with its JWKS) grain-serve will serve. SCOPE is 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 its Auth (DPoP or Bearer); for the command line, kept per DID under GRAIN_CONFIG_DIR, refreshed in place one process at a time under a file lock, and found again as the account last logged in or GRAIN_IDENTIFIER, with GRAIN_PASSWORD to log in when none is kept.

Service auth (auth): a token from com.atproto.server.getServiceAuth for aud and lxm (with an exp for a longer one), and verify, which checks a token's aud, lxm, exp and its signature against the #atproto key of the issuer's DID document: as read in the last five minutes, then as it is now, so a rotated key passes.

xrpc gains Auth::Dpop; store gains Lock (knotless keeps it in mirror.rs); env gains 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 grain CLI 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. Its LOGIN_BEHIND and GRANT_BEHIND texts are knotless's words. client_key and set_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, and refresh and revoke take the client granted the session rather than reading a key from disk. refresh no longer stores what it gets; session does that for the sessions it keeps on disk, so grain-serve can keep its own however it likes. refresh also 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 fmt and cargo update -p grain; the rust check 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, ath and nonce, and the PKCE verifier against the challenge pushed:

  • a command-line login end to end: PAR, the browser's return to the loopback listener (an icon asked for first, and refused), the code redeemed, getSession for the handle over DPoP, the session saved; then refreshed, refused for a spent token, and revoked;
  • a confidential client's login, with its assertion checked, and a sign-up, which takes the account only as its PDS logs in at the same server;
  • returns this login did not start (another state, another issuer), a denial, a bad code, and a PDS that names no authorization server;
  • the confidential client's JWKS (no secret in it), metadata document and assertion;
  • sessions kept, refreshed in place, taken as another process refreshed them, refused, and logged out with the token revoked;
  • service tokens asked of a PDS, and verified: 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 in crypto); verifying against a PLC-hosted DID document.

SCOPE leaves out what hatk asks for only of PDSes that serve spaces (private galleries' and groups' space: and rpc: 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 to authorize. grain-serve has no OAuth routes yet; this is the library they will call.

Review

Start at oauth.rs (Client, post, Request::redeem, refresh), then auth.rs::verify. session.rs is knotless's with the refresh moved under its own lock.

🤖 Generated with Claude Code

chadtmiller.com added 1 commit
Approved

This 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 checks aud, lxm, exp and the signature. It tries the kept DID document first and then a fresh one, so a rotated key passes. A token with no exp is refused, as is one signed by a stranger.
  • redeem: it checks state, iss and sub. 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, check and test workflows had not finished. Please confirm they pass before merging.

Two small things, neither blocking:

  • iss fallback in redeem: if the response has no iss, it falls back to the expected issuer. RFC 9207 wants iss required when the server advertises authorization_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.
chadtmiller.com added 1 commit
Approved

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.

Approved

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.

Merged as 5e8da70
Part of a stack of 2
  1. Core logs in with OAuth, keeps sessions and checks service auth Landed as 5e8da70
  2. main