1use 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
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.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
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_saturates_on_extreme_bounds_without_panicking() {
398 assert_eq!(
402 classify_token_expiry(i64::MAX, i64::MIN, i64::MAX),
403 TokenExpiry::Fresh
404 );
405 assert_eq!(
406 classify_token_expiry(i64::MAX, -1, i64::MAX),
407 TokenExpiry::Fresh
408 );
409 }
410
411 #[test]
412 fn classify_marks_expired_at_or_before_now() {
413 let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
414 assert_eq!(
415 classify_token_expiry(1_000, 1_000, window),
416 TokenExpiry::Expired
417 );
418 assert_eq!(
419 classify_token_expiry(999, 1_000, window),
420 TokenExpiry::Expired
421 );
422 }
423
424 #[test]
425 fn token_expiry_round_trips_through_cookie() {
426 let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
427 let now = 1_000_000;
428 let exp = now + 5 * 86_400;
429 let auth = ClerkAuth::new(&jwt_with_exp(exp));
430 assert_eq!(auth.cookie_exp(), Some(exp));
431 assert_eq!(
432 auth.token_expiry(now, window),
433 TokenExpiry::Expiring { days: 5 }
434 );
435 }
436
437 #[test]
438 fn token_expiry_is_unknown_for_undecodable_cookie() {
439 let window = TOKEN_EXPIRY_WARN_DAYS * 86_400;
440 let garbage = ClerkAuth::new("rawvalue");
441 assert_eq!(garbage.cookie_exp(), None);
442 assert_eq!(
443 garbage.token_expiry(1_000_000, window),
444 TokenExpiry::Unknown
445 );
446 let zero = ClerkAuth::new(&jwt_with_exp(0));
448 assert_eq!(zero.token_expiry(1_000_000, window), TokenExpiry::Unknown);
449 }
450
451 #[test]
452 fn display_name_prefers_username() {
453 let user = serde_json::json!({"username": "teh-hippo", "first_name": "Ignored"});
454 assert_eq!(derive_display_name(&user).as_deref(), Some("teh-hippo"));
455 }
456
457 #[test]
458 fn display_name_uses_first_last_when_no_username() {
459 let user = serde_json::json!({"first_name": "Ada", "last_name": "Lovelace"});
460 assert_eq!(derive_display_name(&user).as_deref(), Some("Ada Lovelace"));
461 }
462
463 #[test]
464 fn display_name_falls_back_to_email_local_part() {
465 let user = serde_json::json!({"username": "yshvq8dp9v@privaterelay.appleid.com"});
466 assert_eq!(derive_display_name(&user).as_deref(), Some("yshvq8dp9v"));
467 }
468
469 #[test]
470 fn parse_client_requires_a_session() {
471 let data = serde_json::json!({"response": {"sessions": []}});
472 assert!(parse_client_response(&data).is_err());
473 }
474
475 #[test]
476 fn authenticate_fetches_user_and_jwt() {
477 let client_body = serde_json::json!({
478 "response": {
479 "last_active_session_id": "sess_1",
480 "sessions": [
481 {"id": "sess_1", "user": {"id": "user_1", "username": "teh-hippo"}}
482 ]
483 }
484 })
485 .to_string();
486 let token_body = serde_json::json!({"jwt": jwt_with_exp(1_893_456_000)}).to_string();
487
488 let http = MockHttp::new(vec![
490 Rule::new("/v1/client/sessions/", 200, token_body),
491 Rule::new("/v1/client", 200, client_body),
492 ]);
493
494 let auth = ClerkAuth::new("eyJtoken");
495 let user_id = pollster::block_on(auth.authenticate(&http)).unwrap();
496 assert_eq!(user_id, "user_1");
497 assert_eq!(auth.display_name(), "teh-hippo");
498
499 let jwt = pollster::block_on(auth.ensure_jwt(0, &http)).unwrap();
501 assert!(jwt.starts_with("eyJ"));
502 }
503
504 #[test]
505 fn ensure_jwt_does_not_refresh_when_fresh() {
506 let exp = 1_000_000i64;
507 let http = MockHttp::new(vec![]);
509 let auth = ClerkAuth::new("eyJtoken");
510 *auth.state.lock().unwrap() = AuthState {
511 jwt: Some(jwt_with_exp(exp)),
512 jwt_exp: exp,
513 session_id: Some("sess_1".into()),
514 user_id: Some("user_1".into()),
515 display_name: None,
516 };
517 let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER - 1, &http)).unwrap();
518 assert_eq!(decode_jwt_exp(&jwt), exp);
519 }
520
521 #[test]
522 fn ensure_jwt_refreshes_at_expiry_boundary() {
523 let exp = 1_000_000i64;
524 let new_exp = exp + 3_600;
525 let token_body = serde_json::json!({"jwt": jwt_with_exp(new_exp)}).to_string();
526 let http = MockHttp::new(vec![Rule::new("/v1/client/sessions/", 200, token_body)]);
527 let auth = ClerkAuth::new("eyJtoken");
528 *auth.state.lock().unwrap() = AuthState {
529 jwt: Some(jwt_with_exp(exp)),
530 jwt_exp: exp,
531 session_id: Some("sess_1".into()),
532 user_id: Some("user_1".into()),
533 display_name: None,
534 };
535 let jwt = pollster::block_on(auth.ensure_jwt(exp - JWT_REFRESH_BUFFER, &http)).unwrap();
537 assert_eq!(decode_jwt_exp(&jwt), new_exp);
538 }
539
540 #[test]
541 fn ensure_jwt_refresh_is_single_flight_under_concurrency() {
542 let exp = 1_000_000i64;
543 let new_exp = exp + 3_600;
544 let token_body = serde_json::json!({"jwt": jwt_with_exp(new_exp)}).to_string();
545 let auth = ClerkAuth::new("eyJtoken");
546 *auth.state.lock().unwrap() = AuthState {
547 jwt: Some(jwt_with_exp(exp)),
548 jwt_exp: exp,
549 session_id: Some("sess_1".into()),
550 user_id: Some("user_1".into()),
551 display_name: None,
552 };
553 let http = ScriptedHttp::new().route("/v1/client/sessions/", Reply::json(&token_body));
554 let now = exp - JWT_REFRESH_BUFFER;
555 let (first, second) = pollster::block_on(async {
556 futures_util::future::join(auth.ensure_jwt(now, &http), auth.ensure_jwt(now, &http))
557 .await
558 });
559 assert!(first.is_ok());
560 assert!(second.is_ok());
561 assert_eq!(http.count("/v1/client/sessions/"), 1);
562 }
563}