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}