use base64::Engine;
use serde_json::Value;
use crate::consts::{CLERK_BASE_URL, CLERK_JS_VERSION, CLERK_TOKEN_JS_VERSION, JWT_REFRESH_BUFFER};
use crate::error::{Error, Result};
use crate::http::{Http, HttpRequest, Method};
pub(crate) fn normalise_token(token: &str) -> String {
let token = token.trim();
if token.starts_with("eyJ") {
return format!("__client={token}");
}
if token.contains("__client=") {
for part in token.split(';') {
if let Some(value) = part.trim().strip_prefix("__client=") {
return format!("__client={value}");
}
}
}
format!("__client={token}")
}
pub(crate) fn decode_jwt_exp(token: &str) -> i64 {
let Some(payload) = token.split('.').nth(1) else {
return 0;
};
let payload = payload.trim_end_matches('=');
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
return 0;
};
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
return 0;
};
value.get("exp").and_then(Value::as_i64).unwrap_or(0)
}
struct ClientInfo {
session_id: String,
user_id: Option<String>,
display_name: Option<String>,
}
fn parse_client_response(data: &Value) -> Result<ClientInfo> {
let response = data
.get("response")
.filter(|value| !value.is_null())
.ok_or_else(|| Error::Auth("invalid Clerk response; the cookie may be expired".into()))?;
let session_id = response
.get("last_active_session_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.ok_or_else(|| Error::Auth("no active session; the cookie may be expired".into()))?
.to_string();
let mut user_id = None;
let mut display_name = None;
if let Some(sessions) = response.get("sessions").and_then(Value::as_array) {
for session in sessions {
if session.get("id").and_then(Value::as_str) == Some(session_id.as_str()) {
let user = session.get("user").cloned().unwrap_or(Value::Null);
user_id = user.get("id").and_then(Value::as_str).map(str::to_string);
display_name = derive_display_name(&user);
break;
}
}
}
Ok(ClientInfo {
session_id,
user_id,
display_name,
})
}
fn derive_display_name(user: &Value) -> Option<String> {
let field = |key: &str| {
user.get(key)
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string()
};
let first = field("first_name");
let last = field("last_name");
let username = field("username");
if !username.is_empty() && !username.contains('@') {
Some(username)
} else if !first.is_empty() && !first.contains('@') {
Some(if last.is_empty() {
first
} else {
format!("{first} {last}")
})
} else if !username.is_empty() && username.contains('@') {
let local: String = username
.split('@')
.next()
.unwrap_or("")
.trim()
.chars()
.take(100)
.collect();
(!local.is_empty()).then_some(local)
} else {
None
}
}
fn parse_token_response(data: &Value) -> Result<String> {
data.get("jwt")
.and_then(Value::as_str)
.filter(|jwt| !jwt.is_empty())
.map(str::to_string)
.ok_or_else(|| Error::Auth("no JWT in the Clerk token response".into()))
}
pub struct ClerkAuth {
cookie: String,
jwt: Option<String>,
jwt_exp: i64,
session_id: Option<String>,
user_id: Option<String>,
display_name: Option<String>,
}
impl ClerkAuth {
pub fn new(token: &str) -> Self {
Self {
cookie: normalise_token(token),
jwt: None,
jwt_exp: 0,
session_id: None,
user_id: None,
display_name: None,
}
}
pub fn user_id(&self) -> Option<&str> {
self.user_id.as_deref()
}
pub fn display_name(&self) -> &str {
self.display_name.as_deref().unwrap_or("Suno")
}
pub async fn authenticate(&mut self, http: &impl Http) -> Result<String> {
self.fetch_session(http).await?;
self.refresh_jwt(http).await?;
self.user_id.clone().ok_or_else(|| {
Error::Auth("could not determine the user ID from the Clerk session".into())
})
}
pub async fn ensure_jwt(&mut self, http: &impl Http) -> Result<String> {
if self.jwt.is_none() || now_unix() >= self.jwt_exp - JWT_REFRESH_BUFFER {
self.refresh_jwt(http).await?;
}
self.jwt
.clone()
.ok_or_else(|| Error::Auth("failed to obtain a JWT".into()))
}
pub fn invalidate_jwt(&mut self) {
self.jwt = None;
}
async fn fetch_session(&mut self, http: &impl Http) -> Result<()> {
let cookie = self.cookie.clone();
let url = format!("{CLERK_BASE_URL}/v1/client?_clerk_js_version={CLERK_JS_VERSION}");
let data = clerk_request_json(http, &cookie, Method::Get, url).await?;
let info = parse_client_response(&data)?;
self.session_id = Some(info.session_id);
self.user_id = info.user_id;
self.display_name = info.display_name;
Ok(())
}
async fn refresh_jwt(&mut self, http: &impl Http) -> Result<()> {
if self.session_id.is_none() {
self.fetch_session(http).await?;
}
let session_id = self
.session_id
.clone()
.ok_or_else(|| Error::Auth("no Clerk session".into()))?;
let cookie = self.cookie.clone();
let url = format!(
"{CLERK_BASE_URL}/v1/client/sessions/{session_id}/tokens?_clerk_js_version={CLERK_TOKEN_JS_VERSION}"
);
let data = clerk_request_json(http, &cookie, Method::Post, url).await?;
let jwt = parse_token_response(&data)?;
self.jwt_exp = decode_jwt_exp(&jwt);
self.jwt = Some(jwt);
Ok(())
}
}
async fn clerk_request_json(
http: &impl Http,
cookie: &str,
method: Method,
url: String,
) -> Result<Value> {
let request = HttpRequest {
method,
url,
headers: vec![("Cookie".to_string(), cookie.to_string())],
};
let response = http
.send(request)
.await
.map_err(|err| Error::Connection(format!("could not connect to Clerk: {err}")))?;
if response.status != 200 {
return Err(Error::Auth(format!(
"Clerk request failed with status {}",
response.status
)));
}
serde_json::from_slice(&response.body)
.map_err(|err| Error::Connection(format!("invalid Clerk response: {err}")))
}
fn now_unix() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{MockHttp, Rule};
fn jwt_with_exp(exp: i64) -> String {
let payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!("{{\"exp\":{exp}}}"));
format!("eyJhbGciOiJIUzI1NiJ9.{payload}.signature")
}
#[test]
fn normalise_accepts_raw_jwt() {
assert_eq!(normalise_token(" eyJabc "), "__client=eyJabc");
}
#[test]
fn normalise_extracts_from_cookie_header() {
assert_eq!(
normalise_token("foo=1; __client=eyJabc; bar=2"),
"__client=eyJabc"
);
}
#[test]
fn normalise_wraps_unknown_value() {
assert_eq!(normalise_token("rawvalue"), "__client=rawvalue");
}
#[test]
fn decode_exp_reads_claim() {
assert_eq!(decode_jwt_exp(&jwt_with_exp(1_893_456_000)), 1_893_456_000);
}
#[test]
fn decode_exp_handles_garbage() {
assert_eq!(decode_jwt_exp("not-a-jwt"), 0);
assert_eq!(decode_jwt_exp(""), 0);
}
#[test]
fn display_name_prefers_username() {
let user = serde_json::json!({"username": "teh-hippo", "first_name": "Ignored"});
assert_eq!(derive_display_name(&user).as_deref(), Some("teh-hippo"));
}
#[test]
fn display_name_uses_first_last_when_no_username() {
let user = serde_json::json!({"first_name": "Ada", "last_name": "Lovelace"});
assert_eq!(derive_display_name(&user).as_deref(), Some("Ada Lovelace"));
}
#[test]
fn display_name_falls_back_to_email_local_part() {
let user = serde_json::json!({"username": "yshvq8dp9v@privaterelay.appleid.com"});
assert_eq!(derive_display_name(&user).as_deref(), Some("yshvq8dp9v"));
}
#[test]
fn parse_client_requires_a_session() {
let data = serde_json::json!({"response": {"sessions": []}});
assert!(parse_client_response(&data).is_err());
}
#[test]
fn authenticate_fetches_user_and_jwt() {
let client_body = serde_json::json!({
"response": {
"last_active_session_id": "sess_1",
"sessions": [
{"id": "sess_1", "user": {"id": "user_1", "username": "teh-hippo"}}
]
}
})
.to_string();
let token_body = serde_json::json!({"jwt": jwt_with_exp(1_893_456_000)}).to_string();
let http = MockHttp::new(vec![
Rule::new("/v1/client/sessions/", 200, token_body),
Rule::new("/v1/client", 200, client_body),
]);
let mut auth = ClerkAuth::new("eyJtoken");
let user_id = pollster::block_on(auth.authenticate(&http)).unwrap();
assert_eq!(user_id, "user_1");
assert_eq!(auth.display_name(), "teh-hippo");
let jwt = pollster::block_on(auth.ensure_jwt(&http)).unwrap();
assert!(jwt.starts_with("eyJ"));
}
}