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}