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, CLERK_TOKEN_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
48struct ClientInfo {
49    session_id: String,
50    user_id: Option<String>,
51    display_name: Option<String>,
52}
53
54fn parse_client_response(data: &Value) -> Result<ClientInfo> {
55    let response = data
56        .get("response")
57        .filter(|value| !value.is_null())
58        .ok_or_else(|| Error::Auth("invalid Clerk response; the cookie may be expired".into()))?;
59
60    let session_id = response
61        .get("last_active_session_id")
62        .and_then(Value::as_str)
63        .filter(|id| !id.is_empty())
64        .ok_or_else(|| Error::Auth("no active session; the cookie may be expired".into()))?
65        .to_string();
66
67    let mut user_id = None;
68    let mut display_name = None;
69    if let Some(sessions) = response.get("sessions").and_then(Value::as_array) {
70        for session in sessions {
71            if session.get("id").and_then(Value::as_str) == Some(session_id.as_str()) {
72                let user = session.get("user").cloned().unwrap_or(Value::Null);
73                user_id = user.get("id").and_then(Value::as_str).map(str::to_string);
74                display_name = derive_display_name(&user);
75                break;
76            }
77        }
78    }
79    Ok(ClientInfo {
80        session_id,
81        user_id,
82        display_name,
83    })
84}
85
86/// Pick a human display name from a Clerk user, preferring a real handle over
87/// an email-derived one, mirroring how the Suno web client labels accounts.
88fn derive_display_name(user: &Value) -> Option<String> {
89    let field = |key: &str| {
90        user.get(key)
91            .and_then(Value::as_str)
92            .unwrap_or("")
93            .trim()
94            .to_string()
95    };
96    let first = field("first_name");
97    let last = field("last_name");
98    let username = field("username");
99
100    if !username.is_empty() && !username.contains('@') {
101        Some(username)
102    } else if !first.is_empty() && !first.contains('@') {
103        Some(if last.is_empty() {
104            first
105        } else {
106            format!("{first} {last}")
107        })
108    } else if !username.is_empty() && username.contains('@') {
109        let local: String = username
110            .split('@')
111            .next()
112            .unwrap_or("")
113            .trim()
114            .chars()
115            .take(100)
116            .collect();
117        (!local.is_empty()).then_some(local)
118    } else {
119        None
120    }
121}
122
123fn parse_token_response(data: &Value) -> Result<String> {
124    data.get("jwt")
125        .and_then(Value::as_str)
126        .filter(|jwt| !jwt.is_empty())
127        .map(str::to_string)
128        .ok_or_else(|| Error::Auth("no JWT in the Clerk token response".into()))
129}
130
131/// Manages the Clerk cookie and the JWT lifecycle for one account.
132pub struct ClerkAuth {
133    cookie: String,
134    jwt: Option<String>,
135    jwt_exp: i64,
136    session_id: Option<String>,
137    user_id: Option<String>,
138    display_name: Option<String>,
139}
140
141impl ClerkAuth {
142    /// Create an authenticator from any accepted token form.
143    pub fn new(token: &str) -> Self {
144        Self {
145            cookie: normalise_token(token),
146            jwt: None,
147            jwt_exp: 0,
148            session_id: None,
149            user_id: None,
150            display_name: None,
151        }
152    }
153
154    /// The Suno user ID, available after [`authenticate`](Self::authenticate).
155    pub fn user_id(&self) -> Option<&str> {
156        self.user_id.as_deref()
157    }
158
159    /// The account display name, or `"Suno"` when none is known.
160    pub fn display_name(&self) -> &str {
161        self.display_name.as_deref().unwrap_or("Suno")
162    }
163
164    /// Fetch the Clerk session and a first JWT, returning the user ID.
165    pub async fn authenticate(&mut self, http: &impl Http) -> Result<String> {
166        self.fetch_session(http).await?;
167        self.refresh_jwt(http).await?;
168        self.user_id.clone().ok_or_else(|| {
169            Error::Auth("could not determine the user ID from the Clerk session".into())
170        })
171    }
172
173    /// Return a valid JWT, refreshing it when missing or near expiry.
174    pub async fn ensure_jwt(&mut self, http: &impl Http) -> Result<String> {
175        if self.jwt.is_none() || now_unix() >= self.jwt_exp - JWT_REFRESH_BUFFER {
176            self.refresh_jwt(http).await?;
177        }
178        self.jwt
179            .clone()
180            .ok_or_else(|| Error::Auth("failed to obtain a JWT".into()))
181    }
182
183    /// Drop the cached JWT so the next [`ensure_jwt`](Self::ensure_jwt) refreshes.
184    pub fn invalidate_jwt(&mut self) {
185        self.jwt = None;
186    }
187
188    async fn fetch_session(&mut self, http: &impl Http) -> Result<()> {
189        let cookie = self.cookie.clone();
190        let url = format!("{CLERK_BASE_URL}/v1/client?_clerk_js_version={CLERK_JS_VERSION}");
191        let data = clerk_request_json(http, &cookie, Method::Get, url).await?;
192        let info = parse_client_response(&data)?;
193        self.session_id = Some(info.session_id);
194        self.user_id = info.user_id;
195        self.display_name = info.display_name;
196        Ok(())
197    }
198
199    async fn refresh_jwt(&mut self, http: &impl Http) -> Result<()> {
200        if self.session_id.is_none() {
201            self.fetch_session(http).await?;
202        }
203        let session_id = self
204            .session_id
205            .clone()
206            .ok_or_else(|| Error::Auth("no Clerk session".into()))?;
207        let cookie = self.cookie.clone();
208        let url = format!(
209            "{CLERK_BASE_URL}/v1/client/sessions/{session_id}/tokens?_clerk_js_version={CLERK_TOKEN_JS_VERSION}"
210        );
211        let data = clerk_request_json(http, &cookie, Method::Post, url).await?;
212        let jwt = parse_token_response(&data)?;
213        self.jwt_exp = decode_jwt_exp(&jwt);
214        self.jwt = Some(jwt);
215        Ok(())
216    }
217}
218
219async fn clerk_request_json(
220    http: &impl Http,
221    cookie: &str,
222    method: Method,
223    url: String,
224) -> Result<Value> {
225    let request = HttpRequest {
226        method,
227        url,
228        headers: vec![("Cookie".to_string(), cookie.to_string())],
229    };
230    let response = http
231        .send(request)
232        .await
233        .map_err(|err| Error::Connection(format!("could not connect to Clerk: {err}")))?;
234    if response.status != 200 {
235        return Err(Error::Auth(format!(
236            "Clerk request failed with status {}",
237            response.status
238        )));
239    }
240    serde_json::from_slice(&response.body)
241        .map_err(|err| Error::Connection(format!("invalid Clerk response: {err}")))
242}
243
244fn now_unix() -> i64 {
245    use std::time::{SystemTime, UNIX_EPOCH};
246    SystemTime::now()
247        .duration_since(UNIX_EPOCH)
248        .map(|d| d.as_secs() as i64)
249        .unwrap_or(0)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::testutil::{MockHttp, Rule};
256
257    fn jwt_with_exp(exp: i64) -> String {
258        let payload =
259            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!("{{\"exp\":{exp}}}"));
260        format!("eyJhbGciOiJIUzI1NiJ9.{payload}.signature")
261    }
262
263    #[test]
264    fn normalise_accepts_raw_jwt() {
265        assert_eq!(normalise_token("  eyJabc  "), "__client=eyJabc");
266    }
267
268    #[test]
269    fn normalise_extracts_from_cookie_header() {
270        assert_eq!(
271            normalise_token("foo=1; __client=eyJabc; bar=2"),
272            "__client=eyJabc"
273        );
274    }
275
276    #[test]
277    fn normalise_wraps_unknown_value() {
278        assert_eq!(normalise_token("rawvalue"), "__client=rawvalue");
279    }
280
281    #[test]
282    fn decode_exp_reads_claim() {
283        assert_eq!(decode_jwt_exp(&jwt_with_exp(1_893_456_000)), 1_893_456_000);
284    }
285
286    #[test]
287    fn decode_exp_handles_garbage() {
288        assert_eq!(decode_jwt_exp("not-a-jwt"), 0);
289        assert_eq!(decode_jwt_exp(""), 0);
290    }
291
292    #[test]
293    fn display_name_prefers_username() {
294        let user = serde_json::json!({"username": "teh-hippo", "first_name": "Ignored"});
295        assert_eq!(derive_display_name(&user).as_deref(), Some("teh-hippo"));
296    }
297
298    #[test]
299    fn display_name_uses_first_last_when_no_username() {
300        let user = serde_json::json!({"first_name": "Ada", "last_name": "Lovelace"});
301        assert_eq!(derive_display_name(&user).as_deref(), Some("Ada Lovelace"));
302    }
303
304    #[test]
305    fn display_name_falls_back_to_email_local_part() {
306        let user = serde_json::json!({"username": "yshvq8dp9v@privaterelay.appleid.com"});
307        assert_eq!(derive_display_name(&user).as_deref(), Some("yshvq8dp9v"));
308    }
309
310    #[test]
311    fn parse_client_requires_a_session() {
312        let data = serde_json::json!({"response": {"sessions": []}});
313        assert!(parse_client_response(&data).is_err());
314    }
315
316    #[test]
317    fn authenticate_fetches_user_and_jwt() {
318        let client_body = serde_json::json!({
319            "response": {
320                "last_active_session_id": "sess_1",
321                "sessions": [
322                    {"id": "sess_1", "user": {"id": "user_1", "username": "teh-hippo"}}
323                ]
324            }
325        })
326        .to_string();
327        let token_body = serde_json::json!({"jwt": jwt_with_exp(1_893_456_000)}).to_string();
328
329        // The token URL also contains "/v1/client", so the specific rule wins by order.
330        let http = MockHttp::new(vec![
331            Rule::new("/v1/client/sessions/", 200, token_body),
332            Rule::new("/v1/client", 200, client_body),
333        ]);
334
335        let mut auth = ClerkAuth::new("eyJtoken");
336        let user_id = pollster::block_on(auth.authenticate(&http)).unwrap();
337        assert_eq!(user_id, "user_1");
338        assert_eq!(auth.display_name(), "teh-hippo");
339
340        let jwt = pollster::block_on(auth.ensure_jwt(&http)).unwrap();
341        assert!(jwt.starts_with("eyJ"));
342    }
343}