Skip to main content

tuwunel_service/oauth/
providers.rs

1use std::collections::BTreeMap;
2
3use serde_json::{Map as JsonObject, Value as JsonValue};
4use tokio::sync::RwLock;
5pub use tuwunel_core::config::IdentityProvider as Provider;
6use tuwunel_core::{Err, Result, debug, debug::INFO_SPAN_LEVEL, err, implement};
7use url::Url;
8
9use crate::{SelfServices, client::read_response_capped};
10
11/// Discovered providers
12#[derive(Default)]
13pub struct Providers {
14	services: SelfServices,
15	providers: RwLock<BTreeMap<ProviderId, Provider>>,
16}
17
18/// Identity Provider ID
19pub type ProviderId = String;
20
21#[implement(Providers)]
22pub(super) fn build(args: &crate::Args<'_>) -> Self {
23	Self {
24		services: args.services.clone(),
25		..Default::default()
26	}
27}
28
29/// Get the Provider configuration after any discovery and adjustments
30/// made on top of the admin's configuration. This incurs network-based
31/// discovery on the first call but responds from cache on subsequent calls.
32#[implement(Providers)]
33#[tracing::instrument(level = "debug", skip(self))]
34pub async fn get(&self, id: &str) -> Result<Provider> {
35	if let Some(provider) = self.get_cached(id).await {
36		return Ok(provider);
37	}
38
39	let config = self.get_config(id)?;
40	let id = config.id().to_owned();
41	let mut map = self.providers.write().await;
42	let provider = self.configure(config).await?;
43
44	debug!(?id, ?provider);
45	_ = map.insert(id, provider.clone());
46
47	Ok(provider)
48}
49
50/// Get the admin-configured Provider which exists prior to any
51/// reconciliation with the well-known discovery (the server's config is
52/// immutable); though it is important to note the server config can be
53/// reloaded. This will Err NotFound for a non-existent idp.
54///
55/// When no provider is found with a matching client_id, providers are then
56/// searched by brand. Brand matching will be invalidated when more than one
57/// provider matches the brand.
58#[implement(Providers)]
59pub fn get_config(&self, id: &str) -> Result<Provider> {
60	let providers = &self.services.config.identity_provider;
61
62	if let Some(provider) = providers
63		.values()
64		.find(|config| config.id() == id)
65		.cloned()
66	{
67		return Ok(provider);
68	}
69
70	if let Some(provider) = providers
71		.values()
72		.find(|config| config.brand.eq_ignore_ascii_case(id))
73		.filter(|_| {
74			providers
75				.values()
76				.filter(|config| config.brand.eq_ignore_ascii_case(id))
77				.count()
78				.eq(&1)
79		})
80		.cloned()
81	{
82		return Ok(provider);
83	}
84
85	Err!(Request(NotFound("Unrecognized Identity Provider")))
86}
87
88/// Get the ID of the provider considered "default" as selected by the admin or
89/// by fallback.
90#[implement(Providers)]
91pub fn get_default_id(&self) -> Option<String> {
92	self.services
93		.config
94		.identity_provider
95		.values()
96		.find(|idp| idp.default)
97		.or_else(|| {
98			self.services
99				.config
100				.identity_provider
101				.values()
102				.next()
103		})
104		.map(Provider::id)
105		.map(ToOwned::to_owned)
106}
107
108/// Get the discovered provider from the runtime cache. ID may be client_id or
109/// brand if brand is unique among provider configurations.
110#[implement(Providers)]
111async fn get_cached(&self, id: &str) -> Option<Provider> {
112	let providers = self.providers.read().await;
113
114	if let Some(provider) = providers.get(id).cloned() {
115		return Some(provider);
116	}
117
118	providers
119		.values()
120		.find(|provider| provider.brand.eq_ignore_ascii_case(id))
121		.filter(|_| {
122			providers
123				.values()
124				.filter(|provider| provider.brand.eq_ignore_ascii_case(id))
125				.count()
126				.eq(&1)
127		})
128		.cloned()
129}
130
131/// Configure an identity provider; takes the admin-configured instance from the
132/// server's config, queries the provider for discovery, and then returns an
133/// updated config based on the proper reconciliation. This final config is then
134/// cached in memory to avoid repeating this process.
135#[implement(Providers)]
136#[tracing::instrument(
137	level = INFO_SPAN_LEVEL,
138	ret(level = "debug"),
139	skip(self),
140)]
141async fn configure(&self, mut provider: Provider) -> Result<Provider> {
142	_ = provider
143		.name
144		.get_or_insert_with(|| provider.brand.clone());
145
146	if provider.issuer_url.is_none() {
147		_ = provider
148			.issuer_url
149			.replace(match provider.brand.as_str() {
150				| "github" => "https://github.com/login/oauth".try_into()?,
151				| "gitlab" => "https://gitlab.com".try_into()?,
152				| "google" => "https://accounts.google.com".try_into()?,
153				| _ => return Err!(Config("issuer_url", "Required for this provider.")),
154			});
155	}
156
157	// MAS rejects `profile`; its userinfo returns only `sub` and `username`.
158	if provider.scope.is_empty() && provider.brand == "mas" {
159		provider.scope = ["openid".to_owned()].into();
160	}
161
162	let response = self
163		.discover(&provider)
164		.await
165		.and_then(|response| {
166			response.as_object().cloned().ok_or_else(|| {
167				err!(Request(NotJson("Expecting JSON object for discovery response")))
168			})
169		})
170		.and_then(|response| check_issuer(response, &provider))?;
171
172	if provider.authorization_url.is_none() {
173		response
174			.get("authorization_endpoint")
175			.and_then(JsonValue::as_str)
176			.map(Url::parse)
177			.transpose()?
178			.or_else(|| make_url(&provider, "authorize").ok())
179			.map(|url| provider.authorization_url.replace(url));
180	}
181
182	if provider.revocation_url.is_none() {
183		response
184			.get("revocation_endpoint")
185			.and_then(JsonValue::as_str)
186			.map(Url::parse)
187			.transpose()?
188			.or_else(|| make_url(&provider, "revocation").ok())
189			.map(|url| provider.revocation_url.replace(url));
190	}
191
192	if provider.introspection_url.is_none() {
193		response
194			.get("introspection_endpoint")
195			.and_then(JsonValue::as_str)
196			.map(Url::parse)
197			.transpose()?
198			.or_else(|| make_url(&provider, "introspection").ok())
199			.map(|url| provider.introspection_url.replace(url));
200	}
201
202	if provider.userinfo_url.is_none() {
203		response
204			.get("userinfo_endpoint")
205			.and_then(JsonValue::as_str)
206			.map(Url::parse)
207			.transpose()?
208			.or_else(|| match provider.brand.as_str() {
209				| "github" => "https://api.github.com/user".try_into().ok(),
210				| _ => make_url(&provider, "userinfo").ok(),
211			})
212			.map(|url| provider.userinfo_url.replace(url));
213	}
214
215	if provider.token_url.is_none() {
216		response
217			.get("token_endpoint")
218			.and_then(JsonValue::as_str)
219			.map(Url::parse)
220			.transpose()?
221			.or_else(|| {
222				let path = if provider.brand == "github" {
223					"access_token"
224				} else {
225					"token"
226				};
227
228				make_url(&provider, path).ok()
229			})
230			.map(|url| provider.token_url.replace(url));
231	}
232
233	if provider.callback_url.is_none()
234		&& let Some(server_url) = self.services.config.well_known.client.as_ref()
235	{
236		let callback_path =
237			format!("_matrix/client/unstable/login/sso/callback/{}", provider.client_id);
238
239		provider.callback_url = Some(server_url.join(&callback_path)?);
240	}
241
242	Ok(provider)
243}
244
245/// Send a network request to a provider at the computed location of the
246/// `.well-known/openid-configuration`, returning the configuration.
247#[implement(Providers)]
248#[tracing::instrument(level = "debug", ret(level = "trace"), skip(self))]
249pub async fn discover(&self, provider: &Provider) -> Result<JsonValue> {
250	let limit = self.services.config.max_response_size;
251	let response = self
252		.services
253		.client
254		.oauth
255		.get(discovery_url(provider)?)
256		.send()
257		.await?
258		.error_for_status()?;
259
260	let body = read_response_capped(response, limit).await?;
261
262	serde_json::from_slice(&body).map_err(Into::into)
263}
264
265/// Compute the location of the `/.well-known/openid-configuration` based on the
266/// local provider config.
267fn discovery_url(provider: &Provider) -> Result<Url> {
268	let default_url = provider
269		.discovery
270		.then(|| make_url(provider, ".well-known/openid-configuration"))
271		.transpose()?;
272
273	let Some(url) = provider
274		.discovery_url
275		.clone()
276		.filter(|_| provider.discovery)
277		.or(default_url)
278	else {
279		return Err!(Config(
280			"discovery_url",
281			"Failed to determine URL for discovery of provider {}",
282			provider.id()
283		));
284	};
285
286	Ok(url)
287}
288
289/// Validate that the locally configured `issuer_url` matches the issuer claimed
290/// in any response. todo: cryptographic validation is not yet implemented here.
291fn check_issuer(
292	response: JsonObject<String, JsonValue>,
293	provider: &Provider,
294) -> Result<JsonObject<String, JsonValue>> {
295	let expected = provider
296		.issuer_url
297		.as_ref()
298		.map(Url::as_str)
299		.map(|url| url.trim_end_matches('/'));
300
301	let responded = response
302		.get("issuer")
303		.and_then(JsonValue::as_str)
304		.map(|url| url.trim_end_matches('/'));
305
306	if expected != responded {
307		return Err!(Request(Unauthorized(
308			"Configured issuer_url {expected:?} does not match discovered {responded:?}",
309		)));
310	}
311
312	Ok(response)
313}
314
315/// Generate a full URL for a request to the idp based on the idp's derived
316/// configuration.
317fn make_url(provider: &Provider, path: &str) -> Result<Url> {
318	let mut suffix = provider.base_path.clone().unwrap_or_default();
319
320	suffix.push_str(path);
321	let issuer = provider.issuer_url.as_ref().ok_or_else(|| {
322		let id = &provider.client_id;
323		err!(Config("issuer_url", "Provider {id:?} required field"))
324	})?;
325	let issuer_path = issuer.path();
326
327	if issuer_path.ends_with('/') {
328		Ok(issuer.join(suffix.as_str())?)
329	} else {
330		let mut url = issuer.to_owned();
331		url.set_path(&format!("{issuer_path}/"));
332		Ok(url.join(&suffix)?)
333	}
334}