Skip to main content

firebase_admin/auth/session_cookie/
verify.rs

1//! Session cookie verification.
2//!
3//! Session cookies are RS256-signed JWTs like ID tokens, but with two
4//! differences confirmed against the official Node.js and Python Admin SDK
5//! source (`COOKIE_CERT_URI`/`COOKIE_ISSUER_PREFIX` vs.
6//! `ID_TOKEN_CERT_URI`/`ID_TOKEN_ISSUER_PREFIX` in
7//! `token-verifier.ts`/`_token_gen.py`), and directly against Google's
8//! endpoints:
9//!
10//! - **Issuer**: `https://session.firebase.google.com/<project-id>`, not
11//!   `https://securetoken.google.com/<project-id>`.
12//! - **Signing keys**: a separate X.509 certificate endpoint
13//!   (`https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys`),
14//!   not the securetoken JWKS used for ID tokens — see
15//!   [`crate::auth::session_cookie::certs`].
16
17use crate::auth::error::TokenVerificationError;
18use crate::auth::id_token::verifier::verify_with_key;
19use crate::auth::id_token::IdTokenClaims;
20use crate::auth::session_cookie::certs::SessionCookieCertCache;
21use crate::core::ProjectId;
22use jsonwebtoken::{decode_header, Algorithm, DecodingKey};
23
24const SESSION_COOKIE_ISSUER_PREFIX: &str = "https://session.firebase.google.com/";
25
26/// Verifies Firebase session cookies against Google's session-cookie
27/// certificate endpoint and the project's expected issuer/audience.
28pub struct SessionCookieVerifier {
29    project_id: ProjectId,
30    certs: SessionCookieCertCache,
31}
32
33impl SessionCookieVerifier {
34    /// Creates a verifier for the given project, using the given cert cache.
35    pub fn new(project_id: ProjectId, certs: SessionCookieCertCache) -> Self {
36        Self { project_id, certs }
37    }
38
39    /// Verifies a session cookie, returning its claims if every check
40    /// passes: signature, `exp`, `iat`/`auth_time`, `aud`, `iss`, and
41    /// non-empty `sub`.
42    pub async fn verify(&self, cookie: &str) -> Result<IdTokenClaims, TokenVerificationError> {
43        let header = decode_header(cookie)?;
44
45        if header.alg != Algorithm::RS256 {
46            return Err(TokenVerificationError::InvalidSignature);
47        }
48
49        let kid = header.kid.ok_or(TokenVerificationError::InvalidSignature)?;
50        let (n, e) = self.certs.public_key(&kid).await?;
51        let decoding_key = DecodingKey::from_rsa_components(&n, &e)
52            .map_err(|_| TokenVerificationError::InvalidSignature)?;
53
54        verify_with_key(
55            cookie,
56            &decoding_key,
57            self.project_id.as_str(),
58            SESSION_COOKIE_ISSUER_PREFIX,
59        )
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
67    use serde_json::json;
68
69    const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../tests/fixtures/test_private_key.pem");
70    const TEST_PROJECT_ID: &str = "test-project";
71
72    fn decoding_key() -> DecodingKey {
73        DecodingKey::from_rsa_pem(include_bytes!(
74            "../../../tests/fixtures/test_public_key.pem"
75        ))
76        .unwrap()
77    }
78
79    fn now() -> i64 {
80        std::time::SystemTime::now()
81            .duration_since(std::time::UNIX_EPOCH)
82            .unwrap()
83            .as_secs() as i64
84    }
85
86    fn sign(claims: &serde_json::Value) -> String {
87        let mut header = Header::new(Algorithm::RS256);
88        header.kid = Some("test-key".to_string());
89        let key = EncodingKey::from_rsa_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).unwrap();
90        encode(&header, claims, &key).unwrap()
91    }
92
93    fn valid_session_cookie_claims(now: i64) -> serde_json::Value {
94        json!({
95            "iss": format!("{SESSION_COOKIE_ISSUER_PREFIX}{TEST_PROJECT_ID}"),
96            "aud": TEST_PROJECT_ID,
97            "iat": now - 10,
98            "exp": now + 3600,
99            "auth_time": now - 10,
100            "sub": "test-uid",
101        })
102    }
103
104    #[test]
105    fn accepts_a_valid_session_cookie() {
106        let token = sign(&valid_session_cookie_claims(now()));
107        let claims = verify_with_key(
108            &token,
109            &decoding_key(),
110            TEST_PROJECT_ID,
111            SESSION_COOKIE_ISSUER_PREFIX,
112        )
113        .unwrap();
114        assert_eq!(claims.sub, "test-uid");
115    }
116
117    #[test]
118    fn rejects_an_id_token_issuer_on_a_session_cookie_endpoint() {
119        // A valid ID token (securetoken.google.com issuer) must NOT verify
120        // as a session cookie — this is exactly the confusion the separate
121        // issuer check exists to prevent.
122        let mut claims = valid_session_cookie_claims(now());
123        claims["iss"] = json!(format!("https://securetoken.google.com/{TEST_PROJECT_ID}"));
124        let token = sign(&claims);
125
126        let err = verify_with_key(
127            &token,
128            &decoding_key(),
129            TEST_PROJECT_ID,
130            SESSION_COOKIE_ISSUER_PREFIX,
131        )
132        .unwrap_err();
133        assert!(matches!(err, TokenVerificationError::IssuerMismatch));
134    }
135
136    #[test]
137    fn rejects_an_expired_session_cookie() {
138        let mut claims = valid_session_cookie_claims(now());
139        claims["exp"] = json!(now() - 100);
140        let token = sign(&claims);
141
142        let err = verify_with_key(
143            &token,
144            &decoding_key(),
145            TEST_PROJECT_ID,
146            SESSION_COOKIE_ISSUER_PREFIX,
147        )
148        .unwrap_err();
149        assert!(matches!(err, TokenVerificationError::Expired));
150    }
151}