Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 310 additions & 1 deletion lib/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomTokenError>> {
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<serde_json::Value>,
}

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<String, Report<CustomTokenError>> {
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<C: ApiHttpClient>(
client: &C,
service_account_email: &str,
uid: &str,
claims: Option<serde_json::Value>,
) -> Result<String, Report<CustomTokenError>> {
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<serde_json::Value>,
) -> Result<String, Report<CustomTokenError>> {
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")]
Expand Down Expand Up @@ -336,6 +530,71 @@ pub trait FirebaseAuthService<C: ApiHttpClient>: 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<Output = Result<String, Report<CustomTokenError>>> + 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<Output = Result<String, Report<CustomTokenError>>> + 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<Output = Result<String, Report<CustomTokenError>>> + 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
Expand Down Expand Up @@ -757,6 +1016,11 @@ pub struct FirebaseAuth<ApiHttpClientT> {
client: ApiHttpClientT,
auth_uri_builder: ApiUriBuilder,
emulator_auth_uri_builder: Option<ApiUriBuilder>,
/// Explicit service account email provided via `live_with_signer()`.
signing_service_account: Option<String>,
/// 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<String>,
}

impl<ApiHttpClientT> FirebaseAuth<ApiHttpClientT>
Expand All @@ -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(),
}
}

Expand All @@ -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(),
}
}
}
Expand All @@ -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<String, Report<CustomTokenError>> {
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<ApiHttpClientT> FirebaseEmulatorAuthService<ApiHttpClientT> for FirebaseAuth<ApiHttpClientT>
Expand Down
Loading
Loading