Skip to main content

firebase_admin/auth/custom_token/
signer.rs

1//! Firebase custom token creation.
2//!
3//! Custom tokens are signed locally with a service account's RSA private key
4//! and require no network call. See
5//! <https://firebase.google.com/docs/auth/admin/create-custom-tokens#create_custom_tokens_using_a_third-party_jwt_library>.
6
7use crate::core::ServiceAccountKey;
8use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
9use serde::Serialize;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12const CUSTOM_TOKEN_AUDIENCE: &str =
13    "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit";
14const CUSTOM_TOKEN_TTL: Duration = Duration::from_secs(60 * 60);
15
16#[derive(Debug, Serialize)]
17struct CustomTokenClaims {
18    iss: String,
19    sub: String,
20    aud: String,
21    iat: i64,
22    exp: i64,
23    uid: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    claims: Option<serde_json::Map<String, serde_json::Value>>,
26}
27
28/// Signs Firebase custom tokens using a service account's private key.
29pub struct CustomTokenSigner {
30    service_account: ServiceAccountKey,
31}
32
33impl CustomTokenSigner {
34    /// Creates a signer that uses the given service account's key.
35    pub fn new(service_account: ServiceAccountKey) -> Self {
36        Self { service_account }
37    }
38
39    /// Creates a custom token for the given user id, optionally embedding
40    /// additional custom claims.
41    pub fn create_custom_token(
42        &self,
43        uid: &str,
44        claims: Option<serde_json::Map<String, serde_json::Value>>,
45    ) -> Result<String, jsonwebtoken::errors::Error> {
46        let now = SystemTime::now()
47            .duration_since(UNIX_EPOCH)
48            .expect("system clock is before the Unix epoch")
49            .as_secs() as i64;
50
51        let payload = CustomTokenClaims {
52            iss: self.service_account.client_email.clone(),
53            sub: self.service_account.client_email.clone(),
54            aud: CUSTOM_TOKEN_AUDIENCE.to_string(),
55            iat: now,
56            exp: now + CUSTOM_TOKEN_TTL.as_secs() as i64,
57            uid: uid.to_string(),
58            claims,
59        };
60
61        let mut header = Header::new(Algorithm::RS256);
62        header.kid = Some(self.service_account.private_key_id.clone());
63
64        let encoding_key = EncodingKey::from_rsa_pem(self.service_account.private_key.as_bytes())?;
65
66        encode(&header, &payload, &encoding_key)
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
74    use serde::Deserialize;
75
76    const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../tests/fixtures/test_private_key.pem");
77    const TEST_PUBLIC_KEY_PEM: &[u8] =
78        include_bytes!("../../../tests/fixtures/test_public_key.pem");
79
80    #[derive(Debug, Deserialize)]
81    struct DecodedClaims {
82        iss: String,
83        sub: String,
84        aud: String,
85        iat: i64,
86        exp: i64,
87        uid: String,
88        claims: Option<serde_json::Map<String, serde_json::Value>>,
89    }
90
91    fn test_service_account() -> ServiceAccountKey {
92        ServiceAccountKey {
93            client_email: "test@test-project.iam.gserviceaccount.com".to_string(),
94            private_key: TEST_PRIVATE_KEY_PEM.to_string(),
95            project_id: "test-project".to_string(),
96            private_key_id: "test-key-id".to_string(),
97        }
98    }
99
100    /// Decodes independently of [`CustomTokenSigner`]/the crate's own
101    /// verifier, using raw `jsonwebtoken` calls against the fixture public
102    /// key, so this test can't pass merely because signing and verifying
103    /// share a bug.
104    fn independently_decode(token: &str) -> DecodedClaims {
105        let header = decode_header(token).unwrap();
106        assert_eq!(header.alg, Algorithm::RS256);
107        assert_eq!(header.kid.as_deref(), Some("test-key-id"));
108
109        let decoding_key = DecodingKey::from_rsa_pem(TEST_PUBLIC_KEY_PEM).unwrap();
110        let mut validation = Validation::new(Algorithm::RS256);
111        validation.set_audience(&[CUSTOM_TOKEN_AUDIENCE]);
112        validation.set_issuer(&["test@test-project.iam.gserviceaccount.com"]);
113        validation.validate_exp = false; // signer sets exp ~1h out; nothing to validate against here.
114
115        decode::<DecodedClaims>(token, &decoding_key, &validation)
116            .unwrap()
117            .claims
118    }
119
120    #[test]
121    fn creates_a_token_with_the_documented_claim_shape() {
122        let signer = CustomTokenSigner::new(test_service_account());
123        let token = signer.create_custom_token("some-uid", None).unwrap();
124
125        let claims = independently_decode(&token);
126        assert_eq!(claims.uid, "some-uid");
127        assert_eq!(claims.iss, "test@test-project.iam.gserviceaccount.com");
128        assert_eq!(claims.sub, "test@test-project.iam.gserviceaccount.com");
129        assert_eq!(claims.aud, CUSTOM_TOKEN_AUDIENCE);
130        assert!(claims.exp > claims.iat);
131        assert!(claims.exp - claims.iat <= CUSTOM_TOKEN_TTL.as_secs() as i64);
132        assert!(claims.claims.is_none());
133    }
134
135    #[test]
136    fn embeds_custom_claims_when_provided() {
137        let signer = CustomTokenSigner::new(test_service_account());
138        let mut extra = serde_json::Map::new();
139        extra.insert("admin".to_string(), serde_json::Value::Bool(true));
140
141        let token = signer
142            .create_custom_token("some-uid", Some(extra.clone()))
143            .unwrap();
144
145        let claims = independently_decode(&token);
146        assert_eq!(claims.claims, Some(extra));
147    }
148}