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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

server: The apps sign in at their own PDS, as grain-serve's public client

Merged
Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:appauth/app-client

What changes

The apps' client metadata. grain-serve serves /oauth/app-client-metadata.json, the client ID the iOS and Android apps log in with at the account's own PDS: application_type: native, token_endpoint_auth_method: none, dpop_bound_access_tokens: true, no key. It sends them back to social.grain:/oauth/callback, the public URL's host reversed as atproto asks of a native client's redirect (login::app_scheme). Its scope is the web's REGISTERED plus rpc:*?aud=<server DID>%23grain_appview (login::app_calls, from App::did), so it covers anything a login here asks for, and an app's call to grain-serve. A server with no public URL and index answers it 501, as the web's metadata.

What an app asks for. login::app_scope(did) is oauth::SCOPE and that rpc scope: the app writes its records and blobs straight to its PDS, then calls grain-serve, social.grain.sync.indexRecords included, through the PDS with atproto-proxy: did:web:grain.social#grain_appview. grain-serve already takes those calls: the PDS signs a service token with aud the bare did:web, which viewer::viewer checks, and the DID document already names #grain_appview. The audience is a query value, which a PDS URL-decodes, so % is escaped as %25 before # as %23: a did:web:localhost%3A<port> stays one DID. For grain.social the scope is rpc:*?aud=did:web:grain.social%23grain_appview.

oauth::Client::public(id), a public client named by its metadata document, for the test below.

Why

grain-serve holds no /oauth/par or /oauth/token of its own, so the apps can no longer sign in through it. The decided design is knotless's: clients are atproto OAuth public clients of the account's own authorization server, and call the appview with service auth. This mirrors crates/knotless/src/oauth.rs (PKCE + DPoP public client, Client::loopback) and crates/knotless/src/auth.rs (service tokens to the server). It differs in two places, and why:

  • knotless's CLI is the loopback client http://localhost?..., which needs no hosted document. A phone cannot receive a redirect at 127.0.0.1, and atproto asks a native client for an https client ID whose host, reversed, is its redirect scheme, so grain-serve hosts that document. It is a sibling of the web's /oauth-client-metadata.json, as knotless-serve's web document is /oauth/web-client-metadata.json.
  • knotless's CLI asks getServiceAuth for each call, scoped rpc:<lxm>?aud=*, method by method. The apps go through atproto-proxy, as Bluesky's apps call their appview. That takes one scope for every grain method, and no token per method. The reference PDS checks a proxied call against rpc with aud as did#service (pipethrough.js), and getServiceAuth checks the bare aud it is asked for. So rpc:*?aud=did:web:grain.social%23grain_appview lets proxying through, and is the only wildcard rpc scope atproto allows (rpc:*?aud=* is refused).

How it was checked

  • the_apps_client_is_a_native_public_one: the document, its redirect, and every word of app_scope registered in its scope.
  • an_app_logs_in_at_its_pds_and_calls_through_it, against the reference PDS: PAR as the public client, approval coming back to <scheme>:/oauth/callback, the code redeemed with PKCE and DPoP, a social.grain.graph.follow written to the PDS, then indexRecords and getPreferences through the PDS's atproto-proxy. The server there is did:web:localhost%3A<port> over http (web::local_site): atproto takes a did:web with a port only for localhost (@atproto/did), so a PDS drops an rpc scope naming did:web:client.grain.internal%3A<port>, which is what the first two heads' runs failed on.
  • Unit tests for app_scheme, app_scope and the escaped port; a_server_with_no_login_says_so covers the 501.

check, harness, rust, test and lexicons pass at 5be6e69.

Shortcomings

  • Development needs an https public URL: a PDS takes a native client's ID only over https, so an app against grain-serve on 127.0.0.1 has to be pointed at a tunnel, or log in against production's PDSes with grain-serve's URL set to the tunnel. A server on a port other than at localhost is no DID an rpc scope can name.
  • The apps' pulls (grain-ios at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzbahapdzh2h, grain-android at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzb7f4yojk2b) depend on this being live at grain.social before they ship.

🤖 Generated with Claude Code

Approved

This adds /oauth/app-client-metadata.json, a native public-client document with no key, DPoP-bound, redirecting to the reversed-host scheme. It also adds app_scheme, app_scope and app_calls helpers, and oauth::Client::public.

I couldn't run anything, so I read the code instead. identity::service_did exists. The new document's scope is REGISTERED plus the rpc scope, and the_apps_client_is_a_native_public_one checks that every word of app_scope is registered. The 501 case is covered by extending a_server_with_no_login_says_so. The new route sits next to the web's, and the new Web.did field is private and filled at construction.

I found nothing that must change. The two caveats you list are real. An https public URL is needed in development, and the app pulls can't ship until this is live at grain.social.

chadtmiller.com added 1 commit
Approved

The new commit fixes what the end-to-end test found. app_calls now escapes % as %25 before # as %23, so a did:web with a port (%3A) survives the PDS's URL-decoding of the audience as one DID. The order of the two replacements is right. A unit test pins the escaped port form, and the grain.social scope is unchanged.

I couldn't run anything. The check, harness, rust and test runs are still queued, so wait for them to pass before merging. I found nothing that must change.

Approved

The new commit stops Web from holding its own copy of the DID. app_client_metadata now reads app.did instead, so the end-to-end test can run the server as did:web:localhost%3A<port> over http, which a PDS accepts for proxying. The test gets a local_site helper for that.

The expect is safe. In lib.rs, did is Some whenever public_url is set, and web is only built when both public_url and index are set. A server with no login still gets the 501 from web(&app)? before the expect runs.

I couldn't run anything. The check, harness, rust and test runs for this head are still queued, so wait for them to pass before merging. I found nothing that must change.

Approved

Carried forward: the change this pull makes at 5be6e69b488174e146ae15a1764fb277998ea8fd is the one reviewed at 53f53b154aedce766375e40cc945a2b5399cbdc6, so that review stands. What is new comes from its base.

Approved

The new head 2db06b7 is a merge of feature/rust into the branch. The one conflict was in crates/grain-serve/tests/web/mod.rs. The pull's own change is the same one I approved at 53f53b1 and 5be6e69. That is the native public-client metadata, app_scope, app_calls with its % and # escaping, Client::public, and the local_site end-to-end test. My earlier approval stands.

I didn't read the 1,647-line diff of the merge itself, and I couldn't run anything. The check, harness, rust and test runs for this head are still queued, so wait for them to pass. That matters most for the merge-resolved test file. I found nothing that must change.

Merged as 2db06b7
Part of a stack of 2
  1. server: The apps sign in at their own PDS, as grain-serve's public client Landed as 2db06b7
  2. main