Skip to main content

firebase_admin/auth/id_token/
verifier.rs

1//! Firebase ID token verification.
2//!
3//! See <https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library>
4//! for the checks a compliant verifier must perform.
5
6use crate::auth::error::TokenVerificationError;
7use crate::auth::id_token::claims::IdTokenClaims;
8use crate::auth::id_token::jwks::JwksCache;
9use crate::core::ProjectId;
10use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
11
12/// Verifies Firebase ID tokens against Google's public keys and the
13/// project's expected issuer/audience.
14pub struct IdTokenVerifier {
15    project_id: ProjectId,
16    jwks: JwksCache,
17}
18
19impl IdTokenVerifier {
20    /// Creates a verifier for the given project, using the given JWKS cache.
21    pub fn new(project_id: ProjectId, jwks: JwksCache) -> Self {
22        Self { project_id, jwks }
23    }
24
25    /// Verifies an ID token, returning its claims if every check passes:
26    /// signature, `exp`, `iat`/`auth_time`, `aud`, `iss`, and non-empty `sub`.
27    pub async fn verify(&self, token: &str) -> Result<IdTokenClaims, TokenVerificationError> {
28        let header = decode_header(token)?;
29
30        if header.alg != Algorithm::RS256 {
31            return Err(TokenVerificationError::InvalidSignature);
32        }
33
34        let kid = header.kid.ok_or(TokenVerificationError::InvalidSignature)?;
35        let (n, e) = self.jwks.public_key(&kid).await?;
36        let decoding_key = DecodingKey::from_rsa_components(&n, &e)
37            .map_err(|_| TokenVerificationError::InvalidSignature)?;
38
39        verify_with_key(
40            token,
41            &decoding_key,
42            self.project_id.as_str(),
43            "https://securetoken.google.com/",
44        )
45    }
46}
47
48/// Verifies a token's signature and claims against an already-resolved
49/// [`DecodingKey`], independent of how that key was obtained. Split out from
50/// [`IdTokenVerifier::verify`] so unit tests can exercise claim-validation
51/// logic with fixture keys, without needing a live JWKS fetch.
52///
53/// `issuer_prefix` is parameterized (rather than hardcoded to
54/// `https://securetoken.google.com/`) so
55/// [`crate::auth::session_cookie::verify`] can reuse this exact validation
56/// logic against session cookies' different issuer
57/// (`https://session.firebase.google.com/<project-id>`) instead of
58/// duplicating it.
59pub(crate) fn verify_with_key(
60    token: &str,
61    decoding_key: &DecodingKey,
62    project_id: &str,
63    issuer_prefix: &str,
64) -> Result<IdTokenClaims, TokenVerificationError> {
65    let expected_issuer = format!("{issuer_prefix}{project_id}");
66    let mut validation = Validation::new(Algorithm::RS256);
67    validation.set_audience(&[project_id]);
68    validation.set_issuer(&[expected_issuer]);
69    validation.validate_exp = true;
70
71    let token_data =
72        decode::<IdTokenClaims>(token, decoding_key, &validation).map_err(|e| match e.kind() {
73            jsonwebtoken::errors::ErrorKind::ExpiredSignature => TokenVerificationError::Expired,
74            jsonwebtoken::errors::ErrorKind::InvalidAudience => {
75                TokenVerificationError::AudienceMismatch
76            }
77            jsonwebtoken::errors::ErrorKind::InvalidIssuer => {
78                TokenVerificationError::IssuerMismatch
79            }
80            jsonwebtoken::errors::ErrorKind::InvalidSignature => {
81                TokenVerificationError::InvalidSignature
82            }
83            _ => TokenVerificationError::Malformed(e),
84        })?;
85
86    let claims = token_data.claims;
87
88    if claims.sub.trim().is_empty() {
89        return Err(TokenVerificationError::MissingSubject);
90    }
91
92    let now = std::time::SystemTime::now()
93        .duration_since(std::time::UNIX_EPOCH)
94        .expect("system clock is before the Unix epoch")
95        .as_secs() as i64;
96
97    if claims.auth_time > now {
98        return Err(TokenVerificationError::NotYetValid);
99    }
100
101    Ok(claims)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use jsonwebtoken::{encode, EncodingKey, Header};
108    use serde_json::json;
109
110    const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../tests/fixtures/test_private_key.pem");
111    const TEST_PROJECT_ID: &str = "test-project";
112
113    fn sign(claims: &serde_json::Value, alg: Algorithm) -> String {
114        let mut header = Header::new(alg);
115        header.kid = Some("test-key".to_string());
116        let key = EncodingKey::from_rsa_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).unwrap();
117        encode(&header, claims, &key).unwrap()
118    }
119
120    fn decoding_key() -> DecodingKey {
121        DecodingKey::from_rsa_pem(include_bytes!(
122            "../../../tests/fixtures/test_public_key.pem"
123        ))
124        .unwrap()
125    }
126
127    fn valid_claims(now: i64) -> serde_json::Value {
128        json!({
129            "iss": format!("https://securetoken.google.com/{TEST_PROJECT_ID}"),
130            "aud": TEST_PROJECT_ID,
131            "iat": now - 10,
132            "exp": now + 3600,
133            "auth_time": now - 10,
134            "sub": "test-uid",
135        })
136    }
137
138    fn now() -> i64 {
139        std::time::SystemTime::now()
140            .duration_since(std::time::UNIX_EPOCH)
141            .unwrap()
142            .as_secs() as i64
143    }
144
145    #[test]
146    fn accepts_a_valid_token() {
147        let token = sign(&valid_claims(now()), Algorithm::RS256);
148        let claims = verify_with_key(
149            &token,
150            &decoding_key(),
151            TEST_PROJECT_ID,
152            "https://securetoken.google.com/",
153        )
154        .unwrap();
155        assert_eq!(claims.sub, "test-uid");
156    }
157
158    #[test]
159    fn rejects_expired_token() {
160        let mut claims = valid_claims(now());
161        claims["exp"] = json!(now() - 100);
162        let token = sign(&claims, Algorithm::RS256);
163        let err = verify_with_key(
164            &token,
165            &decoding_key(),
166            TEST_PROJECT_ID,
167            "https://securetoken.google.com/",
168        )
169        .unwrap_err();
170        assert!(matches!(err, TokenVerificationError::Expired));
171    }
172
173    #[test]
174    fn rejects_wrong_audience() {
175        let mut claims = valid_claims(now());
176        claims["aud"] = json!("some-other-project");
177        let token = sign(&claims, Algorithm::RS256);
178        let err = verify_with_key(
179            &token,
180            &decoding_key(),
181            TEST_PROJECT_ID,
182            "https://securetoken.google.com/",
183        )
184        .unwrap_err();
185        assert!(matches!(err, TokenVerificationError::AudienceMismatch));
186    }
187
188    #[test]
189    fn rejects_wrong_issuer() {
190        let mut claims = valid_claims(now());
191        claims["iss"] = json!("https://securetoken.google.com/some-other-project");
192        let token = sign(&claims, Algorithm::RS256);
193        let err = verify_with_key(
194            &token,
195            &decoding_key(),
196            TEST_PROJECT_ID,
197            "https://securetoken.google.com/",
198        )
199        .unwrap_err();
200        assert!(matches!(err, TokenVerificationError::IssuerMismatch));
201    }
202
203    #[test]
204    fn rejects_missing_subject() {
205        let mut claims = valid_claims(now());
206        claims["sub"] = json!("");
207        let token = sign(&claims, Algorithm::RS256);
208        let err = verify_with_key(
209            &token,
210            &decoding_key(),
211            TEST_PROJECT_ID,
212            "https://securetoken.google.com/",
213        )
214        .unwrap_err();
215        assert!(matches!(err, TokenVerificationError::MissingSubject));
216    }
217
218    #[test]
219    fn rejects_not_yet_valid_auth_time() {
220        let mut claims = valid_claims(now());
221        claims["auth_time"] = json!(now() + 3600);
222        let token = sign(&claims, Algorithm::RS256);
223        let err = verify_with_key(
224            &token,
225            &decoding_key(),
226            TEST_PROJECT_ID,
227            "https://securetoken.google.com/",
228        )
229        .unwrap_err();
230        assert!(matches!(err, TokenVerificationError::NotYetValid));
231    }
232
233    #[test]
234    fn rejects_tampered_signature() {
235        let token = sign(&valid_claims(now()), Algorithm::RS256);
236        let mut parts: Vec<&str> = token.split('.').collect();
237        let tampered_payload = parts[1]
238            .chars()
239            .map(|c| if c == 'A' { 'B' } else { 'A' })
240            .collect::<String>();
241        parts[1] = &tampered_payload;
242        let tampered = parts.join(".");
243        let err = verify_with_key(
244            &tampered,
245            &decoding_key(),
246            TEST_PROJECT_ID,
247            "https://securetoken.google.com/",
248        )
249        .unwrap_err();
250        assert!(matches!(
251            err,
252            TokenVerificationError::InvalidSignature | TokenVerificationError::Malformed(_)
253        ));
254    }
255}