0
2
server: The apps sign in at their own PDS, as grain-serve's public 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, ¶ms).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() | ||