Skip to main content

assay_auth/
oidc.rs

1//! OIDC client — discovery, PKCE, callback, userinfo.
2//!
3//! Plan 12c task 5.1 reference. We wrap the [`openidconnect`] 4 typed
4//! `CoreClient` per upstream so callers don't have to thread its
5//! type-state generics through every handler. Each provider is
6//! discovered once at registration time (`<issuer>/.well-known/openid-configuration`)
7//! and the resulting client is cached behind a slug key.
8//!
9//! The phase-5 surface is intentionally library-only:
10//!
11//! - [`OidcRegistry`] — slug-keyed registry of discovered providers
12//! - [`OidcClient`] — wraps one upstream's discovered metadata + RP creds
13//! - [`UpstreamProvider`] — POD record (slug + issuer + client id/secret +
14//!   scopes); matches the `auth.upstream_providers` row shape that admin
15//!   CRUD will land in a later plan
16//! - [`UpstreamUserInfo`] — verified result of one login round-trip
17//!
18//! Engine boot constructs an empty registry; populated providers come
19//! from a future admin API or seed config (out of phase 5 scope).
20
21use std::collections::{BTreeMap, HashMap};
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24
25use openidconnect::core::{
26    CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreIdTokenClaims, CoreIdTokenVerifier,
27    CoreJsonWebKey, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, CoreProviderMetadata,
28    CoreUserInfoClaims,
29};
30use openidconnect::reqwest as oidc_reqwest;
31use openidconnect::{
32    AuthorizationCode, ClaimsVerificationError, ClientId, ClientSecret, CsrfToken,
33    EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, JsonWebKeySetUrl, Nonce,
34    OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
35    SignatureVerificationError, SubjectIdentifier, TokenResponse,
36};
37use parking_lot::RwLock;
38use url::Url;
39
40use crate::error::{Error, Result};
41
42/// Fallback scopes requested during upstream federation login when a
43/// row's `scopes` column is empty. The admin API normally fills the
44/// column from the request body (or its server-side default of
45/// `'["openid","email","profile"]'`); this constant only fires for
46/// rows that pre-date the V5 migration filling in the default.
47pub const DEFAULT_UPSTREAM_SCOPES: &[&str] = &["openid", "email", "profile"];
48
49/// Lower bound on how long a fetched upstream JWKS is trusted before a
50/// proactive re-fetch — even when the certs endpoint advertises a
51/// shorter `max-age`. Keeps us from re-fetching on essentially every
52/// login.
53const JWKS_MIN_TTL: Duration = Duration::from_secs(300);
54
55/// Upper bound on the JWKS cache TTL. Google advertises multi-hour
56/// `max-age`s on its certs endpoint; we cap the trusted window so a
57/// cache that never gets a rotation-triggered refetch still self-heals
58/// within a bounded interval.
59const JWKS_MAX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
60
61/// TTL used when the certs response carries no usable `Cache-Control:
62/// max-age` directive.
63const JWKS_DEFAULT_TTL: Duration = Duration::from_secs(60 * 60);
64
65/// Minimum gap between *reactive* (unknown-`kid`) JWKS re-fetches. A
66/// burst of tokens carrying a `kid` we'll never hold — bogus, or from a
67/// misconfigured upstream — can't stampede the certs endpoint faster
68/// than this.
69const JWKS_MIN_REFETCH_INTERVAL: Duration = Duration::from_secs(60);
70
71/// In-memory, refreshable cache of one upstream's signing keys.
72///
73/// Seeded from the discovery metadata at registration, then re-fetched
74/// from `jwks_uri` when the keys go stale — either the TTL lapsed
75/// (honoring the certs endpoint's `Cache-Control: max-age`) or an
76/// incoming id_token carried a `kid` we don't yet hold (upstream key
77/// rotation). Before this cache existed the verifier pinned the
78/// boot-time keys forever, so every login signed by a rotated Google
79/// key failed with `Signature verification failed`.
80struct JwksCache {
81    keys: Vec<CoreJsonWebKey>,
82    /// Deadline for a proactive refresh, set from the last fetch's TTL.
83    expires_at: Instant,
84    /// When we last hit `jwks_uri` — rate-limits reactive refetches.
85    last_fetch: Instant,
86}
87
88/// POD record describing one upstream identity provider. Mirrors the
89/// planned `auth.upstream_providers` table shape (see plan 12d) so the
90/// admin API can `INSERT … RETURNING *` and feed the row directly into
91/// [`OidcRegistry::add`] without a translation step.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct UpstreamProvider {
94    /// Stable slug used in routes (`/login/{slug}`) and as the
95    /// `auth.user_upstream.provider` column value. Lower-snake-case
96    /// matches the rest of the codebase's naming.
97    pub slug: String,
98    /// Issuer URL — the value the discovery doc lives under
99    /// (`<issuer>/.well-known/openid-configuration`).
100    pub issuer: String,
101    /// RP client id registered with the upstream.
102    pub client_id: String,
103    /// RP client secret registered with the upstream. Stored as
104    /// plaintext here because phase 5 has no secret-at-rest envelope yet
105    /// — admin CRUD lands with the encryption story.
106    pub client_secret: String,
107    /// Scopes requested at authorize time. Common set:
108    /// `["openid", "email", "profile"]`. `openid` is added implicitly
109    /// by [`openidconnect`]; we forward the rest unchanged.
110    pub scopes: Vec<String>,
111    /// Per-IdP authorize-URL parameters (`prompt`, `hd`, `domain_hint`,
112    /// `idp_*`, …). Validated against the
113    /// [`crate::oidc_provider::auth_params`] whitelist before the row
114    /// reaches this struct. Empty by default.
115    pub auth_params: BTreeMap<String, String>,
116}
117
118/// Verified userinfo returned by [`OidcClient::complete_login`]. Carries
119/// the canonical fields the rest of the auth stack needs to upsert into
120/// `auth.users` + `auth.user_upstream`. `raw_claims` carries the
121/// id_token's full claim set so callers can pluck custom claims (e.g.
122/// `groups`, `roles`) without a second parse.
123#[derive(Clone, Debug)]
124pub struct UpstreamUserInfo {
125    pub provider: String,
126    pub subject: String,
127    pub email: Option<String>,
128    pub email_verified: bool,
129    pub name: Option<String>,
130    pub picture: Option<String>,
131    pub raw_claims: serde_json::Value,
132}
133
134/// A single discovered upstream — wraps the [`openidconnect`] typed
135/// client and the PoD metadata used to construct it.
136///
137/// The CoreClient generic state after `from_provider_metadata +
138/// set_redirect_uri` is `<EndpointSet, EndpointNotSet, EndpointNotSet,
139/// EndpointNotSet, EndpointMaybeSet, EndpointMaybeSet>` — auth URL set
140/// (so `authorize_url` works), token + userinfo MaybeSet (we error at
141/// runtime if the upstream's discovery doc is missing one).
142pub struct OidcClient {
143    inner: CoreClient<
144        EndpointSet,
145        EndpointNotSet,
146        EndpointNotSet,
147        EndpointNotSet,
148        EndpointMaybeSet,
149        EndpointMaybeSet,
150    >,
151    /// Original PoD record for round-trip / introspection
152    /// (e.g. admin "what's configured" pages).
153    provider: UpstreamProvider,
154    /// Owned redirect URL — `set_redirect_uri` consumed it on the
155    /// builder, but operators sometimes want it back without re-parsing.
156    redirect_uri: RedirectUrl,
157    /// `jwks_uri` from the upstream's discovery doc — where we re-fetch
158    /// signing keys when the cache goes stale.
159    jwks_uri: JsonWebKeySetUrl,
160    /// Algorithms the upstream advertised for id_token signing
161    /// (`id_token_signing_alg_values_supported`), captured at discovery
162    /// so a rebuilt verifier restricts algorithms exactly as the
163    /// metadata-derived one did.
164    signing_algs: Vec<CoreJwsSigningAlgorithm>,
165    /// Refreshable signing-key cache. See [`JwksCache`].
166    jwks: Arc<RwLock<JwksCache>>,
167    /// Dedicated HTTP client for JWKS re-fetches. Kept separate from the
168    /// `openidconnect`/`oauth2` reqwest used for token exchange: that
169    /// crate pins a different `reqwest` major, so we fetch keys with the
170    /// auth crate's own `reqwest` (matching [`crate::external_jwt`]).
171    jwks_http: reqwest::Client,
172}
173
174impl OidcClient {
175    /// Borrow the original PoD record.
176    pub fn provider(&self) -> &UpstreamProvider {
177        &self.provider
178    }
179
180    /// Borrow the configured redirect URI.
181    pub fn redirect_uri(&self) -> &RedirectUrl {
182        &self.redirect_uri
183    }
184
185    /// Step 1 of the authorization-code-+-PKCE flow. Generates a PKCE
186    /// pair, asks the [`openidconnect`] client for the redirect URL,
187    /// returns the URL alongside the verifier + nonce for round-trip
188    /// (callers persist them, typically in the session).
189    ///
190    /// `state` lets callers pin a known CSRF value (e.g. the session id)
191    /// rather than the library-generated random one — useful when the
192    /// callback handler uses `state` to look the in-progress login up.
193    /// Pass [`CsrfToken::new_random`] via `CsrfToken::new(...)` if you
194    /// don't have one already.
195    pub fn start_login(&self, state: CsrfToken) -> StartedLogin {
196        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
197        let mut request = self.inner.authorize_url(
198            CoreAuthenticationFlow::AuthorizationCode,
199            move || state,
200            Nonce::new_random,
201        );
202        for scope in &self.provider.scopes {
203            // `openid` scope is added by openidconnect when
204            // `use_openid_scope` is true (default after
205            // `from_provider_metadata`); skip a duplicate so the URL
206            // stays clean.
207            if scope == "openid" {
208                continue;
209            }
210            request = request.add_scope(Scope::new(scope.clone()));
211        }
212        for (k, v) in &self.provider.auth_params {
213            request = request.add_extra_param(k.clone(), v.clone());
214        }
215        let (url, csrf_token, nonce) = request.set_pkce_challenge(pkce_challenge).url();
216        StartedLogin {
217            url,
218            csrf_token,
219            nonce,
220            pkce_verifier,
221        }
222    }
223
224    /// Step 2 — exchange the upstream's `code` for tokens, validate the
225    /// id_token against the cached JWKS + nonce, and (when the upstream
226    /// publishes a userinfo endpoint) supplement the claims with a
227    /// userinfo call.
228    ///
229    /// `pkce_verifier` and `nonce` must be the values returned from
230    /// [`OidcClient::start_login`] for the same login — callers persist
231    /// them server-side keyed by `state`.
232    pub async fn complete_login(
233        &self,
234        code: String,
235        pkce_verifier: PkceCodeVerifier,
236        nonce: Nonce,
237    ) -> Result<UpstreamUserInfo> {
238        let http = build_oidc_http_client(HttpClientOptions::default())?;
239        let token_response = self
240            .inner
241            .exchange_code(AuthorizationCode::new(code))
242            .map_err(|e| Error::Oidc(format!("exchange_code config: {e}")))?
243            .set_pkce_verifier(pkce_verifier)
244            .request_async(&http)
245            .await
246            .map_err(|e| Error::Oidc(format!("token exchange: {e}")))?;
247
248        let id_token = token_response
249            .id_token()
250            .ok_or_else(|| Error::Oidc("upstream returned no id_token".to_string()))?;
251        let claims = self.verify_id_token_claims(id_token, &nonce).await?;
252
253        let subject = claims.subject().to_string();
254        let mut email = claims.email().map(|e| e.to_string());
255        let mut email_verified = claims.email_verified().unwrap_or(false);
256        let mut name = claims
257            .name()
258            .and_then(|map| map.get(None))
259            .map(|n| n.to_string());
260        let mut picture = claims
261            .picture()
262            .and_then(|map| map.get(None))
263            .map(|u| u.to_string());
264
265        // Best-effort userinfo fetch. Some upstreams omit email/name from
266        // the id_token and only expose them via /userinfo. If the
267        // upstream doesn't publish a userinfo endpoint or the call fails,
268        // we keep what the id_token gave us — login still works, the
269        // missing fields just show up as None.
270        let mut raw_claims =
271            serde_json::to_value(claims).unwrap_or_else(|_| serde_json::json!({"sub": subject}));
272        if let Ok(req) = self.inner.user_info(
273            token_response.access_token().clone(),
274            Some(SubjectIdentifier::new(subject.clone())),
275        ) && let Ok(userinfo) = req.request_async(&http).await
276        {
277            let user_claims: CoreUserInfoClaims = userinfo;
278            if email.is_none() {
279                email = user_claims.email().map(|e| e.to_string());
280            }
281            if !email_verified {
282                email_verified = user_claims.email_verified().unwrap_or(email_verified);
283            }
284            if name.is_none() {
285                name = user_claims
286                    .name()
287                    .and_then(|map| map.get(None))
288                    .map(|n| n.to_string());
289            }
290            if picture.is_none() {
291                picture = user_claims
292                    .picture()
293                    .and_then(|map| map.get(None))
294                    .map(|u| u.to_string());
295            }
296            // Merge userinfo into raw_claims so downstream code that
297            // wants e.g. `groups` from userinfo can pluck it out.
298            if let Ok(userinfo_value) = serde_json::to_value(&user_claims) {
299                merge_json(&mut raw_claims, userinfo_value);
300            }
301        }
302
303        Ok(UpstreamUserInfo {
304            provider: self.provider.slug.clone(),
305            subject,
306            email,
307            email_verified,
308            name,
309            picture,
310            raw_claims,
311        })
312    }
313
314    /// Verify the upstream id_token against the cached signing keys,
315    /// transparently refreshing the JWKS when it has gone stale.
316    ///
317    /// Two refresh triggers, matching the OIDC key-rotation guidance:
318    ///  * **Proactive** — the cache TTL (from the certs endpoint's
319    ///    `Cache-Control: max-age`) has lapsed, so we re-fetch before
320    ///    even trying.
321    ///  * **Reactive** — verification fails with `NoMatchingKey`: the
322    ///    token's `kid` isn't one we hold, i.e. the upstream just
323    ///    rotated. We re-fetch once (rate-limited) and retry.
324    ///
325    /// A proactive-refresh network failure is non-fatal — we fall back
326    /// to the cached keys and let verification decide. Only a missing
327    /// key triggers the reactive retry; other failures (bad audience,
328    /// expired, genuinely bad signature) can't be fixed by re-fetching
329    /// keys, so we surface them directly.
330    async fn verify_id_token_claims<'t>(
331        &self,
332        id_token: &'t CoreIdToken,
333        nonce: &Nonce,
334    ) -> Result<&'t CoreIdTokenClaims> {
335        let mut refreshed = false;
336        let keys = if self.jwks.read().expires_at <= Instant::now() {
337            match self.refresh_jwks().await {
338                Ok(fresh) => {
339                    refreshed = true;
340                    fresh
341                }
342                Err(e) => {
343                    tracing::warn!(
344                        slug = %self.provider.slug,
345                        error = %e,
346                        "proactive jwks refresh failed; verifying against cached keys"
347                    );
348                    self.jwks.read().keys.clone()
349                }
350            }
351        } else {
352            self.jwks.read().keys.clone()
353        };
354
355        let verifier = self.id_token_verifier(keys)?;
356        match id_token.claims(&verifier, nonce) {
357            Ok(claims) => Ok(claims),
358            Err(e)
359                if is_unknown_signing_key(&e) && !refreshed && self.reactive_refetch_allowed() =>
360            {
361                tracing::info!(
362                    slug = %self.provider.slug,
363                    "id_token kid not in cached jwks; refetching upstream keys (likely rotation)"
364                );
365                let fresh = self.refresh_jwks().await?;
366                let verifier = self.id_token_verifier(fresh)?;
367                id_token
368                    .claims(&verifier, nonce)
369                    .map_err(|e| Error::Oidc(format!("id_token verify: {e}")))
370            }
371            Err(e) => Err(Error::Oidc(format!("id_token verify: {e}"))),
372        }
373    }
374
375    /// Build an id_token verifier from `keys`, mirroring the
376    /// public/confidential-client choice and allowed-algorithms set that
377    /// [`CoreClient::id_token_verifier`] would have produced from the
378    /// discovery metadata.
379    fn id_token_verifier(&self, keys: Vec<CoreJsonWebKey>) -> Result<CoreIdTokenVerifier<'static>> {
380        let issuer = IssuerUrl::new(self.provider.issuer.clone())
381            .map_err(|e| Error::Oidc(format!("issuer url {}: {e}", self.provider.issuer)))?;
382        let client_id = ClientId::new(self.provider.client_id.clone());
383        let jwks = CoreJsonWebKeySet::new(keys);
384        let verifier = if self.provider.client_secret.is_empty() {
385            CoreIdTokenVerifier::new_public_client(client_id, issuer, jwks)
386        } else {
387            CoreIdTokenVerifier::new_confidential_client(
388                client_id,
389                ClientSecret::new(self.provider.client_secret.clone()),
390                issuer,
391                jwks,
392            )
393        };
394        Ok(verifier.set_allowed_algs(self.signing_algs.clone()))
395    }
396
397    /// Whether enough time has elapsed since the last fetch to permit a
398    /// reactive (unknown-`kid`) refetch.
399    fn reactive_refetch_allowed(&self) -> bool {
400        self.jwks.read().last_fetch.elapsed() >= JWKS_MIN_REFETCH_INTERVAL
401    }
402
403    /// Re-fetch the upstream JWKS, store it (with a TTL derived from the
404    /// certs endpoint's `Cache-Control: max-age`), and return the fresh
405    /// keys.
406    async fn refresh_jwks(&self) -> Result<Vec<CoreJsonWebKey>> {
407        let uri = self.jwks_uri.url().to_string();
408        let resp = self
409            .jwks_http
410            .get(&uri)
411            .send()
412            .await
413            .map_err(|e| Error::Oidc(format!("fetch jwks {uri}: {e}")))?
414            .error_for_status()
415            .map_err(|e| Error::Oidc(format!("fetch jwks {uri}: {e}")))?;
416        let ttl = jwks_cache_ttl(resp.headers());
417        let fetched: CoreJsonWebKeySet = resp
418            .json()
419            .await
420            .map_err(|e| Error::Oidc(format!("parse jwks {uri}: {e}")))?;
421        let keys = fetched.keys().clone();
422
423        let now = Instant::now();
424        {
425            let mut cache = self.jwks.write();
426            cache.keys = keys.clone();
427            cache.expires_at = now + ttl;
428            cache.last_fetch = now;
429        }
430        tracing::debug!(
431            slug = %self.provider.slug,
432            ttl_secs = ttl.as_secs(),
433            keys = keys.len(),
434            "refreshed upstream jwks"
435        );
436        Ok(keys)
437    }
438}
439
440/// Result of [`OidcClient::start_login`]. The HTTP layer redirects the
441/// user to `url` and persists the rest server-side (typically in the
442/// session payload, keyed by `csrf_token` so the callback can look the
443/// in-progress login up via the `state` query param).
444pub struct StartedLogin {
445    pub url: Url,
446    pub csrf_token: CsrfToken,
447    pub nonce: Nonce,
448    pub pkce_verifier: PkceCodeVerifier,
449}
450
451/// Slug-keyed registry of discovered upstreams.
452///
453/// Cheap to clone — interior is `Arc<RwLock<…>>` so HTTP handlers can
454/// share a single registry while admin endpoints add / remove providers
455/// at runtime.
456#[derive(Clone, Default)]
457pub struct OidcRegistry {
458    inner: Arc<RwLock<HashMap<String, Arc<OidcClient>>>>,
459}
460
461impl OidcRegistry {
462    /// Empty registry — engine boot creates one of these and feeds it to
463    /// [`crate::ctx::AuthCtx::with_oidc`]. Providers are added later via
464    /// admin CRUD or seed config.
465    pub fn new() -> Self {
466        Self::default()
467    }
468
469    /// Discover and cache one upstream. Performs a network round-trip to
470    /// `<issuer>/.well-known/openid-configuration` plus the JWKS fetch,
471    /// so call this from boot or from an admin endpoint, not from a
472    /// per-request handler.
473    ///
474    /// `redirect_uri` is the absolute URL the upstream redirects back
475    /// to after login (typically `<public_url>/login/<slug>/callback`).
476    pub async fn add(&self, provider: UpstreamProvider, redirect_uri: Url) -> Result<()> {
477        let issuer = IssuerUrl::new(provider.issuer.clone())
478            .map_err(|e| Error::Oidc(format!("issuer url {}: {e}", provider.issuer)))?;
479        let http = build_oidc_http_client(HttpClientOptions::default())?;
480        let metadata = CoreProviderMetadata::discover_async(issuer, &http)
481            .await
482            .map_err(|e| Error::Oidc(format!("discover {}: {e}", provider.slug)))?;
483        // Capture the bits we need to rebuild an id_token verifier later
484        // (on JWKS refresh) before `from_provider_metadata` consumes the
485        // metadata. `discover_async` already fetched the JWKS into the
486        // metadata, so `initial_keys` is a valid seed for the cache.
487        let jwks_uri = metadata.jwks_uri().clone();
488        let signing_algs = metadata.id_token_signing_alg_values_supported().clone();
489        let initial_keys = metadata.jwks().keys().clone();
490        let redirect = RedirectUrl::new(redirect_uri.to_string())
491            .map_err(|e| Error::Oidc(format!("redirect_uri {redirect_uri}: {e}")))?;
492        let client_secret = if provider.client_secret.is_empty() {
493            None
494        } else {
495            Some(ClientSecret::new(provider.client_secret.clone()))
496        };
497        let inner = CoreClient::from_provider_metadata(
498            metadata,
499            ClientId::new(provider.client_id.clone()),
500            client_secret,
501        )
502        .set_redirect_uri(redirect.clone());
503        let jwks_http = reqwest::Client::builder()
504            .timeout(Duration::from_secs(10))
505            .build()
506            .map_err(|e| Error::Oidc(format!("build jwks http client: {e}")))?;
507        let now = Instant::now();
508        let client = OidcClient {
509            inner,
510            provider: provider.clone(),
511            redirect_uri: redirect,
512            jwks_uri,
513            signing_algs,
514            jwks: Arc::new(RwLock::new(JwksCache {
515                keys: initial_keys,
516                expires_at: now + JWKS_DEFAULT_TTL,
517                last_fetch: now,
518            })),
519            jwks_http,
520        };
521        self.inner
522            .write()
523            .insert(provider.slug.clone(), Arc::new(client));
524        Ok(())
525    }
526
527    /// Look up a discovered upstream by slug. Returns the same `Arc`
528    /// stored at registration time so callers can hold the client for
529    /// the duration of a long-running flow.
530    pub fn client(&self, slug: &str) -> Option<Arc<OidcClient>> {
531        self.inner.read().get(slug).cloned()
532    }
533
534    /// List the slugs of every registered provider (for admin /
535    /// debugging UIs).
536    pub fn slugs(&self) -> Vec<String> {
537        self.inner.read().keys().cloned().collect()
538    }
539
540    /// Remove a provider from the registry. Returns `true` if a row was
541    /// dropped. Pending in-flight logins keep working because they hold
542    /// an `Arc<OidcClient>` from before the removal.
543    pub fn remove(&self, slug: &str) -> bool {
544        self.inner.write().remove(slug).is_some()
545    }
546
547    /// Number of registered providers — handy for tests + metrics.
548    pub fn len(&self) -> usize {
549        self.inner.read().len()
550    }
551
552    /// Whether the registry is empty.
553    pub fn is_empty(&self) -> bool {
554        self.inner.read().is_empty()
555    }
556}
557
558/// Tunables for the discovery / token / userinfo HTTP client.
559///
560/// Defaults: 5s connect timeout, 10s overall request timeout.
561#[derive(Clone, Debug)]
562pub struct HttpClientOptions {
563    pub connect_timeout_secs: u64,
564    pub request_timeout_secs: u64,
565}
566
567impl Default for HttpClientOptions {
568    fn default() -> Self {
569        // TODO: plumb via [auth.oidc] config (discovery_connect_timeout_secs,
570        //       discovery_request_timeout_secs).
571        Self {
572            connect_timeout_secs: 5,
573            request_timeout_secs: 10,
574        }
575    }
576}
577
578/// Build the reqwest client `openidconnect` uses for discovery, token
579/// exchange, JWKS fetches, and userinfo. We disable redirects on the
580/// security advice in the [`openidconnect`] crate docs (SSRF mitigation)
581/// and use rustls — matches the rest of assay's HTTP stack.
582///
583/// Plumbs explicit connect/read timeouts so a stored `issuer` pointing
584/// at a hung endpoint can't pin the engine on boot or admin upsert.
585/// SSRF protection is handled at the literal-host layer in
586/// [`crate::oidc_provider::issuer_validation`].
587pub fn build_oidc_http_client(opts: HttpClientOptions) -> Result<oidc_reqwest::Client> {
588    oidc_reqwest::ClientBuilder::new()
589        .redirect(oidc_reqwest::redirect::Policy::none())
590        .connect_timeout(Duration::from_secs(opts.connect_timeout_secs))
591        .timeout(Duration::from_secs(opts.request_timeout_secs))
592        .build()
593        .map_err(|e| Error::Oidc(format!("build oidc http client: {e}")))
594}
595
596/// TTL for a freshly-fetched JWKS, parsed from `Cache-Control: max-age`
597/// and clamped to `[JWKS_MIN_TTL, JWKS_MAX_TTL]`. Falls back to
598/// [`JWKS_DEFAULT_TTL`] when no usable directive is present.
599fn jwks_cache_ttl(headers: &reqwest::header::HeaderMap) -> Duration {
600    headers
601        .get(reqwest::header::CACHE_CONTROL)
602        .and_then(|v| v.to_str().ok())
603        .and_then(parse_max_age_secs)
604        .map(|secs| Duration::from_secs(secs).clamp(JWKS_MIN_TTL, JWKS_MAX_TTL))
605        .unwrap_or(JWKS_DEFAULT_TTL)
606}
607
608/// Pull the `max-age` value (in seconds) out of a `Cache-Control`
609/// header. Returns `None` if there's no `max-age` directive (e.g.
610/// `s-maxage` only, or `no-cache`).
611fn parse_max_age_secs(cache_control: &str) -> Option<u64> {
612    cache_control.split(',').find_map(|directive| {
613        directive
614            .trim()
615            .strip_prefix("max-age")?
616            .trim_start()
617            .strip_prefix('=')?
618            .trim()
619            .parse::<u64>()
620            .ok()
621    })
622}
623
624/// Whether an id_token verification error means the signing key wasn't
625/// found — i.e. the token's `kid` isn't in our cached JWKS, the
626/// fingerprint of upstream key rotation. Other verification failures
627/// (bad audience, expired, genuinely bad signature) are *not* fixable by
628/// re-fetching keys, so we don't retry on them.
629fn is_unknown_signing_key(e: &ClaimsVerificationError) -> bool {
630    matches!(
631        e,
632        ClaimsVerificationError::SignatureVerification(SignatureVerificationError::NoMatchingKey)
633    )
634}
635
636/// Recursive merge of two JSON values — used so userinfo claims top up
637/// the id_token claims without overwriting them. Object fields merge
638/// recursively; everything else is replaced.
639fn merge_json(target: &mut serde_json::Value, src: serde_json::Value) {
640    match (target, src) {
641        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
642            for (k, v) in b {
643                merge_json(a.entry(k).or_insert(serde_json::Value::Null), v);
644            }
645        }
646        (slot, src) => {
647            // Don't clobber a non-null target with a null source — the
648            // id_token's value wins when userinfo doesn't add anything.
649            if !src.is_null() {
650                *slot = src;
651            }
652        }
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn registry_starts_empty() {
662        let reg = OidcRegistry::new();
663        assert!(reg.is_empty());
664        assert_eq!(reg.len(), 0);
665        assert!(reg.client("google").is_none());
666        assert!(reg.slugs().is_empty());
667    }
668
669    #[test]
670    fn merge_json_merges_objects_and_keeps_existing_on_null() {
671        let mut a = serde_json::json!({"email": "a@x", "groups": ["a"]});
672        let b = serde_json::json!({"email": serde_json::Value::Null, "name": "Alice"});
673        merge_json(&mut a, b);
674        assert_eq!(a["email"], "a@x");
675        assert_eq!(a["name"], "Alice");
676        assert_eq!(a["groups"], serde_json::json!(["a"]));
677    }
678
679    #[test]
680    fn upstream_provider_record_is_clonable() {
681        let p = UpstreamProvider {
682            slug: "google".to_string(),
683            issuer: "https://accounts.google.com".to_string(),
684            client_id: "client".to_string(),
685            client_secret: "secret".to_string(),
686            scopes: vec!["openid".to_string(), "email".to_string()],
687            auth_params: BTreeMap::new(),
688        };
689        let dup = p.clone();
690        assert_eq!(p, dup);
691    }
692
693    #[test]
694    fn parses_max_age_from_cache_control() {
695        assert_eq!(parse_max_age_secs("max-age=3600"), Some(3600));
696        assert_eq!(
697            parse_max_age_secs("public, max-age=600, must-revalidate"),
698            Some(600)
699        );
700        assert_eq!(parse_max_age_secs("max-age = 42"), Some(42));
701        assert_eq!(parse_max_age_secs("private, no-cache"), None);
702        // `s-maxage` is a distinct directive — must not be mistaken for
703        // `max-age`.
704        assert_eq!(parse_max_age_secs("s-maxage=120"), None);
705    }
706
707    #[test]
708    fn jwks_ttl_clamps_and_defaults() {
709        use reqwest::header::{CACHE_CONTROL, HeaderMap, HeaderValue};
710
711        // No header → default.
712        assert_eq!(jwks_cache_ttl(&HeaderMap::new()), JWKS_DEFAULT_TTL);
713
714        let ttl = |v: &'static str| {
715            let mut h = HeaderMap::new();
716            h.insert(CACHE_CONTROL, HeaderValue::from_static(v));
717            jwks_cache_ttl(&h)
718        };
719        // Below floor → clamped up; above ceiling → clamped down.
720        assert_eq!(ttl("max-age=10"), JWKS_MIN_TTL);
721        assert_eq!(ttl("public, max-age=999999"), JWKS_MAX_TTL);
722        // In range → passthrough.
723        assert_eq!(ttl("max-age=1800"), Duration::from_secs(1800));
724    }
725
726    #[test]
727    fn only_missing_key_triggers_refetch() {
728        // A missing `kid` (rotation) is retryable...
729        assert!(is_unknown_signing_key(
730            &ClaimsVerificationError::SignatureVerification(
731                SignatureVerificationError::NoMatchingKey
732            )
733        ));
734        // ...but a genuine bad signature or a non-signature failure is
735        // not — re-fetching keys wouldn't help.
736        assert!(!is_unknown_signing_key(
737            &ClaimsVerificationError::SignatureVerification(
738                SignatureVerificationError::CryptoError("bad sig".into())
739            )
740        ));
741        assert!(!is_unknown_signing_key(&ClaimsVerificationError::Expired(
742            "stale".into()
743        )));
744    }
745
746    /// Discovery against an unreachable URL should fail with `Error::Oidc`,
747    /// not panic. We don't network out from unit tests; this just exercises
748    /// the error path.
749    #[tokio::test]
750    async fn discover_unreachable_issuer_returns_oidc_error() {
751        let reg = OidcRegistry::new();
752        let provider = UpstreamProvider {
753            slug: "ghost".to_string(),
754            issuer: "http://127.0.0.1:1/oidc".to_string(),
755            client_id: "client".to_string(),
756            client_secret: "secret".to_string(),
757            scopes: vec!["openid".to_string()],
758            auth_params: BTreeMap::new(),
759        };
760        let redirect = Url::parse("https://example.com/login/ghost/callback").unwrap();
761        let result = reg.add(provider, redirect).await;
762        assert!(matches!(result, Err(Error::Oidc(_))));
763    }
764}