headless_lms_server/domain/oauth/
client_metadata_document.rs1use 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
27const CACHE_LIFETIME: chrono::Duration = chrono::Duration::hours(1);
29const STALE_CACHE_LIFETIME: chrono::Duration = chrono::Duration::days(7);
31const MAX_DOCUMENT_BYTES: usize = 5 * 1024;
32const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
33const NEW_DOCUMENTS_PER_USER_PER_HOUR: u32 = 10;
35const 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
51pub 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
69pub 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
85pub 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
172fn 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
256fn 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 || first == 0x2002
330 || first == 0x2001 && second == 0
331 || first == 0x0064 && second == 0xff9b)
332}