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