use std::future::Future;
use crate::api::SunoClient;
use crate::app::AppContext;
use crate::auth::{self, AuthState, BrowserAuth};
use crate::cli::AuthArgs;
use crate::core::CliError;
pub async fn run(args: AuthArgs, _ctx: &AppContext) -> Result<(), CliError> {
if args.logout {
run_logout_with_cleanup(AuthState::delete, auth::delete_interactive_browser_profile)?;
return Ok(());
}
let mut state = match AuthState::load() {
Ok(s) => s,
Err(CliError::AuthMissing) => AuthState::default(),
Err(e) => return Err(e),
};
let has_explicit_auth_input =
args.login || args.refresh || args.jwt.is_some() || args.cookie.is_some();
let should_login = args.login
|| (!has_explicit_auth_input && state.jwt.is_none() && state.clerk_client_cookie.is_none());
if args.refresh {
state.clerk_client_cookie.as_ref().ok_or_else(|| {
CliError::Config("no Clerk session cookie stored — run `sunox login` first".into())
})?;
let http = reqwest::Client::new();
auth::refresh_state_explicit(&http, &mut state).await?;
} else if should_login {
eprintln!("Extracting Suno session from your browser...");
let browser_auth = extract_browser_auth_with_fallback(
auth::extract_browser_auth,
auth::extract_interactive_browser_auth,
)
.await?;
let http = reqwest::Client::new();
eprintln!("Exchanging for access token via Clerk...");
let (session_id, jwt) =
auth::clerk_token_exchange(&http, &browser_auth.clerk_client_cookie).await?;
store_browser_auth_state(&mut state, browser_auth, session_id, jwt);
} else if let Some(cookie) = args.cookie.as_deref() {
let browser_auth = auth::normalize_cookie_input(cookie)?;
let http = reqwest::Client::new();
eprintln!("Exchanging cookie for access token...");
let (session_id, jwt) =
auth::clerk_token_exchange(&http, &browser_auth.clerk_client_cookie).await?;
store_browser_auth_state(&mut state, browser_auth, session_id, jwt);
} else if let Some(jwt) = args.jwt.clone() {
store_direct_jwt_state(&mut state, jwt);
} else {
eprintln!("Checking existing authentication...");
}
if let Some(device) = args.device.as_ref() {
state.device_id = Some(device.clone());
}
let should_save_after_verify = args.refresh
|| should_login
|| args.cookie.is_some()
|| args.jwt.is_some()
|| args.device.is_some();
let client = SunoClient::new_with_refresh(state.clone()).await?;
let info = client.billing_info().await?;
if should_save_after_verify {
state.save()?;
}
eprintln!(
"Authenticated! Plan: {}, Credits: {}",
info.plan.name, info.total_credits_left
);
Ok(())
}
fn run_logout_with_cleanup<D, P>(
delete_auth_state: D,
delete_interactive_profile: P,
) -> Result<(), CliError>
where
D: FnOnce() -> Result<(), CliError>,
P: FnOnce() -> Result<(), CliError>,
{
delete_auth_state()?;
delete_interactive_profile()?;
eprintln!("Logged out; removed stored Suno authentication");
Ok(())
}
async fn extract_browser_auth_with_fallback<C, I, Fut>(
browser_cookie_probe: C,
interactive_login: I,
) -> Result<BrowserAuth, CliError>
where
C: FnOnce() -> Result<BrowserAuth, CliError>,
I: FnOnce() -> Fut,
Fut: Future<Output = Result<BrowserAuth, CliError>>,
{
match browser_cookie_probe() {
Ok(auth) => Ok(auth),
Err(cookie_error) => {
eprintln!("Browser cookie extraction failed: {cookie_error}");
eprintln!("Falling back to interactive browser login...");
interactive_login().await.map_err(|interactive_error| {
CliError::Config(format!(
"browser cookie extraction failed ({cookie_error}); interactive browser login failed ({interactive_error})"
))
})
}
}
}
fn store_browser_auth_state(
state: &mut AuthState,
browser_auth: BrowserAuth,
session_id: String,
jwt: String,
) {
state.cookie = Some(browser_auth.cookie_header);
state.clerk_client_cookie = Some(browser_auth.clerk_client_cookie);
state.session_id = Some(session_id);
state.jwt = Some(jwt);
state.device_id = browser_auth
.device_id
.or_else(|| state.device_id.take())
.or_else(|| Some(uuid::Uuid::new_v4().to_string()));
state.browser_environment = browser_auth.browser_environment;
}
fn store_direct_jwt_state(state: &mut AuthState, jwt: String) {
state.jwt = Some(jwt);
state.cookie = None;
state.clerk_client_cookie = None;
state.session_id = None;
state.device_id = Some(uuid::Uuid::new_v4().to_string());
state.browser_environment = None;
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use crate::auth::BrowserEnvironment;
use super::*;
fn auth_with_client(value: &str) -> BrowserAuth {
BrowserAuth {
clerk_client_cookie: value.into(),
cookie_header: format!("__client={value}"),
device_id: None,
browser_environment: None,
}
}
#[tokio::test]
async fn login_auth_uses_browser_cookie_when_available() {
let interactive_called = Cell::new(false);
let auth = extract_browser_auth_with_fallback(
|| Ok(auth_with_client("browser-cookie")),
|| async {
interactive_called.set(true);
Ok(auth_with_client("interactive"))
},
)
.await
.expect("auth");
assert_eq!(auth.clerk_client_cookie, "browser-cookie");
assert!(!interactive_called.get());
}
#[tokio::test]
async fn login_auth_preserves_browser_environment_from_cookie_probe() {
let auth = extract_browser_auth_with_fallback(
|| {
Ok(BrowserAuth {
clerk_client_cookie: "browser-cookie".into(),
cookie_header: "__client=browser-cookie".into(),
device_id: None,
browser_environment: Some(BrowserEnvironment {
browser_source: Some("chrome".into()),
user_agent: None,
accept_language: Some("zh-CN,zh;q=0.9".into()),
}),
})
},
|| async { Ok(auth_with_client("interactive")) },
)
.await
.expect("auth");
let environment = auth.browser_environment.expect("environment");
assert_eq!(environment.browser_source.as_deref(), Some("chrome"));
assert_eq!(
environment.accept_language.as_deref(),
Some("zh-CN,zh;q=0.9")
);
}
#[tokio::test]
async fn login_auth_falls_back_to_interactive_browser_when_cookie_probe_fails() {
let auth = extract_browser_auth_with_fallback(
|| Err(CliError::Config("cookie blocked".into())),
|| async { Ok(auth_with_client("interactive")) },
)
.await
.expect("auth");
assert_eq!(auth.clerk_client_cookie, "interactive");
}
#[test]
fn browser_auth_state_uses_new_browser_environment() {
let mut state = AuthState {
device_id: Some("stored-device".into()),
browser_environment: Some(BrowserEnvironment {
browser_source: Some("interactive-browser".into()),
user_agent: Some("Mozilla/5.0 Test".into()),
accept_language: Some("en-US,en;q=0.9".into()),
}),
..AuthState::default()
};
store_browser_auth_state(
&mut state,
BrowserAuth {
clerk_client_cookie: "client".into(),
cookie_header: "__client=client".into(),
device_id: None,
browser_environment: Some(BrowserEnvironment {
browser_source: Some("edge".into()),
user_agent: None,
accept_language: None,
}),
},
"session".into(),
"jwt".into(),
);
let environment = state.browser_environment.expect("environment");
assert_eq!(environment.browser_source.as_deref(), Some("edge"));
assert_eq!(environment.user_agent, None);
assert_eq!(environment.accept_language, None);
assert_eq!(state.device_id.as_deref(), Some("stored-device"));
}
#[test]
fn browser_auth_state_clears_stored_environment_when_new_auth_has_none() {
let mut state = AuthState {
browser_environment: Some(BrowserEnvironment {
browser_source: Some("interactive-browser".into()),
user_agent: Some("Mozilla/5.0 Test".into()),
accept_language: Some("en-US,en;q=0.9".into()),
}),
..AuthState::default()
};
store_browser_auth_state(
&mut state,
BrowserAuth {
clerk_client_cookie: "client".into(),
cookie_header: "__client=client".into(),
device_id: None,
browser_environment: None,
},
"session".into(),
"jwt".into(),
);
assert!(state.browser_environment.is_none());
}
#[test]
fn browser_auth_state_uses_new_device_id_when_available() {
let mut state = AuthState {
device_id: Some("stored-device".into()),
..AuthState::default()
};
store_browser_auth_state(
&mut state,
BrowserAuth {
clerk_client_cookie: "client".into(),
cookie_header: "__client=client".into(),
device_id: Some("new-device".into()),
browser_environment: Some(BrowserEnvironment {
browser_source: Some("edge".into()),
user_agent: None,
accept_language: None,
}),
},
"session".into(),
"jwt".into(),
);
assert_eq!(state.device_id.as_deref(), Some("new-device"));
}
#[test]
fn direct_jwt_state_clears_stored_refresh_material() {
let mut state = AuthState {
jwt: Some("old-jwt".into()),
cookie: Some("__client=old-client".into()),
session_id: Some("old-session".into()),
device_id: Some("old-device".into()),
browser_environment: Some(BrowserEnvironment {
browser_source: Some("chrome".into()),
user_agent: Some("Mozilla/5.0 Old".into()),
accept_language: Some("en-US,en;q=0.9".into()),
}),
clerk_client_cookie: Some("old-client".into()),
};
store_direct_jwt_state(&mut state, "new-jwt".into());
assert_eq!(state.jwt.as_deref(), Some("new-jwt"));
assert_eq!(state.cookie, None);
assert_eq!(state.clerk_client_cookie, None);
assert_eq!(state.session_id, None);
assert_ne!(state.device_id.as_deref(), Some("old-device"));
assert!(state.browser_environment.is_none());
}
#[test]
fn logout_removes_stored_auth_and_interactive_browser_profile() {
let mut deleted_auth = false;
let mut deleted_profile = false;
run_logout_with_cleanup(
|| {
deleted_auth = true;
Ok(())
},
|| {
deleted_profile = true;
Ok(())
},
)
.expect("logout");
assert!(deleted_auth);
assert!(deleted_profile);
}
}