1use 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
15pub(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
34pub(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
51pub const TOKEN_EXPIRY_WARN_DAYS: i64 = 14;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TokenExpiry {
57 Unknown,
59 Fresh,
61 Expiring { days: i64 },
63 Expired,
65}
66
67pub 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
123fn 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
168pub 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 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 pub fn user_id(&self) -> Option<String> {
196 self.state.lock().unwrap().user_id.clone()
197 }
198
199 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 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 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 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 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 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 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 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 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 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 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}