Skip to main content

suno_core/
auth.rs

1//! Clerk authentication: turn a `__client` cookie into short-lived JWTs.
2//!
3//! The cookie is sent only to Clerk. The Suno API ever sees only the minted JWT.
4
5use std::sync::Mutex;
6
7use base64::Engine;
8use serde_json::Value;
9use tokio::sync::Mutex as AsyncMutex;
10
11use crate::consts::{CLERK_BASE_URL, CLERK_JS_VERSION, JWT_REFRESH_BUFFER};
12use crate::error::{Error, Result};
13use crate::http::{Http, HttpRequest, Method};
14
15/// Normalise any accepted token form into a `__client=...` cookie string.
16///
17/// Accepts a raw JWT (`eyJ...`), a `__client=eyJ...` assignment, or a full
18/// cookie header that contains `__client` somewhere within it.
19pub(crate) fn normalise_token(token: &str) -> String {
20    let token = token.trim();
21    if token.starts_with("eyJ") {
22        return format!("__client={token}");
23    }
24    if token.contains("__client=") {
25        for part in token.split(';') {
26            if let Some(value) = part.trim().strip_prefix("__client=") {
27                return format!("__client={value}");
28            }
29        }
30    }
31    format!("__client={token}")
32}
33
34/// Extract the `exp` claim from a JWT without verifying its signature.
35///
36/// Returns `0` when the token is malformed, which callers treat as "expired".
37pub(crate) fn decode_jwt_exp(token: &str) -> i64 {
38    let Some(payload) = token.split('.').nth(1) else {
39        return 0;
40    };
41    let payload = payload.trim_end_matches('=');
42    let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
43        return 0;
44    };
45    let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
46        return 0;
47    };
48    value.get("exp").and_then(Value::as_i64).unwrap_or(0)
49}
50
51/// Warn when the pasted `__client` cookie is within this many days of expiry.
52pub const TOKEN_EXPIRY_WARN_DAYS: i64 = 14;
53
54/// The lifecycle state of the pasted `__client` cookie relative to now.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TokenExpiry {
57    /// The cookie could not be decoded, so its deadline is unknown.
58    Unknown,
59    /// The cookie is valid and comfortably beyond the warning window.
60    Fresh,
61    /// The cookie expires within the warning window, in `days` (rounded up).
62    Expiring { days: i64 },
63    /// The cookie has already expired.
64    Expired,
65}
66
67/// Classify a cookie's `exp` against `now_unix` and a warning `window_secs`.
68///
69/// `days` is rounded up so any time left short of a full day still reports at
70/// least `1`, never `0`.
71pub fn classify_token_expiry(exp: i64, now_unix: i64, window_secs: i64) -> TokenExpiry {
72    if exp <= now_unix {
73        return TokenExpiry::Expired;
74    }
75    let remaining = exp.saturating_sub(now_unix);
76    if remaining < window_secs {
77        const DAY_SECS: i64 = 86_400;
78        return TokenExpiry::Expiring {
79            days: (remaining + DAY_SECS - 1) / DAY_SECS,
80        };
81    }
82    TokenExpiry::Fresh
83}
84
85struct ClientInfo {
86    session_id: String,
87    user_id: Option<String>,
88    display_name: Option<String>,
89}
90
91fn parse_client_response(data: &Value) -> Result<ClientInfo> {
92    let response = data
93        .get("response")
94        .filter(|value| !value.is_null())
95        .ok_or_else(|| Error::Auth("invalid Clerk response; the cookie may be expired".into()))?;
96
97    let session_id = response
98        .get("last_active_session_id")
99        .and_then(Value::as_str)
100        .filter(|id| !id.is_empty())
101        .ok_or_else(|| Error::Auth("no active session; the cookie may be expired".into()))?
102        .to_string();
103
104    let mut user_id = None;
105    let mut display_name = None;
106    if let Some(sessions) = response.get("sessions").and_then(Value::as_array) {
107        for session in sessions {
108            if session.get("id").and_then(Value::as_str) == Some(session_id.as_str()) {
109                let user = session.get("user").cloned().unwrap_or(Value::Null);
110                user_id = user.get("id").and_then(Value::as_str).map(str::to_string);
111                display_name = derive_display_name(&user);
112                break;
113            }
114        }
115    }
116    Ok(ClientInfo {
117        session_id,
118        user_id,
119        display_name,
120    })
121}
122
123/// Pick a human display name from a Clerk user, preferring a real handle over
124/// an email-derived one, mirroring how the Suno web client labels accounts.
125fn derive_display_name(user: &Value) -> Option<String> {
126    let field = |key: &str| {
127        user.get(key)
128            .and_then(Value::as_str)
129            .unwrap_or("")
130            .trim()
131            .to_string()
132    };
133    let first = field("first_name");
134    let last = field("last_name");
135    let username = field("username");
136
137    if !username.is_empty() && !username.contains('@') {
138        Some(username)
139    } else if !first.is_empty() && !first.contains('@') {
140        Some(if last.is_empty() {
141            first
142        } else {
143            format!("{first} {last}")
144        })
145    } else if !username.is_empty() && username.contains('@') {
146        let local: String = username
147            .split('@')
148            .next()
149            .unwrap_or("")
150            .trim()
151            .chars()
152            .take(100)
153            .collect();
154        (!local.is_empty()).then_some(local)
155    } else {
156        None
157    }
158}
159
160fn parse_token_response(data: &Value) -> Result<String> {
161    data.get("jwt")
162        .and_then(Value::as_str)
163        .filter(|jwt| !jwt.is_empty())
164        .map(str::to_string)
165        .ok_or_else(|| Error::Auth("no JWT in the Clerk token response".into()))
166}
167
168/// Manages the Clerk cookie and the JWT lifecycle for one account.
169pub struct ClerkAuth {
170    cookie: String,
171    state: Mutex<AuthState>,
172    refresh_flight: AsyncMutex<()>,
173}
174
175#[derive(Default)]
176struct AuthState {
177    jwt: Option<String>,
178    jwt_exp: i64,
179    session_id: Option<String>,
180    user_id: Option<String>,
181    display_name: Option<String>,
182}
183
184impl ClerkAuth {
185    /// Create an authenticator from any accepted token form.
186    pub fn new(token: &str) -> Self {
187        Self {
188            cookie: normalise_token(token),
189            state: Mutex::new(AuthState::default()),
190            refresh_flight: AsyncMutex::new(()),
191        }
192    }
193
194    /// The Suno user ID, available after [`authenticate`](Self::authenticate).
195    pub fn user_id(&self) -> Option<String> {
196        self.state.lock().unwrap().user_id.clone()
197    }
198
199    /// The account display name, or `"Suno"` when none is known.
200    pub fn display_name(&self) -> String {
201        self.state
202            .lock()
203            .unwrap()
204            .display_name
205            .clone()
206            .unwrap_or_else(|| "Suno".to_owned())
207    }
208
209    /// Decode the `exp` claim of the stored `__client` cookie, if it decodes.
210    pub fn cookie_exp(&self) -> Option<i64> {
211        let normalised = normalise_token(&self.cookie);
212        let token = normalised.strip_prefix("__client=")?;
213        match decode_jwt_exp(token) {
214            0 => None,
215            exp => Some(exp),
216        }
217    }
218
219    /// Classify how close the stored cookie is to its own expiry.
220    pub fn token_expiry(&self, now_unix: i64, window_secs: i64) -> TokenExpiry {
221        self.cookie_exp()
222            .map(|exp| classify_token_expiry(exp, now_unix, window_secs))
223            .unwrap_or(TokenExpiry::Unknown)
224    }
225
226    /// Fetch the Clerk session and a first JWT, returning the user ID.
227    pub async fn authenticate(&self, http: &impl Http) -> Result<String> {
228        let _guard = self.refresh_flight.lock().await;
229        self.fetch_session(http).await?;
230        self.refresh_jwt(http).await?;
231        self.state.lock().unwrap().user_id.clone().ok_or_else(|| {
232            Error::Auth("could not determine the user ID from the Clerk session".into())
233        })
234    }
235
236    /// Return a valid JWT, refreshing it when missing or near expiry.
237    pub async fn ensure_jwt(&self, now_unix: i64, http: &impl Http) -> Result<String> {
238        if !self.jwt_is_fresh(now_unix) {
239            let _guard = self.refresh_flight.lock().await;
240            if !self.jwt_is_fresh(now_unix) {
241                self.refresh_jwt(http).await?;
242            }
243        }
244        self.state
245            .lock()
246            .unwrap()
247            .jwt
248            .clone()
249            .ok_or_else(|| Error::Auth("failed to obtain a JWT".into()))
250    }
251
252    fn jwt_is_fresh(&self, now_unix: i64) -> bool {
253        let state = self.state.lock().unwrap();
254        state.jwt.is_some() && now_unix < state.jwt_exp - JWT_REFRESH_BUFFER
255    }
256
257    /// Drop the cached JWT so the next [`ensure_jwt`](Self::ensure_jwt) refreshes.
258    pub fn invalidate_jwt(&self) {
259        self.state.lock().unwrap().jwt = None;
260    }
261
262    async fn fetch_session(&self, http: &impl Http) -> Result<()> {
263        let cookie = self.cookie.clone();
264        let url = format!("{CLERK_BASE_URL}/v1/client?_clerk_js_version={CLERK_JS_VERSION}");
265        let data = clerk_request_json(http, &cookie, Method::Get, url).await?;
266        let info = parse_client_response(&data)?;
267        let mut state = self.state.lock().unwrap();
268        state.session_id = Some(info.session_id);
269        state.user_id = info.user_id;
270        state.display_name = info.display_name;
271        Ok(())
272    }
273
274    async fn refresh_jwt(&self, http: &impl Http) -> Result<()> {
275        let mut session_id = self.state.lock().unwrap().session_id.clone();
276        if session_id.is_none() {
277            self.fetch_session(http).await?;
278            session_id = self.state.lock().unwrap().session_id.clone();
279        }
280        let session_id = session_id.ok_or_else(|| Error::Auth("no Clerk session".into()))?;
281        let cookie = self.cookie.clone();
282        let url = format!(
283            "{CLERK_BASE_URL}/v1/client/sessions/{session_id}/tokens?_clerk_js_version={CLERK_JS_VERSION}"
284        );
285        let data = clerk_request_json(http, &cookie, Method::Post, url).await?;
286        let jwt = parse_token_response(&data)?;
287        let mut state = self.state.lock().unwrap();
288        state.jwt_exp = decode_jwt_exp(&jwt);
289        state.jwt = Some(jwt);
290        Ok(())
291    }
292}
293
294async fn clerk_request_json(
295    http: &impl Http,
296    cookie: &str,
297    method: Method,
298    url: String,
299) -> Result<Value> {
300    let request = HttpRequest {
301        method,
302        url,
303        headers: vec![("Cookie".to_string(), cookie.to_string())],
304        body: Vec::new(),
305    };
306    let response = http
307        .send(request)
308        .await
309        .map_err(|err| Error::Connection(format!("could not connect to Clerk: {err}")))?;
310    if response.status != 200 {
311        return Err(Error::Auth(format!(
312            "Clerk request failed with status {}",
313            response.status
314        )));
315    }
316    serde_json::from_slice(&response.body)
317        .map_err(|err| Error::Connection(format!("invalid Clerk response: {err}")))
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::testutil::{MockHttp, Reply, Rule, ScriptedHttp};
324
325    fn jwt_with_exp(exp: i64) -> String {
326        let payload =
327            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!("{{\"exp\":{exp}}}"));
328        format!("eyJhbGciOiJIUzI1NiJ9.{payload}.signature")
329    }
330
331    #[test]
332    fn normalise_accepts_raw_jwt() {
333        assert_eq!(normalise_token("  eyJabc  "), "__client=eyJabc");
334    }
335
336    #[test]
337    fn normalise_extracts_from_cookie_header() {
338        assert_eq!(
339            normalise_token("foo=1; __client=eyJabc; bar=2"),
340            "__client=eyJabc"
341        );
342    }
343
344    #[test]
345    fn normalise_wraps_unknown_value() {
346        assert_eq!(normalise_token("rawvalue"), "__client=rawvalue");
347    }
348
349    #[test]
350    fn decode_exp_reads_claim() {
351        assert_eq!(decode_jwt_exp(&jwt_with_exp(1_893_456_000)), 1_893_456_000);
352    }
353
354    #[test]
355    fn decode_exp_handles_garbage() {
356        assert_eq!(decode_jwt_exp("not-a-jwt"), 0);
357        assert_eq!(decode_jwt_exp(""), 0);
358    }
359
360    #[test]
361    fn classify_marks_fresh_beyond_window() {
362        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
363        let exp = 1_000_000 + window + 1;
364        assert_eq!(
365            classify_token_expiry(exp, 1_000_000, window),
366            TokenExpiry::Fresh
367        );
368    }
369
370    #[test]
371    fn classify_boundary_is_fresh_just_inside_is_expiring() {
372        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
373        let now = 1_000_000;
374        assert_eq!(
375            classify_token_expiry(now + window, now, window),
376            TokenExpiry::Fresh
377        );
378        assert_eq!(
379            classify_token_expiry(now + window - 1, now, window),
380            TokenExpiry::Expiring {
381                days: TOKEN_EXPIRY_WARN_DAYS
382            }
383        );
384    }
385
386    #[test]
387    fn classify_ceils_partial_days() {
388        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
389        let now = 1_000_000;
390        assert_eq!(
391            classify_token_expiry(now + 43_200, now, window),
392            TokenExpiry::Expiring { days: 1 }
393        );
394    }
395
396    #[test]
397    fn classify_saturates_on_extreme_bounds_without_panicking() {
398        // `exp - now_unix` would overflow i64 (a debug panic) for these bounds;
399        // the saturating subtraction must keep this public fn total. Unreachable
400        // via the real positive clock, but the fn is public API.
401        assert_eq!(
402            classify_token_expiry(i64::MAX, i64::MIN, i64::MAX),
403            TokenExpiry::Fresh
404        );
405        assert_eq!(
406            classify_token_expiry(i64::MAX, -1, i64::MAX),
407            TokenExpiry::Fresh
408        );
409    }
410
411    #[test]
412    fn classify_marks_expired_at_or_before_now() {
413        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
414        assert_eq!(
415            classify_token_expiry(1_000, 1_000, window),
416            TokenExpiry::Expired
417        );
418        assert_eq!(
419            classify_token_expiry(999, 1_000, window),
420            TokenExpiry::Expired
421        );
422    }
423
424    #[test]
425    fn token_expiry_round_trips_through_cookie() {
426        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
427        let now = 1_000_000;
428        let exp = now + 5 * 86_400;
429        let auth = ClerkAuth::new(&jwt_with_exp(exp));
430        assert_eq!(auth.cookie_exp(), Some(exp));
431        assert_eq!(
432            auth.token_expiry(now, window),
433            TokenExpiry::Expiring { days: 5 }
434        );
435    }
436
437    #[test]
438    fn token_expiry_is_unknown_for_undecodable_cookie() {
439        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
440        let garbage = ClerkAuth::new("rawvalue");
441        assert_eq!(garbage.cookie_exp(), None);
442        assert_eq!(
443            garbage.token_expiry(1_000_000, window),
444            TokenExpiry::Unknown
445        );
446        // A JWT carrying exp = 0 decodes to nothing usable, so also Unknown.
447        let zero = ClerkAuth::new(&jwt_with_exp(0));
448        assert_eq!(zero.token_expiry(1_000_000, window), TokenExpiry::Unknown);
449    }
450
451    #[test]
452    fn display_name_prefers_username() {
453        let user = serde_json::json!({"username": "teh-hippo", "first_name": "Ignored"});
454        assert_eq!(derive_display_name(&user).as_deref(), Some("teh-hippo"));
455    }
456
457    #[test]
458    fn display_name_uses_first_last_when_no_username() {
459        let user = serde_json::json!({"first_name": "Ada", "last_name": "Lovelace"});
460        assert_eq!(derive_display_name(&user).as_deref(), Some("Ada Lovelace"));
461    }
462
463    #[test]
464    fn display_name_falls_back_to_email_local_part() {
465        let user = serde_json::json!({"username": "yshvq8dp9v@privaterelay.appleid.com"});
466        assert_eq!(derive_display_name(&user).as_deref(), Some("yshvq8dp9v"));
467    }
468
469    #[test]
470    fn parse_client_requires_a_session() {
471        let data = serde_json::json!({"response": {"sessions": []}});
472        assert!(parse_client_response(&data).is_err());
473    }
474
475    #[test]
476    fn authenticate_fetches_user_and_jwt() {
477        let client_body = serde_json::json!({
478            "response": {
479                "last_active_session_id": "sess_1",
480                "sessions": [
481                    {"id": "sess_1", "user": {"id": "user_1", "username": "teh-hippo"}}
482                ]
483            }
484        })
485        .to_string();
486        let token_body = serde_json::json!({"jwt": jwt_with_exp(1_893_456_000)}).to_string();
487
488        // The token URL also contains "/v1/client", so the specific rule wins by order.
489        let http = MockHttp::new(vec![
490            Rule::new("/v1/client/sessions/", 200, token_body),
491            Rule::new("/v1/client", 200, client_body),
492        ]);
493
494        let auth = ClerkAuth::new("eyJtoken");
495        let user_id = pollster::block_on(auth.authenticate(&http)).unwrap();
496        assert_eq!(user_id, "user_1");
497        assert_eq!(auth.display_name(), "teh-hippo");
498
499        // Well before expiry — no refresh needed.
500        let jwt = pollster::block_on(auth.ensure_jwt(0, &http)).unwrap();
501        assert!(jwt.starts_with("eyJ"));
502    }
503
504    #[test]
505    fn ensure_jwt_does_not_refresh_when_fresh() {
506        let exp = 1_000_000i64;
507        // No rules: any HTTP call would return an error.
508        let http = MockHttp::new(vec![]);
509        let auth = ClerkAuth::new("eyJtoken");
510        *auth.state.lock().unwrap() = AuthState {
511            jwt: Some(jwt_with_exp(exp)),
512            jwt_exp: exp,
513            session_id: Some("sess_1".into()),
514            user_id: Some("user_1".into()),
515            display_name: None,
516        };
517        let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER - 1, &http)).unwrap();
518        assert_eq!(decode_jwt_exp(&jwt), exp);
519    }
520
521    #[test]
522    fn ensure_jwt_refreshes_at_expiry_boundary() {
523        let exp = 1_000_000i64;
524        let new_exp = exp + 3_600;
525        let token_body = serde_json::json!({"jwt": jwt_with_exp(new_exp)}).to_string();
526        let http = MockHttp::new(vec![Rule::new("/v1/client/sessions/", 200, token_body)]);
527        let auth = ClerkAuth::new("eyJtoken");
528        *auth.state.lock().unwrap() = AuthState {
529            jwt: Some(jwt_with_exp(exp)),
530            jwt_exp: exp,
531            session_id: Some("sess_1".into()),
532            user_id: Some("user_1".into()),
533            display_name: None,
534        };
535        // At the refresh boundary: a new JWT with new_exp is issued.
536        let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER, &http)).unwrap();
537        assert_eq!(decode_jwt_exp(&jwt), new_exp);
538    }
539
540    #[test]
541    fn ensure_jwt_refresh_is_single_flight_under_concurrency() {
542        let exp = 1_000_000i64;
543        let new_exp = exp + 3_600;
544        let token_body = serde_json::json!({"jwt": jwt_with_exp(new_exp)}).to_string();
545        let auth = ClerkAuth::new("eyJtoken");
546        *auth.state.lock().unwrap() = AuthState {
547            jwt: Some(jwt_with_exp(exp)),
548            jwt_exp: exp,
549            session_id: Some("sess_1".into()),
550            user_id: Some("user_1".into()),
551            display_name: None,
552        };
553        let http = ScriptedHttp::new().route("/v1/client/sessions/", Reply::json(&token_body));
554        let now = exp - JWT_REFRESH_BUFFER;
555        let (first, second) = pollster::block_on(async {
556            futures_util::future::join(auth.ensure_jwt(now, &http), auth.ensure_jwt(now, &http))
557                .await
558        });
559        assert!(first.is_ok());
560        assert!(second.is_ok());
561        assert_eq!(http.count("/v1/client/sessions/"), 1);
562    }
563}