diff --git a/crates/atproto/src/oauth_client.rs b/crates/atproto/src/oauth_client.rs index 0a855e2..161abff 100644 --- a/crates/atproto/src/oauth_client.rs +++ b/crates/atproto/src/oauth_client.rs @@ -149,22 +149,24 @@ pub fn build_oauth_client( } } -/// The client-metadata document served at `GET /client-metadata.json` in production. Must -/// stay consistent with the metadata `build_oauth_client` configures. NOTE: this serializes -/// `AtprotoClientMetadata` directly; verify the exact field set against a live authorization -/// server before relying on it (some servers expect extra fields like `dpop_bound_access_tokens`). +/// The client-metadata document served at `GET /client-metadata.json`. Serves EXACTLY what +/// atrium computes internally (`AtprotoClientMetadata::try_into_client_metadata`) so the served +/// doc matches what the client presents at PAR: `scope` as a space-joined STRING (not a `scopes` +/// array), `dpop_bound_access_tokens: true`, and the `None` fields (`jwks_uri`, +/// `token_endpoint_auth_signing_alg`) OMITTED — the atproto auth server rejects them as JSON null +/// (`invalid_client_metadata`). Serializing `AtprotoClientMetadata` directly gets all three wrong. pub fn client_metadata_doc(public_url: &str, redirect_uri: String) -> serde_json::Value { - let meta = AtprotoClientMetadata { - client_id: format!("{public_url}/client-metadata.json"), - client_uri: Some(public_url.to_string()), - redirect_uris: vec![redirect_uri], - token_endpoint_auth_method: AuthMethod::None, - grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], - scopes: buyer_scopes(), - jwks_uri: None, - token_endpoint_auth_signing_alg: None, - }; - serde_json::to_value(meta).unwrap_or(serde_json::Value::Null) + let scopes = buyer_scopes(); + let scope = scopes.iter().map(|s| s.as_ref()).collect::>().join(" "); + serde_json::json!({ + "client_id": format!("{public_url}/client-metadata.json"), + "client_uri": public_url, + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "scope": scope, + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true, + }) } #[cfg(test)] @@ -183,14 +185,19 @@ mod tests { } #[test] - fn client_metadata_doc_has_core_fields() { + fn client_metadata_doc_matches_atproto_spec() { let doc = client_metadata_doc( "https://shop.example", "https://shop.example/auth/callback".to_string(), ); assert_eq!(doc["client_id"], "https://shop.example/client-metadata.json"); + assert_eq!(doc["client_uri"], "https://shop.example"); assert_eq!(doc["redirect_uris"][0], "https://shop.example/auth/callback"); assert_eq!(doc["token_endpoint_auth_method"], "none"); + assert_eq!(doc["dpop_bound_access_tokens"], true); + // `scope` is a space-joined STRING, not a `scopes` array. + assert_eq!(doc["scope"], "atproto account:email"); + assert!(doc.get("scopes").is_none(), "must not emit a `scopes` array"); let gt: Vec<&str> = doc["grant_types"] .as_array() .expect("grant_types array") @@ -198,6 +205,12 @@ mod tests { .filter_map(|v| v.as_str()) .collect(); assert!(gt.contains(&"authorization_code") && gt.contains(&"refresh_token")); + // These are None → must be OMITTED, not JSON null (the auth server rejects null). + assert!(doc.get("jwks_uri").is_none(), "jwks_uri must be omitted"); + assert!( + doc.get("token_endpoint_auth_signing_alg").is_none(), + "token_endpoint_auth_signing_alg must be omitted" + ); } #[tokio::test] @@ -212,6 +225,29 @@ mod tests { assert!(client.is_ok(), "loopback client should build: {:?}", client.err()); } + // Live: reproduce exactly what `POST /auth/login` does — build the prod client and call + // authorize() for a real handle. Prints Ok(url) / Err(..) / panics with a backtrace. Needs + // Postgres on :5433. Run: RUST_BACKTRACE=1 cargo test ... live_authorize_prod -- --ignored --nocapture + #[sqlx::test(migrations = "../../migrations")] + #[ignore = "live network: reproduces /auth/login authorize() on a fully-migrated db"] + async fn live_authorize_prod(pool: sqlx::PgPool) { + let client = build_oauth_client( + "https://lgtm.shop", + "https://lgtm.shop/auth/callback".to_string(), + pool, + ) + .expect("build client"); + let opts = atrium_oauth::AuthorizeOptions { + scopes: buyer_scopes(), + redirect_uri: Some("https://lgtm.shop/auth/callback".to_string()), + ..Default::default() + }; + match client.authorize("natemoo.re", opts).await { + Ok(url) => println!("AUTHORIZE OK: {url}"), + Err(e) => println!("AUTHORIZE ERR: {e:?}"), + } + } + // Live: proves the rustls-backed HttpClient completes a real outbound HTTPS handshake + // request. This is the exact call shape atrium-oauth makes (DID/handle resolution, PAR). // native-tls's OpenSSL aborted this on the distroless runtime; rustls must not. Any HTTP