~ 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 chadtmiller.com asked to merge into feature/rust from grain.social/grain:appauth/app-client

Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.

crates/grain/src/oauth.rs+10−0
@@ -273,6 +273,16 @@ impl Client {
}
}
/// The public client whose metadata document is at `id`, as grain's
/// apps are: it proves who it is with nothing but PKCE and its DPoP
/// key.
pub fn public(id: &str) -> Client {
Client {
id: id.to_string(),
key: None,
}
}
/// The confidential client whose metadata document is at `id`, signing
/// with `key`.
pub fn confidential(id: &str, key: P256Key) -> Client {
crates/grain-serve/src/lib.rs+1−0
@@ -437,6 +437,7 @@ pub fn router(app: Arc<App>) -> Router {
)
.route(login::METADATA, get(login::client_metadata))
.route(login::JWKS, get(login::client_jwks))
.route(login::APP_METADATA, get(login::app_client_metadata))
.route("/oauth/login", post(login::login))
.route(login::CALLBACK, get(login::callback))
.route("/oauth/switch", post(login::switch))
crates/grain-serve/src/login.rs+79−0
@@ -34,6 +34,12 @@
//! Access tokens are refreshed when about to expire, one refresh an
//! account at a time ([`session`]): a refresh token is spent once, and a
//! second refresh with it ends the whole grant.
//!
//! The iOS and Android apps log in at their own PDS, not here: they are
//! public clients, PKCE and DPoP, whose client ID is the document this
//! server serves at [`APP_METADATA`], and they call this server through
//! their PDS's `atproto-proxy` ([`app_scope`]). This server holds none of
//! their sessions.
pub mod carryover;
@@ -61,6 +67,12 @@ pub const METADATA: &str = "/oauth-client-metadata.json";
pub const JWKS: &str = "/oauth/client-jwks.json";
/// Where the PDS sends the browser back to.
pub const CALLBACK: &str = "/oauth/callback";
/// Where the apps' client metadata is: their client ID, under the public
/// URL.
pub const APP_METADATA: &str = "/oauth/app-client-metadata.json";
/// Where a PDS sends an app back to, under the scheme [`app_scheme`]
/// names.
pub const APP_CALLBACK: &str = "/oauth/callback";
/// The cookie naming the account logged in.
pub const COOKIE: &str = "grain_session";
/// The cookie holding the other accounts a browser is logged in to: their
@@ -336,6 +348,52 @@ pub async fn client_metadata(State(app): State<Arc<App>>) -> Result<Json<Value>,
Ok(Json(doc))
}
/// The scheme a PDS sends an app back to a login under: the public URL's
/// host, reversed, as atproto asks of a native client's redirect, so
/// `https://grain.social` is `social.grain`.
pub fn app_scheme(public_url: &str) -> String {
let host = public_url.split("://").nth(1).unwrap_or(public_url);
let host = host.split([':', '/']).next().unwrap_or_default();
host.rsplit('.').collect::<Vec<_>>().join(".")
}
/// What an app asks for: [`oauth::SCOPE`], and [`app_calls`].
pub fn app_scope(did: &str) -> String {
format!("{} {}", oauth::SCOPE, app_calls(did))
}
/// To call this server, the service `did` names [`crate::did::APPVIEW`],
/// as the account through its PDS, any method, as Bluesky's apps call
/// theirs. The audience is a query value, which a PDS decodes: a port's
/// `%3A` in a `did:web` is escaped again so that it stays one.
pub fn app_calls(did: &str) -> String {
let aud = format!("{did}{}", crate::did::APPVIEW);
format!("rpc:*?aud={}", aud.replace('%', "%25").replace('#', "%23"))
}
/// The apps' client metadata document, which their client ID names: a
/// native public client, binding its tokens to a DPoP key of its own,
/// sent back under [`app_scheme`]. Its scope is what the web registers
/// and [`app_calls`]: all an app may ask for.
pub async fn app_client_metadata(State(app): State<Arc<App>>) -> Result<Json<Value>, Failure> {
let web = web(&app)?;
let did = app.did.as_deref().expect("a server that logs in has a DID");
let redirect = format!("{}:{APP_CALLBACK}", app_scheme(&web.public_url));
let scope = format!("{REGISTERED} {}", app_calls(did));
Ok(Json(json!({
"client_id": format!("{}{APP_METADATA}", web.public_url),
"client_name": "grain",
"client_uri": web.public_url,
"application_type": "native",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"redirect_uris": [redirect],
"scope": scope,
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true,
})))
}
/// The client's public key, which a PDS checks its assertions with.
pub async fn client_jwks(State(app): State<Arc<App>>) -> Result<Json<Value>, Failure> {
Ok(Json(web(&app)?.client.jwks()))
@@ -717,6 +775,27 @@ mod tests {
assert!(from_jwk("{").is_err());
}
#[test]
fn apps_come_back_under_their_host_reversed() {
assert_eq!(app_scheme("https://grain.social"), "social.grain");
assert_eq!(
app_scheme("https://client.grain.internal:4433/"),
"internal.grain.client"
);
assert_eq!(app_scheme("grain.social"), "social.grain");
}
#[test]
fn apps_call_the_appview_through_their_pds() {
let scope = app_scope("did:web:grain.social");
assert!(scope.starts_with(oauth::SCOPE));
assert!(scope.ends_with(" rpc:*?aud=did:web:grain.social%23grain_appview"));
assert_eq!(
app_calls("did:web:localhost%3A3000"),
"rpc:*?aud=did:web:localhost%253A3000%23grain_appview"
);
}
#[test]
fn what_is_asked_is_registered() {
let registered: Vec<&str> = REGISTERED.split(' ').collect();
crates/grain-serve/tests/web/mod.rs+22−5
@@ -38,13 +38,21 @@ pub async fn site_on(index: Index, sign_up_pds: &str) -> Site {
/// [`site_on`], carrying the cookies browsers held before until `until`.
pub async fn site_with(index: Index, sign_up_pds: &str, until: Option<u64>) -> Site {
site_of(index, sign_up_pds, until, None).await
site_of(index, sign_up_pds, until, None, false).await
}
/// [`site`], its DID `did:web:localhost%3A<port>` at the server itself,
/// over plain http, rather than the proxy's: a `did:web` with a port is
/// atproto's only for `localhost`, so a PDS takes an `rpc` scope naming
/// only that one, and resolves it over http.
pub async fn local_site() -> Site {
site_of(Index::memory().unwrap(), &net::net().pds, None, None, true).await
}
/// [`site`], starting groups on the group host at `group_host`.
pub async fn site_starting_groups(group_host: &str) -> Site {
let index = Index::memory().unwrap();
site_of(index, &net::net().pds, None, Some(group_host)).await
site_of(index, &net::net().pds, None, Some(group_host), false).await
}
async fn site_of(
@@ -52,18 +60,27 @@ async fn site_of(
sign_up_pds: &str,
until: Option<u64>,
group_host: Option<&str>,
local: bool,
) -> Site {
let dir = tempfile::tempdir().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = net::tls_proxy(listener.local_addr().unwrap().port());
let port = listener.local_addr().unwrap().port();
let proxy = net::tls_proxy(port);
let index = Arc::new(index);
let web = Web::new(&proxy.url, &index, sign_up_pds).unwrap();
let web = web.carrying(&index, until).unwrap();
let web = web.starting_groups(group_host);
let (did, extra) = match local {
true => (
format!("did:web:localhost%3A{port}"),
&[("GRAIN_DID_WEB_SCHEME", "http")][..],
),
false => (service_did(&proxy.url).unwrap(), &[][..]),
};
let app = Arc::new(App {
ctx: net::ctx(dir.path(), &[]),
ctx: net::ctx(dir.path(), extra),
index: Some(index.clone()),
did: Some(service_did(&proxy.url).unwrap()),
did: Some(did),
cdn: Default::default(),
bsky_appview: None,
web: Some(Arc::new(web)),
crates/grain-serve/tests/login.rs+108−1
@@ -397,11 +397,118 @@ async fn a_login_its_server_fails_says_so() {
);
}
#[tokio::test]
async fn the_apps_client_is_a_native_public_one() {
let site = site().await;
let mut jar = Jar::default();
let doc: Value = site
.get(&mut jar, login::APP_METADATA)
.await
.json()
.await
.unwrap();
let did = site.app.did.clone().unwrap();
let scheme = login::app_scheme(&site.url);
assert_eq!(
doc["client_id"],
format!("{}{}", site.url, login::APP_METADATA)
);
assert_eq!(doc["application_type"], "native");
assert_eq!(doc["token_endpoint_auth_method"], "none");
assert_eq!(doc["dpop_bound_access_tokens"], true);
assert!(doc.get("jwks").is_none() && doc.get("jwks_uri").is_none());
assert_eq!(
doc["redirect_uris"],
json!([format!("{scheme}:{}", login::APP_CALLBACK)])
);
let registered: Vec<&str> = doc["scope"].as_str().unwrap().split(' ').collect();
for scope in login::app_scope(&did).split(' ') {
assert!(registered.contains(&scope), "{scope}");
}
}
/// An app's login, as the apps make one: at the account's own PDS, as the
/// public client [`login::APP_METADATA`] names, coming back under the
/// app's scheme; then a write to that PDS, and this server told of it and
/// asked for the account's preferences, each through the PDS's
/// `atproto-proxy`, as the account. The server is `did:web:localhost`
/// with its port ([`web::local_site`]), which the PDS proxies to.
#[tokio::test]
async fn an_app_logs_in_at_its_pds_and_calls_through_it() {
let site = web::local_site().await;
let account = net::account().await;
let did = site.app.did.clone().unwrap();
let ctx = &site.app.ctx;
let client = grain::oauth::Client::public(&format!("{}{}", site.url, login::APP_METADATA));
let redirect = format!("{}:{}", login::app_scheme(&site.url), login::APP_CALLBACK);
let id = grain::identity::resolve(ctx, &account.handle)
.await
.unwrap();
let scope = login::app_scope(&did);
let request = grain::oauth::authorize(ctx, &client, id, &account.handle, &redirect, &scope)
.await
.unwrap();
let back = approve(&account, "", &request.url).await;
let back = reqwest::Url::parse(&back).unwrap();
assert_eq!(
format!("{}:{}", back.scheme(), back.path()),
redirect,
"{back}"
);
let params = back.query_pairs().into_owned().collect();
let me = request.redeem(ctx, &client, &params).await.unwrap();
assert_eq!(me.did, account.did);
let mut proxied = reqwest::header::HeaderMap::new();
let appview = format!("{did}{}", grain_serve::did::APPVIEW);
proxied.insert("atproto-proxy", appview.parse().unwrap());
let http = reqwest::Client::builder()
.default_headers(proxied)
.build()
.unwrap();
let pds = grain::xrpc::Xrpc::new(ctx.http.clone(), &me.service);
let through = grain::xrpc::Xrpc::new(http, &me.service);
let auth = me.auth();
let follow = json!({
"$type": "social.grain.graph.follow",
"subject": "did:plc:bob",
"createdAt": "2026-10-31T12:00:00.000Z",
});
let input =
json!({"repo": me.did, "collection": "social.grain.graph.follow", "record": follow});
let wrote: Value = pds
.procedure("com.atproto.repo.createRecord", Some(&input), Some(&auth))
.await
.unwrap();
let uri = wrote["uri"].as_str().unwrap();
let told: Value = through
.procedure(
"social.grain.sync.indexRecords",
Some(&json!({ "uris": [uri] })),
Some(&auth),
)
.await
.unwrap();
assert_eq!(told, json!({"indexed": [uri], "notFound": []}));
let prefs: Value = through
.query("social.grain.actor.getPreferences", &[], Some(&auth))
.await
.unwrap();
assert!(prefs.is_object(), "{prefs}");
}
#[tokio::test]
async fn a_server_with_no_login_says_so() {
let served = common::served().await;
let http = reqwest::Client::new();
for path in [login::METADATA, login::JWKS, "/oauth/session"] {
for path in [
login::METADATA,
login::JWKS,
login::APP_METADATA,
"/oauth/session",
] {
let res = http
.get(format!("{}{path}", served.base))
.send()