Skip to main content

isb_server/auth/
oidc.rs

1//! OpenID Connect pieces: the discovery document, JWKS parsing, and ID token
2//! verification (RS256 and ES256 with `ring`).
3//!
4//! An ID token is accepted only when its signature verifies with a key from
5//! the issuer's JWKS, `iss` is the discovered issuer exactly, `aud` holds the
6//! client id (with `azp` equal to it when there are several audiences), `exp`
7//! has not passed and `iat` is not in the future (60s leeway each), and
8//! `nonce` is the one this sign-in sent.
9
10use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use serde::Deserialize;
13use serde_json::Value;
14
15const LEEWAY: i64 = 60;
16const MAX_TOKEN_BYTES: usize = 32 * 1024;
17
18/// The parts of `/.well-known/openid-configuration` isb uses.
19#[derive(Debug, Clone, Deserialize)]
20pub struct Discovery {
21    pub issuer: String,
22    pub authorization_endpoint: String,
23    pub token_endpoint: String,
24    pub jwks_uri: String,
25    #[serde(default)]
26    pub userinfo_endpoint: Option<String>,
27    #[serde(default)]
28    pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
29}
30
31impl Discovery {
32    /// Parse and check a discovery document against the configured issuer:
33    /// the issuer must match (a trailing slash aside) and every endpoint
34    /// must be https (or loopback http, for tests and local providers).
35    pub fn parse(body: &[u8], configured_issuer: &str) -> Result<Discovery, String> {
36        let d: Discovery =
37            serde_json::from_slice(body).map_err(|e| format!("discovery document: {e}"))?;
38        if d.issuer.trim_end_matches('/') != configured_issuer.trim_end_matches('/') {
39            return Err(format!(
40                "discovery document names issuer {:?}, not {configured_issuer:?}",
41                d.issuer
42            ));
43        }
44        for u in [&d.authorization_endpoint, &d.token_endpoint, &d.jwks_uri] {
45            super::oauth::check_url(u)?;
46        }
47        if let Some(u) = &d.userinfo_endpoint {
48            super::oauth::check_url(u)?;
49        }
50        Ok(d)
51    }
52
53    /// `client_secret_basic` unless the provider only lists
54    /// `client_secret_post` (basic is the default when nothing is listed).
55    pub fn prefers_post(&self) -> bool {
56        self.token_endpoint_auth_methods_supported
57            .as_ref()
58            .is_some_and(|m| {
59                m.iter().any(|x| x == "client_secret_post")
60                    && !m.iter().any(|x| x == "client_secret_basic")
61            })
62    }
63}
64
65/// The signing keys of a JWKS, with their key ids.
66pub type Keys = Vec<(Option<String>, Jwk)>;
67
68/// A signing key from a JWKS.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Jwk {
71    Rsa {
72        n: Vec<u8>,
73        e: Vec<u8>,
74    },
75    /// An uncompressed P-256 point.
76    P256(Vec<u8>),
77}
78
79/// The signing keys in a JWKS, with their key ids. Keys of other types or
80/// curves, and keys marked for encryption, are skipped.
81pub fn parse_jwks(body: &[u8]) -> Result<Keys, String> {
82    #[derive(Deserialize)]
83    struct Doc {
84        keys: Vec<Raw>,
85    }
86    #[derive(Deserialize)]
87    struct Raw {
88        #[serde(default)]
89        kty: String,
90        kid: Option<String>,
91        #[serde(rename = "use")]
92        use_: Option<String>,
93        crv: Option<String>,
94        n: Option<String>,
95        e: Option<String>,
96        x: Option<String>,
97        y: Option<String>,
98    }
99    let doc: Doc = serde_json::from_slice(body).map_err(|e| format!("JWKS: {e}"))?;
100    let dec = |s: &Option<String>| s.as_deref().and_then(|s| URL_SAFE_NO_PAD.decode(s).ok());
101    let mut out = Vec::new();
102    for k in doc.keys {
103        if k.use_.as_deref().is_some_and(|u| u != "sig") {
104            continue;
105        }
106        let key = match k.kty.as_str() {
107            "RSA" => match (dec(&k.n), dec(&k.e)) {
108                (Some(n), Some(e)) if n.len() >= 256 && !e.is_empty() && e.len() <= 4 => {
109                    Jwk::Rsa { n, e }
110                }
111                _ => continue,
112            },
113            "EC" if k.crv.as_deref() == Some("P-256") => match (dec(&k.x), dec(&k.y)) {
114                (Some(x), Some(y)) if x.len() == 32 && y.len() == 32 => {
115                    let mut p = vec![4];
116                    p.extend(x);
117                    p.extend(y);
118                    Jwk::P256(p)
119                }
120                _ => continue,
121            },
122            _ => continue,
123        };
124        out.push((k.kid.filter(|s| !s.is_empty()), key));
125    }
126    if out.is_empty() {
127        return Err("JWKS has no usable RS256 or ES256 signing keys".into());
128    }
129    Ok(out)
130}
131
132/// What a verified ID token says about the user.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct IdClaims {
135    pub sub: String,
136    pub email: Option<String>,
137    pub email_verified: bool,
138    pub name: Option<String>,
139}
140
141/// Why a token was not checked: its key is not in the JWKS we hold (fetch
142/// the JWKS again and retry), or it is simply bad.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum TokenError {
145    UnknownKey,
146    Bad(String),
147}
148
149impl std::fmt::Display for TokenError {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            TokenError::UnknownKey => f.write_str("the ID token's signing key is not published"),
153            TokenError::Bad(s) => f.write_str(s),
154        }
155    }
156}
157
158fn bad<T>(s: impl Into<String>) -> Result<T, TokenError> {
159    Err(TokenError::Bad(s.into()))
160}
161
162/// `email_verified` is a boolean, but some providers send the string.
163pub fn truthy(v: Option<&Value>) -> bool {
164    match v {
165        Some(Value::Bool(b)) => *b,
166        Some(Value::String(s)) => s.eq_ignore_ascii_case("true"),
167        _ => false,
168    }
169}
170
171/// Verify an ID token. See the module docs for what is checked.
172#[expect(
173    clippy::too_many_lines,
174    reason = "predates the lint ratchet; split it when next changed"
175)]
176pub fn verify_id_token(
177    token: &str,
178    keys: &[(Option<String>, Jwk)],
179    issuer: &str,
180    client_id: &str,
181    nonce: &str,
182    now: i64,
183) -> Result<IdClaims, TokenError> {
184    if token.len() > MAX_TOKEN_BYTES {
185        return bad("ID token too large");
186    }
187    let mut parts = token.split('.');
188    let (Some(h64), Some(p64), Some(s64), None) =
189        (parts.next(), parts.next(), parts.next(), parts.next())
190    else {
191        return bad("ID token is not a compact JWS");
192    };
193    let json = |part: &str, what: &str| -> Result<Value, TokenError> {
194        let b = URL_SAFE_NO_PAD
195            .decode(part)
196            .map_err(|_| TokenError::Bad(format!("ID token {what} is not base64url")))?;
197        serde_json::from_slice(&b).map_err(|e| TokenError::Bad(format!("ID token {what}: {e}")))
198    };
199    let header = json(h64, "header")?;
200    if header.get("crit").is_some() {
201        return bad("ID token has an unsupported critical header");
202    }
203    let alg = header.get("alg").and_then(Value::as_str).unwrap_or("");
204    let kid = header.get("kid").and_then(Value::as_str);
205    let sig = URL_SAFE_NO_PAD
206        .decode(s64)
207        .map_err(|_| TokenError::Bad("ID token signature is not base64url".into()))?;
208    let signed = &token.as_bytes()[..h64.len() + 1 + p64.len()];
209    let candidates: Vec<&Jwk> = keys
210        .iter()
211        .filter(|(k, key)| {
212            let alg_ok = matches!(
213                (alg, key),
214                ("RS256", Jwk::Rsa { .. }) | ("ES256", Jwk::P256(_))
215            );
216            alg_ok && (kid.is_none() || k.as_deref() == kid)
217        })
218        .map(|(_, k)| k)
219        .collect();
220    if !matches!(alg, "RS256" | "ES256") {
221        return bad(format!("ID token algorithm {alg:?} is not RS256 or ES256"));
222    }
223    if candidates.is_empty() {
224        return Err(TokenError::UnknownKey);
225    }
226    use ring::signature as s;
227    let ok = candidates.iter().any(|k| match k {
228        Jwk::Rsa { n, e } => s::RsaPublicKeyComponents { n, e }
229            .verify(&s::RSA_PKCS1_2048_8192_SHA256, signed, &sig)
230            .is_ok(),
231        Jwk::P256(p) => s::UnparsedPublicKey::new(&s::ECDSA_P256_SHA256_FIXED, p)
232            .verify(signed, &sig)
233            .is_ok(),
234    });
235    if !ok {
236        return bad("ID token signature does not verify");
237    }
238
239    let c = json(p64, "claims")?;
240    let str_claim = |k: &str| c.get(k).and_then(Value::as_str);
241    if str_claim("iss") != Some(issuer) {
242        return bad(format!(
243            "ID token issuer {:?} is not {issuer:?}",
244            str_claim("iss")
245        ));
246    }
247    let auds: Vec<&str> = match c.get("aud") {
248        Some(Value::String(a)) => vec![a.as_str()],
249        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
250        _ => vec![],
251    };
252    if !auds.contains(&client_id) {
253        return bad("ID token audience does not include this client");
254    }
255    match str_claim("azp") {
256        Some(azp) if azp != client_id => return bad("ID token azp is another client"),
257        None if auds.len() > 1 => return bad("ID token has several audiences and no azp"),
258        _ => {}
259    }
260    let num = |k: &str| c.get(k).and_then(Value::as_f64).map(|f| f as i64);
261    match num("exp") {
262        None => return bad("ID token has no exp"),
263        Some(exp) if now > exp + LEEWAY => return bad("ID token has expired"),
264        _ => {}
265    }
266    match num("iat") {
267        None => return bad("ID token has no iat"),
268        Some(iat) if iat > now + LEEWAY => return bad("ID token was issued in the future"),
269        _ => {}
270    }
271    if num("nbf").is_some_and(|nbf| nbf > now + LEEWAY) {
272        return bad("ID token is not valid yet");
273    }
274    let got_nonce = str_claim("nonce").unwrap_or("");
275    if !super::secret::ct_eq(got_nonce.as_bytes(), nonce.as_bytes()) {
276        return bad("ID token nonce does not match this sign-in");
277    }
278    let sub = str_claim("sub").filter(|s| !s.is_empty());
279    let Some(sub) = sub else {
280        return bad("ID token has no sub");
281    };
282    Ok(IdClaims {
283        sub: sub.to_string(),
284        email: str_claim("email").map(str::to_string),
285        email_verified: truthy(c.get("email_verified")),
286        name: str_claim("name").map(str::to_string),
287    })
288}
289
290/// Signing helpers for tests: ES256 keys generated in-test, RS256 with the
291/// crate's test key.
292#[cfg(test)]
293pub(crate) mod testkit {
294    use super::*;
295    use ring::rand::SystemRandom;
296    use ring::signature::{self as s, EcdsaKeyPair, KeyPair, RsaKeyPair};
297
298    pub enum Signer {
299        Es256(EcdsaKeyPair),
300        Rs256(RsaKeyPair),
301    }
302
303    impl Signer {
304        pub fn es256() -> Signer {
305            let rng = SystemRandom::new();
306            let pk8 =
307                EcdsaKeyPair::generate_pkcs8(&s::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
308            Signer::Es256(
309                EcdsaKeyPair::from_pkcs8(&s::ECDSA_P256_SHA256_FIXED_SIGNING, pk8.as_ref(), &rng)
310                    .unwrap(),
311            )
312        }
313
314        pub fn rs256() -> Signer {
315            Signer::Rs256(
316                RsaKeyPair::from_pkcs8(include_bytes!("../server/testdata/access_test_key.pk8"))
317                    .unwrap(),
318            )
319        }
320
321        pub fn jwk(&self, kid: &str) -> Value {
322            let b = |x: &[u8]| URL_SAFE_NO_PAD.encode(x);
323            match self {
324                Signer::Es256(kp) => {
325                    let p = kp.public_key().as_ref();
326                    serde_json::json!({"kty": "EC", "crv": "P-256", "kid": kid, "use": "sig",
327                        "x": b(&p[1..33]), "y": b(&p[33..65])})
328                }
329                Signer::Rs256(kp) => {
330                    let p = ring::rsa::PublicKeyComponents::<Vec<u8>>::from(kp.public());
331                    serde_json::json!({"kty": "RSA", "kid": kid, "alg": "RS256",
332                        "n": b(&p.n), "e": b(&p.e)})
333                }
334            }
335        }
336
337        pub fn sign(&self, kid: &str, claims: &Value) -> String {
338            let alg = match self {
339                Signer::Es256(_) => "ES256",
340                Signer::Rs256(_) => "RS256",
341            };
342            let enc = |v: &Value| URL_SAFE_NO_PAD.encode(serde_json::to_vec(v).unwrap());
343            let input = format!(
344                "{}.{}",
345                enc(&serde_json::json!({"alg": alg, "kid": kid, "typ": "JWT"})),
346                enc(claims)
347            );
348            let rng = SystemRandom::new();
349            let sig = match self {
350                Signer::Es256(kp) => kp.sign(&rng, input.as_bytes()).unwrap().as_ref().to_vec(),
351                Signer::Rs256(kp) => {
352                    let mut sig = vec![0; kp.public().modulus_len()];
353                    kp.sign(&s::RSA_PKCS1_SHA256, &rng, input.as_bytes(), &mut sig)
354                        .unwrap();
355                    sig
356                }
357            };
358            format!("{input}.{}", URL_SAFE_NO_PAD.encode(sig))
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::testkit::Signer;
366    use super::*;
367    use serde_json::json;
368
369    const ISS: &str = "https://id.example.com";
370    const NOW: i64 = 1_800_000_000;
371
372    fn claims() -> Value {
373        json!({"iss": ISS, "aud": "client-1", "sub": "u-1", "exp": NOW + 300, "iat": NOW,
374               "nonce": "n-1", "email": "a@x.io", "email_verified": true, "name": "A"})
375    }
376
377    fn keys(s: &Signer, kid: &str) -> Vec<(Option<String>, Jwk)> {
378        parse_jwks(&serde_json::to_vec(&json!({"keys": [s.jwk(kid)]})).unwrap()).unwrap()
379    }
380
381    #[test]
382    fn verifies_es256_and_rs256() {
383        for s in [Signer::es256(), Signer::rs256()] {
384            let k = keys(&s, "k1");
385            let t = s.sign("k1", &claims());
386            let c = verify_id_token(&t, &k, ISS, "client-1", "n-1", NOW).unwrap();
387            assert_eq!(c.sub, "u-1");
388            assert_eq!(c.email.as_deref(), Some("a@x.io"));
389            assert!(c.email_verified);
390            // Unknown kid asks for a JWKS refetch.
391            let t = s.sign("k2", &claims());
392            assert_eq!(
393                verify_id_token(&t, &k, ISS, "client-1", "n-1", NOW),
394                Err(TokenError::UnknownKey)
395            );
396        }
397    }
398
399    #[test]
400    fn checks_claims() {
401        let s = Signer::es256();
402        let k = keys(&s, "k1");
403        let check = |patch: Value, nonce: &str| {
404            let mut c = claims();
405            for (key, v) in patch.as_object().unwrap() {
406                if v.is_null() {
407                    c.as_object_mut().unwrap().remove(key);
408                } else {
409                    c[key] = v.clone();
410                }
411            }
412            verify_id_token(&s.sign("k1", &c), &k, ISS, "client-1", nonce, NOW)
413                .map_err(|e| e.to_string())
414        };
415        assert!(check(json!({}), "n-1").is_ok());
416        assert!(check(json!({}), "n-2").unwrap_err().contains("nonce"));
417        assert!(
418            check(json!({"nonce": null}), "n-1")
419                .unwrap_err()
420                .contains("nonce")
421        );
422        assert!(
423            check(json!({"iss": "https://evil"}), "n-1")
424                .unwrap_err()
425                .contains("issuer")
426        );
427        assert!(
428            check(json!({"aud": "other"}), "n-1")
429                .unwrap_err()
430                .contains("audience")
431        );
432        assert!(
433            check(json!({"aud": ["client-1", "other"]}), "n-1")
434                .unwrap_err()
435                .contains("azp")
436        );
437        assert!(
438            check(
439                json!({"aud": ["client-1", "other"], "azp": "client-1"}),
440                "n-1"
441            )
442            .is_ok()
443        );
444        assert!(
445            check(json!({"exp": NOW - 61}), "n-1")
446                .unwrap_err()
447                .contains("expired")
448        );
449        assert!(
450            check(json!({"exp": null}), "n-1")
451                .unwrap_err()
452                .contains("exp")
453        );
454        assert!(
455            check(json!({"iat": NOW + 600}), "n-1")
456                .unwrap_err()
457                .contains("future")
458        );
459        assert!(
460            check(json!({"sub": ""}), "n-1")
461                .unwrap_err()
462                .contains("sub")
463        );
464        let c = check(json!({"email_verified": "true"}), "n-1");
465        assert!(c.is_ok());
466        let c = verify_id_token(
467            &s.sign(
468                "k1",
469                &json!({"iss": ISS, "aud": "client-1", "sub": "s", "exp": NOW + 9,
470                                 "iat": NOW, "nonce": "n-1", "email_verified": false}),
471            ),
472            &k,
473            ISS,
474            "client-1",
475            "n-1",
476            NOW,
477        )
478        .unwrap();
479        assert!(!c.email_verified && c.email.is_none());
480    }
481
482    #[test]
483    fn refuses_tampering_and_other_algorithms() {
484        let s = Signer::es256();
485        let k = keys(&s, "k1");
486        let t = s.sign("k1", &claims());
487        let mut parts: Vec<String> = t.split('.').map(str::to_string).collect();
488        let mut c = claims();
489        c["sub"] = json!("admin");
490        parts[1] = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&c).unwrap());
491        let forged = parts.join(".");
492        assert!(matches!(
493            verify_id_token(&forged, &k, ISS, "client-1", "n-1", NOW),
494            Err(TokenError::Bad(_))
495        ));
496        // alg none / HS256 are refused outright.
497        let none = format!(
498            "{}.{}.",
499            URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#),
500            parts[1]
501        );
502        assert!(
503            verify_id_token(&none, &k, ISS, "client-1", "n-1", NOW)
504                .unwrap_err()
505                .to_string()
506                .contains("algorithm")
507        );
508    }
509
510    #[test]
511    fn discovery_is_checked() {
512        let doc = |iss: &str, ep: &str| {
513            serde_json::to_vec(&json!({"issuer": iss, "authorization_endpoint": ep,
514                "token_endpoint": ep, "jwks_uri": ep}))
515            .unwrap()
516        };
517        assert!(Discovery::parse(&doc(ISS, "https://id.example.com/x"), ISS).is_ok());
518        assert!(
519            Discovery::parse(
520                &doc("https://id.example.com/", "https://id.example.com/x"),
521                ISS
522            )
523            .is_ok()
524        );
525        assert!(Discovery::parse(&doc("https://evil", "https://id.example.com/x"), ISS).is_err());
526        assert!(Discovery::parse(&doc(ISS, "http://id.example.com/x"), ISS).is_err());
527        assert!(Discovery::parse(&doc(ISS, "http://127.0.0.1:9/x"), ISS).is_ok());
528    }
529}