Skip to main content

comindly_engine/
oidc.rs

1//! Sign-in through an OpenID Connect provider: the authorization code flow with PKCE.
2
3use 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
26/// How long someone may take at the provider before the sign-in has to start again.
27const PENDING_FOR: Duration = Duration::from_secs(600);
28/// Signing keys and endpoints are fetched again after this long, or when a token fails to verify.
29const DISCOVERY_FOR: Duration = Duration::from_secs(3600);
30const MAX_PENDING: usize = 1000;
31
32/// Where the provider sends people back.
33pub 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    /// The provider's endpoints and signing keys, fetched again when old or when `fresh`.
94    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    /// Where to send the browser, and the state it must bring back.
123    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    /// The verified email address of someone coming back from the provider.
154    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            // The provider may have rotated its signing keys since they were fetched.
180            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    /// `microsoft` or `google` when the issuer is theirs.
208    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    /// Whether someone at `email` may join without an invitation.
218    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    /// What the provider puts in the next ID token.
239    #[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        /// The PKCE verifier each token request carried.
253        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    /// Start a sign-in; the provider will answer with this sign-in's nonce.
368    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}