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