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;
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	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	// Redirect-host allowlist (RFC 7591): every redirect_uri host must be listed.
58	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	// RFC 6749 §3.1.2 / MSC2966: a redirect URI carries no fragment, in all cases.
180	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		// RFC 8252 §7.3: native loopback http with no registered port.
187		| "http" if native && is_loopback(&url) && url.port().is_none() => Ok(()),
188		// RFC 8252 §7.1: native private-use scheme, reverse-DNS, no authority.
189		| 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(&not_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}