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;
24
25use openidconnect::core::{
26    CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims,
27};
28use openidconnect::reqwest as oidc_reqwest;
29use openidconnect::{
30    AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
31    EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
32    RedirectUrl, Scope, SubjectIdentifier, TokenResponse,
33};
34use parking_lot::RwLock;
35use url::Url;
36
37use crate::error::{Error, Result};
38
39/// Fallback scopes requested during upstream federation login when a
40/// row's `scopes` column is empty. The admin API normally fills the
41/// column from the request body (or its server-side default of
42/// `'["openid","email","profile"]'`); this constant only fires for
43/// rows that pre-date the V5 migration filling in the default.
44pub const DEFAULT_UPSTREAM_SCOPES: &[&str] = &["openid", "email", "profile"];
45
46/// POD record describing one upstream identity provider. Mirrors the
47/// planned `auth.upstream_providers` table shape (see plan 12d) so the
48/// admin API can `INSERT … RETURNING *` and feed the row directly into
49/// [`OidcRegistry::add`] without a translation step.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct UpstreamProvider {
52    /// Stable slug used in routes (`/login/{slug}`) and as the
53    /// `auth.user_upstream.provider` column value. Lower-snake-case
54    /// matches the rest of the codebase's naming.
55    pub slug: String,
56    /// Issuer URL — the value the discovery doc lives under
57    /// (`<issuer>/.well-known/openid-configuration`).
58    pub issuer: String,
59    /// RP client id registered with the upstream.
60    pub client_id: String,
61    /// RP client secret registered with the upstream. Stored as
62    /// plaintext here because phase 5 has no secret-at-rest envelope yet
63    /// — admin CRUD lands with the encryption story.
64    pub client_secret: String,
65    /// Scopes requested at authorize time. Common set:
66    /// `["openid", "email", "profile"]`. `openid` is added implicitly
67    /// by [`openidconnect`]; we forward the rest unchanged.
68    pub scopes: Vec<String>,
69    /// Per-IdP authorize-URL parameters (`prompt`, `hd`, `domain_hint`,
70    /// `idp_*`, …). Validated against the
71    /// [`crate::oidc_provider::auth_params`] whitelist before the row
72    /// reaches this struct. Empty by default.
73    pub auth_params: BTreeMap<String, String>,
74}
75
76/// Verified userinfo returned by [`OidcClient::complete_login`]. Carries
77/// the canonical fields the rest of the auth stack needs to upsert into
78/// `auth.users` + `auth.user_upstream`. `raw_claims` carries the
79/// id_token's full claim set so callers can pluck custom claims (e.g.
80/// `groups`, `roles`) without a second parse.
81#[derive(Clone, Debug)]
82pub struct UpstreamUserInfo {
83    pub provider: String,
84    pub subject: String,
85    pub email: Option<String>,
86    pub email_verified: bool,
87    pub name: Option<String>,
88    pub picture: Option<String>,
89    pub raw_claims: serde_json::Value,
90}
91
92/// A single discovered upstream — wraps the [`openidconnect`] typed
93/// client and the PoD metadata used to construct it.
94///
95/// The CoreClient generic state after `from_provider_metadata +
96/// set_redirect_uri` is `<EndpointSet, EndpointNotSet, EndpointNotSet,
97/// EndpointNotSet, EndpointMaybeSet, EndpointMaybeSet>` — auth URL set
98/// (so `authorize_url` works), token + userinfo MaybeSet (we error at
99/// runtime if the upstream's discovery doc is missing one).
100pub struct OidcClient {
101    inner: CoreClient<
102        EndpointSet,
103        EndpointNotSet,
104        EndpointNotSet,
105        EndpointNotSet,
106        EndpointMaybeSet,
107        EndpointMaybeSet,
108    >,
109    /// Original PoD record for round-trip / introspection
110    /// (e.g. admin "what's configured" pages).
111    provider: UpstreamProvider,
112    /// Owned redirect URL — `set_redirect_uri` consumed it on the
113    /// builder, but operators sometimes want it back without re-parsing.
114    redirect_uri: RedirectUrl,
115}
116
117impl OidcClient {
118    /// Borrow the original PoD record.
119    pub fn provider(&self) -> &UpstreamProvider {
120        &self.provider
121    }
122
123    /// Borrow the configured redirect URI.
124    pub fn redirect_uri(&self) -> &RedirectUrl {
125        &self.redirect_uri
126    }
127
128    /// Step 1 of the authorization-code-+-PKCE flow. Generates a PKCE
129    /// pair, asks the [`openidconnect`] client for the redirect URL,
130    /// returns the URL alongside the verifier + nonce for round-trip
131    /// (callers persist them, typically in the session).
132    ///
133    /// `state` lets callers pin a known CSRF value (e.g. the session id)
134    /// rather than the library-generated random one — useful when the
135    /// callback handler uses `state` to look the in-progress login up.
136    /// Pass [`CsrfToken::new_random`] via `CsrfToken::new(...)` if you
137    /// don't have one already.
138    pub fn start_login(&self, state: CsrfToken) -> StartedLogin {
139        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
140        let mut request = self.inner.authorize_url(
141            CoreAuthenticationFlow::AuthorizationCode,
142            move || state,
143            Nonce::new_random,
144        );
145        for scope in &self.provider.scopes {
146            // `openid` scope is added by openidconnect when
147            // `use_openid_scope` is true (default after
148            // `from_provider_metadata`); skip a duplicate so the URL
149            // stays clean.
150            if scope == "openid" {
151                continue;
152            }
153            request = request.add_scope(Scope::new(scope.clone()));
154        }
155        for (k, v) in &self.provider.auth_params {
156            request = request.add_extra_param(k.clone(), v.clone());
157        }
158        let (url, csrf_token, nonce) = request.set_pkce_challenge(pkce_challenge).url();
159        StartedLogin {
160            url,
161            csrf_token,
162            nonce,
163            pkce_verifier,
164        }
165    }
166
167    /// Step 2 — exchange the upstream's `code` for tokens, validate the
168    /// id_token against the cached JWKS + nonce, and (when the upstream
169    /// publishes a userinfo endpoint) supplement the claims with a
170    /// userinfo call.
171    ///
172    /// `pkce_verifier` and `nonce` must be the values returned from
173    /// [`OidcClient::start_login`] for the same login — callers persist
174    /// them server-side keyed by `state`.
175    pub async fn complete_login(
176        &self,
177        code: String,
178        pkce_verifier: PkceCodeVerifier,
179        nonce: Nonce,
180    ) -> Result<UpstreamUserInfo> {
181        let http = build_oidc_http_client(HttpClientOptions::default())?;
182        let token_response = self
183            .inner
184            .exchange_code(AuthorizationCode::new(code))
185            .map_err(|e| Error::Oidc(format!("exchange_code config: {e}")))?
186            .set_pkce_verifier(pkce_verifier)
187            .request_async(&http)
188            .await
189            .map_err(|e| Error::Oidc(format!("token exchange: {e}")))?;
190
191        let id_token = token_response
192            .id_token()
193            .ok_or_else(|| Error::Oidc("upstream returned no id_token".to_string()))?;
194        let id_token_verifier = self.inner.id_token_verifier();
195        let claims = id_token
196            .claims(&id_token_verifier, &nonce)
197            .map_err(|e| Error::Oidc(format!("id_token verify: {e}")))?;
198
199        let subject = claims.subject().to_string();
200        let mut email = claims.email().map(|e| e.to_string());
201        let mut email_verified = claims.email_verified().unwrap_or(false);
202        let mut name = claims
203            .name()
204            .and_then(|map| map.get(None))
205            .map(|n| n.to_string());
206        let mut picture = claims
207            .picture()
208            .and_then(|map| map.get(None))
209            .map(|u| u.to_string());
210
211        // Best-effort userinfo fetch. Some upstreams omit email/name from
212        // the id_token and only expose them via /userinfo. If the
213        // upstream doesn't publish a userinfo endpoint or the call fails,
214        // we keep what the id_token gave us — login still works, the
215        // missing fields just show up as None.
216        let mut raw_claims =
217            serde_json::to_value(claims).unwrap_or_else(|_| serde_json::json!({"sub": subject}));
218        if let Ok(req) = self.inner.user_info(
219            token_response.access_token().clone(),
220            Some(SubjectIdentifier::new(subject.clone())),
221        ) && let Ok(userinfo) = req.request_async(&http).await
222        {
223            let user_claims: CoreUserInfoClaims = userinfo;
224            if email.is_none() {
225                email = user_claims.email().map(|e| e.to_string());
226            }
227            if !email_verified {
228                email_verified = user_claims.email_verified().unwrap_or(email_verified);
229            }
230            if name.is_none() {
231                name = user_claims
232                    .name()
233                    .and_then(|map| map.get(None))
234                    .map(|n| n.to_string());
235            }
236            if picture.is_none() {
237                picture = user_claims
238                    .picture()
239                    .and_then(|map| map.get(None))
240                    .map(|u| u.to_string());
241            }
242            // Merge userinfo into raw_claims so downstream code that
243            // wants e.g. `groups` from userinfo can pluck it out.
244            if let Ok(userinfo_value) = serde_json::to_value(&user_claims) {
245                merge_json(&mut raw_claims, userinfo_value);
246            }
247        }
248
249        Ok(UpstreamUserInfo {
250            provider: self.provider.slug.clone(),
251            subject,
252            email,
253            email_verified,
254            name,
255            picture,
256            raw_claims,
257        })
258    }
259}
260
261/// Result of [`OidcClient::start_login`]. The HTTP layer redirects the
262/// user to `url` and persists the rest server-side (typically in the
263/// session payload, keyed by `csrf_token` so the callback can look the
264/// in-progress login up via the `state` query param).
265pub struct StartedLogin {
266    pub url: Url,
267    pub csrf_token: CsrfToken,
268    pub nonce: Nonce,
269    pub pkce_verifier: PkceCodeVerifier,
270}
271
272/// Slug-keyed registry of discovered upstreams.
273///
274/// Cheap to clone — interior is `Arc<RwLock<…>>` so HTTP handlers can
275/// share a single registry while admin endpoints add / remove providers
276/// at runtime.
277#[derive(Clone, Default)]
278pub struct OidcRegistry {
279    inner: Arc<RwLock<HashMap<String, Arc<OidcClient>>>>,
280}
281
282impl OidcRegistry {
283    /// Empty registry — engine boot creates one of these and feeds it to
284    /// [`crate::ctx::AuthCtx::with_oidc`]. Providers are added later via
285    /// admin CRUD or seed config.
286    pub fn new() -> Self {
287        Self::default()
288    }
289
290    /// Discover and cache one upstream. Performs a network round-trip to
291    /// `<issuer>/.well-known/openid-configuration` plus the JWKS fetch,
292    /// so call this from boot or from an admin endpoint, not from a
293    /// per-request handler.
294    ///
295    /// `redirect_uri` is the absolute URL the upstream redirects back
296    /// to after login (typically `<public_url>/login/<slug>/callback`).
297    pub async fn add(&self, provider: UpstreamProvider, redirect_uri: Url) -> Result<()> {
298        let issuer = IssuerUrl::new(provider.issuer.clone())
299            .map_err(|e| Error::Oidc(format!("issuer url {}: {e}", provider.issuer)))?;
300        let http = build_oidc_http_client(HttpClientOptions::default())?;
301        let metadata = CoreProviderMetadata::discover_async(issuer, &http)
302            .await
303            .map_err(|e| Error::Oidc(format!("discover {}: {e}", provider.slug)))?;
304        let redirect = RedirectUrl::new(redirect_uri.to_string())
305            .map_err(|e| Error::Oidc(format!("redirect_uri {redirect_uri}: {e}")))?;
306        let client_secret = if provider.client_secret.is_empty() {
307            None
308        } else {
309            Some(ClientSecret::new(provider.client_secret.clone()))
310        };
311        let inner = CoreClient::from_provider_metadata(
312            metadata,
313            ClientId::new(provider.client_id.clone()),
314            client_secret,
315        )
316        .set_redirect_uri(redirect.clone());
317        let client = OidcClient {
318            inner,
319            provider: provider.clone(),
320            redirect_uri: redirect,
321        };
322        self.inner
323            .write()
324            .insert(provider.slug.clone(), Arc::new(client));
325        Ok(())
326    }
327
328    /// Look up a discovered upstream by slug. Returns the same `Arc`
329    /// stored at registration time so callers can hold the client for
330    /// the duration of a long-running flow.
331    pub fn client(&self, slug: &str) -> Option<Arc<OidcClient>> {
332        self.inner.read().get(slug).cloned()
333    }
334
335    /// List the slugs of every registered provider (for admin /
336    /// debugging UIs).
337    pub fn slugs(&self) -> Vec<String> {
338        self.inner.read().keys().cloned().collect()
339    }
340
341    /// Remove a provider from the registry. Returns `true` if a row was
342    /// dropped. Pending in-flight logins keep working because they hold
343    /// an `Arc<OidcClient>` from before the removal.
344    pub fn remove(&self, slug: &str) -> bool {
345        self.inner.write().remove(slug).is_some()
346    }
347
348    /// Number of registered providers — handy for tests + metrics.
349    pub fn len(&self) -> usize {
350        self.inner.read().len()
351    }
352
353    /// Whether the registry is empty.
354    pub fn is_empty(&self) -> bool {
355        self.inner.read().is_empty()
356    }
357}
358
359/// Tunables for the discovery / token / userinfo HTTP client.
360///
361/// Defaults: 5s connect timeout, 10s overall request timeout.
362#[derive(Clone, Debug)]
363pub struct HttpClientOptions {
364    pub connect_timeout_secs: u64,
365    pub request_timeout_secs: u64,
366}
367
368impl Default for HttpClientOptions {
369    fn default() -> Self {
370        // TODO: plumb via [auth.oidc] config (discovery_connect_timeout_secs,
371        //       discovery_request_timeout_secs).
372        Self {
373            connect_timeout_secs: 5,
374            request_timeout_secs: 10,
375        }
376    }
377}
378
379/// Build the reqwest client `openidconnect` uses for discovery, token
380/// exchange, JWKS fetches, and userinfo. We disable redirects on the
381/// security advice in the [`openidconnect`] crate docs (SSRF mitigation)
382/// and use rustls — matches the rest of assay's HTTP stack.
383///
384/// Plumbs explicit connect/read timeouts so a stored `issuer` pointing
385/// at a hung endpoint can't pin the engine on boot or admin upsert.
386/// SSRF protection is handled at the literal-host layer in
387/// [`crate::oidc_provider::issuer_validation`].
388pub fn build_oidc_http_client(opts: HttpClientOptions) -> Result<oidc_reqwest::Client> {
389    oidc_reqwest::ClientBuilder::new()
390        .redirect(oidc_reqwest::redirect::Policy::none())
391        .connect_timeout(Duration::from_secs(opts.connect_timeout_secs))
392        .timeout(Duration::from_secs(opts.request_timeout_secs))
393        .build()
394        .map_err(|e| Error::Oidc(format!("build oidc http client: {e}")))
395}
396
397/// Recursive merge of two JSON values — used so userinfo claims top up
398/// the id_token claims without overwriting them. Object fields merge
399/// recursively; everything else is replaced.
400fn merge_json(target: &mut serde_json::Value, src: serde_json::Value) {
401    match (target, src) {
402        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
403            for (k, v) in b {
404                merge_json(a.entry(k).or_insert(serde_json::Value::Null), v);
405            }
406        }
407        (slot, src) => {
408            // Don't clobber a non-null target with a null source — the
409            // id_token's value wins when userinfo doesn't add anything.
410            if !src.is_null() {
411                *slot = src;
412            }
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn registry_starts_empty() {
423        let reg = OidcRegistry::new();
424        assert!(reg.is_empty());
425        assert_eq!(reg.len(), 0);
426        assert!(reg.client("google").is_none());
427        assert!(reg.slugs().is_empty());
428    }
429
430    #[test]
431    fn merge_json_merges_objects_and_keeps_existing_on_null() {
432        let mut a = serde_json::json!({"email": "a@x", "groups": ["a"]});
433        let b = serde_json::json!({"email": serde_json::Value::Null, "name": "Alice"});
434        merge_json(&mut a, b);
435        assert_eq!(a["email"], "a@x");
436        assert_eq!(a["name"], "Alice");
437        assert_eq!(a["groups"], serde_json::json!(["a"]));
438    }
439
440    #[test]
441    fn upstream_provider_record_is_clonable() {
442        let p = UpstreamProvider {
443            slug: "google".to_string(),
444            issuer: "https://accounts.google.com".to_string(),
445            client_id: "client".to_string(),
446            client_secret: "secret".to_string(),
447            scopes: vec!["openid".to_string(), "email".to_string()],
448            auth_params: BTreeMap::new(),
449        };
450        let dup = p.clone();
451        assert_eq!(p, dup);
452    }
453
454    /// Discovery against an unreachable URL should fail with `Error::Oidc`,
455    /// not panic. We don't network out from unit tests; this just exercises
456    /// the error path.
457    #[tokio::test]
458    async fn discover_unreachable_issuer_returns_oidc_error() {
459        let reg = OidcRegistry::new();
460        let provider = UpstreamProvider {
461            slug: "ghost".to_string(),
462            issuer: "http://127.0.0.1:1/oidc".to_string(),
463            client_id: "client".to_string(),
464            client_secret: "secret".to_string(),
465            scopes: vec!["openid".to_string()],
466            auth_params: BTreeMap::new(),
467        };
468        let redirect = Url::parse("https://example.com/login/ghost/callback").unwrap();
469        let result = reg.add(provider, redirect).await;
470        assert!(matches!(result, Err(Error::Oidc(_))));
471    }
472}