use std::{collections::HashMap, sync::Mutex, time::Duration}; use axum::{ extract::{Path, Query, State as AxumState}, http::StatusCode, response::{Html, IntoResponse, Response}, routing::get, Router, }; use serde::{Deserialize, Serialize}; use tauri_plugin_opener::OpenerExt; use tokio::{net::TcpListener, sync::oneshot}; use url::Url; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); const BB_SESSION_CREDENTIAL_HEADER: &str = "X-BB-Session-Credential"; // Builderlab enforces an Origin check on the identity bind endpoints. Browsers // attach this automatically; the desktop reqwest client must set it explicitly // or challenge/verify fail with `invalid_origin`. It also seeds the challenge // body's `origin` field so both agree. const AUTH_COMPLETE_HTML: &str = r#" Buzz authentication complete
Authentication complete

You’re signed in.

You can close this window and return to Buzz.

"#; #[derive(Default)] pub(crate) struct BuilderlabSession(Mutex>); #[derive(Default)] pub(crate) struct BuilderlabLogin(Mutex); #[derive(Default)] struct BuilderlabLoginState { pending: Option, } struct PendingLogin { id: String, cancel: oneshot::Sender<()>, } struct StoredSession { credential: String, } #[derive(Debug, Deserialize)] struct LoginExchangeResponse { session_credential: String, expires_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct BuilderlabAuthInfo { expires_at: String, email: Option, } #[derive(Debug, Deserialize)] struct AuthMeResponse { email: Option, expires_at: String, } struct CallbackState { nonce: String, sender: Mutex>>>, } async fn login_callback( Path(nonce): Path, Query(query): Query>, AxumState(state): AxumState>, ) -> Response { if nonce != state.nonce { return (StatusCode::NOT_FOUND, "Not found").into_response(); } let result = match query.get("code").filter(|code| !code.is_empty()) { Some(code) => Ok(code.clone()), None => Err(query .get("error_description") .or_else(|| query.get("error")) .cloned() .unwrap_or_else(|| "Authentication callback did not include a code".to_owned())), }; if let Some(sender) = state .sender .lock() .expect("callback sender poisoned") .take() { let _ = sender.send(result); } Html(AUTH_COMPLETE_HTML).into_response() } fn builderlab_api_base_url() -> &'static str { option_env!("BUZZ_DESKTOP_BUILD_BUILDERLAB_API_BASE_URL") .unwrap_or("https://app.builderlab.xyz/api/goose") } fn builderlab_origin() -> Result { let url = Url::parse(builderlab_api_base_url()) .map_err(|error| format!("invalid Builderlab API URL: {error}"))?; Ok(url.origin().ascii_serialization()) } fn api_url(path: &str) -> Result { Url::parse(&format!("{}{}", builderlab_api_base_url(), path)) .map_err(|error| format!("invalid Builderlab API URL: {error}")) } fn login_url(return_to: &str) -> Result { let mut login_url = api_url("/v1/auth/login")?; login_url .query_pairs_mut() .append_pair("type", "cli") .append_pair("product", "buzz") .append_pair("returnTo", return_to); Ok(login_url) } fn login_exchange_body(code: &str) -> serde_json::Value { serde_json::json!({ "code": code }) } fn clear_matching_pending_login(login: &BuilderlabLogin, login_id: &str) -> Result<(), String> { let mut state = login.0.lock().map_err(|error| error.to_string())?; if state .pending .as_ref() .is_some_and(|pending| pending.id == login_id) { state.pending = None; } Ok(()) } fn register_pending_builderlab_login( login: &BuilderlabLogin, login_id: &str, cancel: oneshot::Sender<()>, ) -> Result<(), String> { let mut state = login.0.lock().map_err(|error| error.to_string())?; if let Some(previous) = state.pending.take() { let _ = previous.cancel.send(()); } state.pending = Some(PendingLogin { id: login_id.to_owned(), cancel, }); Ok(()) } fn commit_builderlab_login_session( login: &BuilderlabLogin, session: &BuilderlabSession, login_id: &str, credential: String, ) -> Result<(), String> { commit_builderlab_login_session_with_hook(login, session, login_id, credential, || {}) } fn commit_builderlab_login_session_with_hook( login: &BuilderlabLogin, session: &BuilderlabSession, login_id: &str, credential: String, before_session_lock: impl FnOnce(), ) -> Result<(), String> { // Keep this lock order consistent everywhere a Builderlab login result is // committed: first BuilderlabLogin, then BuilderlabSession. Holding the login // lock through the session write prevents cancel/replacement from // interleaving after the ownership check but before credential storage. let mut login_state = login.0.lock().map_err(|error| error.to_string())?; if login_state .pending .as_ref() .is_none_or(|pending| pending.id != login_id) { return Err("Builderlab authentication canceled".to_owned()); } before_session_lock(); *session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession { credential }); login_state.pending = None; Ok(()) } async fn abort_builderlab_login( login: &BuilderlabLogin, server: tokio::task::JoinHandle<()>, login_id: &str, error: String, ) -> Result { server.abort(); clear_matching_pending_login(login, login_id)?; Err(error) } async fn authenticated_user( client: &reqwest::Client, credential: &str, ) -> Result { let response = client .get(api_url("/v1/auth/me")?) .header(BB_SESSION_CREDENTIAL_HEADER, credential) .timeout(Duration::from_secs(30)) .send() .await .map_err(|error| format!("Builderlab session check failed: {error}"))?; if !response.status().is_success() { return Err(format!( "Builderlab session check failed with HTTP {}", response.status() )); } response .json() .await .map_err(|error| format!("invalid Builderlab session response: {error}")) } #[tauri::command] pub(crate) async fn start_builderlab_login( app: tauri::AppHandle, app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, login: tauri::State<'_, BuilderlabLogin>, ) -> Result { let listener = TcpListener::bind("127.0.0.1:0") .await .map_err(|error| format!("could not start local authentication callback: {error}"))?; let port = listener .local_addr() .map_err(|error| format!("could not read local authentication callback: {error}"))? .port(); let nonce = uuid::Uuid::new_v4().simple().to_string(); let return_to = format!("http://127.0.0.1:{port}/callback/{nonce}"); let (sender, receiver) = oneshot::channel(); let callback_state = std::sync::Arc::new(CallbackState { nonce: nonce.clone(), sender: Mutex::new(Some(sender)), }); let router = Router::new() .route("/callback/{nonce}", get(login_callback)) .with_state(callback_state); let server = tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); let login_id = uuid::Uuid::new_v4().to_string(); let (cancel_sender, mut cancel_receiver) = oneshot::channel(); if let Err(error) = register_pending_builderlab_login(&login, &login_id, cancel_sender) { server.abort(); return Err(error); } let login_url = match login_url(&return_to) { Ok(login_url) => login_url, Err(error) => return abort_builderlab_login(&login, server, &login_id, error).await, }; if let Err(error) = app.opener().open_url(login_url.as_str(), None::<&str>) { return abort_builderlab_login( &login, server, &login_id, format!("could not open Builderlab authentication: {error}"), ) .await; } let exchange_code = tokio::select! { result = tokio::time::timeout(LOGIN_TIMEOUT, receiver) => match result { Ok(Ok(Ok(code))) => code, Ok(Ok(Err(error))) => { return abort_builderlab_login(&login, server, &login_id, error).await; } Ok(Err(_)) => { return abort_builderlab_login( &login, server, &login_id, "local authentication callback stopped unexpectedly".to_owned(), ) .await; } Err(_) => { return abort_builderlab_login( &login, server, &login_id, "Builderlab authentication timed out".to_owned(), ) .await; } }, _ = &mut cancel_receiver => { return abort_builderlab_login( &login, server, &login_id, "Builderlab authentication canceled".to_owned(), ) .await; } }; server.abort(); let exchange_url = match api_url("/v1/auth/login/exchange") { Ok(exchange_url) => exchange_url, Err(error) => { clear_matching_pending_login(&login, &login_id)?; return Err(error); } }; let response = match app_state .http_client .post(exchange_url) .json(&login_exchange_body(&exchange_code)) .timeout(Duration::from_secs(30)) .send() .await { Ok(response) => response, Err(error) => { clear_matching_pending_login(&login, &login_id)?; return Err(format!("Builderlab code exchange failed: {error}")); } }; if !response.status().is_success() { let status = response.status(); clear_matching_pending_login(&login, &login_id)?; return Err(format!( "Builderlab code exchange failed with HTTP {status}" )); } let exchanged: LoginExchangeResponse = match response.json().await { Ok(exchanged) => exchanged, Err(error) => { clear_matching_pending_login(&login, &login_id)?; return Err(format!( "invalid Builderlab code exchange response: {error}" )); } }; if exchanged.session_credential.is_empty() { clear_matching_pending_login(&login, &login_id)?; return Err("Builderlab code exchange returned an empty credential".to_owned()); } let me = match authenticated_user(&app_state.http_client, &exchanged.session_credential).await { Ok(me) => me, Err(error) => { clear_matching_pending_login(&login, &login_id)?; return Err(error); } }; if exchanged.expires_at != me.expires_at { clear_matching_pending_login(&login, &login_id)?; return Err("Builderlab session expiry did not match code exchange".to_owned()); } let info = BuilderlabAuthInfo { expires_at: me.expires_at.clone(), email: me.email, }; commit_builderlab_login_session(&login, &session, &login_id, exchanged.session_credential)?; Ok(info) } #[tauri::command] pub(crate) async fn get_builderlab_auth( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result, String> { let stored = session .0 .lock() .map_err(|error| error.to_string())? .as_ref() .map(|stored| stored.credential.clone()); let Some(credential) = stored else { return Ok(None); }; match authenticated_user(&app_state.http_client, &credential).await { Ok(me) => Ok(Some(BuilderlabAuthInfo { expires_at: me.expires_at, email: me.email, })), Err(error) => { *session .0 .lock() .map_err(|lock_error| lock_error.to_string())? = None; Err(error) } } } fn cancel_builderlab_login_attempt(login: &BuilderlabLogin) -> Result { let mut state = login.0.lock().map_err(|error| error.to_string())?; let Some(pending) = state.pending.take() else { return Ok(false); }; let _ = pending.cancel.send(()); Ok(true) } #[tauri::command] pub(crate) fn cancel_builderlab_login( login: tauri::State<'_, BuilderlabLogin>, ) -> Result<(), String> { cancel_builderlab_login_attempt(&login).map(|_| ()) } #[tauri::command] pub(crate) fn clear_builderlab_auth( session: tauri::State<'_, BuilderlabSession>, ) -> Result<(), String> { *session.0.lock().map_err(|error| error.to_string())? = None; Ok(()) } #[derive(Debug, Deserialize)] struct NostrIdentityChallenge { challenge_id: String, nonce: String, verification_code: String, origin: String, expires_at: String, } async fn authenticated_json( client: &reqwest::Client, session: &BuilderlabSession, method: reqwest::Method, path: &str, body: serde_json::Value, ) -> Result { let credential = session .0 .lock() .map_err(|error| error.to_string())? .as_ref() .map(|stored| stored.credential.clone()) .ok_or_else(|| "Sign in to Builderlab first".to_owned())?; let response = client .request(method, api_url(path)?) .header(BB_SESSION_CREDENTIAL_HEADER, credential) .header(reqwest::header::ORIGIN, builderlab_origin()?) .json(&body) .timeout(Duration::from_secs(60)) .send() .await .map_err(|error| format!("Builderlab request failed: {error}"))?; let status = response.status(); let value: serde_json::Value = response .json() .await .map_err(|error| format!("invalid Builderlab response: {error}"))?; if !status.is_success() { // Builderlab error responses carry a structured `{ error: { code, // message, setup_needed, ... } }` body. Pass those through as `Ok` so the // frontend's typed handling and friendly per-code messages apply, instead // of surfacing a raw JSON blob. Only fall back to a plain string when the // body isn't the expected shape. if value.get("error").is_some() { return Ok(value); } return Err(format!("Builderlab request failed (HTTP {status}).")); } Ok(value) } #[tauri::command] pub(crate) async fn get_builderlab_nostr_identity( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/current", serde_json::json!({}), ) .await } #[tauri::command] pub(crate) async fn bind_builderlab_nostr_identity( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { let challenge_value = authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/challenge", serde_json::json!({ "origin": builderlab_origin()? }), ) .await?; // A structured error here (e.g. missing_mapping) arrives as an object with an // `error` field rather than a challenge — hand it straight back so the // frontend maps it to a friendly message instead of hitting a deserialize // failure below. if challenge_value.get("error").is_some() { return Ok(challenge_value); } let challenge: NostrIdentityChallenge = serde_json::from_value(challenge_value) .map_err(|error| format!("invalid Nostr identity challenge: {error}"))?; let keys = app_state.signing_keys()?; let event = crate::commands::build_nostr_identity_binding_event( &keys, &challenge.challenge_id, &challenge.nonce, &challenge.verification_code, &challenge.origin, &challenge.expires_at, )?; authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/verify", serde_json::json!({ "challenge_id": challenge.challenge_id, "nonce": challenge.nonce, "signed_payload": nostr::JsonUtil::as_json(&event), }), ) .await } #[tauri::command] pub(crate) async fn delete_builderlab_nostr_identity( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/delete", serde_json::json!({}), ) .await } #[tauri::command] pub(crate) async fn list_builderlab_communities( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/communities/list", serde_json::json!({}), ) .await } #[tauri::command] pub(crate) async fn check_builderlab_community_name( name: String, app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/communities/availability", serde_json::json!({ "name": name }), ) .await } #[tauri::command] pub(crate) async fn create_builderlab_community( name: String, app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/communities", serde_json::json!({ "name": name }), ) .await } #[tauri::command] pub(crate) async fn archive_builderlab_community( community_id: String, app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/communities/archive", serde_json::json!({ "community_id": community_id }), ) .await } #[tauri::command] pub(crate) async fn unarchive_builderlab_community( community_id: String, app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/communities/unarchive", serde_json::json!({ "community_id": community_id }), ) .await } #[tauri::command] pub(crate) async fn transfer_builderlab_community( community_id: String, transferee_npub: String, app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { // The Builderlab transfer endpoint expects camelCase keys, unlike the // archive/unarchive endpoints which take `community_id`; mirror the web // client's payload exactly. authenticated_json( &app_state.http_client, &session, reqwest::Method::POST, "/v1/buzz/communities/transfer", serde_json::json!({ "communityId": community_id, "transfereeNpub": transferee_npub, }), ) .await } #[cfg(test)] mod tests { use super::*; #[test] fn stale_attempt_cannot_commit_session_after_replacement() { let login = BuilderlabLogin::default(); let session = BuilderlabSession::default(); let (cancel, _receiver) = oneshot::channel(); login.0.lock().unwrap().pending = Some(PendingLogin { id: "builderlab-login-new".to_owned(), cancel, }); let result = commit_builderlab_login_session( &login, &session, "builderlab-login-old", "stale-credential".to_owned(), ); assert_eq!(result.unwrap_err(), "Builderlab authentication canceled"); assert!(session.0.lock().unwrap().is_none()); assert_eq!( login .0 .lock() .unwrap() .pending .as_ref() .map(|pending| pending.id.as_str()), Some("builderlab-login-new"), ); } #[test] fn session_commit_holds_login_ownership_fence_through_write() { let login = BuilderlabLogin::default(); let session = BuilderlabSession::default(); let (cancel, _receiver) = oneshot::channel(); login.0.lock().unwrap().pending = Some(PendingLogin { id: "builderlab-login-a".to_owned(), cancel, }); commit_builderlab_login_session_with_hook( &login, &session, "builderlab-login-a", "fresh-credential".to_owned(), || { assert!( login.0.try_lock().is_err(), "login ownership must remain locked until after the session write", ); }, ) .unwrap(); assert!(login.0.lock().unwrap().pending.is_none()); assert_eq!( session .0 .lock() .unwrap() .as_ref() .map(|stored| stored.credential.as_str()), Some("fresh-credential"), ); } #[test] fn cancel_without_attempt_id_preserves_existing_blind_cancel_behavior() { let login = BuilderlabLogin::default(); let (cancel, mut receiver) = oneshot::channel(); login.0.lock().unwrap().pending = Some(PendingLogin { id: "legacy-flow".to_owned(), cancel, }); assert!(cancel_builderlab_login_attempt(&login).unwrap()); assert!(login.0.lock().unwrap().pending.is_none()); assert!(receiver.try_recv().is_ok()); } #[test] fn auth_complete_page_uses_buzz_brand() { for expected in [ "Buzz authentication complete", "#d7d72e", "#231e1e", "#d7e7f6", "aria-label=\"Buzz\"", "return to Buzz", ] { assert!( AUTH_COMPLETE_HTML.contains(expected), "authentication complete page is missing {expected}" ); } } #[test] fn api_paths_stay_on_builderlab_api_origin() { let login = api_url("/v1/auth/login").unwrap(); assert_eq!( login.origin().ascii_serialization(), "https://app.builderlab.xyz" ); assert_eq!(login.path(), "/api/goose/v1/auth/login"); } #[test] fn login_defaults_to_auth0_login() { let login = login_url("http://127.0.0.1:1234/callback/nonce").unwrap(); let query: HashMap<_, _> = login.query_pairs().into_owned().collect(); assert_eq!(query.get("type").map(String::as_str), Some("cli")); assert_eq!(query.get("product").map(String::as_str), Some("buzz")); assert_eq!( query.get("returnTo").map(String::as_str), Some("http://127.0.0.1:1234/callback/nonce") ); assert!(!query.contains_key("screen_hint")); assert!(!query.contains_key("handoff_challenge")); assert!(!query.contains_key("handoff_challenge_method")); } #[test] fn builderlab_login_exchange_remains_bare_code_without_handoff_secret() { assert_eq!( login_exchange_body("callback-code"), serde_json::json!({ "code": "callback-code" }) ); } }