diff --git a/crates/didbot-serve/src/oauth/discovery.rs b/crates/didbot-serve/src/oauth/discovery.rs index 811afee2..23cf1122 100644 --- a/crates/didbot-serve/src/oauth/discovery.rs +++ b/crates/didbot-serve/src/oauth/discovery.rs @@ -1,4 +1,5 @@ //! Authorization server metadata: `GET /.well-known/oauth-authorization-server`. +//! Protected-resource metadata: `GET /.well-known/oauth-protected-resource`. //! //! atproto's own discovery chain starts at a DID document, not here: a //! client resolves an account's DID, reads its `#atproto_pds` service @@ -59,6 +60,41 @@ pub struct ServerMetadata { pub client_id_metadata_document_supported: bool, } +/// The scope kinds this deployment recognises at the wire level, shared +/// between [`ServerMetadata::scopes_supported`] and +/// [`ProtectedResourceMetadata::scopes_supported`] so the two documents +/// cannot drift apart. +const SCOPES_SUPPORTED: &[&str] = &[ + "atproto", + "transition:generic", + "transition:chat.bsky", + "transition:email", +]; + +/// The protected-resource metadata document, RFC 9728 plus the atproto +/// profile. +/// +/// This deployment is both the authorization server and the resource +/// server, so `resource` and the one entry in `authorization_servers` are +/// the same origin [`ServerMetadata::issuer`] reports — read from the same +/// zone service document, for the same reason: two independently computed +/// strings that happen to agree today are how this drifts tomorrow. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ProtectedResourceMetadata { + /// This deployment's own origin. See [`issuer_for`]. + pub resource: String, + /// Always one entry: this deployment's own issuer. + pub authorization_servers: Vec, + /// The scope kinds this deployment recognises; see + /// [`ServerMetadata::scopes_supported`]. + pub scopes_supported: Vec<&'static str>, + /// Always `["header"]`: a bearer token is presented as an + /// `Authorization: Bearer` header, never a query parameter or form + /// field. + pub bearer_methods_supported: Vec<&'static str>, +} + /// Why server metadata could not be built for this deployment. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum DiscoveryError { @@ -125,15 +161,23 @@ pub fn server_metadata(registry: &dyn Registry) -> Result Result { + let issuer = issuer_for(registry)?; + Ok(ProtectedResourceMetadata { + authorization_servers: vec![issuer.clone()], + resource: issuer, + scopes_supported: SCOPES_SUPPORTED.to_vec(), + bearer_methods_supported: vec!["header"], + }) +} diff --git a/crates/didbot-serve/src/routes.rs b/crates/didbot-serve/src/routes.rs index d692f186..7fcef897 100644 --- a/crates/didbot-serve/src/routes.rs +++ b/crates/didbot-serve/src/routes.rs @@ -638,6 +638,10 @@ pub fn app_with_repos( "/.well-known/oauth-authorization-server", get(oauth_server_metadata), ) + .route( + "/.well-known/oauth-protected-resource", + get(oauth_protected_resource_metadata), + ) .route("/oauth/par", post(oauth_par)) .route("/oauth/authorize", get(oauth_authorize)) .route("/oauth/token", post(oauth_token)) @@ -959,6 +963,18 @@ async fn oauth_server_metadata(State(state): State) -> Response { } } +/// `GET /.well-known/oauth-protected-resource` +/// +/// RFC 9728 plus the atproto profile; see `crate::oauth::discovery` for why +/// this reports the same origin `oauth_server_metadata`'s `issuer` does. +async fn oauth_protected_resource_metadata(State(state): State) -> Response { + use crate::oauth::error::{OAuthError, OAuthErrorCode}; + match crate::oauth::discovery::protected_resource_metadata(state.registry.as_ref()) { + Ok(metadata) => Json(metadata).into_response(), + Err(err) => OAuthError::new(OAuthErrorCode::ServerError, err.to_string()).into_response(), + } +} + /// The wire fields `POST /oauth/par` accepts, form-encoded per RFC 9126. #[derive(serde::Deserialize)] struct OAuthParForm { diff --git a/crates/didbot-serve/src/tests.rs b/crates/didbot-serve/src/tests.rs index 33f2e25e..94d7124a 100644 --- a/crates/didbot-serve/src/tests.rs +++ b/crates/didbot-serve/src/tests.rs @@ -1464,6 +1464,47 @@ async fn unknown_host_has_no_did_document() { assert_eq!(body["error"], json!("NotFound")); } +#[tokio::test] +async fn protected_resource_metadata_is_served_and_public() { + let fake = FakeRegistry::new(); + let document = DidDocument::for_service(&fake.service_did(), PDS_ENDPOINT); + fake.documents + .lock() + .expect("poisoned") + .insert(fake.zone().to_owned(), document); + + // No Authorization header at all: a resource-server discovery document + // has to be reachable before a client holds any token. + let (status, _, body) = + call(Arc::new(fake), get("/.well-known/oauth-protected-resource")).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["resource"], json!(PDS_ENDPOINT)); + assert_eq!(body["authorization_servers"], json!([PDS_ENDPOINT])); + assert_eq!(body["bearer_methods_supported"], json!(["header"])); +} + +#[tokio::test] +async fn protected_resource_metadata_names_the_same_issuer_as_the_authorization_server_document() { + let fake = FakeRegistry::new(); + let document = DidDocument::for_service(&fake.service_did(), PDS_ENDPOINT); + fake.documents + .lock() + .expect("poisoned") + .insert(fake.zone().to_owned(), document); + let registry: Arc = Arc::new(fake); + + let (_, _, resource) = call( + registry.clone(), + get("/.well-known/oauth-protected-resource"), + ) + .await; + let (_, _, server) = call(registry, get("/.well-known/oauth-authorization-server")).await; + + assert_eq!(resource["resource"], server["issuer"]); + assert_eq!(resource["authorization_servers"][0], server["issuer"]); +} + /// `deleteAgent` for a DID this deployment never minted cannot reach the /// registry at all any more, and that is the *authorization* answer rather /// than the 404 it used to be: an operator credential was the only thing