1use 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#[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 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 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
65pub type Keys = Vec<(Option<String>, Jwk)>;
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Jwk {
71 Rsa {
72 n: Vec<u8>,
73 e: Vec<u8>,
74 },
75 P256(Vec<u8>),
77}
78
79pub 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#[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#[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
162pub 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#[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#[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 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 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}