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