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