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.
What changes
Sign-in. The app is an atproto OAuth public native client,
https://grain.social/oauth/app-client-metadata.json, redirecting tosocial.grain:/oauth/callback(grain.social reversed; the manifest claims thesocial.grainscheme,grain://stays for the other deep links).AuthManager.beginLoginresolves the handle (https://<handle>/.well-known/atproto-did, thenpublic.api.bsky.app'sresolveHandle; a DID typed directly is taken as it is), fetches the DID document (plc.directory ordid:web's/.well-known/did.json) for the#atproto_pdsendpoint and theat://handle, reads the PDS'soauth-protected-resourceand the authorization server's metadata (itsissuermust match), then runs PAR there with PKCE S256, DPoP,stateandlogin_hint. On return,stateandiss(when sent) are checked and the code redeemed at the server's token endpoint;submust be the DID resolved. The scope asked for is exactly the one in the server pull's document, endingrpc:*?aud=did:web:grain.social%23grain_appview. Theaudis a query value the PDS URL-decodes, soServiceIdentity.rpcScopeescapes%as%25and then#as%23(did:web:localhost%3A3000#grain_appviewis writtendid:web:localhost%253A3000%23grain_appview). Theatproto-proxyheader carries it unescaped.Create account runs PAR with
prompt=createathttps://selfhosted.social's authorization server. The account is known only fromsub, 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,htuwithout query,ath), andatproto-proxy: did:web:grain.social#grain_appviewon every method outsidecom.atproto.*. DPoP nonces are kept per origin in memory (DpopNonces), shared by the token calls and the PDS calls. A nonce refusal (401 whoseWWW-Authenticateor body saysuse_dpop_nonce, or a 400 whose body does) is replayed once; any other 401 refreshes once and retries. A PDS sendsDPoP-Nonceon every response, so a header alone no longer counts as a nonce refusal. Signed-out calls go straight tohttps://grain.social.Writes go to the PDS:
com.atproto.repo.createRecord,putRecord,deleteRecord,applyWrites(#create|#update|#delete) withrepothe account's DID, andcom.atproto.repo.uploadBlob. The call sites keep their signatures. After a write,social.grain.sync.indexRecordsis called through the proxy with the record URIs (deleted ones included), 25 a call. A failure there is logged and never fails the write. Thedev.hatkwrite 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
rpcscope are all derived fromAuthManager.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/authorizeor/oauth/token, so the app has to sign in where the account lives. This mirrors knotless's CLI:crates/knotless/src/oauth.rsfor the PAR/PKCE/DPoP public client, per-origin nonces (keep_nonce,nonce_refused), the sign-up check (made()),iss/statechecking inredeem, andrevoke;crates/knotless/src/identity.rs(and grain'scrates/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, onerpcscope for every grain method, as Bluesky's apps reach their appview, not agetServiceAuthtoken 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 ofTokenStorage's per-DID, Keystore-sealed storage and the multi-account code. AppAuth-Android has neither DPoP nor PAR. So the app's ownDPoP,AuthManagerandXrpcClientare 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 (
testworkflow, 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 newAuthManager, asubfor another account, a wrongstateoriss, noiss, denial and errors on the callback, sign-up accepted and refused by themade()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 oncom.atproto.*,htuandath, the nonce named inWWW-Authenticate, nonces kept per origin, and a 401 carrying a nonce butinvalid_tokenrefreshing.EndpointsTest: the write shapes (repo defaults to the account), no proxy on writes,indexRecordsafter create/put/delete/applyWrites in batches of 25, an index failure not failing the write, and#update/#deletewrites.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
social.grain:redirect are untested until grain-serve publishes the document.deleteGallerystill callssocial.grain.unspecced.deleteGallerythrough 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 answersdeleteGallery, deleting a gallery stays broken until then.SERVER_URL. atproto takes adid:webwith a port only onlocalhost, so a PDS refuses anrpcscope whoseaudis, say,did:web:10.0.2.2%3A3000: the configured server has to belocalhost:<port>or a portless https host such as a tunnel. The manifest's scheme is static and has to be changed with it.login_hintand for display until the profile fetch fills it in.made()check fails has already been issued tokens, and they are not revoked (knotless doesn't revoke them either).Review
Start at
data/auth/AuthManager.kt(beginLogin,completeLogin,exchangeCode,made), thendata/api/XrpcClient.kt'sexecuteandnonceRefused, then the writes indata/api/Endpoints.kt.🤖 Generated with Claude Code