didbot_serve/oauth/discovery.rs
1//! Authorization server metadata: `GET /.well-known/oauth-authorization-server`.
2//! Protected-resource metadata: `GET /.well-known/oauth-protected-resource`.
3//!
4//! atproto's own discovery chain starts at a DID document, not here: a
5//! client resolves an account's DID, reads its `#atproto_pds` service
6//! entry, and fetches this document from that origin. "Discovery fails
7//! before anything else runs if this is wrong" (per this crate's brief)
8//! means the `issuer` this module reports and the `serviceEndpoint` that
9//! same account's DID document already carries must be the exact same
10//! string — not two independently computed URLs that happen to agree today.
11//! [`issuer_for`] gets that by construction: it reads the value already
12//! published in the zone's own service document
13//! ([`didbot_pds::Registry::did_document`] at the zone hostname) rather than
14//! reconstructing a hostname from configuration, which is also this
15//! module's answer to "route the issuer through `Zone`, do not hardcode a
16//! hostname."
17
18use didbot_identity::document::ATPROTO_PDS_FRAGMENT;
19use didbot_pds::Registry;
20use serde::Serialize;
21
22/// The authorization server metadata document, RFC 8414 plus the fields the
23/// atproto OAuth profile requires.
24#[derive(Debug, Clone, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub struct ServerMetadata {
27 /// This deployment's own origin, read from its zone's own service
28 /// document; see [`issuer_for`].
29 pub issuer: String,
30 /// `GET /oauth/authorize`.
31 pub authorization_endpoint: String,
32 /// `POST /oauth/token`.
33 pub token_endpoint: String,
34 /// `POST /oauth/par`.
35 pub pushed_authorization_request_endpoint: String,
36 /// Always `true`: PAR is not optional on this deployment.
37 pub require_pushed_authorization_requests: bool,
38 /// Always `["code"]`: this profile is authorization-code only.
39 pub response_types_supported: Vec<&'static str>,
40 /// The modes a push may name as its `response_mode`; see
41 /// `crate::oauth::authorize::ResponseMode`.
42 pub response_modes_supported: Vec<&'static str>,
43 /// Always `["authorization_code", "refresh_token"]`.
44 pub grant_types_supported: Vec<&'static str>,
45 /// Always `["S256"]`: PKCE's `plain` method is refused at PAR.
46 pub code_challenge_methods_supported: Vec<&'static str>,
47 /// `none` for a public client and `private_key_jwt` for a confidential
48 /// one, as the atproto profile requires; see `crate::oauth::client_auth`.
49 pub token_endpoint_auth_methods_supported: Vec<&'static str>,
50 /// Always `["ES256"]`: the one algorithm a confidential client's
51 /// assertion is verified with.
52 pub token_endpoint_auth_signing_alg_values_supported: Vec<&'static str>,
53 /// The scopes a client may name here; see `SCOPES_SUPPORTED` (this
54 /// module, private). What any particular agent may be granted is
55 /// `plan/scope-policy.md`'s ceiling.
56 pub scopes_supported: Vec<&'static str>,
57 /// Always `["ES256"]`: the DPoP proof signing algorithm this deployment
58 /// accepts.
59 pub dpop_signing_alg_values_supported: Vec<&'static str>,
60 /// Always `true`: authorization responses carry `iss`, per RFC 9207.
61 pub authorization_response_iss_parameter_supported: bool,
62 /// atproto's own extension: this server accepts a `client_id` that is a
63 /// fetchable metadata document URL rather than only a pre-registered
64 /// identifier.
65 pub client_id_metadata_document_supported: bool,
66}
67
68/// The scopes both metadata documents advertise, shared so the two cannot
69/// drift apart.
70///
71/// `atproto` is the one the profile requires a server to list. The granular
72/// scopes (`repo:`, `rpc:`, `blob:`) are a grammar, so they have no list to
73/// give. The atproto profile asks a server to list the `transition:` scopes
74/// only if it supports them, and PAR refuses every one of them
75/// (`crate::oauth::decision::verdict_for`).
76const SCOPES_SUPPORTED: &[&str] = &["atproto"];
77
78/// The protected-resource metadata document, RFC 9728 plus the atproto
79/// profile.
80///
81/// This deployment is both the authorization server and the resource
82/// server, so `resource` and the one entry in `authorization_servers` are
83/// the same origin [`ServerMetadata::issuer`] reports — read from the same
84/// zone service document, for the same reason: two independently computed
85/// strings that happen to agree today are how this drifts tomorrow.
86#[derive(Debug, Clone, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub struct ProtectedResourceMetadata {
89 /// This deployment's own origin. See [`issuer_for`].
90 pub resource: String,
91 /// Always one entry: this deployment's own issuer.
92 pub authorization_servers: Vec<String>,
93 /// The scope kinds this deployment recognises; see
94 /// [`ServerMetadata::scopes_supported`].
95 pub scopes_supported: Vec<&'static str>,
96 /// Always `["header"]`: a bearer token is presented as an
97 /// `Authorization: Bearer` header, never a query parameter or form
98 /// field.
99 pub bearer_methods_supported: Vec<&'static str>,
100}
101
102/// Why server metadata could not be built for this deployment.
103#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
104pub enum DiscoveryError {
105 /// The zone hostname has no service document — unreachable in a
106 /// deployment that has finished starting up, since the same document
107 /// backs `GET /.well-known/did.json` on the zone host, but a caller
108 /// building metadata during startup could hit it.
109 #[error("this deployment's zone has no service document to derive an issuer from")]
110 NoServiceDocument,
111 /// The service document has no `#atproto_pds` entry — the same
112 /// unreachable-in-practice case, named rather than panicked on.
113 #[error("this deployment's service document has no #atproto_pds entry")]
114 NoServiceEndpoint,
115}
116
117/// The exact origin a resolver following this deployment's own DID document
118/// would derive — read from the document itself, never recomputed.
119pub fn issuer_for(registry: &dyn Registry) -> Result<String, DiscoveryError> {
120 let document = registry
121 .did_document(registry.zone())
122 .ok_or(DiscoveryError::NoServiceDocument)?;
123 let endpoint = document
124 .service
125 .iter()
126 .find(|service| service.id.ends_with(ATPROTO_PDS_FRAGMENT))
127 .map(|service| service.service_endpoint.clone())
128 .ok_or(DiscoveryError::NoServiceEndpoint)?;
129 Ok(endpoint.trim_end_matches('/').to_owned())
130}
131
132/// The absolute `htu` a DPoP proof must bind to for a request this
133/// deployment serves at `path` — RFC 9449 §4.3's "a caller-built canonical
134/// absolute URL (scheme, host, and path)", built the same way
135/// [`issuer_for`] builds `issuer`: read out of this deployment's own zone
136/// service document rather than the request's `Host` header or any
137/// `X-Forwarded-*` header, both of which are ordinary caller-supplied text
138/// once `didbot-tls` is terminating TLS itself with no reverse proxy in
139/// front rewriting anything — see `crate::rate_limit`'s own doc for the same
140/// argument applied to a caller's address. This also gets a development
141/// deployment's plain-http `*.localhost` origin and a production
142/// deployment's `https://` origin right for free, in both directions,
143/// because whichever one is true is already baked into the service document
144/// an operator configured, not computed here.
145///
146/// `path` is expected to start with `/`, matching every route this crate's
147/// router registers (`"/oauth/token"`, and so on).
148pub fn request_htu(registry: &dyn Registry, path: &str) -> Result<String, DiscoveryError> {
149 let issuer = issuer_for(registry)?;
150 Ok(format!("{issuer}{path}"))
151}
152
153/// Builds the metadata document for this deployment.
154pub fn server_metadata(registry: &dyn Registry) -> Result<ServerMetadata, DiscoveryError> {
155 let issuer = issuer_for(registry)?;
156 Ok(ServerMetadata {
157 authorization_endpoint: format!("{issuer}/oauth/authorize"),
158 token_endpoint: format!("{issuer}/oauth/token"),
159 pushed_authorization_request_endpoint: format!("{issuer}/oauth/par"),
160 require_pushed_authorization_requests: true,
161 response_types_supported: vec!["code"],
162 response_modes_supported: crate::oauth::authorize::ResponseMode::ALL
163 .iter()
164 .map(|mode| mode.as_str())
165 .collect(),
166 grant_types_supported: vec!["authorization_code", "refresh_token"],
167 code_challenge_methods_supported: vec!["S256"],
168 token_endpoint_auth_methods_supported: vec![
169 crate::oauth::client_auth::NONE,
170 crate::oauth::client_auth::PRIVATE_KEY_JWT,
171 ],
172 token_endpoint_auth_signing_alg_values_supported: vec![
173 crate::oauth::client_auth::SIGNING_ALG,
174 ],
175 scopes_supported: SCOPES_SUPPORTED.to_vec(),
176 dpop_signing_alg_values_supported: vec![crate::oauth::dpop::JWT_ALG],
177 authorization_response_iss_parameter_supported: true,
178 client_id_metadata_document_supported: true,
179 issuer,
180 })
181}
182
183/// Builds the protected-resource metadata document for this deployment.
184pub fn protected_resource_metadata(
185 registry: &dyn Registry,
186) -> Result<ProtectedResourceMetadata, DiscoveryError> {
187 let issuer = issuer_for(registry)?;
188 Ok(ProtectedResourceMetadata {
189 authorization_servers: vec![issuer.clone()],
190 resource: issuer,
191 scopes_supported: SCOPES_SUPPORTED.to_vec(),
192 bearer_methods_supported: vec!["header"],
193 })
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::oauth::authorize::{GrantAnyScope, ScopePolicy};
200 use crate::oauth::decision::verdict_for;
201 use crate::oauth::scope::ScopeSet;
202
203 /// A scope the metadata offers is one a push naming it can be granted,
204 /// under the widest ceiling this crate ships.
205 #[test]
206 fn every_advertised_scope_is_one_a_push_can_be_granted() {
207 let ceiling = GrantAnyScope.ceiling(
208 "did:web:agent.pds.example",
209 "https://app.client.example/client-metadata.json",
210 );
211 for scope in SCOPES_SUPPORTED {
212 let requested = ScopeSet::parse(scope).expect("an advertised scope parses");
213 let verdict = verdict_for(&requested, &ceiling, "ceiling");
214 assert!(
215 !verdict.is_deny(),
216 "`{scope}` is advertised and refused: {verdict:?}"
217 );
218 }
219 }
220}