Something went wrong. Try again.
atproto made easy crates.io/crates/jacquard
atproto rust
Something went wrong. Try again.
19 kB · 493 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494//! OAuth session bootstrap for fixture identities: host the client metadata//! through the native ingress, run Jacquard's full PAR/DPoP authorization//! flow, and drive the provider's authorization UI programmatically through//! its JSON API (cookie-aware).
use jacquard::identity::JacquardResolver;use jacquard_common::DefaultStr;use jacquard_common::deps::fluent_uri;use jacquard_common::deps::smol_str::SmolStr;use jacquard_oauth::atproto::{AtprotoClientMetadata, GrantType, atproto_client_metadata};use jacquard_oauth::authstore::MemoryAuthStore;use jacquard_oauth::client::{CallbackValidationPolicy, OAuthClient, OAuthSession};use jacquard_oauth::scopes::Scopes;use jacquard_oauth::session::ClientData;use jacquard_oauth::types::{AuthorizeOptions, CallbackParams};
use crate::bootstrap::FixtureStack;use crate::provider::FixtureIdentity;use crate::transport::FixtureTransport;
/// The hosted client metadata URL doubles as the OAuth client id. The host/// uses a non-special-use TLD: the atproto OAuth profile rejects client ids/// on local-hostname TLDs such as `.test`. Resolution stays fixture-internal/// (fixture DNS + host resolve mappings), so the client does not use public/// identity resolution.pub const CLIENT_ID: &str = "https://client.jacquard-e2e.dev/client-metadata.json";const REDIRECT_URI: &str = "https://client.jacquard-e2e.dev/oauth-callback";/// Scopes covering `com.atproto.access`-gated endpoints such as/// `com.atproto.space.getDelegationToken`. The space grant is required for/// the token to carry per-space permissions at consent time.const CLIENT_SCOPE: &str = "atproto transition:generic space:dev.jacquard.e2e.space?authority=*&action=read&action=create&action=update&action=delete";const TRANQUIL_SCOPE: &str = "atproto rpc?aud=did:web:localhost.jacquard-e2e.test:service&lxm=test.blobby.get repo:app.bsky.feed.post blob:text/plain";
/// API prefix of the reference PDS's authorization UI, served on the same/// origin as the authorization endpoint.const PROVIDER_API: &str = "/@atproto/oauth-provider/~api";
pub fn client_metadata() -> AtprotoClientMetadata<SmolStr> { AtprotoClientMetadata { client_id: fluent_uri::Uri::parse(CLIENT_ID.to_string()).expect("client id is a URI"), client_uri: None, redirect_uris: vec![ fluent_uri::Uri::parse(REDIRECT_URI.to_string()).expect("redirect URI is a URI"), ], grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], scopes: Scopes::<SmolStr>::new(SmolStr::from(CLIENT_SCOPE)) .expect("client scopes are valid"), jwks_uri: None, client_name: Some(SmolStr::new_static("jacquard-e2e")), logo_uri: None, tos_uri: None, privacy_policy_uri: None, }}
/// Write the wire-format client metadata document the ingress serves at/// `CLIENT_ID`, derived from Jacquard's own metadata conversion. Idempotent;/// the ingress reads the file per request.pub fn ensure_client_metadata_file(stack: &FixtureStack) -> Result<(), String> { let path = stack.files.root.join("client-metadata.json"); if path.exists() { return Ok(()); } let wire = atproto_client_metadata(&client_metadata(), &None) .map_err(|e| format!("convert client metadata: {e}"))?; let json = serde_json::to_string_pretty(&wire) .map_err(|e| format!("serialize client metadata: {e}"))?; std::fs::write(&path, json).map_err(|e| format!("write client metadata: {e}"))?; Ok(())}
/// Build an `OAuthClient` over the fixture resolver and in-memory auth store./// The store must live as long as the flow: `start_auth` persists PAR state/// that `callback` consumes through it.pub fn oauth_client( stack: &FixtureStack,) -> OAuthClient<JacquardResolver<FixtureTransport>, MemoryAuthStore> { OAuthClient::new( MemoryAuthStore::new(), ClientData { keyset: None, config: client_metadata(), }, stack.transport.as_ref().clone(), )}
/// Run the complete authorization flow for `identity` without a browser:/// PAR through Jacquard's client, JSON sign-in and consent through the/// provider's cookie-aware UI API, then Jacquard's callback/code exchange.pub async fn oauth_session( stack: &FixtureStack, identity: &FixtureIdentity,) -> Result<OAuthSession<JacquardResolver<FixtureTransport>, MemoryAuthStore>, String> { ensure_client_metadata_file(stack)?;
let password = std::fs::read_to_string(stack.files.root.join("reference").join("admin-password")) .map_err(|e| format!("read account password: {e}"))? .trim() .to_string();
let client = oauth_client(stack); let authorize_url = client .start_auth( identity.handle, AuthorizeOptions::<String> { redirect_uri: None, scopes: Scopes::new(CLIENT_SCOPE.to_string()) .map_err(|e| format!("parse client scopes: {e}"))?, prompt: None, state: Some("jacquard-e2e".to_string()), }, ) .await .map_err(|e| stack.sanitize_error("oauth PAR", e))?;
let session = drive_ui_consent(stack, &authorize_url, identity, &password).await?;
let params = CallbackParams { code: session.code, state: Some(session.state), iss: session.iss, }; let oauth = client.callback(params).await.map_err(|e| { // Never include the code or state in diagnostics. stack.sanitize_error("oauth callback", e) })?; Ok(oauth)}
/// The Tranquil OAuth session bootstrap: a loopback (native) client — the/// published Tranquil image's OAuth metadata fetcher only reaches global/// hosts, so a web client id on the fixture ingress is unreachable; the/// loopback path builds client metadata locally on the provider with no/// fetch at all. Authorization is driven through Tranquil's JSON submit/// API (no cookies or CSRF, unlike the reference's SPA).pub type FixtureOAuthClient = OAuthClient<JacquardResolver<FixtureTransport>, MemoryAuthStore>;
pub struct TranquilAuthorization { pub client: FixtureOAuthClient, pub callback: CallbackParams,}
pub async fn tranquil_oauth_authorization( stack: &FixtureStack, identity: &FixtureIdentity,) -> Result<TranquilAuthorization, String> { let password = std::fs::read_to_string(stack.files.root.join("tranquil").join("admin-password")) .map_err(|e| format!("read account password: {e}"))? .trim() .to_string();
let client_id = "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Foauth-callback&scope=atproto%20rpc%3Faud%3Ddid%3Aweb%3Alocalhost.jacquard-e2e.test%3Aservice%26lxm%3Dtest.blobby.get%20repo%3Aapp.bsky.feed.post%20blob%3Atext%2Fplain"; let redirect_uri = "http://127.0.0.1/oauth-callback"; let metadata = AtprotoClientMetadata { client_id: fluent_uri::Uri::parse(client_id.to_string()) .map_err(|e| format!("loopback client id: {e:?}"))?, client_uri: None, redirect_uris: vec![ fluent_uri::Uri::parse(redirect_uri.to_string()) .map_err(|e| format!("loopback redirect URI: {e:?}"))?, ], grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], scopes: Scopes::<SmolStr>::new(SmolStr::new_static(TRANQUIL_SCOPE)) .map_err(|e| format!("parse loopback scopes: {e}"))?, jwks_uri: None, client_name: Some(SmolStr::new_static("jacquard-e2e")), logo_uri: None, tos_uri: None, privacy_policy_uri: None, }; let client = OAuthClient::new( MemoryAuthStore::new(), ClientData { keyset: None, config: metadata, }, stack.transport.as_ref().clone(), ) .with_callback_validation(CallbackValidationPolicy::AllowMissingIssuer);
let authorize_url = client .start_auth( identity.did, AuthorizeOptions::<String> { redirect_uri: None, scopes: Scopes::new(TRANQUIL_SCOPE.to_string()) .map_err(|e| format!("parse client scopes: {e}"))?, prompt: None, state: Some("jacquard-e2e".to_string()), }, ) .await .map_err(|e| stack.sanitize_error("oauth PAR (tranquil)", e))?;
// Tranquil's authorize endpoint takes the credentials as a JSON body; // the request_uri rides the authorize URL's query. let request_uri = authorize_url .split("request_uri=") .nth(1) .and_then(|rest| rest.split('&').next()) .map(|raw| urldecode(raw)) .ok_or("authorize URL missing request_uri")?;
let base = stack.context.coordinates.provider_url.trim_end_matches('/'); let http = stack.cookie_client()?; let submit = http .post(format!("{base}/oauth/authorize")) .header("accept", "application/json") .json(&serde_json::json!({ "request_uri": request_uri, "username": identity.handle, "password": password, "remember_device": false, })) .send() .await .map_err(|e| format!("authorize submit: {e}"))?; if !submit.status().is_success() { let body = submit.text().await.unwrap_or_default(); let excerpt: String = body.chars().filter(|c| !c.is_control()).take(200).collect(); return Err(format!("authorize submit failed: {excerpt}")); } let body: serde_json::Value = submit .json() .await .map_err(|e| format!("decode authorize response: {e}"))?; let mut location = body .get("redirect_uri") .and_then(|v| v.as_str()) .ok_or("authorize response missing redirect_uri")? .to_string();
// A consent hop may be required; its response carries the code. if location.contains("/oauth/consent") { let consent = http .post(format!("{base}/oauth/authorize/consent")) .header("accept", "application/json") .json(&serde_json::json!({ "request_uri": request_uri, "approved_scopes": [ "atproto", "repo:app.bsky.feed.post", "blob:text/plain", "rpc?aud=did:web:localhost.jacquard-e2e.test:service&lxm=test.blobby.get" ], "remember": false, })) .send() .await .map_err(|e| format!("consent submit: {e}"))?; if !consent.status().is_success() { let body = consent.text().await.unwrap_or_default(); let excerpt: String = body.chars().filter(|c| !c.is_control()).take(200).collect(); return Err(format!("consent submit failed: {excerpt}")); } let body: serde_json::Value = consent .json() .await .map_err(|e| format!("decode consent response: {e}"))?; location = body .get("redirect_uri") .and_then(|v| v.as_str()) .ok_or("consent response missing redirect_uri")? .to_string(); }
let params = extract_callback_params(&location)?; Ok(TranquilAuthorization { client, callback: CallbackParams { code: params.code, state: Some(params.state), iss: params.iss, }, })}
struct UiConsent { code: DefaultStr, state: DefaultStr, iss: Option<DefaultStr>,}
/// Drive the provider's authorization UI over its JSON API: fetch the/// authorize page (establishing the UI session cookies), sign in with the/// fixture password, and consent for the identity's DID. Returns the code/// and state extracted from the consent redirect URL.async fn drive_ui_consent( stack: &FixtureStack, authorize_url: &str, identity: &FixtureIdentity, password: &str,) -> Result<UiConsent, String> { // A cookie-aware clone of the fixture transport's reqwest client: same // CA, per-host resolve mappings, no redirects, no proxies. Only the // authorize page origin is ever contacted, by construction. let http = stack.cookie_client()?;
let page = http .get(authorize_url) // The authorize UI requires browser-style Fetch Metadata headers. .header("accept", "text/html") .header("sec-fetch-site", "same-origin") .header("sec-fetch-mode", "navigate") .header("sec-fetch-dest", "document") .header("sec-fetch-user", "?1") .header("upgrade-insecure-requests", "1") .send() .await .map_err(|e| format!("fetch authorize page: {e}"))?; // A 303 here means the provider already has a completed authorization // for this account+client and redirects straight to the callback; the // code/state ride its Location header. if page.status() == http::StatusCode::FOUND || page.status() == http::StatusCode::SEE_OTHER { let location = page .headers() .get(http::header::LOCATION) .and_then(|v| v.to_str().ok()) .ok_or("authorize redirect missing location")? .to_string(); eprintln!("oauth driver: authorize 303 -> {location}"); return extract_callback_params(&location); } if !page.status().is_success() { return Err(format!( "authorize page returned {}", page.status().as_u16() )); } let set_cookies = page .headers() .get_all("set-cookie") .iter() .filter_map(|v| v.to_str().ok()) .map(|s| s.to_string()) .collect::<Vec<_>>(); // Drain the body so cookies from the response are fully applied. let _ = page.text().await;
let authorize_origin = origin_of(authorize_url)?; let api = format!("{authorize_origin}{PROVIDER_API}");
// The UI API requires the CSRF token issued with the authorize page // (cookie `csrf-token`, echoed back as `x-csrf-token`). let csrf = set_cookies .iter() .find_map(|c| c.strip_prefix("csrf-token=")) .map(|v| v.split(';').next().unwrap_or(v).to_string()) .ok_or("authorize page set no csrf-token cookie")?;
let sign_in = http .post(format!("{api}/sign-in")) .header("accept", "application/json") .header("sec-fetch-site", "same-origin") .header("sec-fetch-mode", "same-origin") .header("sec-fetch-dest", "empty") .header("referer", authorize_url) .header("x-csrf-token", &csrf) .json(&serde_json::json!({ "locale": "en", "username": identity.handle, "password": password, "remember": true, })) .send() .await .map_err(|e| format!("sign-in request: {e}"))?; if !sign_in.status().is_success() { // The response body never contains the password, but keep only a // short excerpt for the diagnostic. let body = sign_in.text().await.unwrap_or_default(); let excerpt: String = body.chars().filter(|c| !c.is_control()).take(200).collect(); return Err(format!("sign-in failed: {excerpt}")); } let signed_in: serde_json::Value = sign_in .json() .await .map_err(|e| format!("decode sign-in response: {e}"))?; let account_did = signed_in .pointer("/account/did") .and_then(|v| v.as_str()) .ok_or("sign-in response missing account.did")? .to_string(); if account_did != identity.did { return Err(format!( "sign-in returned a different DID than the fixture identity" )); }
let consent = http .post(format!("{api}/consent")) .header("accept", "application/json") .header("sec-fetch-site", "same-origin") .header("sec-fetch-mode", "same-origin") .header("sec-fetch-dest", "empty") .header("referer", authorize_url) .header("x-csrf-token", &csrf) .json(&serde_json::json!({ "did": account_did })) .send() .await .map_err(|e| format!("consent request: {e}"))?; if !consent.status().is_success() { let body = consent.text().await.unwrap_or_default(); let excerpt: String = body.chars().filter(|c| !c.is_control()).take(200).collect(); return Err(format!("consent failed: {excerpt}")); } let consented: serde_json::Value = consent .json() .await .map_err(|e| format!("decode consent response: {e}"))?; let url = consented .get("url") .and_then(|v| v.as_str()) .ok_or("consent response missing redirect url")?; if std::env::var("JACQUARD_E2E_DEBUG").as_deref() == Ok("1") { eprintln!("oauth driver: consent redirect: {url}"); } extract_callback_params(url)}
/// Extract `code`, `state`, and `iss` from an OAuth callback redirect URL.fn extract_callback_params(url: &str) -> Result<UiConsent, String> { let redirect = fluent_uri::Uri::parse(url).map_err(|e| format!("parse consent redirect: {e}"))?; let query = redirect.query().unwrap_or_default(); let mut code = None; let mut state = None; let mut iss = None; for pair in query.as_str().split('&') { let mut it = pair.splitn(2, '='); let key = it.next().unwrap_or_default(); let value = it.next().unwrap_or_default(); let decoded = urldecode(value); match key { "code" => code = Some(decoded), "state" => state = Some(decoded), "iss" => iss = Some(decoded), _ => {} } } let code = code.ok_or("consent redirect missing code")?; let state = state.ok_or("consent redirect missing state")?; Ok(UiConsent { code: DefaultStr::new(&code), state: DefaultStr::new(&state), iss: iss.map(|s| DefaultStr::new(&s)), })}
fn origin_of(url: &str) -> Result<String, String> { let parsed = fluent_uri::Uri::parse(url).map_err(|e| format!("parse authorize URL: {e}"))?; let scheme = parsed.scheme().as_str(); let authority = parsed .authority() .map(|a| a.as_str()) .ok_or("authorize URL has no authority")?; if !matches!(scheme, "http" | "https") { return Err("authorize URL is not HTTP(S)".to_string()); } Ok(format!("{scheme}://{authority}"))}
fn urldecode(value: &str) -> String { let bytes = value.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { match bytes[i] { b'%' if i + 2 < bytes.len() => { let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""); if let Ok(byte) = u8::from_str_radix(hex, 16) { out.push(byte); i += 3; } else { out.push(bytes[i]); i += 1; } } b'+' => { out.push(b' '); i += 1; } b => { out.push(b); i += 1; } } } String::from_utf8_lossy(&out).into_owned()}