Skip to main content

headless_lms_server/domain/oauth/
client_metadata_document.rs

1//! OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document): a client
2//! whose `client_id` is an https URL is registered by fetching the JSON document at that URL.
3//!
4//! The URL comes from whoever starts an authorization request, so fetching it must not reach
5//! internal services: the host must resolve only to public addresses, the connection is pinned to
6//! the checked addresses, redirects are not followed and the response size and time are capped.
7
8use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
9use std::num::NonZeroU32;
10use std::sync::LazyLock;
11use std::time::Duration;
12
13use chrono::Utc;
14use governor::{DefaultKeyedRateLimiter, Quota};
15use headless_lms_models::{
16    library::oauth::scopes::metadata_document_client_scopes,
17    oauth_client::{MetadataDocumentClient, OAuthClient},
18};
19use serde::Deserialize;
20use sqlx::{PgConnection, PgPool};
21use url::Url;
22use uuid::Uuid;
23
24use crate::domain::error::{ControllerError, OAuthErrorCode};
25use crate::domain::oauth::helpers::{oauth_error, oauth_invalid_request};
26
27/// The registration is re-fetched when the cached copy is older than this.
28const CACHE_LIFETIME: chrono::Duration = chrono::Duration::hours(1);
29/// A cached copy this old is still used when the document cannot be fetched.
30const STALE_CACHE_LIFETIME: chrono::Duration = chrono::Duration::days(7);
31const MAX_DOCUMENT_BYTES: usize = 5 * 1024;
32const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
33/// Per server process; an agent registers one document, so this only bounds abuse.
34const NEW_DOCUMENTS_PER_USER_PER_HOUR: u32 = 10;
35/// Per server process; a document's host is fetched again at most this often, however many
36/// authorize requests name it.
37const REFETCHES_PER_DOCUMENT_PER_HOUR: u32 = 6;
38
39static NEW_DOCUMENT_FETCHES: LazyLock<DefaultKeyedRateLimiter<Uuid>> = LazyLock::new(|| {
40    DefaultKeyedRateLimiter::keyed(Quota::per_hour(
41        NonZeroU32::new(NEW_DOCUMENTS_PER_USER_PER_HOUR).expect("the quota is positive"),
42    ))
43});
44
45static DOCUMENT_REFETCHES: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
46    DefaultKeyedRateLimiter::keyed(Quota::per_hour(
47        NonZeroU32::new(REFETCHES_PER_DOCUMENT_PER_HOUR).expect("the quota is positive"),
48    ))
49});
50
51/// Whether the client id names a metadata document rather than a registered client.
52pub fn is_metadata_document_client_id(client_id: &str) -> bool {
53    client_id.starts_with("https://")
54}
55
56#[derive(Debug, Deserialize)]
57struct ClientMetadataDocument {
58    client_id: String,
59    client_name: Option<String>,
60    client_uri: Option<String>,
61    #[serde(default)]
62    redirect_uris: Vec<String>,
63    token_endpoint_auth_method: Option<String>,
64    grant_types: Option<Vec<String>>,
65    response_types: Option<Vec<String>>,
66    client_secret: Option<String>,
67}
68
69/// The stored registration for a metadata document `client_id`, as last fetched, without
70/// fetching; `None` if the document was never fetched.
71pub async fn find_cached_client(
72    conn: &mut PgConnection,
73    client_id: &str,
74) -> Result<Option<OAuthClient>, ControllerError> {
75    match OAuthClient::find_by_client_id_optional(conn, client_id).await? {
76        Some(cached) if !cached.is_metadata_document_client() => Err(oauth_invalid_request(
77            "client_id is registered without a metadata document",
78            None,
79            None,
80        )),
81        cached => Ok(cached),
82    }
83}
84
85/// The client registration for a metadata document `client_id`, fetched and cached as needed.
86/// `user_id` is the signed-in user the fetch is made for: fetching a document that was never
87/// fetched before counts against their hourly quota.
88///
89/// No pooled connection is held while the document is fetched. A cached copy older than the cache
90/// lifetime but still usable is returned at once and refreshed in the background.
91pub async fn resolve_client(
92    pool: &PgPool,
93    client_id: &str,
94    user_id: Uuid,
95) -> Result<OAuthClient, ControllerError> {
96    let cached = find_cached_client(&mut *pool.acquire().await?, client_id).await?;
97    let is_limited = match cached {
98        Some(cached) => {
99            let age = cached
100                .metadata_document_fetched_at
101                .map_or(chrono::TimeDelta::MAX, |fetched_at| Utc::now() - fetched_at);
102            if age < CACHE_LIFETIME {
103                return Ok(cached);
104            }
105            if age < STALE_CACHE_LIFETIME {
106                if DOCUMENT_REFETCHES.check_key(&client_id.to_string()).is_ok() {
107                    let pool = pool.clone();
108                    let client_id = client_id.to_string();
109                    tokio::spawn(async move {
110                        if let Err(reason) = fetch_and_store(&pool, &client_id).await {
111                            tracing::warn!(
112                                client_id,
113                                %reason,
114                                "Refreshing a client metadata document failed"
115                            );
116                        }
117                    });
118                }
119                return Ok(cached);
120            }
121            DOCUMENT_REFETCHES
122                .check_key(&client_id.to_string())
123                .is_err()
124        }
125        None => NEW_DOCUMENT_FETCHES.check_key(&user_id).is_err(),
126    };
127    if is_limited {
128        return Err(oauth_error(
129            OAuthErrorCode::TemporarilyUnavailable,
130            "too many client metadata documents were fetched; try again later",
131            None,
132            None,
133        ));
134    }
135    fetch_and_store(pool, client_id).await.map_err(|reason| {
136        tracing::warn!(client_id, %reason, "Fetching a client metadata document failed");
137        oauth_invalid_request(
138            "the client metadata document could not be fetched or is invalid",
139            None,
140            None,
141        )
142    })
143}
144
145async fn fetch_and_store(pool: &PgPool, client_id: &str) -> Result<OAuthClient, String> {
146    let document = fetch_and_validate(client_id).await?;
147    let scopes = metadata_document_client_scopes();
148    let client_name = document.client_name.clone().unwrap_or_else(|| {
149        Url::parse(client_id)
150            .ok()
151            .and_then(|url| url.host_str().map(str::to_string))
152            .unwrap_or_else(|| client_id.to_string())
153    });
154    let mut conn = pool
155        .acquire()
156        .await
157        .map_err(|err| format!("no database connection: {err}"))?;
158    OAuthClient::upsert_metadata_document_client(
159        &mut conn,
160        MetadataDocumentClient {
161            client_id,
162            client_name: &client_name,
163            redirect_uris: &document.redirect_uris,
164            client_uri: document.client_uri.as_deref(),
165            scopes: &scopes,
166        },
167    )
168    .await
169    .map_err(|err| format!("storing the client failed: {err}"))
170}
171
172/// Checks the `client_id` URL as the draft requires: https, a host, a path, no fragment, no
173/// credentials and no dot segments. Returns the URL and its host.
174fn validate_client_id_url(client_id: &str) -> Result<(Url, String), String> {
175    let url = Url::parse(client_id).map_err(|err| format!("client_id is not a URL: {err}"))?;
176    if url.scheme() != "https" {
177        return Err("client_id must use https".into());
178    }
179    if url.fragment().is_some() || !url.username().is_empty() || url.password().is_some() {
180        return Err("client_id must not have a fragment or credentials".into());
181    }
182    if url.path() == "/" || url.path().is_empty() {
183        return Err("client_id must have a path".into());
184    }
185    let raw_path = client_id
186        .split_once("://")
187        .and_then(|(_, rest)| rest.find('/').map(|i| &rest[i..]))
188        .unwrap_or("");
189    if raw_path
190        .split(['/', '?'])
191        .any(|segment| segment == "." || segment == "..")
192    {
193        return Err("client_id must not have dot path segments".into());
194    }
195    let host = url
196        .host_str()
197        .ok_or("client_id must have a host")?
198        .to_string();
199    Ok((url, host))
200}
201
202async fn fetch_and_validate(client_id: &str) -> Result<ClientMetadataDocument, String> {
203    let (url, host) = validate_client_id_url(client_id)?;
204    let port = url.port_or_known_default().unwrap_or(443);
205    let addresses: Vec<SocketAddr> = tokio::time::timeout(
206        FETCH_TIMEOUT,
207        tokio::net::lookup_host((host.as_str(), port)),
208    )
209    .await
210    .map_err(|_| "resolving the client_id host timed out".to_string())?
211    .map_err(|err| format!("resolving the client_id host failed: {err}"))?
212    .collect();
213    if addresses.is_empty() {
214        return Err("the client_id host has no addresses".into());
215    }
216    if let Some(address) = addresses.iter().find(|a| !is_public_address(a.ip())) {
217        return Err(format!(
218            "the client_id host resolves to a non-public address {}",
219            address.ip()
220        ));
221    }
222
223    let client = headless_lms_utils::http::base_client_builder()
224        .redirect(reqwest::redirect::Policy::none())
225        .timeout(FETCH_TIMEOUT)
226        .https_only(true)
227        .resolve_to_addrs(&host, &addresses)
228        .build()
229        .map_err(|err| format!("building the HTTP client failed: {err}"))?;
230    let mut response = client
231        .get(url.clone())
232        .header(reqwest::header::ACCEPT, "application/json")
233        .send()
234        .await
235        .map_err(|err| format!("fetching the document failed: {err}"))?;
236    if !response.status().is_success() {
237        return Err(format!("the document responded with {}", response.status()));
238    }
239    let mut body = Vec::new();
240    while let Some(chunk) = response
241        .chunk()
242        .await
243        .map_err(|err| format!("reading the document failed: {err}"))?
244    {
245        body.extend_from_slice(&chunk);
246        if body.len() > MAX_DOCUMENT_BYTES {
247            return Err("the document is too large".into());
248        }
249    }
250    let document: ClientMetadataDocument =
251        serde_json::from_slice(&body).map_err(|err| format!("the document is not valid: {err}"))?;
252    validate_document(&document, client_id)?;
253    Ok(document)
254}
255
256/// Redirect URIs are left to the database's `is_valid_oauth_uri`, the one rule for every client.
257fn validate_document(document: &ClientMetadataDocument, client_id: &str) -> Result<(), String> {
258    if document.client_id != client_id {
259        return Err("the document's client_id does not match its URL".into());
260    }
261    if document.client_secret.is_some() {
262        return Err("the document must not contain a client_secret".into());
263    }
264    if let Some(method) = &document.token_endpoint_auth_method
265        && method != "none"
266    {
267        return Err(format!(
268            "token_endpoint_auth_method {method} is not supported; use none"
269        ));
270    }
271    if let Some(grant_types) = &document.grant_types
272        && !grant_types.iter().any(|g| g == "authorization_code")
273    {
274        return Err("grant_types must include authorization_code".into());
275    }
276    if let Some(response_types) = &document.response_types
277        && !response_types.iter().any(|r| r == "code")
278    {
279        return Err("response_types must include code".into());
280    }
281    if let Some(uri) = &document.client_uri
282        && !uri.starts_with("https://")
283    {
284        return Err(format!("{uri} must be an https URL"));
285    }
286    if let Some(name) = &document.client_name
287        && (name.trim().is_empty() || name.chars().count() > 100)
288    {
289        return Err("client_name must be 1-100 characters".into());
290    }
291    Ok(())
292}
293
294fn is_public_address(ip: IpAddr) -> bool {
295    match ip {
296        IpAddr::V4(ip) => is_public_ipv4(ip),
297        IpAddr::V6(ip) => match ip.to_ipv4() {
298            Some(embedded) => is_public_ipv4(embedded),
299            None => is_public_ipv6(ip),
300        },
301    }
302}
303
304fn is_public_ipv4(ip: Ipv4Addr) -> bool {
305    let [a, b, ..] = ip.octets();
306    !(ip.is_private()
307        || ip.is_loopback()
308        || ip.is_link_local()
309        || ip.is_broadcast()
310        || ip.is_documentation()
311        || ip.is_unspecified()
312        || ip.is_multicast()
313        || a == 0
314        || (a == 100 && (64..128).contains(&b))
315        || (a == 192 && b == 0)
316        || (a == 198 && (18..20).contains(&b))
317        || a >= 240)
318}
319
320fn is_public_ipv6(ip: Ipv6Addr) -> bool {
321    let [first, second, ..] = ip.segments();
322    !(ip.is_loopback()
323        || ip.is_unspecified()
324        || ip.is_multicast()
325        || (first & 0xfe00) == 0xfc00
326        || (first & 0xffc0) == 0xfe80
327        || first == 0x2001 && second == 0x0db8
328        // 6to4 and Teredo tunnel to IPv4 addresses that may be private.
329        || first == 0x2002
330        || first == 0x2001 && second == 0
331        || first == 0x0064 && second == 0xff9b)
332}