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, 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/// 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
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	// Redirect allowlist (RFC 7591): every redirect_uri must name a listed host,
70	// or a listed private-use scheme when it carries no host.
71	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	// RFC 6749 §3.1.2 / MSC2966: a redirect URI carries no fragment, in all cases.
193	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		// RFC 8252 §7.3: native loopback http with no registered port.
200		| "http" if native && is_loopback(&url) && url.port().is_none() => Ok(()),
201		// RFC 8252 §7.1: native private-use scheme, reverse-DNS, no authority.
202		| 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(&not_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		// A private-use scheme has no host, so the scheme stands in for one.
416		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}