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

grain.social/grain-android

1 collaborator
0
1

Grain signs in at the account's own PDS, as an atproto OAuth client

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

What changes

Sign-in. The app is an atproto OAuth public native client, https://grain.social/oauth/app-client-metadata.json, redirecting to social.grain:/oauth/callback (grain.social reversed; the manifest claims the social.grain scheme, grain:// stays for the other deep links). AuthManager.beginLogin resolves the handle (https://<handle>/.well-known/atproto-did, then public.api.bsky.app's resolveHandle; a DID typed directly is taken as it is), fetches the DID document (plc.directory or did:web's /.well-known/did.json) for the #atproto_pds endpoint and the at:// handle, reads the PDS's oauth-protected-resource and the authorization server's metadata (its issuer must match), then runs PAR there with PKCE S256, DPoP, state and login_hint. On return, state and iss (when sent) are checked and the code redeemed at the server's token endpoint; sub must be the DID resolved. The scope asked for is exactly the one in the server pull's document, ending rpc:*?aud=did:web:grain.social%23grain_appview. The aud is a query value the PDS URL-decodes, so ServiceIdentity.rpcScope escapes % as %25 and then # as %23 (did:web:localhost%3A3000#grain_appview is written did:web:localhost%253A3000%23grain_appview). The atproto-proxy header carries it unescaped.

Create account runs PAR with prompt=create at https://selfhosted.social's authorization server. The account is known only from sub, so it is resolved and accepted only when its own PDS's authorization server is the one the sign-up ran at.

The pending sign-in (verifier, state, issuer, endpoints, the account asked for, and its DPoP key) is kept in TokenStorage, so a process killed while the Custom Tab is up still finishes.

Per account, besides tokens, expiry, scope and DPoP key, the app keeps the PDS, issuer, token and revocation endpoints (AccountServer). Refresh goes to that token endpoint; a 4xx still drops the account, and refreshes still coalesce.

Calls while signed in go to the account's PDS with Authorization: DPoP, a proof per request (htm, htu without query, ath), and atproto-proxy: did:web:grain.social#grain_appview on every method outside com.atproto.*. DPoP nonces are kept per origin in memory (DpopNonces), shared by the token calls and the PDS calls. A nonce refusal (401 whose WWW-Authenticate or body says use_dpop_nonce, or a 400 whose body does) is replayed once; any other 401 refreshes once and retries. A PDS sends DPoP-Nonce on every response, so a header alone no longer counts as a nonce refusal. Signed-out calls go straight to https://grain.social.

Writes go to the PDS: com.atproto.repo.createRecord, putRecord, deleteRecord, applyWrites (#create|#update|#delete) with repo the account's DID, and com.atproto.repo.uploadBlob. The call sites keep their signatures. After a write, social.grain.sync.indexRecords is called through the proxy with the record URIs (deleted ones included), 25 a call. A failure there is logged and never fails the write. The dev.hatk write paths are gone.

Sign-out posts the refresh token to the revocation endpoint (DPoP, client_id) in the background and as far as the server lets it, then forgets the account as before. Push unregistration still runs first.

Sessions from grain's own server have no PDS on record. They are dropped once at launch, and the login screen says "Grain has been updated. Sign in again to keep using it." through the existing reauthReason.

The client ID, redirect scheme, proxy DID and rpc scope are all derived from AuthManager.SERVER_URL (ServiceIdentity; a port becomes %3A). The app had no runtime server switch to keep, so pointing a build elsewhere means changing that constant and the manifest's scheme.

Why

grain-serve serves no /oauth/par, /oauth/authorize or /oauth/token, so the app has to sign in where the account lives. This mirrors knotless's CLI: crates/knotless/src/oauth.rs for the PAR/PKCE/DPoP public client, per-origin nonces (keep_nonce, nonce_refused), the sign-up check (made()), iss/state checking in redeem, and revoke; crates/knotless/src/identity.rs (and grain's crates/grain/src/identity.rs) for the handle resolvers and their order.

It differs from knotless in two places. A phone can't take a loopback redirect, and atproto asks a native client for an https client ID whose host, reversed, is its redirect scheme, so grain-serve hosts the document. And calls go through atproto-proxy, one rpc scope for every grain method, as Bluesky's apps reach their appview, not a getServiceAuth token per method.

Library. I checked sh.christian.ozone:oauth (0.3.x). It is a work-in-progress Kotlin Multiplatform module built on Ktor with its own client and session types. Using it would mean a second HTTP stack next to OkHttp and a rewrite of TokenStorage's per-DID, Keystore-sealed storage and the multi-account code. AppAuth-Android has neither DPoP nor PAR. So the app's own DPoP, AuthManager and XrpcClient are extended instead; the new code is a resolver of about 150 lines and a nonce map.

Depends on the server pull at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzb6nkbx3q2b in knotless://grain.social/grain (base feature/rust), which serves /oauth/app-client-metadata.json. Nothing here can sign in until that is live at grain.social.

How it was checked

JVM tests, run on the runner (test workflow, green at 61d64f4) and not on this machine:

  • AuthManagerTest: discovery (well-known, the Bluesky fallback, a DID typed directly, did:web, an unresolvable handle, an issuer mismatch, an empty handle), PAR's form, proof and nonce replay, the token exchange and what is stored, the pending sign-in surviving a new AuthManager, a sub for another account, a wrong state or iss, no iss, denial and errors on the callback, sign-up accepted and refused by the made() check, refresh at the stored token endpoint, revocation on sign-out (and not after a dead refresh), the old-session drop and its message, the exact scope string, and the scope and proxy for a server on a port.
  • XrpcClientTest: PDS routing and the proxy header, no proxy signed out or on com.atproto.*, htu and ath, the nonce named in WWW-Authenticate, nonces kept per origin, and a 401 carrying a nonce but invalid_token refreshing.
  • EndpointsTest: the write shapes (repo defaults to the account), no proxy on writes, indexRecords after create/put/delete/applyWrites in batches of 25, an index failure not failing the write, and #update/#delete writes.
  • The view-model and screen tests run against stub accounts that have a PDS on record.

Not tried on a device or emulator: the server document isn't live yet. DpopParTest (androidTest) now runs PAR against bsky.social's discovered authorization server, and will fail until the document is published.

Shortcomings

  • No live sign-in has been run against any PDS. The Custom Tab round trip and the real social.grain: redirect are untested until grain-serve publishes the document.
  • deleteGallery still calls social.grain.unspecced.deleteGallery through the proxy, and so do the other non-write methods (dev.hatk.getRecord, putPreference, createReport, push registration…). Renaming them to grain-serve's names is a separate step. Unless grain-serve answers deleteGallery, deleting a gallery stays broken until then.
  • A debug build can only sign in with an https client ID, so it needs grain.social's document or an https tunnel set as SERVER_URL. atproto takes a did:web with a port only on localhost, so a PDS refuses an rpc scope whose aud is, say, did:web:10.0.2.2%3A3000: the configured server has to be localhost:<port> or a portless https host such as a tunnel. The manifest's scheme is static and has to be changed with it.
  • Handles from the DID document aren't verified bidirectionally. The handle is used only as login_hint and for display until the profile fetch fills it in.
  • A sign-up whose made() check fails has already been issued tokens, and they are not revoked (knotless doesn't revoke them either).
  • The nonce map lives only in memory, so the first call to each server after a launch costs one replay.

Review

Start at data/auth/AuthManager.kt (beginLogin, completeLogin, exchangeCode, made), then data/api/XrpcClient.kt's execute and nonceRefused, then the writes in data/api/Endpoints.kt.

🤖 Generated with Claude Code

Merged as ce05607