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 - 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_marks_expired_at_or_before_now() {
398        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
399        assert_eq!(
400            classify_token_expiry(1_000, 1_000, window),
401            TokenExpiry::Expired
402        );
403        assert_eq!(
404            classify_token_expiry(999, 1_000, window),
405            TokenExpiry::Expired
406        );
407    }
408
409    #[test]
410    fn token_expiry_round_trips_through_cookie() {
411        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
412        let now = 1_000_000;
413        let exp = now + 5 * 86_400;
414        let auth = ClerkAuth::new(&jwt_with_exp(exp));
415        assert_eq!(auth.cookie_exp(), Some(exp));
416        assert_eq!(
417            auth.token_expiry(now, window),
418            TokenExpiry::Expiring { days: 5 }
419        );
420    }
421
422    #[test]
423    fn token_expiry_is_unknown_for_undecodable_cookie() {
424        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
425        let garbage = ClerkAuth::new("rawvalue");
426        assert_eq!(garbage.cookie_exp(), None);
427        assert_eq!(
428            garbage.token_expiry(1_000_000, window),
429            TokenExpiry::Unknown
430        );
431        // A JWT carrying exp = 0 decodes to nothing usable, so also Unknown.
432        let zero = ClerkAuth::new(&jwt_with_exp(0));
433        assert_eq!(zero.token_expiry(1_000_000, window), TokenExpiry::Unknown);
434    }
435
436    #[test]
437    fn display_name_prefers_username() {
438        let user = serde_json::json!({"username": "teh-hippo", "first_name": "Ignored"});
439        assert_eq!(derive_display_name(&user).as_deref(), Some("teh-hippo"));
440    }
441
442    #[test]
443    fn display_name_uses_first_last_when_no_username() {
444        let user = serde_json::json!({"first_name": "Ada", "last_name": "Lovelace"});
445        assert_eq!(derive_display_name(&user).as_deref(), Some("Ada Lovelace"));
446    }
447
448    #[test]
449    fn display_name_falls_back_to_email_local_part() {
450        let user = serde_json::json!({"username": "yshvq8dp9v@privaterelay.appleid.com"});
451        assert_eq!(derive_display_name(&user).as_deref(), Some("yshvq8dp9v"));
452    }
453
454    #[test]
455    fn parse_client_requires_a_session() {
456        let data = serde_json::json!({"response": {"sessions": []}});
457        assert!(parse_client_response(&data).is_err());
458    }
459
460    #[test]
461    fn authenticate_fetches_user_and_jwt() {
462        let client_body = serde_json::json!({
463            "response": {
464                "last_active_session_id": "sess_1",
465                "sessions": [
466                    {"id": "sess_1", "user": {"id": "user_1", "username": "teh-hippo"}}
467                ]
468            }
469        })
470        .to_string();
471        let token_body = serde_json::json!({"jwt": jwt_with_exp(1_893_456_000)}).to_string();
472
473        // The token URL also contains "/v1/client", so the specific rule wins by order.
474        let http = MockHttp::new(vec![
475            Rule::new("/v1/client/sessions/", 200, token_body),
476            Rule::new("/v1/client", 200, client_body),
477        ]);
478
479        let auth = ClerkAuth::new("eyJtoken");
480        let user_id = pollster::block_on(auth.authenticate(&http)).unwrap();
481        assert_eq!(user_id, "user_1");
482        assert_eq!(auth.display_name(), "teh-hippo");
483
484        // Well before expiry — no refresh needed.
485        let jwt = pollster::block_on(auth.ensure_jwt(0, &http)).unwrap();
486        assert!(jwt.starts_with("eyJ"));
487    }
488
489    #[test]
490    fn ensure_jwt_does_not_refresh_when_fresh() {
491        let exp = 1_000_000i64;
492        // No rules: any HTTP call would return an error.
493        let http = MockHttp::new(vec![]);
494        let auth = ClerkAuth::new("eyJtoken");
495        *auth.state.lock().unwrap() = AuthState {
496            jwt: Some(jwt_with_exp(exp)),
497            jwt_exp: exp,
498            session_id: Some("sess_1".into()),
499            user_id: Some("user_1".into()),
500            display_name: None,
501        };
502        let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER - 1, &http)).unwrap();
503        assert_eq!(decode_jwt_exp(&jwt), exp);
504    }
505
506    #[test]
507    fn ensure_jwt_refreshes_at_expiry_boundary() {
508        let exp = 1_000_000i64;
509        let new_exp = exp + 3_600;
510        let token_body = serde_json::json!({"jwt": jwt_with_exp(new_exp)}).to_string();
511        let http = MockHttp::new(vec![Rule::new("/v1/client/sessions/", 200, token_body)]);
512        let auth = ClerkAuth::new("eyJtoken");
513        *auth.state.lock().unwrap() = AuthState {
514            jwt: Some(jwt_with_exp(exp)),
515            jwt_exp: exp,
516            session_id: Some("sess_1".into()),
517            user_id: Some("user_1".into()),
518            display_name: None,
519        };
520        // At the refresh boundary: a new JWT with new_exp is issued.
521        let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER, &http)).unwrap();
522        assert_eq!(decode_jwt_exp(&jwt), new_exp);
523    }
524
525    #[test]
526    fn ensure_jwt_refresh_is_single_flight_under_concurrency() {
527        let exp = 1_000_000i64;
528        let new_exp = exp + 3_600;
529        let token_body = serde_json::json!({"jwt": jwt_with_exp(new_exp)}).to_string();
530        let auth = ClerkAuth::new("eyJtoken");
531        *auth.state.lock().unwrap() = AuthState {
532            jwt: Some(jwt_with_exp(exp)),
533            jwt_exp: exp,
534            session_id: Some("sess_1".into()),
535            user_id: Some("user_1".into()),
536            display_name: None,
537        };
538        let http = ScriptedHttp::new().route("/v1/client/sessions/", Reply::json(&token_body));
539        let now = exp - JWT_REFRESH_BUFFER;
540        let (first, second) = pollster::block_on(async {
541            futures_util::future::join(auth.ensure_jwt(now, &http), auth.ensure_jwt(now, &http))
542                .await
543        });
544        assert!(first.is_ok());
545        assert!(second.is_ok());
546        assert_eq!(http.count("/v1/client/sessions/"), 1);
547    }
548}