1use 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
12pub(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
31pub(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
48pub const TOKEN_EXPIRY_WARN_DAYS: i64 = 14;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum TokenExpiry {
54 Unknown,
56 Fresh,
58 Expiring { days: i64 },
60 Expired,
62}
63
64pub 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
120fn 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
165pub 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 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 pub fn user_id(&self) -> Option<&str> {
190 self.user_id.as_deref()
191 }
192
193 pub fn display_name(&self) -> &str {
195 self.display_name.as_deref().unwrap_or("Suno")
196 }
197
198 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 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 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 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 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 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 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 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 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 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}