1use std::collections::HashMap;
4use std::sync::Mutex;
5use std::time::{Duration, Instant};
6
7use anyhow::{bail, Context, Result};
8use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
9use openidconnect::{
10 AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
11 EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
12 TokenResponse,
13};
14
15use crate::server::OidcConfig;
16
17type Client = CoreClient<
18 EndpointSet,
19 EndpointNotSet,
20 EndpointNotSet,
21 EndpointNotSet,
22 EndpointMaybeSet,
23 EndpointMaybeSet,
24>;
25
26const PENDING_FOR: Duration = Duration::from_secs(600);
28const DISCOVERY_FOR: Duration = Duration::from_secs(3600);
30const MAX_PENDING: usize = 1000;
31
32pub const CALLBACK: &str = "/auth/oidc/callback";
34
35struct Pending {
36 nonce: Nonce,
37 verifier: PkceCodeVerifier,
38 started: Instant,
39}
40
41pub struct Oidc {
42 pub config: OidcConfig,
43 redirect: RedirectUrl,
44 http: reqwest::Client,
45 client: tokio::sync::Mutex<Option<(Client, Instant)>>,
46 pending: Mutex<HashMap<String, Pending>>,
47}
48
49#[derive(Debug)]
50struct HttpError(String);
51
52impl std::fmt::Display for HttpError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.write_str(&self.0)
55 }
56}
57
58impl std::error::Error for HttpError {}
59
60async fn send(
61 http: reqwest::Client,
62 req: openidconnect::HttpRequest,
63) -> Result<openidconnect::HttpResponse, HttpError> {
64 let err = |e: &dyn std::fmt::Display| HttpError(e.to_string());
65 let req = reqwest::Request::try_from(req).map_err(|e| err(&e))?;
66 let res = http.execute(req).await.map_err(|e| err(&e))?;
67 let mut out = openidconnect::http::Response::builder().status(res.status());
68 for (k, v) in res.headers() {
69 out = out.header(k, v);
70 }
71 let body = res.bytes().await.map_err(|e| err(&e))?;
72 out.body(body.to_vec()).map_err(|e| err(&e))
73}
74
75impl Oidc {
76 pub fn new(config: OidcConfig, public_url: &str) -> Result<Oidc> {
77 IssuerUrl::new(config.issuer.clone()).context("COMINDLY_OIDC_ISSUER is not a URL")?;
78 let redirect = RedirectUrl::new(format!("{public_url}{CALLBACK}"))
79 .context("COMINDLY_PUBLIC_URL is not a URL")?;
80 let http = reqwest::Client::builder()
81 .redirect(reqwest::redirect::Policy::none())
82 .timeout(Duration::from_secs(15))
83 .build()?;
84 Ok(Oidc {
85 config,
86 redirect,
87 http,
88 client: Default::default(),
89 pending: Default::default(),
90 })
91 }
92
93 async fn client(&self, fresh: bool) -> Result<Client> {
95 let mut cached = self.client.lock().await;
96 if let Some((c, at)) = cached.as_ref() {
97 if !fresh && at.elapsed() < DISCOVERY_FOR {
98 return Ok(c.clone());
99 }
100 }
101 let http = self.http.clone();
102 let call = move |r| send(http.clone(), r);
103 let issuer = IssuerUrl::new(self.config.issuer.clone())?;
104 let meta = CoreProviderMetadata::discover_async(issuer, &call)
105 .await
106 .with_context(|| {
107 format!(
108 "cannot reach the sign-in provider at {}",
109 self.config.issuer
110 )
111 })?;
112 let client = CoreClient::from_provider_metadata(
113 meta,
114 ClientId::new(self.config.client_id.clone()),
115 Some(ClientSecret::new(self.config.client_secret.clone())),
116 )
117 .set_redirect_uri(self.redirect.clone());
118 *cached = Some((client.clone(), Instant::now()));
119 Ok(client)
120 }
121
122 pub async fn start(&self) -> Result<(String, String)> {
124 let client = self.client(false).await?;
125 let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
126 let (url, state, nonce) = client
127 .authorize_url(
128 CoreAuthenticationFlow::AuthorizationCode,
129 CsrfToken::new_random,
130 Nonce::new_random,
131 )
132 .add_scope(Scope::new("email".into()))
133 .add_scope(Scope::new("profile".into()))
134 .set_pkce_challenge(challenge)
135 .url();
136 let mut pending = self.pending.lock().unwrap();
137 pending.retain(|_, p| p.started.elapsed() < PENDING_FOR);
138 anyhow::ensure!(
139 pending.len() < MAX_PENDING,
140 "Too many sign-ins in progress; try again in a few minutes"
141 );
142 pending.insert(
143 state.secret().clone(),
144 Pending {
145 nonce,
146 verifier,
147 started: Instant::now(),
148 },
149 );
150 Ok((url.to_string(), state.secret().clone()))
151 }
152
153 pub async fn finish(&self, state: &str, code: &str) -> Result<String> {
155 let pending = self
156 .pending
157 .lock()
158 .unwrap()
159 .remove(state)
160 .filter(|p| p.started.elapsed() < PENDING_FOR)
161 .context("This sign-in expired or was already used; start again")?;
162 let client = self.client(false).await?;
163 let http = self.http.clone();
164 let call = move |r| send(http.clone(), r);
165 let tokens = client
166 .exchange_code(AuthorizationCode::new(code.to_string()))?
167 .set_pkce_verifier(pending.verifier)
168 .request_async(&call)
169 .await
170 .map_err(|e| {
171 crate::ui::warn(&format!("sign-in provider refused the code: {e:?}"));
172 anyhow::anyhow!("The sign-in provider did not accept this sign-in; start again")
173 })?;
174 let id_token = tokens
175 .id_token()
176 .context("The sign-in provider sent no ID token")?;
177 let claims = match id_token.claims(&client.id_token_verifier(), &pending.nonce) {
178 Ok(c) => c.clone(),
179 Err(_) => {
181 let client = self.client(true).await?;
182 let verified = id_token
183 .claims(&client.id_token_verifier(), &pending.nonce)
184 .cloned();
185 verified.map_err(|e| {
186 crate::ui::warn(&format!("ID token rejected: {e}"));
187 anyhow::anyhow!("The sign-in could not be verified; start again")
188 })?
189 }
190 };
191 if claims.email_verified() == Some(false) {
192 bail!("The sign-in provider has not verified this email address");
193 }
194 let email = claims
195 .email()
196 .map(|e| e.as_str().to_string())
197 .or_else(|| {
198 claims
199 .preferred_username()
200 .map(|u| u.as_str().to_string())
201 .filter(|u| u.contains('@'))
202 })
203 .context("The sign-in provider sent no email address; ask its admin to include the email claim")?;
204 Ok(crate::server::normalize(&email))
205 }
206
207 pub fn brand(&self) -> Option<&'static str> {
209 let host = self.config.issuer.split("://").nth(1)?.split('/').next()?;
210 match host {
211 "login.microsoftonline.com" | "sts.windows.net" => Some("microsoft"),
212 "accounts.google.com" => Some("google"),
213 _ => None,
214 }
215 }
216
217 pub fn may_join(&self, email: &str) -> bool {
219 email
220 .rsplit_once('@')
221 .is_some_and(|(_, d)| self.config.allowed_domains.iter().any(|a| a == d))
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use openidconnect::core::{
229 CoreIdToken, CoreIdTokenClaims, CoreJsonWebKeySet, CoreJwsSigningAlgorithm,
230 CoreRsaPrivateSigningKey,
231 };
232 use openidconnect::{
233 Audience, EmptyAdditionalClaims, EndUserEmail, PrivateSigningKey, StandardClaims,
234 SubjectIdentifier,
235 };
236 use std::sync::Arc;
237
238 #[derive(Clone)]
240 struct Next {
241 email: Option<String>,
242 verified: Option<bool>,
243 audience: String,
244 nonce: Option<String>,
245 expires_in: i64,
246 }
247
248 struct Provider {
249 issuer: String,
250 key: CoreRsaPrivateSigningKey,
251 next: Mutex<Next>,
252 verifiers: Mutex<Vec<String>>,
254 }
255
256 fn signing_key() -> CoreRsaPrivateSigningKey {
257 use rsa::pkcs1::EncodeRsaPrivateKey;
258 let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048).unwrap();
259 let pem = key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF).unwrap();
260 CoreRsaPrivateSigningKey::from_pem(
261 &pem,
262 Some(openidconnect::JsonWebKeyId::new("k1".into())),
263 )
264 .unwrap()
265 }
266
267 async fn provider() -> Arc<Provider> {
268 use axum::routing::{get, post};
269 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
270 let issuer = format!("http://{}", listener.local_addr().unwrap());
271 let p = Arc::new(Provider {
272 issuer: issuer.clone(),
273 key: signing_key(),
274 next: Mutex::new(Next {
275 email: Some("Dev@Example.com".into()),
276 verified: None,
277 audience: "comindly".into(),
278 nonce: None,
279 expires_in: 300,
280 }),
281 verifiers: Default::default(),
282 });
283 let (meta, jwks, token) = (p.clone(), p.clone(), p.clone());
284 let app = axum::Router::new()
285 .route(
286 "/.well-known/openid-configuration",
287 get(move || async move {
288 let i = &meta.issuer;
289 axum::Json(serde_json::json!({
290 "issuer": i,
291 "authorization_endpoint": format!("{i}/authorize"),
292 "token_endpoint": format!("{i}/token"),
293 "jwks_uri": format!("{i}/jwks"),
294 "response_types_supported": ["code"],
295 "subject_types_supported": ["public"],
296 "id_token_signing_alg_values_supported": ["RS256"],
297 }))
298 }),
299 )
300 .route(
301 "/jwks",
302 get(move || async move {
303 axum::Json(CoreJsonWebKeySet::new(vec![jwks.key.as_verification_key()]))
304 }),
305 )
306 .route(
307 "/token",
308 post(move |body: String| async move {
309 let form: HashMap<String, String> =
310 openidconnect::url::form_urlencoded::parse(body.as_bytes())
311 .into_owned()
312 .collect();
313 token
314 .verifiers
315 .lock()
316 .unwrap()
317 .push(form.get("code_verifier").cloned().unwrap_or_default());
318 let n = token.next.lock().unwrap().clone();
319 let now = chrono::Utc::now();
320 let mut standard = StandardClaims::new(SubjectIdentifier::new("u1".into()));
321 standard = standard
322 .set_email(n.email.map(EndUserEmail::new))
323 .set_email_verified(n.verified);
324 let claims = CoreIdTokenClaims::new(
325 IssuerUrl::new(token.issuer.clone()).unwrap(),
326 vec![Audience::new(n.audience)],
327 now + chrono::Duration::seconds(n.expires_in),
328 now - chrono::Duration::seconds(60),
329 standard,
330 EmptyAdditionalClaims {},
331 )
332 .set_nonce(n.nonce.map(Nonce::new));
333 let id = CoreIdToken::new(
334 claims,
335 &token.key,
336 CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
337 None,
338 None,
339 )
340 .unwrap();
341 axum::Json(serde_json::json!({
342 "access_token": "at",
343 "token_type": "Bearer",
344 "expires_in": 3600,
345 "id_token": id,
346 }))
347 }),
348 );
349 tokio::spawn(async move { axum::serve(listener, app).await });
350 p
351 }
352
353 fn oidc(issuer: &str) -> Oidc {
354 Oidc::new(
355 OidcConfig {
356 issuer: issuer.into(),
357 client_id: "comindly".into(),
358 client_secret: "client secret".into(),
359 label: "Example".into(),
360 allowed_domains: vec!["example.com".into()],
361 },
362 "https://comindly.example.com",
363 )
364 .unwrap()
365 }
366
367 async fn begin(p: &Provider, o: &Oidc) -> (String, String) {
369 let (url, state) = o.start().await.unwrap();
370 let url = openidconnect::url::Url::parse(&url).unwrap();
371 let q: HashMap<String, String> = url.query_pairs().into_owned().collect();
372 assert_eq!(q["state"], state);
373 assert_eq!(q["code_challenge_method"], "S256");
374 assert_eq!(
375 q["redirect_uri"],
376 "https://comindly.example.com/auth/oidc/callback"
377 );
378 assert!(q["scope"].contains("openid") && q["scope"].contains("email"));
379 p.next.lock().unwrap().nonce = Some(q["nonce"].clone());
380 (state, q["code_challenge"].clone())
381 }
382
383 #[tokio::test]
384 async fn a_signed_and_matching_id_token_gives_the_verified_email_once() {
385 let p = provider().await;
386 let o = oidc(&p.issuer);
387 let (state, challenge) = begin(&p, &o).await;
388 assert_eq!(o.finish(&state, "code-1").await.unwrap(), "dev@example.com");
389 let verifier = p.verifiers.lock().unwrap()[0].clone();
390 let expected =
391 PkceCodeChallenge::from_code_verifier_sha256(&PkceCodeVerifier::new(verifier));
392 assert_eq!(
393 expected.as_str(),
394 challenge,
395 "the code is redeemed with this sign-in's PKCE secret"
396 );
397 assert!(
398 o.finish(&state, "code-1").await.is_err(),
399 "a state works once"
400 );
401 assert!(o.finish("made-up", "code-1").await.is_err());
402 assert!(o.may_join("dev@example.com"));
403 assert!(!o.may_join("dev@example.com.evil.org"));
404 assert!(!o.may_join("dev@other.org"));
405 assert_eq!(o.brand(), None);
406 assert_eq!(
407 oidc("https://login.microsoftonline.com/tenant/v2.0").brand(),
408 Some("microsoft")
409 );
410 assert_eq!(oidc("https://accounts.google.com").brand(), Some("google"));
411 }
412
413 #[tokio::test]
414 async fn tokens_for_someone_else_stale_or_replayed_are_refused() {
415 let p = provider().await;
416 let o = oidc(&p.issuer);
417 type Change = fn(&mut Next);
418 let cases: [(&str, Change); 5] = [
419 ("another client's token", |n| {
420 n.audience = "other-app".into()
421 }),
422 ("an expired token", |n| n.expires_in = -120),
423 ("another sign-in's nonce", |n| n.nonce = Some("old".into())),
424 ("an unverified email", |n| n.verified = Some(false)),
425 ("no email", |n| n.email = None),
426 ];
427 for (what, change) in cases {
428 let (state, _) = begin(&p, &o).await;
429 change(&mut p.next.lock().unwrap());
430 assert!(
431 o.finish(&state, "code").await.is_err(),
432 "{what} was accepted"
433 );
434 *p.next.lock().unwrap() = Next {
435 email: Some("dev@example.com".into()),
436 verified: None,
437 audience: "comindly".into(),
438 nonce: None,
439 expires_in: 300,
440 };
441 }
442 }
443
444 #[tokio::test]
445 async fn an_unreachable_provider_is_an_error_not_a_panic() {
446 let o = oidc("http://127.0.0.1:9");
447 let e = o.start().await.unwrap_err();
448 assert!(format!("{e:#}").contains("cannot reach the sign-in provider"));
449 }
450}