Skip to main content

tuwunel_api/oidc/
registration.rs

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/// RFC 7591 §3.2.2 client-registration error response.
12#[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	// Initial access token (RFC 7591): gate registration when one is configured.
40	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	// Redirect allowlist (RFC 7591): every redirect_uri must name a listed host,
63	// or a listed private-use scheme when it carries no host.
64	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	// RFC 6749 §3.1.2 / MSC2966: a redirect URI carries no fragment, in all cases.
183	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		// RFC 8252 §7.3: native loopback http with no registered port.
190		| "http" if native && is_loopback(&url) && url.port().is_none() => Ok(()),
191		// RFC 8252 §7.1: native private-use scheme, reverse-DNS, no authority.
192		| 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(&not_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		// A private-use scheme has no host, so the scheme stands in for one.
415		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}