Skip to main content

assay_auth/
external_jwt.rs

1//! External JWT issuer pass-through validation.
2//!
3//! When configured, the engine accepts `Authorization: Bearer <jwt>`
4//! tokens minted by an upstream OIDC provider (Hydra, Keycloak,
5//! Auth0, …) without managing its own users. Each issuer's JWKS is
6//! discovered once via `<issuer>/.well-known/openid-configuration`,
7//! cached in memory, and used to verify incoming tokens at request
8//! time. Tokens carrying an `iss` outside the configured list are
9//! rejected — they fall through to the existing internal-JWT path.
10//!
11//! This is the v0.3.2 restoration of v0.12.1's `--auth-issuer` /
12//! `--auth-audience` behavior, configured via TOML instead of CLI
13//! flags. See the `[[auth.external_issuers]]` block in `engine.toml`.
14//!
15//! ## What it is
16//!
17//! Pass-through validation: the upstream IdP is the source of truth
18//! for users and sessions; the engine just verifies signatures and
19//! claims and treats the request as authenticated. Zero schema
20//! impact, no user-table writes, no engine-managed sessions.
21//!
22//! ## What it isn't
23//!
24//! This is **not** OIDC federation — there's no `/login/<slug>`
25//! redirect, no PKCE, no callback. For that use case see
26//! [`crate::oidc::OidcRegistry`] (operators wanting the engine to
27//! own login flow). Pass-through is for deployments where the
28//! engine sits behind another service that already terminates auth
29//! and forwards the JWT.
30
31use std::collections::HashSet;
32use std::sync::Arc;
33use std::time::Duration;
34
35use jsonwebtoken::dangerous::insecure_decode;
36use jsonwebtoken::jwk::JwkSet;
37use jsonwebtoken::{DecodingKey, Validation, decode, decode_header};
38use parking_lot::RwLock;
39use serde::de::DeserializeOwned;
40
41use crate::error::{Error, Result};
42
43/// Verifier for one external OIDC issuer. Holds a cached JWKS plus the
44/// claims policy (`iss`, `aud`) the operator configured. Construct via
45/// [`ExternalJwtIssuer::discover`] at engine boot; clone freely (the
46/// JWKS sits behind an `Arc<RwLock>`).
47#[derive(Clone)]
48pub struct ExternalJwtIssuer {
49    issuer_url: String,
50    audience: HashSet<String>,
51    jwks_uri: String,
52    jwks: Arc<RwLock<JwkSet>>,
53    refresh_interval: Duration,
54}
55
56impl ExternalJwtIssuer {
57    /// Discover the issuer's metadata (`<issuer_url>/.well-known/openid-configuration`),
58    /// fetch the initial JWKS, and return a verifier ready for use.
59    /// Spawns a background task that refreshes the JWKS every
60    /// `refresh_secs` seconds — handles upstream key rotation without
61    /// operator intervention.
62    pub async fn discover(
63        issuer_url: String,
64        audience: Vec<String>,
65        refresh_secs: u64,
66    ) -> Result<Self> {
67        let trimmed = issuer_url.trim_end_matches('/').to_string();
68        let discovery_url = format!("{trimmed}/.well-known/openid-configuration");
69
70        let client = reqwest::Client::builder()
71            .timeout(Duration::from_secs(10))
72            .build()
73            .map_err(|e| Error::Oidc(format!("build http client: {e}")))?;
74
75        let metadata: serde_json::Value = client
76            .get(&discovery_url)
77            .send()
78            .await
79            .map_err(|e| Error::Oidc(format!("discover {discovery_url}: {e}")))?
80            .error_for_status()
81            .map_err(|e| Error::Oidc(format!("discover {discovery_url}: {e}")))?
82            .json()
83            .await
84            .map_err(|e| Error::Oidc(format!("parse {discovery_url}: {e}")))?;
85
86        let jwks_uri = metadata
87            .get("jwks_uri")
88            .and_then(|v| v.as_str())
89            .ok_or_else(|| Error::Oidc(format!("{discovery_url} missing `jwks_uri` field")))?
90            .to_string();
91
92        let jwks = fetch_jwks(&client, &jwks_uri).await?;
93
94        let verifier = Self {
95            issuer_url: trimmed.clone(),
96            audience: audience.into_iter().collect(),
97            jwks_uri: jwks_uri.clone(),
98            jwks: Arc::new(RwLock::new(jwks)),
99            refresh_interval: Duration::from_secs(refresh_secs.max(60)),
100        };
101
102        verifier.spawn_refresh(client);
103        Ok(verifier)
104    }
105
106    /// `iss` claim this verifier accepts. Useful for matching incoming
107    /// tokens to the right verifier without calling [`Self::verify`]
108    /// (avoids signature work for tokens from other issuers).
109    pub fn issuer(&self) -> &str {
110        &self.issuer_url
111    }
112
113    /// Verify a JWT. The token's `iss` must match this verifier's
114    /// configured issuer; `aud` must overlap the configured audience
115    /// (or audience must be empty — operator opt-in to skip aud check).
116    /// Signature is verified against the cached JWKS, looked up by
117    /// `kid` from the JWT header.
118    pub fn verify<T: DeserializeOwned>(&self, token: &str) -> Result<jsonwebtoken::TokenData<T>> {
119        let header =
120            decode_header(token).map_err(|e| Error::Oidc(format!("decode jwt header: {e}")))?;
121        let kid = header
122            .kid
123            .as_ref()
124            .ok_or_else(|| Error::Oidc("jwt header missing `kid`".to_string()))?;
125
126        let jwk = {
127            let jwks = self.jwks.read();
128            jwks.find(kid).cloned()
129        };
130        let jwk = jwk.ok_or_else(|| {
131            Error::Oidc(format!(
132                "kid `{kid}` not in cached jwks for issuer `{}`",
133                self.issuer_url
134            ))
135        })?;
136
137        let key = DecodingKey::from_jwk(&jwk)
138            .map_err(|e| Error::Oidc(format!("build decoding key from jwk: {e}")))?;
139
140        let mut validation = Validation::new(header.alg);
141        validation.set_issuer(&[&self.issuer_url]);
142        if self.audience.is_empty() {
143            // Operator explicitly opted out of audience checking.
144            // jsonwebtoken's default validation requires `aud`, so disable it.
145            validation.validate_aud = false;
146        } else {
147            let aud: Vec<&str> = self.audience.iter().map(String::as_str).collect();
148            validation.set_audience(&aud);
149        }
150
151        decode::<T>(token, &key, &validation)
152            .map_err(|e| Error::Oidc(format!("verify jwt against `{}`: {e}", self.issuer_url)))
153    }
154
155    fn spawn_refresh(&self, client: reqwest::Client) {
156        let jwks = Arc::clone(&self.jwks);
157        let jwks_uri = self.jwks_uri.clone();
158        let interval = self.refresh_interval;
159        let issuer_url = self.issuer_url.clone();
160
161        tokio::spawn(async move {
162            loop {
163                tokio::time::sleep(interval).await;
164                match fetch_jwks(&client, &jwks_uri).await {
165                    Ok(fresh) => {
166                        *jwks.write() = fresh;
167                        tracing::debug!(
168                            target: "assay-auth::external_jwt",
169                            issuer = %issuer_url,
170                            "refreshed jwks"
171                        );
172                    }
173                    Err(e) => {
174                        tracing::warn!(
175                            target: "assay-auth::external_jwt",
176                            issuer = %issuer_url,
177                            error = %e,
178                            "failed to refresh jwks; keeping previous keys"
179                        );
180                    }
181                }
182            }
183        });
184    }
185}
186
187async fn fetch_jwks(client: &reqwest::Client, uri: &str) -> Result<JwkSet> {
188    let body: serde_json::Value = client
189        .get(uri)
190        .send()
191        .await
192        .map_err(|e| Error::Oidc(format!("fetch jwks {uri}: {e}")))?
193        .error_for_status()
194        .map_err(|e| Error::Oidc(format!("fetch jwks {uri}: {e}")))?
195        .json()
196        .await
197        .map_err(|e| Error::Oidc(format!("parse jwks {uri}: {e}")))?;
198    serde_json::from_value(body).map_err(|e| Error::Oidc(format!("decode jwks {uri}: {e}")))
199}
200
201/// Look up the verifier matching the token's `iss` claim and validate
202/// against it. Decodes the token's claims twice — once unverified to
203/// pull out `iss`, once verified — but the unverified decode is just a
204/// base64 split, so the extra cost is negligible. Returns `None` if no
205/// configured verifier accepts the token's `iss` (caller falls through
206/// to the next auth strategy).
207pub fn verify_with_any<T: DeserializeOwned>(
208    issuers: &[ExternalJwtIssuer],
209    token: &str,
210) -> Option<Result<jsonwebtoken::TokenData<T>>> {
211    if issuers.is_empty() {
212        return None;
213    }
214
215    // Unverified peek at `iss` so we route directly to the right
216    // verifier instead of trying every key set linearly. The actual
217    // signature + claim verification happens inside the matched
218    // verifier — `insecure_decode` here only parses the payload.
219    #[derive(serde::Deserialize)]
220    struct IssClaim {
221        iss: String,
222    }
223    let unverified = insecure_decode::<IssClaim>(token).ok()?;
224    let iss = unverified.claims.iss;
225    let trimmed = iss.trim_end_matches('/');
226
227    for issuer in issuers {
228        if issuer.issuer() == trimmed || issuer.issuer() == iss {
229            return Some(issuer.verify::<T>(token));
230        }
231    }
232    None
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
239    use serde::{Deserialize, Serialize};
240
241    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
242    struct TestClaims {
243        iss: String,
244        aud: String,
245        sub: String,
246        exp: usize,
247    }
248
249    /// Build a verifier with a hand-crafted JWKS — skips the discovery
250    /// network call so unit tests stay hermetic. Uses HS256 because
251    /// jsonwebtoken's `from_jwk` accepts symmetric keys, and we don't
252    /// need RSA's complexity for proving the verifier wires up.
253    fn verifier_for_tests(issuer: &str, audience: Vec<String>, jwks: JwkSet) -> ExternalJwtIssuer {
254        ExternalJwtIssuer {
255            issuer_url: issuer.trim_end_matches('/').to_string(),
256            audience: audience.into_iter().collect(),
257            jwks_uri: format!("{issuer}/jwks"),
258            jwks: Arc::new(RwLock::new(jwks)),
259            refresh_interval: Duration::from_secs(3600),
260        }
261    }
262
263    fn hs256_jwks_with_kid(kid: &str, secret: &[u8]) -> JwkSet {
264        let json = serde_json::json!({
265            "keys": [{
266                "kty": "oct",
267                "use": "sig",
268                "alg": "HS256",
269                "kid": kid,
270                "k": base64_url(secret)
271            }]
272        });
273        serde_json::from_value(json).unwrap()
274    }
275
276    fn base64_url(b: &[u8]) -> String {
277        // Hand-rolled base64url (RFC 4648 §5, no padding) so the test
278        // doesn't pull in an extra dev-dep just to encode a symmetric
279        // key into a test JWK.
280        const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
281        let mut out = Vec::with_capacity(b.len().div_ceil(3) * 4);
282        for chunk in b.chunks(3) {
283            let mut buf = [0u8; 3];
284            buf[..chunk.len()].copy_from_slice(chunk);
285            let n = u32::from_be_bytes([0, buf[0], buf[1], buf[2]]);
286            out.push(T[((n >> 18) & 0x3F) as usize]);
287            out.push(T[((n >> 12) & 0x3F) as usize]);
288            if chunk.len() >= 2 {
289                out.push(T[((n >> 6) & 0x3F) as usize]);
290            }
291            if chunk.len() == 3 {
292                out.push(T[(n & 0x3F) as usize]);
293            }
294        }
295        String::from_utf8(out).expect("ascii")
296    }
297
298    fn issue_test_token(secret: &[u8], kid: &str, claims: &TestClaims) -> String {
299        let mut header = Header::new(Algorithm::HS256);
300        header.kid = Some(kid.to_string());
301        encode(&header, claims, &EncodingKey::from_secret(secret)).unwrap()
302    }
303
304    #[test]
305    fn verifies_token_from_configured_issuer() {
306        let secret = b"unit-test-secret-key-32bytes!!!!";
307        let kid = "test-key-1";
308        let issuer = "https://hydra.example.com";
309        let aud = "test-app";
310        let claims = TestClaims {
311            iss: issuer.to_string(),
312            aud: aud.to_string(),
313            sub: "user-42".to_string(),
314            exp: (std::time::SystemTime::now()
315                .duration_since(std::time::UNIX_EPOCH)
316                .unwrap()
317                .as_secs()
318                + 3600) as usize,
319        };
320        let token = issue_test_token(secret, kid, &claims);
321
322        let v = verifier_for_tests(
323            issuer,
324            vec![aud.to_string()],
325            hs256_jwks_with_kid(kid, secret),
326        );
327        let out = v.verify::<TestClaims>(&token).unwrap();
328        assert_eq!(out.claims, claims);
329    }
330
331    #[test]
332    fn rejects_token_with_wrong_issuer() {
333        let secret = b"unit-test-secret-key-32bytes!!!!";
334        let kid = "test-key-1";
335        let claims = TestClaims {
336            iss: "https://other.example.com".to_string(),
337            aud: "test-app".to_string(),
338            sub: "user-42".to_string(),
339            exp: (std::time::SystemTime::now()
340                .duration_since(std::time::UNIX_EPOCH)
341                .unwrap()
342                .as_secs()
343                + 3600) as usize,
344        };
345        let token = issue_test_token(secret, kid, &claims);
346
347        let v = verifier_for_tests(
348            "https://hydra.example.com",
349            vec!["test-app".to_string()],
350            hs256_jwks_with_kid(kid, secret),
351        );
352        assert!(v.verify::<TestClaims>(&token).is_err());
353    }
354
355    #[test]
356    fn rejects_token_with_wrong_audience() {
357        let secret = b"unit-test-secret-key-32bytes!!!!";
358        let kid = "test-key-1";
359        let issuer = "https://hydra.example.com";
360        let claims = TestClaims {
361            iss: issuer.to_string(),
362            aud: "some-other-app".to_string(),
363            sub: "user-42".to_string(),
364            exp: (std::time::SystemTime::now()
365                .duration_since(std::time::UNIX_EPOCH)
366                .unwrap()
367                .as_secs()
368                + 3600) as usize,
369        };
370        let token = issue_test_token(secret, kid, &claims);
371
372        let v = verifier_for_tests(
373            issuer,
374            vec!["test-app".to_string()],
375            hs256_jwks_with_kid(kid, secret),
376        );
377        assert!(v.verify::<TestClaims>(&token).is_err());
378    }
379
380    #[test]
381    fn rejects_token_with_unknown_kid() {
382        let secret = b"unit-test-secret-key-32bytes!!!!";
383        let issuer = "https://hydra.example.com";
384        let claims = TestClaims {
385            iss: issuer.to_string(),
386            aud: "test-app".to_string(),
387            sub: "user-42".to_string(),
388            exp: (std::time::SystemTime::now()
389                .duration_since(std::time::UNIX_EPOCH)
390                .unwrap()
391                .as_secs()
392                + 3600) as usize,
393        };
394        // Token signed with kid="rotated-key" but JWKS only knows "current-key".
395        let token = issue_test_token(secret, "rotated-key", &claims);
396
397        let v = verifier_for_tests(
398            issuer,
399            vec!["test-app".to_string()],
400            hs256_jwks_with_kid("current-key", secret),
401        );
402        let err = v.verify::<TestClaims>(&token).unwrap_err().to_string();
403        assert!(err.contains("kid"), "error should mention kid: {err}");
404    }
405
406    #[test]
407    fn rejects_expired_token() {
408        let secret = b"unit-test-secret-key-32bytes!!!!";
409        let kid = "test-key-1";
410        let issuer = "https://hydra.example.com";
411        let claims = TestClaims {
412            iss: issuer.to_string(),
413            aud: "test-app".to_string(),
414            sub: "user-42".to_string(),
415            // 1 hour ago — already expired.
416            exp: (std::time::SystemTime::now()
417                .duration_since(std::time::UNIX_EPOCH)
418                .unwrap()
419                .as_secs()
420                - 3600) as usize,
421        };
422        let token = issue_test_token(secret, kid, &claims);
423
424        let v = verifier_for_tests(
425            issuer,
426            vec!["test-app".to_string()],
427            hs256_jwks_with_kid(kid, secret),
428        );
429        assert!(v.verify::<TestClaims>(&token).is_err());
430    }
431
432    #[test]
433    fn empty_audience_list_skips_aud_check() {
434        let secret = b"unit-test-secret-key-32bytes!!!!";
435        let kid = "test-key-1";
436        let issuer = "https://hydra.example.com";
437        let claims = TestClaims {
438            iss: issuer.to_string(),
439            aud: "literally-anything".to_string(),
440            sub: "user-42".to_string(),
441            exp: (std::time::SystemTime::now()
442                .duration_since(std::time::UNIX_EPOCH)
443                .unwrap()
444                .as_secs()
445                + 3600) as usize,
446        };
447        let token = issue_test_token(secret, kid, &claims);
448
449        // No audience configured → operator opted out of `aud` checking.
450        let v = verifier_for_tests(issuer, vec![], hs256_jwks_with_kid(kid, secret));
451        assert!(v.verify::<TestClaims>(&token).is_ok());
452    }
453
454    #[test]
455    fn verify_with_any_routes_by_iss() {
456        let secret_a = b"key-A-secret-32bytes-unit-tests!";
457        let secret_b = b"key-B-secret-32bytes-unit-tests!";
458        let issuer_a = "https://hydra-a.example.com";
459        let issuer_b = "https://hydra-b.example.com";
460
461        let v_a = verifier_for_tests(
462            issuer_a,
463            vec!["test-app".to_string()],
464            hs256_jwks_with_kid("a-key", secret_a),
465        );
466        let v_b = verifier_for_tests(
467            issuer_b,
468            vec!["test-app".to_string()],
469            hs256_jwks_with_kid("b-key", secret_b),
470        );
471
472        let claims_b = TestClaims {
473            iss: issuer_b.to_string(),
474            aud: "test-app".to_string(),
475            sub: "user-42".to_string(),
476            exp: (std::time::SystemTime::now()
477                .duration_since(std::time::UNIX_EPOCH)
478                .unwrap()
479                .as_secs()
480                + 3600) as usize,
481        };
482        let token_b = issue_test_token(secret_b, "b-key", &claims_b);
483
484        // Token from issuer B should route to verifier B and succeed.
485        let result = verify_with_any::<TestClaims>(&[v_a, v_b], &token_b)
486            .expect("verifier should match issuer_b")
487            .expect("verification should succeed");
488        assert_eq!(result.claims, claims_b);
489    }
490
491    #[test]
492    fn verify_with_any_returns_none_for_unknown_issuer() {
493        let secret = b"unit-test-secret-key-32bytes!!!!";
494        let v = verifier_for_tests(
495            "https://hydra.example.com",
496            vec!["test-app".to_string()],
497            hs256_jwks_with_kid("a-key", secret),
498        );
499        let claims = TestClaims {
500            iss: "https://stranger.example.com".to_string(),
501            aud: "test-app".to_string(),
502            sub: "user-42".to_string(),
503            exp: (std::time::SystemTime::now()
504                .duration_since(std::time::UNIX_EPOCH)
505                .unwrap()
506                .as_secs()
507                + 3600) as usize,
508        };
509        let token = issue_test_token(secret, "a-key", &claims);
510
511        let result = verify_with_any::<TestClaims>(&[v], &token);
512        assert!(result.is_none(), "unknown issuer should fall through");
513    }
514
515    #[test]
516    fn verify_with_any_returns_none_for_empty_issuer_list() {
517        let secret = b"unit-test-secret-key-32bytes!!!!";
518        let claims = TestClaims {
519            iss: "https://anywhere.example.com".to_string(),
520            aud: "test-app".to_string(),
521            sub: "user-42".to_string(),
522            exp: 9999999999,
523        };
524        let token = issue_test_token(secret, "x", &claims);
525        assert!(verify_with_any::<TestClaims>(&[], &token).is_none());
526    }
527}