From 69832f5d44e22d9997b7c02bd6d767b011246b8e Mon Sep 17 00:00:00 2001 From: Sydney-o9 Date: Fri, 27 Mar 2026 23:12:49 +1100 Subject: [PATCH] Issue 29. Custom Token Creation. - [x] Implemented the suggestion made in https://github.com/expl/rs-firebase-admin-sdk/issues/29 for the maintainer to consider. --- lib/src/auth/mod.rs | 311 ++++++++++++++++++++++++++++- lib/src/auth/test.rs | 460 ++++++++++++++++++++++++++++++++++++++++++- lib/src/lib.rs | 11 ++ 3 files changed, 778 insertions(+), 4 deletions(-) diff --git a/lib/src/auth/mod.rs b/lib/src/auth/mod.rs index ab7c148..65f49fa 100644 --- a/lib/src/auth/mod.rs +++ b/lib/src/auth/mod.rs @@ -11,18 +11,212 @@ use crate::api_uri::{ApiUriBuilder, FirebaseAuthEmulatorRestApi, FirebaseAuthRes use crate::client::ApiHttpClient; use crate::client::error::ApiClientError; use crate::util::{I128EpochMs, StrEpochMs, StrEpochSec}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; pub use claims::Claims; -use error_stack::Report; +use error_stack::{Report, ResultExt}; use http::Method; pub use import::{UserImportRecord, UserImportRecords}; use oob_code::{OobCodeAction, OobCodeActionLink, OobCodeActionType}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::future::Future; +use std::time::{SystemTime, UNIX_EPOCH}; use std::vec; use time::{Duration, OffsetDateTime}; const FIREBASE_AUTH_REST_AUTHORITY: &str = "identitytoolkit.googleapis.com"; +const CUSTOM_TOKEN_AUDIENCE: &str = + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; +const EMULATOR_SIGNING_ACCOUNT: &str = "firebase-auth-emulator@example.com"; + +/// Error returned by [`FirebaseAuthService::create_custom_token`] and +/// [`FirebaseAuthService::create_custom_token_with_claims`]. +#[derive(thiserror::Error, Debug, Clone)] +pub enum CustomTokenError { + #[error("{0}")] + InvalidArgument(String), + #[error( + "No signing service account available. Deploy to Cloud Run, GCE, or GKE for \ + auto-discovery, or use App::auth_with_signer() to provide one explicitly." + )] + MissingServiceAccount, + #[error( + "Failed to discover service account email from the GCE metadata server. \ + Ensure the instance has a service account attached, or use App::auth_with_signer()." + )] + ServiceAccountDiscoveryFailed, + #[error("Failed to sign custom token via IAM Credentials API")] + SigningFailed, +} + +/// JWT claim names that Firebase reserves and cannot appear in developer claims. +/// Matches the Node.js Firebase Admin SDK `BLACKLISTED_CLAIMS` list. +const BLACKLISTED_CLAIMS: &[&str] = &[ + "acr", + "amr", + "at_hash", + "aud", + "auth_time", + "azp", + "cnf", + "c_hash", + "exp", + "iat", + "iss", + "jti", + "nbf", + "nonce", +]; + +fn validate_custom_token_args( + uid: &str, + claims: Option<&serde_json::Value>, +) -> Result<(), Report> { + if uid.is_empty() { + return Err(Report::new(CustomTokenError::InvalidArgument( + "uid must be a non-empty string".into(), + ))); + } + if uid.chars().count() > 128 { + return Err(Report::new(CustomTokenError::InvalidArgument( + "uid must be 128 characters or fewer".into(), + ))); + } + if let Some(claims) = claims { + let obj = claims.as_object().ok_or_else(|| { + Report::new(CustomTokenError::InvalidArgument( + "claims must be a JSON object".into(), + )) + })?; + for key in obj.keys() { + if BLACKLISTED_CLAIMS.contains(&key.as_str()) { + return Err(Report::new(CustomTokenError::InvalidArgument(format!( + "claim \"{key}\" is reserved and cannot be used as a developer claim" + )))); + } + } + } + Ok(()) +} + +#[derive(Serialize)] +struct SignJwtRequest { + payload: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SignJwtResponse { + signed_jwt: String, +} + +#[derive(Serialize)] +struct CustomTokenPayload<'a> { + iss: &'a str, + sub: &'a str, + aud: &'static str, + iat: u64, + exp: u64, + uid: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + claims: Option, +} + +const METADATA_SERVER_ENDPOINT: &str = + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email"; + +/// Fetch the service account email from the GCE metadata server. +/// Works on Cloud Run, GKE, GCE, and Cloud Functions. +async fn discover_service_account_email() -> Result> { + let response = reqwest::Client::new() + .get(METADATA_SERVER_ENDPOINT) + .header("Metadata-Flavor", "Google") + .send() + .await + .change_context(CustomTokenError::ServiceAccountDiscoveryFailed)?; + + if !response.status().is_success() { + return Err(Report::new(CustomTokenError::ServiceAccountDiscoveryFailed)); + } + + response + .text() + .await + .change_context(CustomTokenError::ServiceAccountDiscoveryFailed) +} + +async fn sign_custom_token( + client: &C, + service_account_email: &str, + uid: &str, + claims: Option, +) -> Result> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .change_context(CustomTokenError::SigningFailed)? + .as_secs(); + + let payload = CustomTokenPayload { + iss: service_account_email, + sub: service_account_email, + aud: CUSTOM_TOKEN_AUDIENCE, + iat: now, + exp: now + 3600, + uid, + claims, + }; + + let payload_json = + serde_json::to_string(&payload).change_context(CustomTokenError::SigningFailed)?; + + let encoded_email = urlencoding::encode(service_account_email); + let iam_url = format!( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{encoded_email}:signJwt" + ); + + let response: SignJwtResponse = client + .send_request_body( + iam_url, + Method::POST, + SignJwtRequest { + payload: payload_json, + }, + ) + .await + .change_context(CustomTokenError::SigningFailed)?; + + Ok(response.signed_jwt) +} + +/// Build an unsigned JWT (alg: "none", empty signature) for use with the Firebase Auth +/// Emulator. The emulator accepts these tokens for `signInWithCustomToken` without +/// verifying the signature, which means no IAM call or RSA key is needed in tests. +fn sign_custom_token_emulated( + uid: &str, + claims: Option, +) -> Result> { + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .change_context(CustomTokenError::SigningFailed)? + .as_secs(); + + let payload = CustomTokenPayload { + iss: EMULATOR_SIGNING_ACCOUNT, + sub: EMULATOR_SIGNING_ACCOUNT, + aud: CUSTOM_TOKEN_AUDIENCE, + iat: now, + exp: now + 3600, + uid, + claims, + }; + let payload_json = + serde_json::to_string(&payload).change_context(CustomTokenError::SigningFailed)?; + let encoded_payload = URL_SAFE_NO_PAD.encode(payload_json); + + Ok(format!("{header}.{encoded_payload}.")) +} #[derive(Serialize, Debug, Clone, Default)] #[serde(rename_all = "camelCase")] @@ -336,6 +530,71 @@ pub trait FirebaseAuthService: Send + Sync + 'static { fn get_client(&self) -> &C; fn get_auth_uri_builder(&self) -> &ApiUriBuilder; + /// Returns `true` when this instance targets the Firebase Auth Emulator. + fn is_emulated(&self) -> bool { + false + } + + /// Resolve the service account email to use for custom token signing. + /// + /// The default implementation always returns [`CustomTokenError::MissingServiceAccount`]. + /// [`FirebaseAuth`] overrides this: it returns the explicit email set via + /// [`App::auth_with_signer`] if available, otherwise auto-discovers it from the + /// GCE metadata server (cached after the first call). + fn resolve_signing_service_account( + &self, + ) -> impl Future>> + Send { + async { Err(Report::new(CustomTokenError::MissingServiceAccount)) } + } + + /// Mint a Firebase custom token for `uid` signed via the IAM Credentials API. + /// + /// When deployed on Cloud Run, GCE, GKE, or Cloud Functions, the signing service + /// account is discovered automatically from the GCE metadata server. To override, + /// use [`App::auth_with_signer`]. The service account must have the + /// `iam.serviceAccounts.signJwt` permission (granted by + /// `roles/iam.serviceAccountTokenCreator`). + fn create_custom_token( + &self, + uid: &str, + ) -> impl Future>> + Send { + let uid = uid.to_string(); + let is_emulated = self.is_emulated(); + async move { + validate_custom_token_args(&uid, None)?; + if is_emulated { + return sign_custom_token_emulated(&uid, None); + } + let sa_email = self.resolve_signing_service_account().await?; + sign_custom_token(self.get_client(), &sa_email, &uid, None).await + } + } + + /// Mint a Firebase custom token for `uid` with additional developer claims, + /// signed via the IAM Credentials API. + /// + /// `claims` must be a JSON object (`serde_json::Value::Object`). Any other + /// variant will be rejected by Firebase when the token is exchanged. + /// + /// See [`create_custom_token`][Self::create_custom_token] for service account + /// discovery and permission requirements. + fn create_custom_token_with_claims( + &self, + uid: &str, + claims: serde_json::Value, + ) -> impl Future>> + Send { + let uid = uid.to_string(); + let is_emulated = self.is_emulated(); + async move { + validate_custom_token_args(&uid, Some(&claims))?; + if is_emulated { + return sign_custom_token_emulated(&uid, Some(claims)); + } + let sa_email = self.resolve_signing_service_account().await?; + sign_custom_token(self.get_client(), &sa_email, &uid, Some(claims)).await + } + } + /// Creates a new user account with the specified properties. /// # Example /// ```rust @@ -757,6 +1016,11 @@ pub struct FirebaseAuth { client: ApiHttpClientT, auth_uri_builder: ApiUriBuilder, emulator_auth_uri_builder: Option, + /// Explicit service account email provided via `live_with_signer()`. + signing_service_account: Option, + /// Service account email auto-discovered from the GCE metadata server. + /// Populated lazily on the first `create_custom_token` call and cached thereafter. + discovered_service_account: tokio::sync::OnceCell, } impl FirebaseAuth @@ -773,6 +1037,8 @@ where client, auth_uri_builder: ApiUriBuilder::new(fb_auth_root), emulator_auth_uri_builder: Some(ApiUriBuilder::new(fb_emu_root)), + signing_service_account: None, + discovered_service_account: tokio::sync::OnceCell::new(), } } @@ -786,6 +1052,30 @@ where client, auth_uri_builder: ApiUriBuilder::new(fb_auth_root), emulator_auth_uri_builder: None, + signing_service_account: None, + discovered_service_account: tokio::sync::OnceCell::new(), + } + } + + /// Create Firebase Authentication manager for live project with IAM Credentials signing. + /// + /// `service_account_email` is the service account used to sign custom tokens via + /// the IAM Credentials API. It must have `iam.serviceAccounts.signJwt` permission. + pub fn live_with_signer( + project_id: &str, + service_account_email: &str, + client: ApiHttpClientT, + ) -> Self { + let fb_auth_root = "https://".to_string() + + FIREBASE_AUTH_REST_AUTHORITY + + &format!("/v1/projects/{project_id}"); + + Self { + client, + auth_uri_builder: ApiUriBuilder::new(fb_auth_root), + emulator_auth_uri_builder: None, + signing_service_account: Some(service_account_email.to_string()), + discovered_service_account: tokio::sync::OnceCell::new(), } } } @@ -801,6 +1091,25 @@ where fn get_auth_uri_builder(&self) -> &ApiUriBuilder { &self.auth_uri_builder } + + fn is_emulated(&self) -> bool { + self.emulator_auth_uri_builder.is_some() + } + + async fn resolve_signing_service_account(&self) -> Result> { + if let Some(email) = &self.signing_service_account { + return Ok(email.clone()); + } + // Auto-discovery is not meaningful for emulator instances. + if self.emulator_auth_uri_builder.is_some() { + return Err(Report::new(CustomTokenError::MissingServiceAccount)); + } + // Discover from the GCE metadata server, cached after the first call. + self.discovered_service_account + .get_or_try_init(discover_service_account_email) + .await + .cloned() + } } impl FirebaseEmulatorAuthService for FirebaseAuth diff --git a/lib/src/auth/test.rs b/lib/src/auth/test.rs index 91f5887..05ff92e 100644 --- a/lib/src/auth/test.rs +++ b/lib/src/auth/test.rs @@ -2,15 +2,23 @@ use super::import::{PasswordHash, UserImportRecord}; #[cfg(feature = "tokens")] // use super::token::jwt::JWToken; use super::{ - AttributeOp, Claims, FirebaseAuth, FirebaseAuthService, FirebaseEmulatorAuthService, NewUser, - OobCode, OobCodeAction, OobCodeActionType, UserIdentifiers, UserList, UserUpdate, + AttributeOp, Claims, CustomTokenError, FirebaseAuth, FirebaseAuthService, + FirebaseEmulatorAuthService, NewUser, OobCode, OobCodeAction, OobCodeActionType, + UserIdentifiers, UserList, UserUpdate, }; use crate::App; -use crate::client::ReqwestApiClient; +use crate::client::{ApiHttpClient, ReqwestApiClient, error::ApiClientError}; +use bytes::Bytes; +use error_stack::Report; +use http::Method; use serde::{Deserialize, Serialize}; use serde_json::Value; use serial_test::serial; use std::collections::BTreeMap; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; #[cfg(feature = "tokens")] use time::Duration; @@ -524,6 +532,394 @@ async fn test_generate_email_action_link() { auth.clear_all_users().await.unwrap(); } +#[tokio::test] +async fn test_create_custom_token_without_signer_returns_error() { + // Live auth (not emulated) with no signer configured — must fail. + let auth = FirebaseAuth::live("test-project", MockIamClient::new()); + let result = auth.create_custom_token("some-uid").await; + assert!( + result.is_err(), + "Expected an error when no signer is configured on a live instance" + ); + let err = result.unwrap_err(); + assert!( + err.contains::(), + "Error should be CustomTokenError" + ); +} + +#[tokio::test] +async fn test_create_custom_token_empty_uid_is_rejected() { + let auth = get_auth_service(); + let err = auth.create_custom_token("").await.unwrap_err(); + assert!(matches!( + err.current_context(), + CustomTokenError::InvalidArgument(_) + )); +} + +#[tokio::test] +async fn test_create_custom_token_uid_too_long_is_rejected() { + let auth = get_auth_service(); + let uid = "a".repeat(129); + let err = auth.create_custom_token(&uid).await.unwrap_err(); + assert!(matches!( + err.current_context(), + CustomTokenError::InvalidArgument(_) + )); +} + +#[tokio::test] +async fn test_create_custom_token_claims_not_object_is_rejected() { + let auth = get_auth_service(); + let err = auth + .create_custom_token_with_claims("uid", Value::Array(vec![])) + .await + .unwrap_err(); + assert!(matches!( + err.current_context(), + CustomTokenError::InvalidArgument(_) + )); +} + +#[tokio::test] +async fn test_create_custom_token_blacklisted_claim_is_rejected() { + let auth = get_auth_service(); + for reserved in ["aud", "exp", "iat", "iss", "nbf", "nonce", "jti"] { + let claims = serde_json::json!({ reserved: "whatever" }); + let err = auth + .create_custom_token_with_claims("uid", claims) + .await + .unwrap_err(); + assert!( + matches!(err.current_context(), CustomTokenError::InvalidArgument(_)), + "Expected InvalidArgument for reserved claim \"{reserved}\"" + ); + } +} + +// uid boundary conditions + +#[tokio::test] +async fn test_create_custom_token_uid_exactly_128_ascii_chars_passes_validation() { + let auth = get_auth_service(); + let uid = "a".repeat(128); + // Validation passes — emulated auth produces a token. + auth.create_custom_token(&uid).await.unwrap(); +} + +#[tokio::test] +async fn test_create_custom_token_uid_128_unicode_chars_passes_validation() { + let auth = get_auth_service(); + // Each '😀' is 4 bytes; 128 chars = 512 bytes — must pass char-count check + let uid = "😀".repeat(128); + // Validation passes — emulated auth produces a token. + auth.create_custom_token(&uid).await.unwrap(); +} + +#[tokio::test] +async fn test_create_custom_token_uid_129_unicode_chars_is_rejected() { + let auth = get_auth_service(); + let uid = "😀".repeat(129); + let err = auth.create_custom_token(&uid).await.unwrap_err(); + assert!( + matches!(err.current_context(), CustomTokenError::InvalidArgument(_)), + "129 unicode chars should be rejected" + ); +} + +// claims validation + +#[tokio::test] +async fn test_create_custom_token_all_14_blacklisted_claims_are_rejected() { + let auth = get_auth_service(); + let blacklisted = [ + "acr", + "amr", + "at_hash", + "aud", + "auth_time", + "azp", + "cnf", + "c_hash", + "exp", + "iat", + "iss", + "jti", + "nbf", + "nonce", + ]; + for reserved in blacklisted { + let claims = serde_json::json!({ reserved: "value" }); + let err = auth + .create_custom_token_with_claims("uid", claims) + .await + .unwrap_err(); + assert!( + matches!(err.current_context(), CustomTokenError::InvalidArgument(_)), + "claim \"{reserved}\" should be rejected" + ); + } +} + +#[tokio::test] +async fn test_create_custom_token_valid_claims_pass_validation() { + let auth = get_auth_service(); + let claims = serde_json::json!({ "role": "admin", "premium": true }); + // Validation passes — emulated auth produces a token. + auth.create_custom_token_with_claims("uid", claims) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_create_custom_token_empty_claims_object_passes_validation() { + let auth = get_auth_service(); + let claims = serde_json::json!({}); + // Validation passes — emulated auth produces a token. + auth.create_custom_token_with_claims("uid", claims) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_create_custom_token_null_claims_rejected() { + let auth = get_auth_service(); + let err = auth + .create_custom_token_with_claims("uid", Value::Null) + .await + .unwrap_err(); + assert!(matches!( + err.current_context(), + CustomTokenError::InvalidArgument(_) + )); +} + +#[tokio::test] +async fn test_create_custom_token_string_claims_rejected() { + let auth = get_auth_service(); + let err = auth + .create_custom_token_with_claims("uid", Value::String("nope".into())) + .await + .unwrap_err(); + assert!(matches!( + err.current_context(), + CustomTokenError::InvalidArgument(_) + )); +} + +#[tokio::test] +async fn test_create_custom_token_number_claims_rejected() { + let auth = get_auth_service(); + let err = auth + .create_custom_token_with_claims("uid", Value::Number(42.into())) + .await + .unwrap_err(); + assert!(matches!( + err.current_context(), + CustomTokenError::InvalidArgument(_) + )); +} + +// mock IAM client + +/// Captures the IAM signJwt request URL and body; returns a fake signed JWT. +struct MockIamClient { + call_count: Arc, + captured_url: Arc>>, + captured_body: Arc>>, +} + +impl MockIamClient { + fn new() -> Self { + Self { + call_count: Arc::new(AtomicUsize::new(0)), + captured_url: Arc::new(Mutex::new(None)), + captured_body: Arc::new(Mutex::new(None)), + } + } +} + +impl ApiHttpClient for MockIamClient { + async fn send_request( + &self, + _uri: String, + _method: Method, + ) -> Result> { + unimplemented!() + } + + async fn send_request_with_params< + R: serde::de::DeserializeOwned + Send, + P: Iterator + Send, + >( + &self, + _uri: String, + _params: P, + _method: Method, + ) -> Result> { + unimplemented!() + } + + async fn send_request_body< + Req: serde::Serialize + Send, + Resp: serde::de::DeserializeOwned + Send, + >( + &self, + uri: String, + _method: Method, + request_body: Req, + ) -> Result> { + self.call_count.fetch_add(1, Ordering::SeqCst); + *self.captured_url.lock().unwrap() = Some(uri); + *self.captured_body.lock().unwrap() = Some(serde_json::to_value(&request_body).unwrap()); + let resp = serde_json::json!({ "signedJwt": "fake.jwt.token" }); + Ok(serde_json::from_value(resp).unwrap()) + } + + async fn send_request_body_get_bytes( + &self, + _uri: String, + _method: Method, + _request_body: Req, + ) -> Result> { + unimplemented!() + } + + async fn send_request_body_empty_response( + &self, + _uri: String, + _method: Method, + _request_body: Req, + ) -> Result<(), Report> { + unimplemented!() + } +} + +fn make_mock_auth( + sa_email: &str, +) -> ( + FirebaseAuth, + Arc, + Arc>>, + Arc>>, +) { + let mock = MockIamClient::new(); + let call_count = mock.call_count.clone(); + let captured_url = mock.captured_url.clone(); + let captured_body = mock.captured_body.clone(); + let auth = FirebaseAuth::live_with_signer("test-project", sa_email, mock); + (auth, call_count, captured_url, captured_body) +} + +/// Extracts and parses the `payload` JSON string from the captured IAM request body. +fn decode_captured_payload(captured_body: &Arc>>) -> Value { + let lock = captured_body.lock().unwrap(); + let body = lock.as_ref().expect("IAM was never called"); + let payload_str = body["payload"].as_str().expect("payload field missing"); + serde_json::from_str(payload_str).expect("payload is not valid JSON") +} + +// JWT payload structure + +#[tokio::test] +async fn test_custom_token_payload_required_fields() { + let sa = "signer@project.iam.gserviceaccount.com"; + let (auth, _, _, captured_body) = make_mock_auth(sa); + + let token = auth.create_custom_token("user-123").await.unwrap(); + assert_eq!(token, "fake.jwt.token"); + + let payload = decode_captured_payload(&captured_body); + assert_eq!(payload["iss"], sa); + assert_eq!(payload["sub"], sa); + assert_eq!( + payload["aud"], + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit" + ); + assert_eq!(payload["uid"], "user-123"); + + let iat = payload["iat"].as_u64().unwrap(); + let exp = payload["exp"].as_u64().unwrap(); + assert_eq!(exp - iat, 3600, "token lifetime must be exactly 1 hour"); + + assert!( + payload.get("claims").is_none(), + "claims must be absent when not provided" + ); +} + +#[tokio::test] +async fn test_custom_token_payload_with_claims() { + let sa = "signer@project.iam.gserviceaccount.com"; + let (auth, _, _, captured_body) = make_mock_auth(sa); + + let claims = serde_json::json!({ "role": "admin", "tier": 2 }); + auth.create_custom_token_with_claims("user-456", claims.clone()) + .await + .unwrap(); + + let payload = decode_captured_payload(&captured_body); + assert_eq!(payload["uid"], "user-456"); + assert_eq!( + payload["claims"], claims, + "claims must be nested under 'claims' key" + ); +} + +#[tokio::test] +async fn test_custom_token_payload_omits_claims_when_absent() { + let sa = "signer@project.iam.gserviceaccount.com"; + let (auth, _, _, captured_body) = make_mock_auth(sa); + + auth.create_custom_token("user-789").await.unwrap(); + + let payload = decode_captured_payload(&captured_body); + assert!( + payload.get("claims").is_none(), + "claims key must not be present in the payload when not provided" + ); +} + +// IAM URL + +#[tokio::test] +async fn test_custom_token_iam_url_contains_service_account() { + let sa = "signer@project.iam.gserviceaccount.com"; + let (auth, _, captured_url, _) = make_mock_auth(sa); + + auth.create_custom_token("uid").await.unwrap(); + + let url = captured_url.lock().unwrap().clone().unwrap(); + assert!( + url.contains("iamcredentials.googleapis.com"), + "must call IAM Credentials API" + ); + assert!(url.ends_with(":signJwt"), "must use signJwt endpoint"); + // email is URL-encoded in the path (@ → %40) + assert!( + url.contains("signer%40project.iam.gserviceaccount.com"), + "service account email must be URL-encoded in the IAM path" + ); +} + +// call count + +#[tokio::test] +async fn test_create_custom_token_calls_iam_once_per_invocation() { + let sa = "signer@project.iam.gserviceaccount.com"; + let (auth, call_count, _, _) = make_mock_auth(sa); + + auth.create_custom_token("uid1").await.unwrap(); + auth.create_custom_token("uid2").await.unwrap(); + + assert_eq!( + call_count.load(Ordering::SeqCst), + 2, + "IAM must be called once per create_custom_token invocation" + ); +} + #[cfg(feature = "tokens")] #[tokio::test] #[serial] @@ -550,3 +946,61 @@ async fn test_create_session_cookie() { auth.clear_all_users().await.unwrap(); } + +// emulated custom token (unsigned JWT) + +#[tokio::test] +async fn test_emulated_create_custom_token_returns_unsigned_jwt() { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let auth = get_auth_service(); + let token = auth.create_custom_token("test-uid").await.unwrap(); + + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT must have 3 dot-separated parts"); + assert_eq!(parts[2], "", "signature segment must be empty for alg=none"); + + let header: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); + assert_eq!(header["alg"], "none"); + assert_eq!(header["typ"], "JWT"); + + let payload: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + assert_eq!(payload["uid"], "test-uid"); + assert_eq!(payload["iss"], "firebase-auth-emulator@example.com"); + assert_eq!(payload["sub"], "firebase-auth-emulator@example.com"); + assert_eq!( + payload["aud"], + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit" + ); + assert!(payload["iat"].is_number()); + assert!(payload["exp"].is_number()); + assert_eq!( + payload["exp"].as_u64().unwrap() - payload["iat"].as_u64().unwrap(), + 3600 + ); + assert!( + payload.get("claims").is_none(), + "claims must be absent when not provided" + ); +} + +#[tokio::test] +async fn test_emulated_create_custom_token_with_claims() { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let auth = get_auth_service(); + let claims = serde_json::json!({ "role": "admin", "tier": 2 }); + let token = auth + .create_custom_token_with_claims("uid2", claims) + .await + .unwrap(); + + let parts: Vec<&str> = token.split('.').collect(); + let payload: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + assert_eq!(payload["uid"], "uid2"); + assert_eq!(payload["claims"]["role"], "admin"); + assert_eq!(payload["claims"]["tier"], 2); +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index e205089..826fff8 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -96,6 +96,17 @@ impl App { FirebaseAuth::live(&self.project_id, client) } + /// Create Firebase authentication manager with custom token signing support. + /// + /// `service_account_email` is passed to the IAM Credentials API to sign custom tokens. + /// The service account must have the `iam.serviceAccounts.signJwt` permission + /// (granted by `roles/iam.serviceAccountTokenCreator`). + pub fn auth_with_signer(&self, service_account_email: &str) -> FirebaseAuth { + let client = ReqwestApiClient::new(reqwest::Client::new(), self.credentials.clone()); + + FirebaseAuth::live_with_signer(&self.project_id, service_account_email, client) + } + /// Create OIDC token verifier #[cfg(feature = "tokens")] pub async fn id_token_verifier(