1use axum::{Json, body::Body, extract::State, response::IntoResponse};
2use http::{HeaderMap, Response, StatusCode, header::AUTHORIZATION};
3use serde_json::json;
4use tuwunel_core::{Err, Result, info};
5use tuwunel_service::oauth::server::DcrRequest;
6use url::{Host, Url};
7
8use super::{oauth_error, redirect_allowlisted};
9use crate::ClientIp;
10
11#[derive(Debug)]
13enum DcrError {
14 Metadata(&'static str),
15 RedirectUri(&'static str),
16}
17
18impl IntoResponse for DcrError {
19 fn into_response(self) -> Response<Body> {
20 let (error, description) = match self {
21 | Self::Metadata(description) => ("invalid_client_metadata", description),
22 | Self::RedirectUri(description) => ("invalid_redirect_uri", description),
23 };
24
25 oauth_error(StatusCode::BAD_REQUEST, error, description)
26 }
27}
28
29pub(crate) async fn registration_route(
30 State(services): State<crate::State>,
31 ClientIp(client): ClientIp,
32 headers: HeaderMap,
33 Json(body): Json<DcrRequest>,
34) -> Result<Response<Body>> {
35 let oidc = services.oauth.get_server()?;
36 services.oauth.check_rate_limit(client)?;
37 let config = &services.config;
38
39 let required_token = config.oidc_registration_access_token.as_str();
41
42 if !required_token.is_empty() {
43 let presented = headers
44 .get(AUTHORIZATION)
45 .and_then(|value| value.to_str().ok())
46 .and_then(|value| value.strip_prefix("Bearer "));
47
48 if presented != Some(required_token) {
49 return Err!(Request(Forbidden(
50 "A valid initial access token is required; this server has \
51 oidc_registration_access_token set"
52 )));
53 }
54 }
55
56 let require_client_uri = config.oidc_registration_require_client_uri;
57
58 if let Err(error) = validate_client_metadata(&body, require_client_uri) {
59 return Ok(error.into_response());
60 }
61
62 let allowed = &config.oidc_registration_allowed_redirect_hosts;
65
66 if !allowed.is_empty()
67 && !body
68 .redirect_uris
69 .iter()
70 .all(|uri| redirect_allowlisted(allowed, uri))
71 {
72 return Err!(Request(Forbidden(
73 "A redirect_uri host or scheme is not in the registration allowlist"
74 )));
75 }
76
77 let reg = oidc.register_client(body).await?;
78
79 info!(
80 "OIDC client registered: {} ({})",
81 reg.client_id,
82 reg.client_name.as_deref().unwrap_or("unnamed")
83 );
84
85 Ok((
86 StatusCode::CREATED,
87 Json(json!({
88 "client_id": reg.client_id,
89 "client_id_issued_at": reg.registered_at,
90 "redirect_uris": reg.redirect_uris,
91 "client_name": reg.client_name,
92 "client_uri": reg.client_uri,
93 "logo_uri": reg.logo_uri,
94 "contacts": reg.contacts,
95 "token_endpoint_auth_method": reg.token_endpoint_auth_method,
96 "grant_types": reg.grant_types,
97 "response_types": reg.response_types,
98 "application_type": reg.application_type,
99 "policy_uri": reg.policy_uri,
100 "tos_uri": reg.tos_uri,
101 "software_id": reg.software_id,
102 "software_version": reg.software_version,
103 })),
104 )
105 .into_response())
106}
107
108fn validate_client_metadata(body: &DcrRequest, require_client_uri: bool) -> Result<(), DcrError> {
109 if body.redirect_uris.is_empty() {
110 return Err(DcrError::RedirectUri("redirect_uris must not be empty"));
111 }
112
113 let client_url = match body.client_uri.as_deref() {
114 | Some(uri) => Some(parse_https(uri).ok_or(DcrError::Metadata(
115 "client_uri must be an https URL with a host and no userinfo",
116 ))?),
117 | None if require_client_uri => return Err(DcrError::Metadata("client_uri is required")),
118 | None => None,
119 };
120 let base = client_url.as_ref().and_then(Url::host_str);
121
122 for uri in [&body.logo_uri, &body.tos_uri, &body.policy_uri]
123 .into_iter()
124 .flatten()
125 {
126 match parse_https(uri).as_ref().and_then(Url::host_str) {
127 | Some(host) if shares_base(host, base) => {},
128 | Some(_) =>
129 return Err(DcrError::Metadata("a metadata URI must share the client_uri host")),
130 | None => return Err(DcrError::Metadata("a metadata URI must be an https URL")),
131 }
132 }
133
134 let native = body.application_type.as_deref() == Some("native");
135 for uri in &body.redirect_uris {
136 validate_redirect_uri(uri, native, base)?;
137 }
138
139 if body
140 .response_types
141 .as_ref()
142 .is_some_and(|types| !types.iter().any(|ty| ty == "code"))
143 {
144 return Err(DcrError::Metadata("response_types must include \"code\""));
145 }
146
147 if body.grant_types.as_ref().is_some_and(|types| {
148 !types.iter().any(|ty| ty == "authorization_code")
149 || !types.iter().any(|ty| ty == "refresh_token")
150 }) {
151 return Err(DcrError::Metadata(
152 "grant_types must include \"authorization_code\" and \"refresh_token\"",
153 ));
154 }
155
156 Ok(())
157}
158
159fn parse_https(uri: &str) -> Option<Url> {
160 let url = Url::parse(uri).ok()?;
161 let clean = url.scheme() == "https"
162 && url.host().is_some()
163 && url.username().is_empty()
164 && url.password().is_none();
165
166 clean.then_some(url)
167}
168
169fn shares_base(host: &str, base: Option<&str>) -> bool {
170 base.is_none_or(|base| {
171 host == base
172 || host
173 .strip_suffix(base)
174 .is_some_and(|prefix| prefix.ends_with('.'))
175 })
176}
177
178fn validate_redirect_uri(uri: &str, native: bool, base: Option<&str>) -> Result<(), DcrError> {
179 let url =
180 Url::parse(uri).map_err(|_| DcrError::RedirectUri("redirect_uri is not a valid URI"))?;
181
182 if url.fragment().is_some() {
184 return Err(DcrError::RedirectUri("redirect_uri must not contain a fragment"));
185 }
186
187 match url.scheme() {
188 | "https" => validate_web_redirect(&url, base),
189 | "http" if native && is_loopback(&url) && url.port().is_none() => Ok(()),
191 | scheme if native && url.host().is_none() && is_reverse_dns(scheme, base) => Ok(()),
193 | _ => Err(DcrError::RedirectUri(
194 "redirect_uri scheme is not permitted for this application_type",
195 )),
196 }
197}
198
199fn validate_web_redirect(url: &Url, base: Option<&str>) -> Result<(), DcrError> {
200 if !url.username().is_empty() || url.password().is_some() {
201 return Err(DcrError::RedirectUri("redirect_uri must not contain userinfo"));
202 }
203
204 match url.host_str() {
205 | Some(host) if shares_base(host, base) => Ok(()),
206 | Some(_) =>
207 Err(DcrError::RedirectUri("redirect_uri host must share the client_uri host")),
208 | None => Err(DcrError::RedirectUri("redirect_uri must have a host")),
209 }
210}
211
212fn is_loopback(url: &Url) -> bool {
213 match url.host() {
214 | Some(Host::Domain(domain)) => domain == "localhost",
215 | Some(Host::Ipv4(ip)) => ip.is_loopback(),
216 | Some(Host::Ipv6(ip)) => ip.is_loopback(),
217 | _ => false,
218 }
219}
220
221fn is_reverse_dns(scheme: &str, base: Option<&str>) -> bool {
222 if !scheme.contains('.') {
223 return false;
224 }
225
226 let Some(host) = base else {
227 return true;
228 };
229
230 let mut scheme_labels = scheme.split('.');
231 host.rsplit('.')
232 .all(|label| scheme_labels.next() == Some(label))
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn request(client_uri: Option<&str>, redirect_uris: &[&str]) -> DcrRequest {
240 DcrRequest {
241 redirect_uris: redirect_uris
242 .iter()
243 .copied()
244 .map(ToOwned::to_owned)
245 .collect(),
246 client_name: None,
247 client_uri: client_uri.map(ToOwned::to_owned),
248 logo_uri: None,
249 contacts: Vec::new(),
250 token_endpoint_auth_method: None,
251 grant_types: None,
252 response_types: None,
253 application_type: None,
254 policy_uri: None,
255 tos_uri: None,
256 software_id: None,
257 software_version: None,
258 }
259 }
260
261 fn native(mut request: DcrRequest) -> DcrRequest {
262 request.application_type = Some("native".to_owned());
263 request
264 }
265
266 #[test]
267 fn web_redirect_rules() {
268 let ok = request(Some("https://example.com"), &["https://example.com/cb"]);
269 validate_client_metadata(&ok, true).unwrap();
270
271 let subdomain = request(Some("https://example.com"), &["https://app.example.com/cb"]);
272 validate_client_metadata(&subdomain, true).unwrap();
273
274 let off_base = request(Some("https://example.com"), &["https://evil.com/cb"]);
275 assert!(matches!(
276 validate_client_metadata(&off_base, true),
277 Err(DcrError::RedirectUri(_))
278 ));
279
280 let fragment = request(Some("https://example.com"), &["https://example.com/cb#x"]);
281 assert!(matches!(
282 validate_client_metadata(&fragment, true),
283 Err(DcrError::RedirectUri(_))
284 ));
285
286 let userinfo = request(Some("https://example.com"), &["https://u:p@example.com/cb"]);
287 assert!(matches!(
288 validate_client_metadata(&userinfo, true),
289 Err(DcrError::RedirectUri(_))
290 ));
291 }
292
293 #[test]
294 fn client_uri_rules() {
295 let missing = request(None, &["https://example.com/cb"]);
296 assert!(matches!(validate_client_metadata(&missing, true), Err(DcrError::Metadata(_))));
297
298 let relaxed = request(None, &["https://anywhere.test/cb"]);
299 validate_client_metadata(&relaxed, false).unwrap();
300
301 let not_https = request(Some("http://example.com"), &["https://example.com/cb"]);
302 assert!(matches!(validate_client_metadata(¬_https, true), Err(DcrError::Metadata(_))));
303
304 let userinfo = request(Some("https://u:p@example.com"), &["https://example.com/cb"]);
305 assert!(matches!(validate_client_metadata(&userinfo, true), Err(DcrError::Metadata(_))));
306
307 let empty = request(Some("https://example.com"), &[]);
308 assert!(matches!(validate_client_metadata(&empty, true), Err(DcrError::RedirectUri(_))));
309 }
310
311 #[test]
312 fn native_redirect_rules() {
313 let loopback = native(request(Some("https://example.com"), &["http://127.0.0.1/cb"]));
314 validate_client_metadata(&loopback, true).unwrap();
315
316 let localhost = native(request(Some("https://example.com"), &["http://localhost/cb"]));
317 validate_client_metadata(&localhost, true).unwrap();
318
319 let ipv6 = native(request(Some("https://example.com"), &["http://[::1]/cb"]));
320 validate_client_metadata(&ipv6, true).unwrap();
321
322 let ported = native(request(Some("https://example.com"), &["http://127.0.0.1:8080/cb"]));
323 assert!(matches!(validate_client_metadata(&ported, true), Err(DcrError::RedirectUri(_))));
324
325 let private = native(request(Some("https://example.com"), &["com.example.app:/cb"]));
326 validate_client_metadata(&private, true).unwrap();
327
328 for (client_uri, redirect_uri) in [
329 ("https://javascript", "javascript:alert(1)"),
330 ("https://data", "data:text/html,dangerous"),
331 ("https://vbscript", "vbscript:msgbox(1)"),
332 ("https://myapp", "myapp:/cb"),
333 ] {
334 let dotless = native(request(Some(client_uri), &[redirect_uri]));
335 assert!(matches!(
336 validate_client_metadata(&dotless, true),
337 Err(DcrError::RedirectUri(_))
338 ));
339 }
340
341 let bad_private = native(request(Some("https://example.com"), &["com.evil.app:/cb"]));
342 assert!(matches!(
343 validate_client_metadata(&bad_private, true),
344 Err(DcrError::RedirectUri(_))
345 ));
346
347 let claimed = native(request(Some("https://example.com"), &["https://example.com/cb"]));
348 validate_client_metadata(&claimed, true).unwrap();
349
350 let web_http = request(Some("https://example.com"), &["http://127.0.0.1/cb"]);
351 assert!(matches!(
352 validate_client_metadata(&web_http, true),
353 Err(DcrError::RedirectUri(_))
354 ));
355 }
356
357 #[test]
358 fn grant_and_response_rules() {
359 let mut bad_response = request(Some("https://example.com"), &["https://example.com/cb"]);
360 bad_response.response_types = Some(vec!["token".to_owned()]);
361 assert!(matches!(
362 validate_client_metadata(&bad_response, true),
363 Err(DcrError::Metadata(_))
364 ));
365
366 let mut ok_response = request(Some("https://example.com"), &["https://example.com/cb"]);
367 ok_response.response_types = Some(vec!["code".to_owned(), "token".to_owned()]);
368 validate_client_metadata(&ok_response, true).unwrap();
369
370 let mut bad_grant = request(Some("https://example.com"), &["https://example.com/cb"]);
371 bad_grant.grant_types = Some(vec!["authorization_code".to_owned()]);
372 assert!(matches!(validate_client_metadata(&bad_grant, true), Err(DcrError::Metadata(_))));
373
374 let mut ok_grant = request(Some("https://example.com"), &["https://example.com/cb"]);
375 ok_grant.grant_types = Some(vec![
376 "authorization_code".to_owned(),
377 "refresh_token".to_owned(),
378 "urn:custom".to_owned(),
379 ]);
380 validate_client_metadata(&ok_grant, true).unwrap();
381 }
382
383 #[test]
384 fn metadata_uri_common_base() {
385 let mut off_base = request(Some("https://example.com"), &["https://example.com/cb"]);
386 off_base.logo_uri = Some("https://cdn.evil.com/logo.png".to_owned());
387 assert!(matches!(validate_client_metadata(&off_base, true), Err(DcrError::Metadata(_))));
388
389 let mut on_base = request(Some("https://example.com"), &["https://example.com/cb"]);
390 on_base.logo_uri = Some("https://cdn.example.com/logo.png".to_owned());
391 validate_client_metadata(&on_base, true).unwrap();
392 }
393
394 #[test]
395 fn fragment_rejected_in_all_cases() {
396 let loopback = native(request(Some("https://example.com"), &["http://127.0.0.1/cb#x"]));
397 assert!(matches!(
398 validate_client_metadata(&loopback, true),
399 Err(DcrError::RedirectUri(_))
400 ));
401
402 let private = native(request(Some("https://example.com"), &["com.example.app:/cb#x"]));
403 assert!(matches!(
404 validate_client_metadata(&private, true),
405 Err(DcrError::RedirectUri(_))
406 ));
407 }
408
409 #[test]
410 fn allowlist_covers_hosts_and_private_use_schemes() {
411 let allowed = ["element.io".to_owned(), "io.element.android".to_owned()];
412
413 assert!(redirect_allowlisted(&allowed, "https://element.io/oauth/ios/x"));
414 assert!(redirect_allowlisted(&allowed, "io.element.android:/callback"));
416 assert!(redirect_allowlisted(&["Element.IO".to_owned()], "https://element.io/cb"));
417
418 assert!(!redirect_allowlisted(&allowed, "https://attacker.example/callback"));
419 assert!(!redirect_allowlisted(&allowed, "com.attacker.app:/callback"));
420 assert!(!redirect_allowlisted(&allowed, "https://app.element.io/cb"));
421 assert!(!redirect_allowlisted(&[], "https://element.io/cb"));
422 assert!(!redirect_allowlisted(&allowed, "not a uri"));
423 }
424
425 #[test]
426 fn error_envelope_is_bad_request() {
427 assert_eq!(DcrError::Metadata("x").into_response().status(), StatusCode::BAD_REQUEST);
428 assert_eq!(
429 DcrError::RedirectUri("x")
430 .into_response()
431 .status(),
432 StatusCode::BAD_REQUEST
433 );
434 }
435}