Skip to main content

slave_framework/
oidc.rs

1//! Sign-in through an OpenID Connect provider.
2//!
3//! slave apps do not handle passwords or mint their own tokens. Users sign in
4//! with an OIDC provider (Google, Microsoft Entra, Auth0, Okta, Zitadel,
5//! Keycloak, Authentik, Logto, …); clients send the provider's **ID token** as
6//! `Authorization: Bearer <id_token>`, and [`OidcVerifier`] checks it:
7//! signature (the provider's JWKS), issuer, audience (one of your client IDs)
8//! and expiry.
9//!
10//! Configure it with `OIDC_ISSUER_URL` and `OIDC_AUDIENCES` (the client IDs of
11//! your apps, comma-separated).
12
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use openidconnect::core::{
17    CoreIdToken, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm,
18    CoreProviderMetadata,
19};
20use openidconnect::{ClientId, IssuerUrl, JsonWebKeySetUrl, Nonce};
21use tokio::sync::RwLock;
22
23use crate::{Error, Result};
24
25/// Environment variable holding the provider's issuer URL.
26pub const ISSUER_ENV: &str = "OIDC_ISSUER_URL";
27/// Environment variable holding the accepted client IDs (comma-separated).
28pub const AUDIENCES_ENV: &str = "OIDC_AUDIENCES";
29
30/// How to configure sign-in, shown when settings are missing.
31pub const SETUP_HINT: &str = "Register your apps with an OpenID Connect provider, then set \
32    OIDC_ISSUER_URL (e.g. https://accounts.google.com) and OIDC_AUDIENCES (their client IDs, \
33    comma-separated) in .env.";
34
35/// Minimum time between JWKS refreshes triggered by unknown signing keys.
36const JWKS_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
37
38/// Which provider to trust, and for which client IDs.
39#[derive(Debug, Clone)]
40pub struct OidcConfig {
41    pub issuer: String,
42    /// Client IDs whose ID tokens are accepted (the token's `aud`).
43    pub audiences: Vec<String>,
44}
45
46impl OidcConfig {
47    /// Reads `OIDC_ISSUER_URL` and `OIDC_AUDIENCES`.
48    pub fn from_env() -> Result<Self> {
49        let issuer = std::env::var(ISSUER_ENV)
50            .ok()
51            .filter(|issuer| !issuer.trim().is_empty())
52            .ok_or(Error::MissingOidcSetting(ISSUER_ENV))?;
53        let audiences: Vec<String> = std::env::var(AUDIENCES_ENV)
54            .unwrap_or_default()
55            .split(',')
56            .map(str::trim)
57            .filter(|audience| !audience.is_empty())
58            .map(str::to_owned)
59            .collect();
60        if audiences.is_empty() {
61            return Err(Error::MissingOidcSetting(AUDIENCES_ENV));
62        }
63        Ok(Self {
64            issuer: issuer.trim().to_owned(),
65            audiences,
66        })
67    }
68}
69
70/// Who signed in, from a verified ID token.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Identity {
73    /// The provider (`iss`); with `subject`, the stable identity of the user.
74    pub issuer: String,
75    /// The user's ID at the provider (`sub`).
76    pub subject: String,
77    pub email: Option<String>,
78    pub email_verified: Option<bool>,
79    pub name: Option<String>,
80}
81
82/// Why a token was not accepted.
83#[derive(Debug, thiserror::Error)]
84pub enum VerifyError {
85    /// Malformed, wrongly signed, expired, or for another issuer or audience.
86    #[error("invalid ID token: {0}")]
87    Invalid(String),
88    /// The provider's keys could not be fetched.
89    #[error("cannot reach the OpenID Connect provider: {0}")]
90    Unavailable(String),
91}
92
93/// Verifies ID tokens from one provider. Cheap to clone.
94#[derive(Clone)]
95pub struct OidcVerifier {
96    inner: Arc<Inner>,
97}
98
99struct Inner {
100    issuer: IssuerUrl,
101    audiences: Vec<ClientId>,
102    jwks_url: JsonWebKeySetUrl,
103    http: openidconnect::reqwest::Client,
104    jwks: RwLock<(CoreJsonWebKeySet, Instant)>,
105}
106
107impl std::fmt::Debug for OidcVerifier {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("OidcVerifier")
110            .field("issuer", &self.inner.issuer.as_str())
111            .field("audiences", &self.inner.audiences)
112            .finish()
113    }
114}
115
116/// Asymmetric algorithms accepted for ID tokens (never `none` or HMAC).
117fn allowed_algs() -> [CoreJwsSigningAlgorithm; 8] {
118    use CoreJwsSigningAlgorithm::*;
119    [
120        RsaSsaPkcs1V15Sha256,
121        RsaSsaPkcs1V15Sha384,
122        RsaSsaPkcs1V15Sha512,
123        RsaSsaPssSha256,
124        RsaSsaPssSha384,
125        EcdsaP256Sha256,
126        EcdsaP384Sha384,
127        EdDsa,
128    ]
129}
130
131impl OidcVerifier {
132    /// Discovers the provider (`/.well-known/openid-configuration`) and
133    /// fetches its signing keys.
134    pub async fn discover(config: &OidcConfig) -> Result<Self> {
135        let issuer = IssuerUrl::new(config.issuer.clone())
136            .map_err(|err| Error::Oidc(format!("invalid {ISSUER_ENV}: {err}")))?;
137        let http = openidconnect::reqwest::ClientBuilder::new()
138            .redirect(openidconnect::reqwest::redirect::Policy::none())
139            .build()
140            .map_err(|err| Error::Oidc(format!("HTTP client: {err}")))?;
141        let metadata = CoreProviderMetadata::discover_async(issuer.clone(), &http)
142            .await
143            .map_err(|err| {
144                Error::Oidc(format!("discovery at {} failed: {err}", issuer.as_str()))
145            })?;
146        let jwks_url = metadata.jwks_uri().clone();
147        let jwks = CoreJsonWebKeySet::fetch_async(&jwks_url, &http)
148            .await
149            .map_err(|err| Error::Oidc(format!("fetching signing keys failed: {err}")))?;
150        Ok(Self {
151            inner: Arc::new(Inner {
152                issuer,
153                audiences: config
154                    .audiences
155                    .iter()
156                    .cloned()
157                    .map(ClientId::new)
158                    .collect(),
159                jwks_url,
160                http,
161                jwks: RwLock::new((jwks, Instant::now())),
162            }),
163        })
164    }
165
166    /// The provider's issuer URL.
167    pub fn issuer(&self) -> &str {
168        self.inner.issuer.as_str()
169    }
170
171    /// Checks `id_token` and returns who it identifies.
172    pub async fn verify(&self, id_token: &str) -> std::result::Result<Identity, VerifyError> {
173        let token: CoreIdToken = id_token
174            .trim()
175            .parse()
176            .map_err(|err| VerifyError::Invalid(format!("malformed: {err}")))?;
177
178        match self.check(&token).await {
179            Ok(identity) => Ok(identity),
180            // An unknown key usually means the provider rotated its keys.
181            Err(err) if self.refresh_keys_if_stale().await? => {
182                self.check(&token).await.map_err(|_| err)
183            }
184            Err(err) => Err(err),
185        }
186    }
187
188    async fn check(&self, token: &CoreIdToken) -> std::result::Result<Identity, VerifyError> {
189        let jwks = self.inner.jwks.read().await.0.clone();
190        let mut last_error = None;
191        for audience in &self.inner.audiences {
192            let verifier = CoreIdTokenVerifier::new_public_client(
193                audience.clone(),
194                self.inner.issuer.clone(),
195                jwks.clone(),
196            )
197            .set_allowed_algs(allowed_algs());
198            // Clients check the nonce of their own sign-in; the backend cannot.
199            match token.claims(&verifier, |_: Option<&Nonce>| Ok(())) {
200                Ok(claims) => {
201                    return Ok(Identity {
202                        issuer: claims.issuer().to_string(),
203                        subject: claims.subject().to_string(),
204                        email: claims.email().map(|email| email.to_string()),
205                        email_verified: claims.email_verified(),
206                        name: claims
207                            .name()
208                            .and_then(|name| name.get(None))
209                            .map(|name| name.to_string()),
210                    });
211                }
212                Err(err) => last_error = Some(err.to_string()),
213            }
214        }
215        Err(VerifyError::Invalid(
216            last_error.unwrap_or_else(|| "no audience configured".into()),
217        ))
218    }
219
220    /// Refetches the JWKS unless it was fetched recently. Returns whether it did.
221    async fn refresh_keys_if_stale(&self) -> std::result::Result<bool, VerifyError> {
222        let mut jwks = self.inner.jwks.write().await;
223        if jwks.1.elapsed() < JWKS_REFRESH_INTERVAL {
224            return Ok(false);
225        }
226        let fresh = CoreJsonWebKeySet::fetch_async(&self.inner.jwks_url, &self.inner.http)
227            .await
228            .map_err(|err| VerifyError::Unavailable(err.to_string()))?;
229        *jwks = (fresh, Instant::now());
230        Ok(true)
231    }
232}
233
234/// The sign-in side for apps that render pages on a server (e.g. an Axum
235/// theme): the Authorization Code flow with PKCE and a nonce.
236///
237/// 1. [`authorize_url`](Self::authorize_url) gives the provider URL to
238///    redirect the browser to, and a [`PendingSignIn`] to keep (for example
239///    in a short-lived HttpOnly cookie).
240/// 2. On the redirect back, [`exchange`](Self::exchange) checks the state,
241///    swaps the code for tokens and verifies the ID token (signature, issuer,
242///    audience, nonce). Send that ID token to the MCP backend as the bearer.
243#[derive(Clone)]
244pub struct OidcClient {
245    metadata: CoreProviderMetadata,
246    client_id: ClientId,
247    client_secret: Option<openidconnect::ClientSecret>,
248    http: openidconnect::reqwest::Client,
249}
250
251impl std::fmt::Debug for OidcClient {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("OidcClient")
254            .field("issuer", &self.metadata.issuer().as_str())
255            .field("client_id", &self.client_id.as_str())
256            .finish()
257    }
258}
259
260/// What a sign-in in progress must remember between the redirect to the
261/// provider and the redirect back. Store it where only this browser can read
262/// it (an HttpOnly cookie); it is useless once the sign-in completes.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct PendingSignIn {
265    pub state: String,
266    pub pkce_verifier: String,
267    pub nonce: String,
268}
269
270impl PendingSignIn {
271    /// A compact form for a cookie value.
272    pub fn encode(&self) -> String {
273        format!("{}.{}.{}", self.state, self.pkce_verifier, self.nonce)
274    }
275
276    pub fn decode(value: &str) -> Option<Self> {
277        let mut parts = value.split('.');
278        let pending = Self {
279            state: parts.next()?.to_owned(),
280            pkce_verifier: parts.next()?.to_owned(),
281            nonce: parts.next()?.to_owned(),
282        };
283        parts.next().is_none().then_some(pending)
284    }
285}
286
287impl OidcClient {
288    /// Discovers the provider for the app registered as `client_id`
289    /// (`client_secret` for confidential clients, `None` for public ones).
290    pub async fn discover(
291        issuer: &str,
292        client_id: &str,
293        client_secret: Option<&str>,
294    ) -> Result<Self> {
295        let issuer = IssuerUrl::new(issuer.to_owned())
296            .map_err(|err| Error::Oidc(format!("invalid issuer URL: {err}")))?;
297        let http = openidconnect::reqwest::ClientBuilder::new()
298            .redirect(openidconnect::reqwest::redirect::Policy::none())
299            .build()
300            .map_err(|err| Error::Oidc(format!("HTTP client: {err}")))?;
301        let metadata = CoreProviderMetadata::discover_async(issuer.clone(), &http)
302            .await
303            .map_err(|err| {
304                Error::Oidc(format!("discovery at {} failed: {err}", issuer.as_str()))
305            })?;
306        Ok(Self {
307            metadata,
308            client_id: ClientId::new(client_id.to_owned()),
309            client_secret: client_secret
310                .filter(|secret| !secret.is_empty())
311                .map(|secret| openidconnect::ClientSecret::new(secret.to_owned())),
312            http,
313        })
314    }
315
316    /// The provider URL to send the browser to, and what to remember until it
317    /// comes back to `redirect_url` (registered at the provider).
318    pub fn authorize_url(&self, redirect_url: &str) -> Result<(String, PendingSignIn)> {
319        let client = openidconnect::core::CoreClient::from_provider_metadata(
320            self.metadata.clone(),
321            self.client_id.clone(),
322            self.client_secret.clone(),
323        )
324        .set_redirect_uri(
325            openidconnect::RedirectUrl::new(redirect_url.to_owned())
326                .map_err(|err| Error::Oidc(format!("invalid redirect URL: {err}")))?,
327        );
328        let (challenge, verifier) = openidconnect::PkceCodeChallenge::new_random_sha256();
329        let (url, state, nonce) = client
330            .authorize_url(
331                openidconnect::core::CoreAuthenticationFlow::AuthorizationCode,
332                openidconnect::CsrfToken::new_random,
333                Nonce::new_random,
334            )
335            .add_scope(openidconnect::Scope::new("email".into()))
336            .add_scope(openidconnect::Scope::new("profile".into()))
337            .set_pkce_challenge(challenge)
338            .url();
339        Ok((
340            url.to_string(),
341            PendingSignIn {
342                state: state.secret().clone(),
343                pkce_verifier: verifier.secret().clone(),
344                nonce: nonce.secret().clone(),
345            },
346        ))
347    }
348
349    /// Completes the sign-in started with `pending`: checks `state`, swaps
350    /// `code` for tokens and returns the verified ID token.
351    pub async fn exchange(
352        &self,
353        redirect_url: &str,
354        code: &str,
355        state: &str,
356        pending: &PendingSignIn,
357    ) -> Result<String> {
358        if state != pending.state {
359            return Err(Error::Oidc(
360                "sign-in state does not match; start again".into(),
361            ));
362        }
363        let client = openidconnect::core::CoreClient::from_provider_metadata(
364            self.metadata.clone(),
365            self.client_id.clone(),
366            self.client_secret.clone(),
367        )
368        .set_redirect_uri(
369            openidconnect::RedirectUrl::new(redirect_url.to_owned())
370                .map_err(|err| Error::Oidc(format!("invalid redirect URL: {err}")))?,
371        );
372        let response = client
373            .exchange_code(openidconnect::AuthorizationCode::new(code.to_owned()))
374            .map_err(|err| Error::Oidc(format!("token endpoint: {err}")))?
375            .set_pkce_verifier(openidconnect::PkceCodeVerifier::new(
376                pending.pkce_verifier.clone(),
377            ))
378            .request_async(&self.http)
379            .await
380            .map_err(|err| Error::Oidc(format!("exchanging the code failed: {err}")))?;
381        let id_token = openidconnect::TokenResponse::id_token(&response)
382            .ok_or_else(|| Error::Oidc("the provider returned no ID token".into()))?;
383        let verifier = client.id_token_verifier().set_allowed_algs(allowed_algs());
384        id_token
385            .claims(&verifier, &Nonce::new(pending.nonce.clone()))
386            .map_err(|err| Error::Oidc(format!("invalid ID token: {err}")))?;
387        Ok(id_token.to_string())
388    }
389}