Skip to main content

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}