firebase_admin/auth/mode.rs
1//! Runtime live/emulator mode selection.
2//!
3//! Unlike an approach that makes the client generic over a credentials or
4//! mode type (which forces every method signature to diverge between live
5//! and emulator variants), [`ClientMode`] is a plain runtime value. Every
6//! [`crate::auth::AuthClient`] method is defined exactly once and branches
7//! internally on `self.mode` only where behavior genuinely differs.
8
9use crate::auth::identity_toolkit::IdentityToolkitEndpoints;
10
11/// The environment variable Firebase's own SDKs use to auto-detect a running
12/// Auth Emulator.
13pub const EMULATOR_HOST_ENV_VAR: &str = "FIREBASE_AUTH_EMULATOR_HOST";
14
15/// Selects whether an [`crate::auth::AuthClient`] talks to production
16/// Firebase or a local emulator instance.
17#[derive(Debug, Clone)]
18pub enum ClientMode {
19 /// Talk to production Firebase Authentication.
20 Live,
21 /// Talk to a local Firebase Auth Emulator at `host` (e.g. `localhost:9099`).
22 Emulator {
23 /// The emulator's host and port.
24 host: String,
25 },
26}
27
28impl ClientMode {
29 /// Resolves the mode to use: an explicitly-requested emulator host takes
30 /// priority, then the `FIREBASE_AUTH_EMULATOR_HOST` environment variable,
31 /// and otherwise [`ClientMode::Live`].
32 pub fn resolve(explicit_emulator_host: Option<String>) -> Self {
33 if let Some(host) = explicit_emulator_host {
34 return ClientMode::Emulator { host };
35 }
36 if let Ok(host) = std::env::var(EMULATOR_HOST_ENV_VAR) {
37 if !host.trim().is_empty() {
38 return ClientMode::Emulator { host };
39 }
40 }
41 ClientMode::Live
42 }
43
44 /// Builds the Identity Toolkit endpoint set for this mode.
45 pub fn endpoints(&self, project_id: &str) -> IdentityToolkitEndpoints {
46 match self {
47 ClientMode::Live => IdentityToolkitEndpoints::live(project_id),
48 ClientMode::Emulator { host } => IdentityToolkitEndpoints::emulator(host, project_id),
49 }
50 }
51
52 /// Whether requests in this mode require an OAuth2 bearer token.
53 ///
54 /// The Firebase Auth Emulator does not enforce authentication.
55 pub fn requires_bearer_token(&self) -> bool {
56 matches!(self, ClientMode::Live)
57 }
58
59 /// The dummy `key=` query parameter value to attach to Identity Toolkit
60 /// REST calls in emulator mode, or `None` in live mode.
61 ///
62 /// Every Identity Toolkit v1 `accounts:*` endpoint requires an API key
63 /// query parameter to be *present*, even on the emulator — it isn't
64 /// validated there, but its absence produces the same
65 /// `PERMISSION_DENIED` / "The request is missing a valid API key." error
66 /// the production API returns for unauthenticated calls. Production
67 /// calls instead rely solely on the OAuth2 bearer token (see
68 /// [`Self::requires_bearer_token`]) and must not send this parameter.
69 pub fn emulator_api_key(&self) -> Option<&'static str> {
70 match self {
71 ClientMode::Live => None,
72 ClientMode::Emulator { .. } => Some("fake-api-key"),
73 }
74 }
75
76 /// The magic `Authorization: Bearer` token value the Firebase Auth
77 /// Emulator recognizes as a privileged/admin caller, or `None` in live
78 /// mode (where a real OAuth2 token is required instead — see
79 /// [`Self::requires_bearer_token`]).
80 ///
81 /// Confirmed against the emulator's own source
82 /// (`firebase-tools/src/emulator/auth/operations.ts`): several
83 /// operations, including user creation, branch on whether the request
84 /// carries recognized OAuth2 credentials (`ctx.security?.Oauth2`) to
85 /// decide whether to treat the caller as an admin. The literal string
86 /// `"owner"` is what the official Admin SDKs send as the bearer token
87 /// when `FIREBASE_AUTH_EMULATOR_HOST` is set, and is the value the
88 /// emulator's Exegesis-based request validator recognizes for this
89 /// purpose. Without it, admin-only operations are instead evaluated on
90 /// the unprivileged/client path and fail with errors like
91 /// `MISSING_ID_TOKEN` that only make sense for that path.
92 pub fn emulator_bearer_token(&self) -> Option<&'static str> {
93 match self {
94 ClientMode::Live => None,
95 ClientMode::Emulator { .. } => Some("owner"),
96 }
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use std::sync::Mutex;
104
105 // `std::env::var` reads process-global state; serialize tests that touch
106 // `EMULATOR_HOST_ENV_VAR` so they can't observe each other's values when
107 // the test binary runs them concurrently.
108 static ENV_VAR_TEST_LOCK: Mutex<()> = Mutex::new(());
109
110 fn with_emulator_env_var<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
111 let _guard = ENV_VAR_TEST_LOCK.lock().unwrap();
112 let previous = std::env::var(EMULATOR_HOST_ENV_VAR).ok();
113 match value {
114 Some(v) => std::env::set_var(EMULATOR_HOST_ENV_VAR, v),
115 None => std::env::remove_var(EMULATOR_HOST_ENV_VAR),
116 }
117
118 let result = f();
119
120 match previous {
121 Some(v) => std::env::set_var(EMULATOR_HOST_ENV_VAR, v),
122 None => std::env::remove_var(EMULATOR_HOST_ENV_VAR),
123 }
124 result
125 }
126
127 #[test]
128 fn explicit_host_wins_over_env_var() {
129 with_emulator_env_var(Some("env-host:9099"), || {
130 let mode = ClientMode::resolve(Some("explicit-host:9099".to_string()));
131 assert!(matches!(mode, ClientMode::Emulator { host } if host == "explicit-host:9099"));
132 });
133 }
134
135 #[test]
136 fn env_var_is_used_when_no_explicit_host_given() {
137 with_emulator_env_var(Some("env-host:9099"), || {
138 let mode = ClientMode::resolve(None);
139 assert!(matches!(mode, ClientMode::Emulator { host } if host == "env-host:9099"));
140 });
141 }
142
143 #[test]
144 fn whitespace_only_env_var_is_treated_as_unset() {
145 with_emulator_env_var(Some(" "), || {
146 let mode = ClientMode::resolve(None);
147 assert!(matches!(mode, ClientMode::Live));
148 });
149 }
150
151 #[test]
152 fn defaults_to_live_with_nothing_set() {
153 with_emulator_env_var(None, || {
154 let mode = ClientMode::resolve(None);
155 assert!(matches!(mode, ClientMode::Live));
156 });
157 }
158
159 #[test]
160 fn requires_bearer_token_only_in_live_mode() {
161 assert!(ClientMode::Live.requires_bearer_token());
162 assert!(!ClientMode::Emulator {
163 host: "localhost:9099".to_string()
164 }
165 .requires_bearer_token());
166 }
167
168 #[test]
169 fn emulator_bearer_token_is_owner_only_in_emulator_mode() {
170 assert_eq!(ClientMode::Live.emulator_bearer_token(), None);
171 assert_eq!(
172 ClientMode::Emulator {
173 host: "localhost:9099".to_string()
174 }
175 .emulator_bearer_token(),
176 Some("owner")
177 );
178 }
179
180 #[test]
181 fn emulator_api_key_is_present_only_in_emulator_mode() {
182 assert_eq!(ClientMode::Live.emulator_api_key(), None);
183 assert!(ClientMode::Emulator {
184 host: "localhost:9099".to_string()
185 }
186 .emulator_api_key()
187 .is_some());
188 }
189}