#![cfg(not(target_arch = "wasm32"))]
use anyhow::{Context, Result};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const DEFAULT_OPENRTC_FIREBASE_API_KEY: &str = "AIzaSyA62Krj-7ZYFT5xjrTUq7mXana41Ahj_mM";
pub const DEFAULT_IDENTITY_TOOLKIT_BASE_URL: &str = "https://identitytoolkit.googleapis.com";
pub const DEFAULT_SECURE_TOKEN_BASE_URL: &str = "https://securetoken.googleapis.com";
pub const DEFAULT_OPENRTC_API_ORIGIN: &str = "https://api.openrtc.app";
#[derive(Debug, Clone)]
pub struct FirebaseAuthConfig {
pub api_key: String,
pub identity_toolkit_base_url: String,
pub secure_token_base_url: String,
pub refresh_margin: Duration,
}
impl FirebaseAuthConfig {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
identity_toolkit_base_url: DEFAULT_IDENTITY_TOOLKIT_BASE_URL.to_string(),
secure_token_base_url: DEFAULT_SECURE_TOKEN_BASE_URL.to_string(),
refresh_margin: Duration::from_secs(5 * 60),
}
}
pub fn openrtc_default() -> Self {
Self::new(DEFAULT_OPENRTC_FIREBASE_API_KEY)
}
pub fn identity_toolkit_base_url(mut self, base_url: impl Into<String>) -> Self {
self.identity_toolkit_base_url = base_url.into();
self
}
pub fn secure_token_base_url(mut self, base_url: impl Into<String>) -> Self {
self.secure_token_base_url = base_url.into();
self
}
pub fn refresh_margin(mut self, refresh_margin: Duration) -> Self {
self.refresh_margin = refresh_margin;
self
}
fn identity_toolkit_endpoint(&self, method: &str) -> String {
format!(
"{}/v1/{}?key={}",
self.identity_toolkit_base_url.trim_end_matches('/'),
method,
percent_encode(self.api_key.trim())
)
}
fn secure_token_endpoint(&self) -> String {
format!(
"{}/v1/token?key={}",
self.secure_token_base_url.trim_end_matches('/'),
percent_encode(self.api_key.trim())
)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum FirebaseAuthKind {
Anonymous,
CustomToken,
Space,
Refresh,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FirebaseAuthSession {
pub id_token: String,
pub refresh_token: Option<String>,
pub expires_in_seconds: Option<i64>,
pub expires_at_ms: Option<i64>,
pub user_id: Option<String>,
pub auth_kind: FirebaseAuthKind,
}
impl FirebaseAuthSession {
pub fn is_usable_at(&self, now_ms: i64, margin: Duration) -> bool {
if self.id_token.trim().is_empty() {
return false;
}
let Some(expires_at_ms) = self.expires_at_ms else {
return true;
};
let margin_ms = margin.as_millis().min(i64::MAX as u128) as i64;
now_ms.saturating_add(margin_ms) < expires_at_ms
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicSpaceTokenRequest {
pub api_key: String,
pub space_key: String,
pub member_id: Option<String>,
pub ttl: Option<u64>,
pub sandbox_app_id: Option<String>,
}
impl PublicSpaceTokenRequest {
pub fn new(api_key: impl Into<String>, space_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
space_key: space_key.into(),
member_id: None,
ttl: None,
sandbox_app_id: None,
}
}
pub fn member_id(mut self, member_id: impl Into<String>) -> Self {
self.member_id = Some(member_id.into());
self
}
pub fn ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
pub fn sandbox_app_id(mut self, sandbox_app_id: impl Into<String>) -> Self {
self.sandbox_app_id = Some(sandbox_app_id.into());
self
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicSpaceTokenResponse {
pub token: String,
pub expires_at: Option<i64>,
pub token_lifetime_seconds: Option<i64>,
pub namespace_id: Option<String>,
pub space_app_tag: Option<String>,
pub sandbox_app_id: Option<String>,
pub base_namespace_id: Option<String>,
}
#[derive(Clone)]
pub struct NativeAuthState {
session: Arc<RwLock<Option<FirebaseAuthSession>>>,
refresh_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
refresh_margin: Arc<RwLock<Duration>>,
}
impl Default for NativeAuthState {
fn default() -> Self {
Self {
session: Arc::new(RwLock::new(None)),
refresh_handle: Arc::new(Mutex::new(None)),
refresh_margin: Arc::new(RwLock::new(Duration::from_secs(5 * 60))),
}
}
}
impl NativeAuthState {
pub fn new() -> Self {
Self::default()
}
pub fn token_provider(&self) -> Box<dyn Fn() -> Option<String> + Send + Sync> {
let session = self.session.clone();
let refresh_margin = self.refresh_margin.clone();
Box::new(move || {
let now_ms = now_millis();
let margin = refresh_margin
.read()
.map(|guard| *guard)
.unwrap_or_else(|_| Duration::from_secs(60));
session.read().ok().and_then(|guard| {
guard
.as_ref()
.filter(|session| session.is_usable_at(now_ms, margin))
.map(|session| session.id_token.clone())
})
})
}
pub fn current_session(&self) -> Option<FirebaseAuthSession> {
self.session.read().ok().and_then(|guard| guard.clone())
}
pub fn set_session(&self, session: FirebaseAuthSession) {
self.stop_refresh_task();
if let Ok(mut guard) = self.session.write() {
*guard = Some(session);
}
}
pub fn clear(&self) {
self.stop_refresh_task();
if let Ok(mut guard) = self.session.write() {
*guard = None;
}
}
pub async fn sign_in_anonymously(
&self,
config: FirebaseAuthConfig,
) -> Result<FirebaseAuthSession> {
let session = sign_in_anonymously(&config).await?;
self.install_session(config, session.clone());
Ok(session)
}
pub async fn sign_in_with_custom_token(
&self,
config: FirebaseAuthConfig,
custom_token: &str,
) -> Result<FirebaseAuthSession> {
let session = exchange_custom_token(&config, custom_token).await?;
self.install_session(config, session.clone());
Ok(session)
}
pub async fn sign_in_with_public_space_token(
&self,
firebase_config: FirebaseAuthConfig,
openrtc_api_origin: impl AsRef<str>,
request: PublicSpaceTokenRequest,
) -> Result<FirebaseAuthSession> {
let token_response = mint_public_space_token(openrtc_api_origin, request).await?;
let mut session = exchange_custom_token(&firebase_config, &token_response.token).await?;
session.auth_kind = FirebaseAuthKind::Space;
self.install_session(firebase_config, session.clone());
Ok(session)
}
pub async fn refresh(&self, config: FirebaseAuthConfig) -> Result<FirebaseAuthSession> {
let refresh_token = self
.current_session()
.and_then(|session| session.refresh_token)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.context("No Firebase refresh token available")?;
let session = refresh_firebase_token(&config, &refresh_token).await?;
self.install_session(config, session.clone());
Ok(session)
}
fn install_session(&self, config: FirebaseAuthConfig, session: FirebaseAuthSession) {
self.stop_refresh_task();
if let Ok(mut margin) = self.refresh_margin.write() {
*margin = config.refresh_margin;
}
if let Ok(mut guard) = self.session.write() {
*guard = Some(session);
}
self.start_refresh_task(config);
}
fn start_refresh_task(&self, config: FirebaseAuthConfig) {
let session = self.session.clone();
let refresh_margin = config.refresh_margin;
let handle = tokio::spawn(async move {
loop {
let Some(current) = session.read().ok().and_then(|guard| guard.clone()) else {
return;
};
let Some(refresh_token) = current
.refresh_token
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
else {
return;
};
let wait = refresh_wait_duration(¤t, refresh_margin);
tokio::time::sleep(wait).await;
match refresh_firebase_token(&config, &refresh_token).await {
Ok(next) => {
if let Ok(mut guard) = session.write() {
*guard = Some(next);
}
}
Err(error) => {
crate::logging::log_error(&format!(
"[OPENRTC][NATIVE-AUTH] Firebase token refresh failed: {}",
error
));
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
}
});
if let Ok(mut guard) = self.refresh_handle.lock() {
*guard = Some(handle);
}
}
fn stop_refresh_task(&self) {
if let Ok(mut guard) = self.refresh_handle.lock() {
if let Some(handle) = guard.take() {
handle.abort();
}
}
}
}
impl Drop for NativeAuthState {
fn drop(&mut self) {
if Arc::strong_count(&self.refresh_handle) == 1 {
self.stop_refresh_task();
}
}
}
pub async fn sign_in_anonymously(config: &FirebaseAuthConfig) -> Result<FirebaseAuthSession> {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct RequestPayload {
return_secure_token: bool,
}
let payload: IdentityToolkitResponse = reqwest::Client::new()
.post(config.identity_toolkit_endpoint("accounts:signUp"))
.json(&RequestPayload {
return_secure_token: true,
})
.send()
.await
.map_err(|error| anyhow::anyhow!("anonymous Firebase sign-in request failed: {}", error))?
.error_for_status_with_body("anonymous Firebase sign-in")
.await?
.json()
.await
.map_err(|error| anyhow::anyhow!("failed parsing anonymous sign-in response: {}", error))?;
Ok(payload.into_session(FirebaseAuthKind::Anonymous, now_millis()))
}
pub async fn exchange_custom_token(
config: &FirebaseAuthConfig,
custom_token: &str,
) -> Result<FirebaseAuthSession> {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct RequestPayload<'a> {
token: &'a str,
return_secure_token: bool,
}
let token = custom_token.trim();
if token.is_empty() {
anyhow::bail!("custom token must not be empty");
}
let payload: IdentityToolkitResponse = reqwest::Client::new()
.post(config.identity_toolkit_endpoint("accounts:signInWithCustomToken"))
.json(&RequestPayload {
token,
return_secure_token: true,
})
.send()
.await
.map_err(|error| anyhow::anyhow!("custom token exchange request failed: {}", error))?
.error_for_status_with_body("custom token exchange")
.await?
.json()
.await
.map_err(|error| {
anyhow::anyhow!("failed parsing custom token exchange response: {}", error)
})?;
Ok(payload.into_session(FirebaseAuthKind::CustomToken, now_millis()))
}
pub async fn refresh_firebase_token(
config: &FirebaseAuthConfig,
refresh_token: &str,
) -> Result<FirebaseAuthSession> {
#[derive(serde::Deserialize)]
#[serde(rename_all = "snake_case")]
struct RefreshResponse {
id_token: String,
refresh_token: Option<String>,
expires_in: Option<String>,
user_id: Option<String>,
}
let token = refresh_token.trim();
if token.is_empty() {
anyhow::bail!("refresh token must not be empty");
}
let payload: RefreshResponse = reqwest::Client::new()
.post(config.secure_token_endpoint())
.form(&[("grant_type", "refresh_token"), ("refresh_token", token)])
.send()
.await
.map_err(|error| anyhow::anyhow!("Firebase token refresh request failed: {}", error))?
.error_for_status_with_body("Firebase token refresh")
.await?
.json()
.await
.map_err(|error| {
anyhow::anyhow!("failed parsing Firebase token refresh response: {}", error)
})?;
let expires_in_seconds = payload.expires_in.as_deref().and_then(parse_i64);
Ok(FirebaseAuthSession {
id_token: payload.id_token,
refresh_token: payload.refresh_token.or_else(|| Some(token.to_string())),
expires_in_seconds,
expires_at_ms: expires_in_seconds.map(|seconds| expiry_from_now(now_millis(), seconds)),
user_id: payload.user_id,
auth_kind: FirebaseAuthKind::Refresh,
})
}
pub async fn mint_public_space_token(
openrtc_api_origin: impl AsRef<str>,
request: PublicSpaceTokenRequest,
) -> Result<PublicSpaceTokenResponse> {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct RequestPayload<'a> {
api_key: &'a str,
space_key: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
member_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
sandbox_app_id: Option<&'a str>,
}
let api_key = request.api_key.trim();
let space_key = request.space_key.trim();
if api_key.is_empty() || space_key.is_empty() {
anyhow::bail!("public space token requires api_key and space_key");
}
let endpoint = format!(
"{}/v1SpacePublicTokens",
openrtc_api_origin.as_ref().trim_end_matches('/')
);
let response: PublicSpaceTokenResponse = reqwest::Client::new()
.post(endpoint)
.json(&RequestPayload {
api_key,
space_key,
member_id: request.member_id.as_deref(),
ttl: request.ttl,
sandbox_app_id: request.sandbox_app_id.as_deref(),
})
.send()
.await
.map_err(|error| anyhow::anyhow!("public space token request failed: {}", error))?
.error_for_status_with_body("public space token mint")
.await?
.json()
.await
.map_err(|error| {
anyhow::anyhow!("failed parsing public space token response: {}", error)
})?;
if response.token.trim().is_empty() {
anyhow::bail!("public space token response missing token");
}
Ok(response)
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct IdentityToolkitResponse {
id_token: String,
refresh_token: Option<String>,
expires_in: Option<String>,
local_id: Option<String>,
}
impl IdentityToolkitResponse {
fn into_session(self, auth_kind: FirebaseAuthKind, now_ms: i64) -> FirebaseAuthSession {
let expires_in_seconds = self.expires_in.as_deref().and_then(parse_i64);
FirebaseAuthSession {
id_token: self.id_token,
refresh_token: self.refresh_token,
expires_in_seconds,
expires_at_ms: expires_in_seconds.map(|seconds| expiry_from_now(now_ms, seconds)),
user_id: self.local_id,
auth_kind,
}
}
}
trait ResponseExt {
async fn error_for_status_with_body(self, label: &str) -> Result<reqwest::Response>;
}
impl ResponseExt for reqwest::Response {
async fn error_for_status_with_body(self, label: &str) -> Result<reqwest::Response> {
if self.status().is_success() {
return Ok(self);
}
let status = self.status();
let body = self.text().await.unwrap_or_default();
Err(anyhow::anyhow!(
"{} failed status={} body={}",
label,
status,
body
))
}
}
fn refresh_wait_duration(session: &FirebaseAuthSession, margin: Duration) -> Duration {
let Some(expires_at_ms) = session.expires_at_ms else {
return Duration::from_secs(30 * 60);
};
let margin_ms = margin.as_millis().min(i64::MAX as u128) as i64;
let target_ms = expires_at_ms.saturating_sub(margin_ms);
let now = now_millis();
if target_ms <= now {
Duration::from_secs(1)
} else {
Duration::from_millis((target_ms - now) as u64)
}
}
fn expiry_from_now(now_ms: i64, expires_in_seconds: i64) -> i64 {
now_ms.saturating_add(expires_in_seconds.saturating_mul(1000))
}
fn parse_i64(value: &str) -> Option<i64> {
value.trim().parse::<i64>().ok()
}
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(i64::MAX as u128) as i64
}
fn percent_encode(input: &str) -> String {
let mut out = String::new();
for b in input.as_bytes() {
match *b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{
exchange_custom_token, mint_public_space_token, refresh_firebase_token,
sign_in_anonymously, FirebaseAuthConfig, FirebaseAuthKind, FirebaseAuthSession,
NativeAuthState, PublicSpaceTokenRequest,
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn now_millis_for_test() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
async fn respond_once(
response_body: &'static str,
) -> (String, tokio::sync::oneshot::Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let read = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..read]).to_string();
let _ = tx.send(request);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
socket.write_all(response.as_bytes()).await.unwrap();
let _ = socket.shutdown().await;
});
(format!("http://{}", addr), rx)
}
#[test]
fn token_provider_returns_current_unexpired_token() {
let state = NativeAuthState::new();
let provider = state.token_provider();
assert_eq!(provider(), None);
state.set_session(FirebaseAuthSession {
id_token: "id-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_in_seconds: Some(3600),
expires_at_ms: Some(now_millis_for_test() + 3_600_000),
user_id: Some("user-123".to_string()),
auth_kind: FirebaseAuthKind::Anonymous,
});
assert_eq!(provider().as_deref(), Some("id-token"));
}
#[test]
fn token_provider_hides_expired_token() {
let state = NativeAuthState::new();
let provider = state.token_provider();
state.set_session(FirebaseAuthSession {
id_token: "id-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_in_seconds: Some(1),
expires_at_ms: Some(now_millis_for_test() - 1_000),
user_id: Some("user-123".to_string()),
auth_kind: FirebaseAuthKind::Anonymous,
});
assert_eq!(provider(), None);
}
#[tokio::test]
async fn anonymous_sign_in_uses_identity_toolkit_signup() {
let (base, request_rx) = respond_once(
r#"{"idToken":"id-token","refreshToken":"refresh-token","expiresIn":"3600","localId":"anon-user"}"#,
).await;
let config = FirebaseAuthConfig::new("firebase-key")
.identity_toolkit_base_url(base)
.refresh_margin(Duration::from_secs(60));
let session = sign_in_anonymously(&config).await.unwrap();
let request = request_rx.await.unwrap();
assert!(request.starts_with("POST /v1/accounts:signUp?key=firebase-key "));
assert!(request.contains(r#""returnSecureToken":true"#));
assert_eq!(session.id_token, "id-token");
assert_eq!(session.refresh_token.as_deref(), Some("refresh-token"));
assert_eq!(session.user_id.as_deref(), Some("anon-user"));
assert_eq!(session.auth_kind, FirebaseAuthKind::Anonymous);
}
#[tokio::test]
async fn custom_token_exchange_uses_identity_toolkit_custom_token_endpoint() {
let (base, request_rx) = respond_once(
r#"{"idToken":"id-token","refreshToken":"refresh-token","expiresIn":"3600","localId":"custom-user"}"#,
).await;
let config = FirebaseAuthConfig::new("firebase-key").identity_toolkit_base_url(base);
let session = exchange_custom_token(&config, "custom-token")
.await
.unwrap();
let request = request_rx.await.unwrap();
assert!(request.starts_with("POST /v1/accounts:signInWithCustomToken?key=firebase-key "));
assert!(request.contains(r#""token":"custom-token""#));
assert_eq!(session.id_token, "id-token");
assert_eq!(session.user_id.as_deref(), Some("custom-user"));
assert_eq!(session.auth_kind, FirebaseAuthKind::CustomToken);
}
#[tokio::test]
async fn public_space_token_mint_posts_public_key_and_space_key() {
let (base, request_rx) = respond_once(
r#"{"token":"space-custom-token","expiresAt":123,"tokenLifetimeSeconds":3600,"namespaceId":"namespace","spaceAppTag":"space::namespace"}"#,
).await;
let response = mint_public_space_token(
base,
PublicSpaceTokenRequest::new("pk_live_test", "shared-space").member_id("member-1"),
)
.await
.unwrap();
let request = request_rx.await.unwrap();
assert!(request.starts_with("POST /v1SpacePublicTokens "));
assert!(request.contains(r#""apiKey":"pk_live_test""#));
assert!(request.contains(r#""spaceKey":"shared-space""#));
assert!(request.contains(r#""memberId":"member-1""#));
assert_eq!(response.token, "space-custom-token");
assert_eq!(response.space_app_tag.as_deref(), Some("space::namespace"));
}
#[tokio::test]
async fn refresh_token_uses_secure_token_endpoint() {
let (base, request_rx) = respond_once(
r#"{"id_token":"new-id-token","refresh_token":"new-refresh-token","expires_in":"3600","user_id":"user-123"}"#,
).await;
let config = FirebaseAuthConfig::new("firebase-key").secure_token_base_url(base);
let session = refresh_firebase_token(&config, "old-refresh-token")
.await
.unwrap();
let request = request_rx.await.unwrap();
assert!(request.starts_with("POST /v1/token?key=firebase-key "));
assert!(request.contains("grant_type=refresh_token"));
assert!(request.contains("refresh_token=old-refresh-token"));
assert_eq!(session.id_token, "new-id-token");
assert_eq!(session.refresh_token.as_deref(), Some("new-refresh-token"));
assert_eq!(session.user_id.as_deref(), Some("user-123"));
assert_eq!(session.auth_kind, FirebaseAuthKind::Refresh);
}
#[tokio::test]
async fn state_signs_in_with_public_space_token_and_installs_provider() {
let (space_base, space_request_rx) = respond_once(
r#"{"token":"space-custom-token","expiresAt":123,"tokenLifetimeSeconds":3600,"namespaceId":"namespace","spaceAppTag":"space::namespace"}"#,
).await;
let (identity_base, identity_request_rx) = respond_once(
r#"{"idToken":"space-id-token","refreshToken":"space-refresh-token","expiresIn":"3600","localId":"space-user"}"#,
).await;
let auth = NativeAuthState::new();
let config =
FirebaseAuthConfig::new("firebase-key").identity_toolkit_base_url(identity_base);
let session = auth
.sign_in_with_public_space_token(
config,
space_base,
PublicSpaceTokenRequest::new("pk_live_test", "shared-space"),
)
.await
.unwrap();
let space_request = space_request_rx.await.unwrap();
let identity_request = identity_request_rx.await.unwrap();
assert!(space_request.starts_with("POST /v1SpacePublicTokens "));
assert!(identity_request
.starts_with("POST /v1/accounts:signInWithCustomToken?key=firebase-key "));
assert!(identity_request.contains(r#""token":"space-custom-token""#));
assert_eq!(session.id_token, "space-id-token");
assert_eq!(session.auth_kind, FirebaseAuthKind::Space);
assert_eq!(auth.token_provider()().as_deref(), Some("space-id-token"));
}
}