Skip to main content

arcly_http_identity/federation/
idtoken.rs

1//! Inbound OIDC `id_token` verification — the difference between "social login"
2//! and real *federation*.
3//!
4//! Consuming a provider's OAuth2 access token + userinfo endpoint (what the
5//! example's `OAuth2Provider`s do) proves the app can *call the provider*, not
6//! that the end user authenticated for *this* app. Proper OIDC verifies the
7//! signed `id_token`: signature against the provider's JWKS, `iss`, `aud`
8//! (== this app's client id), `exp`, and the `nonce` the app planted at
9//! `/authorize` (replay defence).
10//!
11//! Network stays out of the crate: the app fetches the provider JWKS (cached)
12//! and hands the public keys in. Verification itself is done here with
13//! `jsonwebtoken`.
14
15use async_trait::async_trait;
16use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
17use serde::Deserialize;
18
19use crate::error::{IdentityError, Result};
20
21/// The verified, trusted subset of an `id_token`'s claims.
22#[derive(Debug, Clone)]
23pub struct VerifiedIdToken {
24    pub iss: String,
25    pub sub: String,
26    pub aud: String,
27    pub email: Option<String>,
28    pub email_verified: bool,
29    pub name: Option<String>,
30    pub nonce: Option<String>,
31    /// Any additional claims (e.g. `groups`, `hd`) for attribute/role mapping.
32    pub extra: serde_json::Map<String, serde_json::Value>,
33}
34
35/// Verifies inbound provider `id_token`s. Implement over your JWKS cache; a
36/// ready [`JwtIdTokenVerifier`] is provided for the common case.
37#[async_trait]
38pub trait IdTokenVerifier: Send + Sync + 'static {
39    /// Verify signature + `iss`/`aud`/`exp` and, when `expected_nonce` is set,
40    /// that the token's `nonce` matches. Returns the trusted claims.
41    async fn verify(&self, id_token: &str, expected_nonce: Option<&str>)
42        -> Result<VerifiedIdToken>;
43}
44
45#[derive(Debug, Deserialize)]
46struct RawIdClaims {
47    iss: String,
48    sub: String,
49    // `aud` is validated by `jsonwebtoken` against the configured audience
50    // (string or array), so it isn't re-read here.
51    #[serde(default)]
52    email: Option<String>,
53    #[serde(default)]
54    email_verified: Option<serde_json::Value>,
55    #[serde(default)]
56    name: Option<String>,
57    #[serde(default)]
58    nonce: Option<String>,
59    #[serde(flatten)]
60    extra: serde_json::Map<String, serde_json::Value>,
61}
62
63/// A `kid`-keyed public verification key from the provider JWKS.
64struct KeyEntry {
65    kid: Option<String>,
66    alg: Algorithm,
67    key: DecodingKey,
68}
69
70/// Concrete verifier: validates `iss`/`aud`/`exp` and `nonce` against a set of
71/// provider public keys the app has fetched from the JWKS endpoint.
72pub struct JwtIdTokenVerifier {
73    issuer: String,
74    audience: String,
75    keys: Vec<KeyEntry>,
76    leeway_secs: u64,
77}
78
79impl JwtIdTokenVerifier {
80    /// `issuer` must equal the provider's `iss`; `audience` is this app's OIDC
81    /// client id (the token's `aud`).
82    pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> Self {
83        Self {
84            issuer: issuer.into(),
85            audience: audience.into(),
86            keys: Vec::new(),
87            leeway_secs: 60,
88        }
89    }
90
91    /// Add an RSA public key (PEM) from the provider JWKS, tagged with its `kid`.
92    pub fn add_rsa_pem(mut self, kid: Option<String>, alg: Algorithm, pem: &str) -> Result<Self> {
93        let key = DecodingKey::from_rsa_pem(pem.as_bytes())
94            .map_err(|e| IdentityError::Internal(format!("id_token key: {e}")))?;
95        self.keys.push(KeyEntry { kid, alg, key });
96        Ok(self)
97    }
98
99    /// Add an EC public key (PEM), tagged with its `kid`.
100    pub fn add_ec_pem(mut self, kid: Option<String>, alg: Algorithm, pem: &str) -> Result<Self> {
101        let key = DecodingKey::from_ec_pem(pem.as_bytes())
102            .map_err(|e| IdentityError::Internal(format!("id_token key: {e}")))?;
103        self.keys.push(KeyEntry { kid, alg, key });
104        Ok(self)
105    }
106
107    fn select_key(&self, kid: Option<&str>) -> Option<&KeyEntry> {
108        // Prefer an exact kid match; fall back to the sole key when the token
109        // carries no kid (some providers omit it).
110        self.keys
111            .iter()
112            .find(|k| k.kid.as_deref() == kid && kid.is_some())
113            .or_else(|| {
114                if self.keys.len() == 1 {
115                    self.keys.first()
116                } else {
117                    self.keys.iter().find(|k| k.kid.is_none())
118                }
119            })
120    }
121}
122
123#[async_trait]
124impl IdTokenVerifier for JwtIdTokenVerifier {
125    async fn verify(
126        &self,
127        id_token: &str,
128        expected_nonce: Option<&str>,
129    ) -> Result<VerifiedIdToken> {
130        let header = decode_header(id_token).map_err(|_| IdentityError::InvalidToken)?;
131        let entry = self
132            .select_key(header.kid.as_deref())
133            .ok_or(IdentityError::InvalidToken)?;
134
135        let mut validation = Validation::new(entry.alg);
136        validation.set_issuer(&[self.issuer.as_str()]);
137        validation.set_audience(&[self.audience.as_str()]);
138        validation.validate_exp = true;
139        validation.leeway = self.leeway_secs;
140
141        let data = decode::<RawIdClaims>(id_token, &entry.key, &validation)
142            .map_err(|_| IdentityError::InvalidToken)?;
143        let claims = data.claims;
144
145        // Replay defence: the nonce we planted at /authorize must come back.
146        if let Some(expected) = expected_nonce {
147            match &claims.nonce {
148                Some(n) if n == expected => {}
149                _ => return Err(IdentityError::InvalidToken),
150            }
151        }
152
153        // `aud` may be an array; jsonwebtoken already checked membership, but
154        // normalise to the configured audience for the return value.
155        let email_verified = matches!(
156            claims.email_verified,
157            Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_)) // some providers send "true"
158        ) && !matches!(
159            claims.email_verified,
160            Some(serde_json::Value::String(ref s)) if s != "true"
161        );
162
163        Ok(VerifiedIdToken {
164            iss: claims.iss,
165            sub: claims.sub,
166            aud: self.audience.clone(),
167            email: claims.email,
168            email_verified,
169            name: claims.name,
170            nonce: claims.nonce,
171            extra: claims.extra,
172        })
173    }
174}