Sign-in happens at the account's own PDS. The app is an atproto OAuth public native client, https://grain.social/oauth/app-client-metadata.json, coming back on social.grain:/oauth/callback (the scheme is registered; grain:// stays for deep links). A handle resolves through its /.well-known/atproto-did, then com.atproto.identity.resolveHandle at public.api.bsky.app; a DID typed directly works too. The DID document comes from plc.directory or the did:web host, and gives the PDS (#atproto_pds) and the handle (the at:// alias, taken only if it resolves back). The PDS's oauth-protected-resource names the authorization server, whose metadata must name the same issuer. Then PAR with PKCE S256, DPoP, state and login_hint; the browser; a check of state and iss; the token exchange, whose sub must be the resolved DID. The scope asked for is exactly grain's list, ending in rpc:*?aud=did:web:grain.social%23grain_appview.
Create account pushes prompt=create to the authorization server of https://selfhosted.social. The new account is known only from sub. It is resolved and kept only if its PDS signs in at that same issuer.
Each account keeps its handle, PDS, issuer, token and revocation endpoints, tokens, expiry, granted scope and DPoP key, in the Keychain under its DID. DPoP nonces are kept in memory per origin, because an account's authorization server and PDS are different origins.
Signed-in calls go to the PDS. Each call carries Authorization: DPoP, a fresh proof (htm, htu without query, ath, the origin's nonce) and, for every method outside com.atproto.*, atproto-proxy: did:web:grain.social#grain_appview. A nonce refusal (a 401 whose WWW-Authenticate says use_dpop_nonce, or a 400 whose error is use_dpop_nonce) is sent once more with the nonce. Any other 401 refreshes once and retries, as before. Signed-out calls go to https://grain.social with no credentials.
Writes go straight to the repo: com.atproto.repo.createRecord/putRecord/deleteRecord/applyWrites with repo set to the account's DID (applyWrites $types are com.atproto.repo.applyWrites#create|#update|#delete), and com.atproto.repo.uploadBlob for images. Once a write lands, its URIs (deleted ones included, at most 25 per call) go to social.grain.sync.indexRecords through the proxy, so the next read sees them. A failure there is logged and never fails the user's action. Bluesky cross-posts are not sent there. The call sites keep their signatures, and the dev.hatk.* write paths are gone.
Refresh uses the account's stored token endpoint. A 4xx drops that account, as before; requests are still coalesced, and an account is still refreshed before a switch. Sign out first runs the push-unregister hook, then makes a best-effort revocation of the refresh token, then forgets the account locally as before.
Sessions from the old server are dropped once at launch. Any account that has tokens but no PDS and authorization server on record, or a grant missing a required scope, is removed. If none is left, the login screen shows the existing re-auth message. The migration flag is bumped to scopeMigrationDone_v3.
Why
grain-serve, the Rust server replacing grain's Node app, serves no /oauth/par, /oauth/authorize or /oauth/token for the apps. It does serve the native client metadata and social.grain.sync.indexRecords. This depends on the server pull at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzb6nkbx3q2b in knotless://grain.social/grain (base feature/rust). Until that is deployed, sign-in fails at the client metadata fetch.
It follows knotless's CLI. crates/knotless/src/oauth.rs gives the public client's PAR/PKCE/DPoP, nonces kept per origin and retried once on use_dpop_nonce, the issuer check on metadata, the state/iss check on return, sub checked against the account, the sign-up made() check, and revocation. crates/knotless/src/identity.rs gives the handle resolvers in the same order, DID documents from PLC or did:web, and the #atproto_pds lookup. It departs from knotless in two places. The client_id is an https metadata document that grain-serve hosts, because a native app cannot be a loopback client. And Grain's methods reach the AppView through atproto-proxy, as Bluesky's apps reach theirs, rather than through per-method getServiceAuth.
Library. I looked at ATProtoKit/OAuthenticator (a fork of ChimeHQ's) and found no other Swift package that covers atproto OAuth end to end. OAuthenticator describes its PAR and DPoP support as preliminary, needs an outside JWT library for proofs, and keeps one login in its own storage. Fitting that to per-DID Keychain accounts and per-origin nonces would have cost more than extending the app's own DPoP and PKCE code, which already worked. So the app's code is extended (Grain/API/OAuth/).
How it was checked
Unit tests on the iPhone 17 Pro Max simulator (iOS 26, Xcode 26), xcodebuild test -only-testing: per class, 270 tests (and OAuthSignInTests again after the scope escaping), all against stubbed URL sessions with no network:
OAuthSignInTests (new) covers:
handle resolution through well-known and the AppView fallback;
a DID typed directly, with its handle verified back;
did:web documents;
issuer mismatch;
the PAR form, the exact scope (and its escaping for a ported did:web audience) and PKCE;
PAR nonce retry;
state/iss mismatch, denial, and a wrong sub;
sign-up, and its refusal when the account is hosted elsewhere;
revocation on sign-out, including a failed revocation;
the launch migration.
PDSCallTests (new) covers: the PDS host, the proxy header (absent for com.atproto.*), proof claims (htm, htu, ath), the nonce resent and kept per origin, an expired-token 401 that carries a nonce header still refreshing, and a plain 400 not being retried.
EndpointContractTests covers the write paths: repo, $types, indexRecords batching, a failed index not failing the write, cross-posts not indexed, and blobs.
Run and passing after updating them (swiftformat and swiftlint clean): XRPCClientTests, AuthRefreshTests, AuthManagerAccountTests, TokenStorageTests, AccountSwitchingTests, GrainTests, GalleryServiceTests, GalleryPublishTests, CrossPostPublishTests, StoryPublishTests, BlueskyCrossPostTests, ProfileFavoritesAndModerationTests, ProfileDetailViewModelTests, StoryViewSyncTests, StoryCommentsViewModelTests, LoginRenderTests, LegacyStateMigrationTests, RoutingTests.
The full suite was not run, and nothing was tried against a live PDS: grain-serve's client metadata is not deployed yet. This repo has no build or test CI on knotless.
Shortcomings
Untested end to end. Neither a real sign-in, a real PAR at bsky.social or selfhosted.social, nor a real proxied call has been exercised.
A dev build (sim-local, http://127.0.0.1:3000) derives its client_id and proxy DID from the server URL (did:web:127.0.0.1%3A3000; in the scope, which a PDS URL-decodes, aud=did:web:127.0.0.1%253A3000%23grain_appview). A PDS only fetches an https client_id, so OAuth in a dev build needs that server behind an https tunnel, or a build pointed at grain.social. Handle resolution in dev also goes to the public resolvers, so a local PDS's *.test handles resolve only by DID.
Reads and other procedures (social.grain.unspecced.*, dev.hatk.getRecord, dev.hatk.getFeed, dev.hatk.putPreference, dev.hatk.createReport, dev.hatk.push.*, social.grain.unspecced.deleteGallery/deleteAccount) keep their names and go through the proxy. Renaming them to grain-serve's names is a separate step. So is the gallery publish's "did it land" check, which still reads through dev.hatk.getRecord.
The PDS is read once, at sign-in. An account that migrates to another PDS has to sign in again.
Nonces live only in memory, so the first call after a launch takes one extra round trip at each origin.
Signing out awaits the revocation request, so a slow authorization server makes sign-out slower (it is best-effort, so a failure does not block it).
Every existing session is signed out once on update.
Review
Start at Grain/API/OAuth/OAuthClient.swift and IdentityResolver.swift, then XRPCClient.perform, then AuthManager.performLogin/performRefresh/runSessionMigrationIfNeeded. The riskiest part is the nonce and refresh handling in XRPCClient.perform: a mistake there signs people out or loops.
What changes
Sign-in happens at the account's own PDS. The app is an atproto OAuth public native client,
https://grain.social/oauth/app-client-metadata.json, coming back onsocial.grain:/oauth/callback(the scheme is registered;grain://stays for deep links). A handle resolves through its/.well-known/atproto-did, thencom.atproto.identity.resolveHandleatpublic.api.bsky.app; a DID typed directly works too. The DID document comes fromplc.directoryor thedid:webhost, and gives the PDS (#atproto_pds) and the handle (theat://alias, taken only if it resolves back). The PDS'soauth-protected-resourcenames the authorization server, whose metadata must name the same issuer. Then PAR with PKCE S256, DPoP,stateandlogin_hint; the browser; a check ofstateandiss; the token exchange, whosesubmust be the resolved DID. The scope asked for is exactly grain's list, ending inrpc:*?aud=did:web:grain.social%23grain_appview.Create account pushes
prompt=createto the authorization server ofhttps://selfhosted.social. The new account is known only fromsub. It is resolved and kept only if its PDS signs in at that same issuer.Each account keeps its handle, PDS, issuer, token and revocation endpoints, tokens, expiry, granted scope and DPoP key, in the Keychain under its DID. DPoP nonces are kept in memory per origin, because an account's authorization server and PDS are different origins.
Signed-in calls go to the PDS. Each call carries
Authorization: DPoP, a fresh proof (htm, htu without query,ath, the origin's nonce) and, for every method outsidecom.atproto.*,atproto-proxy: did:web:grain.social#grain_appview. A nonce refusal (a 401 whoseWWW-Authenticatesaysuse_dpop_nonce, or a 400 whose error isuse_dpop_nonce) is sent once more with the nonce. Any other 401 refreshes once and retries, as before. Signed-out calls go tohttps://grain.socialwith no credentials.Writes go straight to the repo:
com.atproto.repo.createRecord/putRecord/deleteRecord/applyWriteswithreposet to the account's DID (applyWrites$types arecom.atproto.repo.applyWrites#create|#update|#delete), andcom.atproto.repo.uploadBlobfor images. Once a write lands, its URIs (deleted ones included, at most 25 per call) go tosocial.grain.sync.indexRecordsthrough the proxy, so the next read sees them. A failure there is logged and never fails the user's action. Bluesky cross-posts are not sent there. The call sites keep their signatures, and thedev.hatk.*write paths are gone.Refresh uses the account's stored token endpoint. A 4xx drops that account, as before; requests are still coalesced, and an account is still refreshed before a switch. Sign out first runs the push-unregister hook, then makes a best-effort revocation of the refresh token, then forgets the account locally as before.
Sessions from the old server are dropped once at launch. Any account that has tokens but no PDS and authorization server on record, or a grant missing a required scope, is removed. If none is left, the login screen shows the existing re-auth message. The migration flag is bumped to
scopeMigrationDone_v3.Why
grain-serve, the Rust server replacing grain's Node app, serves no
/oauth/par,/oauth/authorizeor/oauth/tokenfor the apps. It does serve the native client metadata andsocial.grain.sync.indexRecords. This depends on the server pull at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzb6nkbx3q2b in knotless://grain.social/grain (basefeature/rust). Until that is deployed, sign-in fails at the client metadata fetch.It follows knotless's CLI.
crates/knotless/src/oauth.rsgives the public client's PAR/PKCE/DPoP, nonces kept per origin and retried once onuse_dpop_nonce, the issuer check on metadata, thestate/isscheck on return,subchecked against the account, the sign-upmade()check, and revocation.crates/knotless/src/identity.rsgives the handle resolvers in the same order, DID documents from PLC ordid:web, and the#atproto_pdslookup. It departs from knotless in two places. The client_id is an https metadata document that grain-serve hosts, because a native app cannot be a loopback client. And Grain's methods reach the AppView throughatproto-proxy, as Bluesky's apps reach theirs, rather than through per-methodgetServiceAuth.Library. I looked at ATProtoKit/OAuthenticator (a fork of ChimeHQ's) and found no other Swift package that covers atproto OAuth end to end. OAuthenticator describes its PAR and DPoP support as preliminary, needs an outside JWT library for proofs, and keeps one login in its own storage. Fitting that to per-DID Keychain accounts and per-origin nonces would have cost more than extending the app's own DPoP and PKCE code, which already worked. So the app's code is extended (
Grain/API/OAuth/).How it was checked
Unit tests on the iPhone 17 Pro Max simulator (iOS 26, Xcode 26),
xcodebuild test -only-testing:per class, 270 tests (andOAuthSignInTestsagain after the scope escaping), all against stubbed URL sessions with no network:OAuthSignInTests(new) covers:did:webdocuments;did:webaudience) and PKCE;state/issmismatch, denial, and a wrongsub;PDSCallTests(new) covers: the PDS host, the proxy header (absent forcom.atproto.*), proof claims (htm, htu, ath), the nonce resent and kept per origin, an expired-token 401 that carries a nonce header still refreshing, and a plain 400 not being retried.EndpointContractTestscovers the write paths: repo,$types, indexRecords batching, a failed index not failing the write, cross-posts not indexed, and blobs.XRPCClientTests,AuthRefreshTests,AuthManagerAccountTests,TokenStorageTests,AccountSwitchingTests,GrainTests,GalleryServiceTests,GalleryPublishTests,CrossPostPublishTests,StoryPublishTests,BlueskyCrossPostTests,ProfileFavoritesAndModerationTests,ProfileDetailViewModelTests,StoryViewSyncTests,StoryCommentsViewModelTests,LoginRenderTests,LegacyStateMigrationTests,RoutingTests.The full suite was not run, and nothing was tried against a live PDS: grain-serve's client metadata is not deployed yet. This repo has no build or test CI on knotless.
Shortcomings
sim-local,http://127.0.0.1:3000) derives its client_id and proxy DID from the server URL (did:web:127.0.0.1%3A3000; in the scope, which a PDS URL-decodes,aud=did:web:127.0.0.1%253A3000%23grain_appview). A PDS only fetches an https client_id, so OAuth in a dev build needs that server behind an https tunnel, or a build pointed at grain.social. Handle resolution in dev also goes to the public resolvers, so a local PDS's*.testhandles resolve only by DID.social.grain.unspecced.*,dev.hatk.getRecord,dev.hatk.getFeed,dev.hatk.putPreference,dev.hatk.createReport,dev.hatk.push.*,social.grain.unspecced.deleteGallery/deleteAccount) keep their names and go through the proxy. Renaming them to grain-serve's names is a separate step. So is the gallery publish's "did it land" check, which still reads throughdev.hatk.getRecord.Review
Start at
Grain/API/OAuth/OAuthClient.swiftandIdentityResolver.swift, thenXRPCClient.perform, thenAuthManager.performLogin/performRefresh/runSessionMigrationIfNeeded. The riskiest part is the nonce and refresh handling inXRPCClient.perform: a mistake there signs people out or loops.🤖 Generated with Claude Code