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 #[allow(clippy::unwrap_used)]
198 fn locked_state(&self) -> std::sync::MutexGuard<'_, AuthState> {
199 self.state.lock().unwrap()
200 }
201
202 pub fn user_id(&self) -> Option<String> {
204 self.locked_state().user_id.clone()
205 }
206
207 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 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 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 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 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 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 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 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 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 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 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 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}