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 futures_util::lock::Mutex as AsyncMutex;
9use serde_json::Value;
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    /// Lock the auth-state mutex, panicking on poison. Single access point; the
195    /// guarded sections only clone or assign fields and never panic, so poison
196    /// (a prior panic while holding the lock) is unreachable.
197    #[allow(clippy::unwrap_used)]
198    fn locked_state(&self) -> std::sync::MutexGuard<'_, AuthState> {
199        self.state.lock().unwrap()
200    }
201
202    /// The Suno user ID, available after [`authenticate`](Self::authenticate).
203    pub fn user_id(&self) -> Option<String> {
204        self.locked_state().user_id.clone()
205    }
206
207    /// The account display name, or `"Suno"` when none is known.
208    pub fn display_name(&self) -> String {
209        self.locked_state()
210            .display_name
211            .clone()
212            .unwrap_or_else(|| "Suno".to_owned())
213    }
214
215    /// Decode the `exp` claim of the stored `__client` cookie, if it decodes.
216    pub fn cookie_exp(&self) -> Option<i64> {
217        let normalised = normalise_token(&self.cookie);
218        let token = normalised.strip_prefix("__client=")?;
219        match decode_jwt_exp(token) {
220            0 => None,
221            exp => Some(exp),
222        }
223    }
224
225    /// Classify how close the stored cookie is to its own expiry.
226    pub fn token_expiry(&self, now_unix: i64, window_secs: i64) -> TokenExpiry {
227        self.cookie_exp()
228            .map(|exp| classify_token_expiry(exp, now_unix, window_secs))
229            .unwrap_or(TokenExpiry::Unknown)
230    }
231
232    /// Fetch the Clerk session and a first JWT, returning the user ID.
233    pub async fn authenticate(&self, http: &impl Http) -> Result<String> {
234        let _guard = self.refresh_flight.lock().await;
235        self.fetch_session(http).await?;
236        self.refresh_jwt(http).await?;
237        self.locked_state().user_id.clone().ok_or_else(|| {
238            Error::Auth("could not determine the user ID from the Clerk session".into())
239        })
240    }
241
242    /// Return a valid JWT, refreshing it when missing or near expiry.
243    pub async fn ensure_jwt(&self, now_unix: i64, http: &impl Http) -> Result<String> {
244        if !self.jwt_is_fresh(now_unix) {
245            let _guard = self.refresh_flight.lock().await;
246            if !self.jwt_is_fresh(now_unix) {
247                self.refresh_jwt(http).await?;
248            }
249        }
250        self.locked_state()
251            .jwt
252            .clone()
253            .ok_or_else(|| Error::Auth("failed to obtain a JWT".into()))
254    }
255
256    fn jwt_is_fresh(&self, now_unix: i64) -> bool {
257        let state = self.locked_state();
258        state.jwt.is_some() && now_unix < state.jwt_exp - JWT_REFRESH_BUFFER
259    }
260
261    /// Drop the cached JWT so the next [`ensure_jwt`](Self::ensure_jwt) refreshes.
262    pub fn invalidate_jwt(&self) {
263        self.locked_state().jwt = None;
264    }
265
266    async fn fetch_session(&self, http: &impl Http) -> Result<()> {
267        let cookie = self.cookie.clone();
268        let url = format!("{CLERK_BASE_URL}/v1/client?_clerk_js_version={CLERK_JS_VERSION}");
269        let data = clerk_request_json(http, &cookie, Method::Get, url).await?;
270        let info = parse_client_response(&data)?;
271        let mut state = self.locked_state();
272        state.session_id = Some(info.session_id);
273        state.user_id = info.user_id;
274        state.display_name = info.display_name;
275        Ok(())
276    }
277
278    async fn refresh_jwt(&self, http: &impl Http) -> Result<()> {
279        let mut session_id = self.locked_state().session_id.clone();
280        if session_id.is_none() {
281            self.fetch_session(http).await?;
282            session_id = self.locked_state().session_id.clone();
283        }
284        let session_id = session_id.ok_or_else(|| Error::Auth("no Clerk session".into()))?;
285        let cookie = self.cookie.clone();
286        let url = format!(
287            "{CLERK_BASE_URL}/v1/client/sessions/{session_id}/tokens?_clerk_js_version={CLERK_JS_VERSION}"
288        );
289        let data = clerk_request_json(http, &cookie, Method::Post, url).await?;
290        let jwt = parse_token_response(&data)?;
291        let mut state = self.locked_state();
292        state.jwt_exp = decode_jwt_exp(&jwt);
293        state.jwt = Some(jwt);
294        Ok(())
295    }
296}
297
298async fn clerk_request_json(
299    http: &impl Http,
300    cookie: &str,
301    method: Method,
302    url: String,
303) -> Result<Value> {
304    let request = HttpRequest {
305        method,
306        url,
307        headers: vec![("Cookie".to_string(), cookie.to_string())],
308        body: Vec::new(),
309    };
310    let response = http
311        .send(request)
312        .await
313        .map_err(|err| Error::Connection(format!("could not connect to Clerk: {err}")))?;
314    if response.status != 200 {
315        return Err(Error::Auth(format!(
316            "Clerk request failed with status {}",
317            response.status
318        )));
319    }
320    serde_json::from_slice(&response.body)
321        .map_err(|err| Error::Connection(format!("invalid Clerk response: {err}")))
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::testutil::{MockHttp, Reply, Rule, ScriptedHttp};
328
329    fn jwt_with_exp(exp: i64) -> String {
330        let payload =
331            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!("{{\"exp\":{exp}}}"));
332        format!("eyJhbGciOiJIUzI1NiJ9.{payload}.signature")
333    }
334
335    #[test]
336    fn normalise_accepts_raw_jwt() {
337        assert_eq!(normalise_token("  eyJabc  "), "__client=eyJabc");
338    }
339
340    #[test]
341    fn normalise_extracts_from_cookie_header() {
342        assert_eq!(
343            normalise_token("foo=1; __client=eyJabc; bar=2"),
344            "__client=eyJabc"
345        );
346    }
347
348    #[test]
349    fn normalise_wraps_unknown_value() {
350        assert_eq!(normalise_token("rawvalue"), "__client=rawvalue");
351    }
352
353    #[test]
354    fn decode_exp_reads_claim() {
355        assert_eq!(decode_jwt_exp(&jwt_with_exp(1_893_456_000)), 1_893_456_000);
356    }
357
358    #[test]
359    fn decode_exp_handles_garbage() {
360        assert_eq!(decode_jwt_exp("not-a-jwt"), 0);
361        assert_eq!(decode_jwt_exp(""), 0);
362    }
363
364    #[test]
365    fn classify_marks_fresh_beyond_window() {
366        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
367        let exp = 1_000_000 + window + 1;
368        assert_eq!(
369            classify_token_expiry(exp, 1_000_000, window),
370            TokenExpiry::Fresh
371        );
372    }
373
374    #[test]
375    fn classify_boundary_is_fresh_just_inside_is_expiring() {
376        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
377        let now = 1_000_000;
378        assert_eq!(
379            classify_token_expiry(now + window, now, window),
380            TokenExpiry::Fresh
381        );
382        assert_eq!(
383            classify_token_expiry(now + window - 1, now, window),
384            TokenExpiry::Expiring {
385                days: TOKEN_EXPIRY_WARN_DAYS
386            }
387        );
388    }
389
390    #[test]
391    fn classify_ceils_partial_days() {
392        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
393        let now = 1_000_000;
394        assert_eq!(
395            classify_token_expiry(now + 43_200, now, window),
396            TokenExpiry::Expiring { days: 1 }
397        );
398    }
399
400    #[test]
401    fn classify_saturates_on_extreme_bounds_without_panicking() {
402        // `exp - now_unix` would overflow i64 (a debug panic) for these bounds;
403        // the saturating subtraction must keep this public fn total. Unreachable
404        // via the real positive clock, but the fn is public API.
405        assert_eq!(
406            classify_token_expiry(i64::MAX, i64::MIN, i64::MAX),
407            TokenExpiry::Fresh
408        );
409        assert_eq!(
410            classify_token_expiry(i64::MAX, -1, i64::MAX),
411            TokenExpiry::Fresh
412        );
413    }
414
415    #[test]
416    fn classify_marks_expired_at_or_before_now() {
417        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
418        assert_eq!(
419            classify_token_expiry(1_000, 1_000, window),
420            TokenExpiry::Expired
421        );
422        assert_eq!(
423            classify_token_expiry(999, 1_000, window),
424            TokenExpiry::Expired
425        );
426    }
427
428    #[test]
429    fn token_expiry_round_trips_through_cookie() {
430        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
431        let now = 1_000_000;
432        let exp = now + 5 * 86_400;
433        let auth = ClerkAuth::new(&jwt_with_exp(exp));
434        assert_eq!(auth.cookie_exp(), Some(exp));
435        assert_eq!(
436            auth.token_expiry(now, window),
437            TokenExpiry::Expiring { days: 5 }
438        );
439    }
440
441    #[test]
442    fn token_expiry_is_unknown_for_undecodable_cookie() {
443        let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
444        let garbage = ClerkAuth::new("rawvalue");
445        assert_eq!(garbage.cookie_exp(), None);
446        assert_eq!(
447            garbage.token_expiry(1_000_000, window),
448            TokenExpiry::Unknown
449        );
450        // A JWT carrying exp = 0 decodes to nothing usable, so also Unknown.
451        let zero = ClerkAuth::new(&jwt_with_exp(0));
452        assert_eq!(zero.token_expiry(1_000_000, window), TokenExpiry::Unknown);
453    }
454
455    #[test]
456    fn display_name_prefers_username() {
457        let user = serde_json::json!({"username": "teh-hippo", "first_name": "Ignored"});
458        assert_eq!(derive_display_name(&user).as_deref(), Some("teh-hippo"));
459    }
460
461    #[test]
462    fn display_name_uses_first_last_when_no_username() {
463        let user = serde_json::json!({"first_name": "Ada", "last_name": "Lovelace"});
464        assert_eq!(derive_display_name(&user).as_deref(), Some("Ada Lovelace"));
465    }
466
467    #[test]
468    fn display_name_falls_back_to_email_local_part() {
469        let user = serde_json::json!({"username": "yshvq8dp9v@privaterelay.appleid.com"});
470        assert_eq!(derive_display_name(&user).as_deref(), Some("yshvq8dp9v"));
471    }
472
473    #[test]
474    fn parse_client_requires_a_session() {
475        let data = serde_json::json!({"response": {"sessions": []}});
476        assert!(parse_client_response(&data).is_err());
477    }
478
479    #[test]
480    fn authenticate_fetches_user_and_jwt() {
481        let client_body = serde_json::json!({
482            "response": {
483                "last_active_session_id": "sess_1",
484                "sessions": [
485                    {"id": "sess_1", "user": {"id": "user_1", "username": "teh-hippo"}}
486                ]
487            }
488        })
489        .to_string();
490        let token_body = serde_json::json!({"jwt": jwt_with_exp(1_893_456_000)}).to_string();
491
492        // The token URL also contains "/v1/client", so the specific rule wins by order.
493        let http = MockHttp::new(vec![
494            Rule::new("/v1/client/sessions/", 200, token_body),
495            Rule::new("/v1/client", 200, client_body),
496        ]);
497
498        let auth = ClerkAuth::new("eyJtoken");
499        let user_id = pollster::block_on(auth.authenticate(&http)).unwrap();
500        assert_eq!(user_id, "user_1");
501        assert_eq!(auth.display_name(), "teh-hippo");
502
503        // Well before expiry — no refresh needed.
504        let jwt = pollster::block_on(auth.ensure_jwt(0, &http)).unwrap();
505        assert!(jwt.starts_with("eyJ"));
506    }
507
508    #[test]
509    fn ensure_jwt_does_not_refresh_when_fresh() {
510        let exp = 1_000_000i64;
511        // No rules: any HTTP call would return an error.
512        let http = MockHttp::new(vec![]);
513        let auth = ClerkAuth::new("eyJtoken");
514        *auth.state.lock().unwrap() = AuthState {
515            jwt: Some(jwt_with_exp(exp)),
516            jwt_exp: exp,
517            session_id: Some("sess_1".into()),
518            user_id: Some("user_1".into()),
519            display_name: None,
520        };
521        let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER - 1, &http)).unwrap();
522        assert_eq!(decode_jwt_exp(&jwt), exp);
523    }
524
525    #[test]
526    fn ensure_jwt_refreshes_at_expiry_boundary() {
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 http = MockHttp::new(vec![Rule::new("/v1/client/sessions/", 200, token_body)]);
531        let auth = ClerkAuth::new("eyJtoken");
532        *auth.state.lock().unwrap() = AuthState {
533            jwt: Some(jwt_with_exp(exp)),
534            jwt_exp: exp,
535            session_id: Some("sess_1".into()),
536            user_id: Some("user_1".into()),
537            display_name: None,
538        };
539        // At the refresh boundary: a new JWT with new_exp is issued.
540        let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER, &http)).unwrap();
541        assert_eq!(decode_jwt_exp(&jwt), new_exp);
542    }
543
544    #[test]
545    fn ensure_jwt_refresh_is_single_flight_under_concurrency() {
546        let exp = 1_000_000i64;
547        let new_exp = exp + 3_600;
548        let token_body = serde_json::json!({"jwt": jwt_with_exp(new_exp)}).to_string();
549        let auth = ClerkAuth::new("eyJtoken");
550        *auth.state.lock().unwrap() = AuthState {
551            jwt: Some(jwt_with_exp(exp)),
552            jwt_exp: exp,
553            session_id: Some("sess_1".into()),
554            user_id: Some("user_1".into()),
555            display_name: None,
556        };
557        let http = ScriptedHttp::new().route("/v1/client/sessions/", Reply::json(&token_body));
558        let now = exp - JWT_REFRESH_BUFFER;
559        let (first, second) = pollster::block_on(async {
560            futures_util::future::join(auth.ensure_jwt(now, &http), auth.ensure_jwt(now, &http))
561                .await
562        });
563        assert!(first.is_ok());
564        assert!(second.is_ok());
565        assert_eq!(http.count("/v1/client/sessions/"), 1);
566    }
567}