Skip to main content

foundry_common/tempo/
auth.rs

1//! Tempo wallet device-code authorization flow.
2//!
3//! Implements the CLI side of the tempoxyz/accounts `cli-auth` device-code
4//! protocol: generates a local secp256k1 access key, creates a PKCE-protected
5//! device code, opens `wallet.tempo.xyz/cli-auth?code=<CODE>` in the browser,
6//! polls until the user authorizes the key on their passkey wallet, and writes
7//! the resulting `keyAuthorization` to `~/.tempo/wallet/keys.toml`.
8
9use crate::tempo::{
10    KeyEntry, KeyType, StoredTokenLimit, WalletType, decode_key_authorization, upsert_key_entry,
11};
12use alloy_primitives::{Address, B256, hex};
13use alloy_signer_local::PrivateKeySigner;
14use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
15use eyre::Result;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18#[cfg(any(unix, windows))]
19use std::process::Command;
20use std::{
21    env,
22    sync::LazyLock,
23    time::{Duration, Instant},
24};
25use tempo_primitives::transaction::{SignatureType, SignedKeyAuthorization};
26use tokio::sync::Mutex;
27
28/// Default device-code service URL (production wallet.tempo.xyz).
29const DEFAULT_CLI_AUTH_URL: &str = "https://wallet.tempo.xyz/cli-auth";
30
31/// Returns `true` if `url`'s host is `tempo.xyz` or a subdomain of it.
32pub(crate) fn is_known_tempo_endpoint(url: &url::Url) -> bool {
33    url.host_str().is_some_and(|host| host == "tempo.xyz" || host.ends_with(".tempo.xyz"))
34}
35
36/// Env var to override the device-code service URL (for tests / staging).
37const TEMPO_CLI_AUTH_URL_ENV: &str = "TEMPO_CLI_AUTH_URL";
38
39const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
40const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
41
42/// Per-process serialization of concurrent `ensure_access_key` calls.
43///
44/// Prevents two `cast` invocations in the same process from racing two browser
45/// popups for the same chain.
46static AUTH_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
47
48/// Configuration for [`ensure_access_key`].
49#[derive(Clone, Debug)]
50pub struct EnsureAccessKeyConfig {
51    /// Chain ID the access key is being authorized for.
52    pub chain_id: u64,
53    /// Device-code service base URL. Defaults to [`DEFAULT_CLI_AUTH_URL`].
54    pub(crate) service_url: String,
55    /// Poll interval.
56    pub(crate) poll_interval: Duration,
57    /// Total timeout for the authorization flow.
58    pub(crate) timeout: Duration,
59    /// If `true`, print the authorization URL to stderr instead of opening a
60    /// browser.
61    pub no_browser: bool,
62}
63
64impl EnsureAccessKeyConfig {
65    /// Build a config from the environment for the given chain.
66    ///
67    /// `no_browser` defaults to `true` under `CI`; callers (e.g. `cast tempo
68    /// login --no-browser`) may override it.
69    pub fn from_env(chain_id: u64) -> Self {
70        Self {
71            chain_id,
72            service_url: env::var(TEMPO_CLI_AUTH_URL_ENV)
73                .unwrap_or_else(|_| DEFAULT_CLI_AUTH_URL.to_string()),
74            poll_interval: DEFAULT_POLL_INTERVAL,
75            timeout: DEFAULT_TIMEOUT,
76            no_browser: env::var_os("CI").is_some(),
77        }
78    }
79}
80
81/// Open `url` via the OS default browser handler. On platforms without a known
82/// opener, this is a no-op (the URL is still printed by [`ensure_access_key`]).
83fn open_browser(_url: &str) {
84    #[cfg(target_os = "macos")]
85    let _ = Command::new("open").arg(_url).spawn();
86    #[cfg(target_os = "windows")]
87    let _ = Command::new("cmd").args(["/c", "start", "", _url]).spawn();
88    #[cfg(all(unix, not(target_os = "macos")))]
89    let _ = Command::new("xdg-open").arg(_url).spawn();
90}
91
92/// Result of [`ensure_access_key`].
93#[derive(Debug, Clone)]
94pub struct AccessKeyOutcome {
95    pub wallet_address: Address,
96    pub key_address: Address,
97    pub chain_id: u64,
98}
99
100/// Run the device-code flow, persist the resulting key to `keys.toml`, and
101/// return the new entry's identifying fields.
102pub async fn ensure_access_key(cfg: EnsureAccessKeyConfig) -> Result<AccessKeyOutcome> {
103    let _guard = AUTH_LOCK.lock().await;
104
105    let signer = PrivateKeySigner::random();
106    let key_address = signer.address();
107    // The server requires uncompressed SEC1 (65-byte `0x04 || X || Y`); the
108    // default `to_sec1_bytes()` would emit the compressed 33-byte form.
109    let pub_key_hex = format!(
110        "0x{}",
111        hex::encode(signer.credential().verifying_key().to_encoded_point(false).as_bytes()),
112    );
113
114    let code_verifier = random_code_verifier();
115    let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
116    let service = cfg.service_url.trim_end_matches('/');
117
118    let create_req = CreateCodeRequest {
119        chain_id: cfg.chain_id,
120        code_challenge: sha256_b64url(&code_verifier),
121        key_type: "secp256k1",
122        pub_key: pub_key_hex,
123    };
124    let code = create_code_with_retry(&client, service, &create_req, cfg.timeout).await?;
125
126    let browser_url = format!("{service}?code={code}");
127    if cfg.no_browser {
128        let _ = crate::sh_eprintln!("Open this URL to authorize: {browser_url}");
129    } else {
130        let _ = crate::sh_eprintln!(
131            "Opening wallet.tempo to authorize an access key…\n  {browser_url}"
132        );
133        open_browser(&browser_url);
134    }
135
136    let poll = PollRequest { code_verifier };
137    let started = Instant::now();
138    loop {
139        // Retry transient network/5xx/429 failures within `cfg.timeout`.
140        let send_res = client.post(format!("{service}/poll/{code}")).json(&poll).send().await;
141
142        let resp = match send_res {
143            Ok(r) => r,
144            Err(e) if is_transient_error(&e) && started.elapsed() < cfg.timeout => {
145                tracing::debug!(error = %e, "transient error polling device code, retrying");
146                tokio::time::sleep(cfg.poll_interval).await;
147                continue;
148            }
149            Err(e) => return Err(e.into()),
150        };
151
152        let status = resp.status();
153        if !status.is_success() {
154            if is_transient_status(status) && started.elapsed() < cfg.timeout {
155                tracing::debug!(%status, "transient HTTP status polling device code, retrying");
156                tokio::time::sleep(cfg.poll_interval).await;
157                continue;
158            }
159            let body = resp.text().await.unwrap_or_default();
160            eyre::bail!("device-code poll failed ({status}): {body}");
161        }
162
163        let body: PollResponse = resp.json().await?;
164        match body {
165            PollResponse::Pending => {
166                if started.elapsed() > cfg.timeout {
167                    eyre::bail!("timed out waiting for wallet authorization (code {code})");
168                }
169                tokio::time::sleep(cfg.poll_interval).await;
170            }
171            PollResponse::Expired => {
172                eyre::bail!("device code {code} expired before authorization");
173            }
174            PollResponse::Authorized { account_address, key_authorization } => {
175                let hex_str = key_authorization.ok_or_else(|| {
176                    eyre::eyre!("wallet authorized response missing key_authorization")
177                })?;
178                let signed: SignedKeyAuthorization = decode_key_authorization(&hex_str)?;
179                // Reject mismatches before persisting — an unusable keys.toml
180                // entry would silently break the next 402 retry.
181                if signed.authorization.key_id != key_address {
182                    eyre::bail!(
183                        "wallet authorized key {} but the locally generated key is {}",
184                        signed.authorization.key_id,
185                        key_address,
186                    );
187                }
188                if signed.authorization.chain_id != cfg.chain_id {
189                    eyre::bail!(
190                        "wallet authorized chain {} but {} was requested",
191                        signed.authorization.chain_id,
192                        cfg.chain_id,
193                    );
194                }
195                if signed.authorization.key_type != SignatureType::Secp256k1 {
196                    eyre::bail!(
197                        "wallet returned keyType {:?} but secp256k1 was requested",
198                        signed.authorization.key_type,
199                    );
200                }
201                // A 402 access key is a limited key; an admin key is never valid here.
202                if signed.authorization.is_admin() {
203                    eyre::bail!(
204                        "wallet returned an admin key authorization, expected a limited access key"
205                    );
206                }
207                // A T6 account-bound authorization must target the authorizing account.
208                if let Some(account) = signed.authorization.account
209                    && account != account_address
210                {
211                    eyre::bail!(
212                        "wallet authorized account {account} but the authorizing account is {account_address}",
213                    );
214                }
215                let chain_id = signed.authorization.chain_id;
216                let key_authorization =
217                    if hex_str.starts_with("0x") { hex_str } else { format!("0x{hex_str}") };
218                let entry = KeyEntry {
219                    wallet_type: WalletType::Passkey,
220                    wallet_address: account_address,
221                    chain_id,
222                    key_type: match signed.authorization.key_type {
223                        SignatureType::P256 => KeyType::P256,
224                        SignatureType::WebAuthn => KeyType::WebAuthn,
225                        _ => KeyType::Secp256k1,
226                    },
227                    key_address: Some(key_address),
228                    key: Some(format!("0x{}", hex::encode(signer.to_bytes()))),
229                    key_authorization: Some(key_authorization),
230                    expiry: signed.authorization.expiry.map(|n| n.get()),
231                    limits: signed
232                        .authorization
233                        .limits
234                        .unwrap_or_default()
235                        .into_iter()
236                        .map(|l| StoredTokenLimit { currency: l.token, limit: l.limit.to_string() })
237                        .collect(),
238                };
239                upsert_key_entry(entry)?;
240                return Ok(AccessKeyOutcome {
241                    wallet_address: account_address,
242                    key_address,
243                    chain_id,
244                });
245            }
246        }
247    }
248}
249
250fn is_transient_error(err: &reqwest::Error) -> bool {
251    err.is_timeout() || err.is_connect() || err.is_request()
252}
253
254fn is_transient_status(status: reqwest::StatusCode) -> bool {
255    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
256}
257
258/// POST `/code` with exponential backoff on transient errors, bounded by `timeout`.
259async fn create_code_with_retry(
260    client: &reqwest::Client,
261    service: &str,
262    req: &CreateCodeRequest,
263    timeout: Duration,
264) -> Result<String> {
265    let started = Instant::now();
266    let mut backoff = Duration::from_millis(500);
267    loop {
268        let send_res = client.post(format!("{service}/code")).json(req).send().await;
269
270        match send_res {
271            Ok(resp) => {
272                let status = resp.status();
273                if status.is_success() {
274                    let CreateCodeResponse { code } = resp.json().await?;
275                    return Ok(code);
276                }
277                if is_transient_status(status) && started.elapsed() < timeout {
278                    tracing::debug!(%status, "transient HTTP status creating device code, retrying");
279                    tokio::time::sleep(backoff).await;
280                    backoff = (backoff * 2).min(Duration::from_secs(5));
281                    continue;
282                }
283                let body = resp.text().await.unwrap_or_default();
284                eyre::bail!("device-code create failed ({status}): {body}");
285            }
286            Err(e) if is_transient_error(&e) && started.elapsed() < timeout => {
287                tracing::debug!(error = %e, "transient error creating device code, retrying");
288                tokio::time::sleep(backoff).await;
289                backoff = (backoff * 2).min(Duration::from_secs(5));
290            }
291            Err(e) => return Err(e.into()),
292        }
293    }
294}
295
296fn random_code_verifier() -> String {
297    let bytes = B256::random();
298    URL_SAFE_NO_PAD.encode(bytes.as_slice())
299}
300
301fn sha256_b64url(input: &str) -> String {
302    let digest = Sha256::digest(input.as_bytes());
303    URL_SAFE_NO_PAD.encode(digest)
304}
305
306#[derive(Serialize)]
307#[serde(rename_all = "camelCase")]
308struct CreateCodeRequest {
309    /// `0x`-hex per the SDK schema (server accepts hex string or bigint, not a plain JSON number).
310    #[serde(serialize_with = "serialize_u64_hex")]
311    chain_id: u64,
312    code_challenge: String,
313    key_type: &'static str,
314    pub_key: String,
315}
316
317fn serialize_u64_hex<S: serde::Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
318    s.serialize_str(&format!("0x{v:x}"))
319}
320
321#[derive(Deserialize)]
322struct CreateCodeResponse {
323    code: String,
324}
325
326#[derive(Serialize)]
327#[serde(rename_all = "camelCase")]
328struct PollRequest {
329    code_verifier: String,
330}
331
332/// Matches `tempoxyz/wallet` poll response shape.
333#[derive(Deserialize)]
334#[serde(tag = "status", rename_all = "lowercase")]
335enum PollResponse {
336    Pending,
337    Expired,
338    Authorized {
339        account_address: Address,
340        #[serde(default)]
341        key_authorization: Option<String>,
342    },
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::tempo::{TEMPO_HOME_ENV, read_tempo_keys_file, test_env_mutex};
349    use axum::{Json, Router, extract::State, routing::post};
350    use std::sync::{Arc, Mutex};
351
352    #[test]
353    fn pkce_challenge_matches_sdk_format() {
354        // Vector from RFC 7636 §4.2.
355        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
356        let challenge = sha256_b64url(verifier);
357        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
358    }
359
360    /// Recover the EOA from a SEC1-encoded public key (compressed or
361    /// uncompressed).
362    fn address_from_sec1_hex(s: &str) -> Address {
363        let stripped = s.strip_prefix("0x").unwrap_or(s);
364        let bytes = hex::decode(stripped).expect("valid hex");
365        let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes).expect("valid SEC1 pubkey");
366        Address::from_public_key(&vk)
367    }
368
369    /// Shape of the `keyAuthorization` the mock `/poll` returns.
370    #[derive(Clone, Copy, Default)]
371    struct MockAuthShape {
372        /// Return a T6 admin key authorization.
373        admin: bool,
374        /// Bind the authorization to this account.
375        account: Option<Address>,
376    }
377
378    #[derive(Clone)]
379    struct MockState {
380        wallet: Arc<Mutex<Option<Address>>>,
381        /// Derived from the `pubKey` posted to `/code` so `/poll` can echo
382        /// back a matching `keyId`, like a real wallet would.
383        key_id: Arc<Mutex<Option<Address>>>,
384        /// Chain ID the mock `/poll` returns in `keyAuthorization`.
385        poll_chain_id: u64,
386        /// Shape of the returned authorization, for exercising rejection paths.
387        shape: MockAuthShape,
388    }
389
390    async fn create_code_handler(
391        State(state): State<MockState>,
392        Json(body): Json<serde_json::Value>,
393    ) -> Json<serde_json::Value> {
394        // Sanity: required fields present and chainId is a 0x-hex string,
395        // matching the SDK wire format the live server enforces.
396        let pub_key = body
397            .get("pubKey")
398            .and_then(|v| v.as_str())
399            .unwrap_or_else(|| panic!("pubKey missing: {body}"));
400        assert!(body.get("codeChallenge").is_some(), "codeChallenge missing: {body}");
401        let chain_id = body.get("chainId").unwrap_or_else(|| panic!("chainId missing: {body}"));
402        let chain_str = chain_id
403            .as_str()
404            .unwrap_or_else(|| panic!("chainId must be string, got {chain_id}: {body}"));
405        assert!(chain_str.starts_with("0x"), "chainId must be 0x-hex, got {chain_str}");
406        let wallet: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
407        *state.wallet.lock().unwrap() = Some(wallet);
408        *state.key_id.lock().unwrap() = Some(address_from_sec1_hex(pub_key));
409        Json(serde_json::json!({ "code": "ABCDEFGH" }))
410    }
411
412    /// Build the RLP-hex `SignedKeyAuthorization` blob the live server returns
413    /// in the `key_authorization` field.
414    fn signed_key_auth_hex(
415        chain_id: u64,
416        key_id: Address,
417        expiry: u64,
418        shape: MockAuthShape,
419    ) -> String {
420        use alloy_rlp::Encodable;
421        use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature};
422        let mut auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id);
423        if shape.admin {
424            // An admin authorization carries no expiry; bind it to its account (or zero, which the
425            // `is_admin` check rejects before the account is even inspected).
426            auth = auth.into_admin(shape.account.unwrap_or(Address::ZERO));
427        } else {
428            auth = auth.with_expiry(expiry);
429            if let Some(account) = shape.account {
430                auth = auth.with_account(account);
431            }
432        }
433        let sig: PrimitiveSignature = serde_json::from_value(serde_json::json!({
434            "type": "secp256k1", "r": "0x0", "s": "0x0", "yParity": 0
435        }))
436        .unwrap();
437        let signed = auth.into_signed(sig);
438        let mut buf = Vec::new();
439        signed.encode(&mut buf);
440        format!("0x{}", hex::encode(buf))
441    }
442
443    async fn poll_handler(State(state): State<MockState>) -> Json<serde_json::Value> {
444        let wallet = state.wallet.lock().unwrap().expect("create_code must be called first");
445        let key_id = state.key_id.lock().unwrap().expect("create_code must be called first");
446        Json(serde_json::json!({
447            "status": "authorized",
448            "account_address": wallet,
449            "key_authorization":
450                signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999, state.shape),
451        }))
452    }
453
454    /// Spawn a mock wallet.tempo server whose `/poll` echoes `poll_chain_id`.
455    async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) {
456        spawn_mock_wallet_with(poll_chain_id, MockAuthShape::default()).await
457    }
458
459    /// Spawn a mock wallet.tempo server with a custom authorization shape.
460    async fn spawn_mock_wallet_with(
461        poll_chain_id: u64,
462        shape: MockAuthShape,
463    ) -> (String, tokio::task::JoinHandle<()>) {
464        let app = Router::new()
465            .route("/code", post(create_code_handler))
466            .route("/poll/{code}", post(poll_handler))
467            .with_state(MockState {
468                wallet: Arc::default(),
469                key_id: Arc::default(),
470                poll_chain_id,
471                shape,
472            });
473
474        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
475        let addr = listener.local_addr().unwrap();
476        let handle = tokio::spawn(async move {
477            axum::serve(listener, app).await.unwrap();
478        });
479        (format!("http://{addr}"), handle)
480    }
481
482    fn test_cfg(service_url: String) -> EnsureAccessKeyConfig {
483        EnsureAccessKeyConfig {
484            chain_id: 4217,
485            service_url,
486            poll_interval: Duration::from_millis(10),
487            timeout: Duration::from_secs(2),
488            no_browser: true,
489        }
490    }
491
492    #[tokio::test(flavor = "multi_thread")]
493    async fn ensure_access_key_happy_path_writes_keys_toml() {
494        // SAFETY: serialized with other tests that mutate TEMPO_HOME.
495        let _g = test_env_mutex().lock().await;
496        let tmp = tempfile::tempdir().unwrap();
497        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
498
499        let (service_url, server) = spawn_mock_wallet(4217).await;
500        let outcome = ensure_access_key(test_cfg(service_url)).await.unwrap();
501
502        let expected_wallet: Address =
503            "0x0000000000000000000000000000000000000042".parse().unwrap();
504        assert_eq!(outcome.chain_id, 4217);
505        assert_eq!(outcome.wallet_address, expected_wallet);
506
507        let file = read_tempo_keys_file().expect("keys.toml written");
508        assert_eq!(file.keys.len(), 1);
509        let entry = &file.keys[0];
510        assert_eq!(entry.wallet_address, outcome.wallet_address);
511        assert_eq!(entry.key_address, Some(outcome.key_address));
512        assert_eq!(entry.chain_id, 4217);
513        assert_eq!(entry.expiry, Some(9_999_999_999));
514        let decoded: tempo_primitives::transaction::SignedKeyAuthorization =
515            crate::tempo::decode_key_authorization(entry.key_authorization.as_deref().unwrap())
516                .expect("RLP roundtrip");
517        assert_eq!(decoded.authorization.chain_id, 4217);
518
519        server.abort();
520        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
521    }
522
523    #[tokio::test(flavor = "multi_thread")]
524    async fn ensure_access_key_rejects_wrong_chain_id() {
525        // Wallet returns chain 99999 but client requested 4217 → must reject
526        // and persist nothing, else discovery would later fail to find a key
527        // for the requested chain.
528        let _g = test_env_mutex().lock().await;
529        let tmp = tempfile::tempdir().unwrap();
530        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
531
532        let (service_url, server) = spawn_mock_wallet(99999).await;
533        let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
534        assert!(
535            err.to_string().contains("wallet authorized chain 99999 but 4217 was requested"),
536            "expected chain mismatch error, got: {err}"
537        );
538        assert!(read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()));
539
540        server.abort();
541        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
542    }
543
544    #[tokio::test(flavor = "multi_thread")]
545    async fn ensure_access_key_rejects_admin_authorization() {
546        // An admin key authorization from the wallet must be rejected before keys.toml is written.
547        let _g = test_env_mutex().lock().await;
548        let tmp = tempfile::tempdir().unwrap();
549        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
550
551        // Bind the admin auth to the mock wallet account (0x..42) so it is rejected purely for
552        // being an admin key, not for an account mismatch.
553        let account: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
554        let shape = MockAuthShape { admin: true, account: Some(account) };
555        let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
556
557        let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
558        assert!(
559            err.to_string().contains("admin key authorization"),
560            "expected admin-key rejection, got: {err}"
561        );
562        assert!(
563            read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()),
564            "an admin authorization must not be persisted to keys.toml"
565        );
566
567        server.abort();
568        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
569    }
570
571    #[tokio::test(flavor = "multi_thread")]
572    async fn ensure_access_key_rejects_cross_account_binding() {
573        // An authorization bound to an account other than the authorizing one must be rejected
574        // before keys.toml is written.
575        let _g = test_env_mutex().lock().await;
576        let tmp = tempfile::tempdir().unwrap();
577        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
578
579        // The mock authorizes account 0x..42 but binds the authorization to 0x..dead.
580        let other: Address = "0x000000000000000000000000000000000000dead".parse().unwrap();
581        let shape = MockAuthShape { admin: false, account: Some(other) };
582        let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
583
584        let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
585        assert!(
586            err.to_string().contains("wallet authorized account"),
587            "expected cross-account rejection, got: {err}"
588        );
589        assert!(
590            read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()),
591            "a cross-account authorization must not be persisted to keys.toml"
592        );
593
594        server.abort();
595        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
596    }
597}