tuwunel_service/oauth/
providers.rs1use 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#[derive(Default)]
13pub struct Providers {
14 services: SelfServices,
15 providers: RwLock<BTreeMap<ProviderId, Provider>>,
16}
17
18pub 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#[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#[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#[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#[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#[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 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#[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
265fn 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
289fn 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
315fn 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}