From 64948e82a67f046d28a0b1e2fe46facf9cd27cdb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 23 Apr 2025 10:24:55 -0400 Subject: [PATCH] JWT claims from Sora parsed --- .../api_clients/openai_sora_client/Cargo.toml | 2 +- .../openai_sora_client/src/creds/mod.rs | 3 + .../src/creds/sora_cookies.rs | 15 ++ .../src/creds/sora_jwt_bearer_token.rs | 26 ++++ .../src/creds/sora_sentinel.rs | 15 ++ .../api_clients/openai_sora_client/src/lib.rs | 6 +- .../src/utils/lightweight_jwt_parse.rs | 139 ++++++++++++++++++ .../openai_sora_client/src/utils/mod.rs | 1 + 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 crates/lib/api_clients/openai_sora_client/src/creds/mod.rs create mode 100644 crates/lib/api_clients/openai_sora_client/src/creds/sora_cookies.rs create mode 100644 crates/lib/api_clients/openai_sora_client/src/creds/sora_jwt_bearer_token.rs create mode 100644 crates/lib/api_clients/openai_sora_client/src/creds/sora_sentinel.rs create mode 100644 crates/lib/api_clients/openai_sora_client/src/utils/lightweight_jwt_parse.rs create mode 100644 crates/lib/api_clients/openai_sora_client/src/utils/mod.rs diff --git a/crates/lib/api_clients/openai_sora_client/Cargo.toml b/crates/lib/api_clients/openai_sora_client/Cargo.toml index 17536a948..7a0025388 100644 --- a/crates/lib/api_clients/openai_sora_client/Cargo.toml +++ b/crates/lib/api_clients/openai_sora_client/Cargo.toml @@ -40,4 +40,4 @@ rand = { workspace = true } [dev-dependencies] testing = { path = "../../testing" } -filesys = { path = "../../files/filesys" } +filesys = { path = "../../files/filesys" } \ No newline at end of file diff --git a/crates/lib/api_clients/openai_sora_client/src/creds/mod.rs b/crates/lib/api_clients/openai_sora_client/src/creds/mod.rs new file mode 100644 index 000000000..ce6837da3 --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/creds/mod.rs @@ -0,0 +1,3 @@ +pub mod sora_jwt_bearer_token; +pub mod sora_cookies; +pub mod sora_sentinel; \ No newline at end of file diff --git a/crates/lib/api_clients/openai_sora_client/src/creds/sora_cookies.rs b/crates/lib/api_clients/openai_sora_client/src/creds/sora_cookies.rs new file mode 100644 index 000000000..e8089fb1f --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/creds/sora_cookies.rs @@ -0,0 +1,15 @@ +/// Cookies are the credential that are always required. +/// You can mint a JWT bearer token with just the cookies. +pub struct SoraCookies { + cookies: String, +} + +impl SoraCookies { + pub fn new(cookies: String) -> Self { + SoraCookies { cookies } + } + + pub fn get_cookies(&self) -> &str { + &self.cookies + } +} diff --git a/crates/lib/api_clients/openai_sora_client/src/creds/sora_jwt_bearer_token.rs b/crates/lib/api_clients/openai_sora_client/src/creds/sora_jwt_bearer_token.rs new file mode 100644 index 000000000..cb23ea0fa --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/creds/sora_jwt_bearer_token.rs @@ -0,0 +1,26 @@ +use crate::utils::lightweight_jwt_parse::{lightweight_jwt_parse, SoraJwtClaims}; +use errors::AnyhowResult; + +/// Sora bearer tokens are JWT tokens that can be minted with a valid cookie/session. +pub struct SoraJwtBearerToken { + token: String, + jwt_claims: SoraJwtClaims, +} + +impl SoraJwtBearerToken { + pub fn new(token: String) -> AnyhowResult { + let jwt_claims = lightweight_jwt_parse(&token)?; + Ok(SoraJwtBearerToken { + token, + jwt_claims, + }) + } + + pub fn token_str(&self) -> &str { + &self.token + } + + pub fn jwt_claims(&self) -> &SoraJwtClaims { + &self.jwt_claims + } +} diff --git a/crates/lib/api_clients/openai_sora_client/src/creds/sora_sentinel.rs b/crates/lib/api_clients/openai_sora_client/src/creds/sora_sentinel.rs new file mode 100644 index 000000000..535e7bce5 --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/creds/sora_sentinel.rs @@ -0,0 +1,15 @@ + +/// Sora Sentinel tokens can be generated without cookies or JWTs, and are needed for some API calls. +pub struct SoraSentinel { + sentinel: String, +} + +impl SoraSentinel { + pub fn new(sentinel: String) -> Self { + SoraSentinel { sentinel } + } + + pub fn get_sentinel(&self) -> &str { + &self.sentinel + } +} diff --git a/crates/lib/api_clients/openai_sora_client/src/lib.rs b/crates/lib/api_clients/openai_sora_client/src/lib.rs index 56e662dbd..70ceb24dd 100644 --- a/crates/lib/api_clients/openai_sora_client/src/lib.rs +++ b/crates/lib/api_clients/openai_sora_client/src/lib.rs @@ -1,7 +1,9 @@ pub mod bearer; pub mod credentials; +pub mod creds; pub mod image_gen; pub mod job; -pub mod upload; pub mod sentinel_refresh; -mod sora_error; \ No newline at end of file +pub mod sora_error; +pub mod upload; +pub mod utils; \ No newline at end of file diff --git a/crates/lib/api_clients/openai_sora_client/src/utils/lightweight_jwt_parse.rs b/crates/lib/api_clients/openai_sora_client/src/utils/lightweight_jwt_parse.rs new file mode 100644 index 000000000..e06c1dae8 --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/utils/lightweight_jwt_parse.rs @@ -0,0 +1,139 @@ +//! Adapted from 'jwt' crate + +use anyhow::anyhow; +use base64::Engine; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use chrono::{DateTime, Utc}; +use log::warn; +use serde_json::Value; +use errors::AnyhowResult; + +const SEPARATOR: &str = "."; + +#[derive(Clone, Debug)] +pub struct SoraJwtClaims { + pub created: DateTime, + pub expiration: DateTime, + pub user_id: String, + pub email: String, + pub email_verified: bool, +} + +pub fn lightweight_jwt_parse(token: &str) -> AnyhowResult { + let [_header_str, claims_str, _signature_str] = split_components(token)?; + + let claims = decode_base64(claims_str)?; + let claims : serde_json::Map = serde_json::from_str(&claims)?; + + let iat = claims.get("iat") + .map(|val| val.as_number()) + .flatten() + .ok_or(anyhow!("no iat claim"))?; + + let iat = iat.as_i64() + .ok_or(anyhow!("iat is not a number"))?; + + let iat = DateTime::from_timestamp(iat, 0) + .ok_or(anyhow!("iat is not a valid timestamp"))?; + + let exp = claims.get("exp") + .map(|val| val.as_number()) + .flatten() + .ok_or(anyhow!("no exp claim"))?; + + let exp = exp.as_i64() + .ok_or(anyhow!("exp is not a number"))?; + + let exp = DateTime::from_timestamp(exp, 0) + .ok_or(anyhow!("iat is not a valid timestamp"))?; + + let user_id = claims.get("https://api.openai.com/auth") + .map(|val| val.get("user_id")) + .flatten() + .map(|val| val.as_str()) + .flatten() + .ok_or(anyhow!("no user_id claim"))?; + + let profile = claims.get("https://api.openai.com/profile") + .ok_or(anyhow!("no user_id claim"))?; + + let email = profile.get("email") + .map(|val| val.as_str()) + .flatten() + .ok_or(anyhow!("no email claim"))?; + + let email_verified = profile.get("email_verified") + .map(|val| val.as_bool()) + .flatten() + .unwrap_or_else(|| { + warn!("no email_verified claim"); + false + }); + + Ok(SoraJwtClaims { + created: iat, + expiration: exp, + user_id: user_id.to_string(), + email: email.to_string(), + email_verified, + }) +} + +fn split_components(token: &str) -> AnyhowResult<[&str; 3]> { + let mut components = token.split(SEPARATOR); + let header = components.next().ok_or(anyhow!("no header component"))?; + let claims = components.next().ok_or(anyhow!("no claims component"))?; + let signature = components.next().ok_or(anyhow!("no signature component"))?; + + if components.next().is_some() { + return Err(anyhow!("too many components")); + } + + Ok([header, claims, signature]) +} + +fn decode_base64(raw: &str) -> AnyhowResult { + let json_bytes = BASE64_URL_SAFE_NO_PAD.decode(raw)?; + let json_str = String::from_utf8(json_bytes.clone())?; + Ok(json_str) +} + +#[cfg(test)] +mod tests { + use crate::utils::lightweight_jwt_parse::lightweight_jwt_parse; + + #[test] + fn test_sora_jwt_bearer_token() { + let token = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6Ik\ + pXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfTTFuUTN0UjV2\ + VzdYOWpMMnBFNmdIOGRLNCIsImV4cCI6MTc0NTgwMDQ3MiwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InV\ + zZXJfaWQiOiJ1c2VyLTZOeEpmbEFIb0VCREp6Wmw5aVhocWJERyJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2\ + ZpbGUiOnsiZW1haWwiOiJ2b2NvZGVzMjAyMEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6M\ + Tc0NDkzNjQ3MSwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiI0NTZmZjY5Yi0xZTZhLTQzOWYt\ + YTYzMC1jNmFjYzI3NzU1YjAiLCJuYmYiOjE3NDQ5MzY0NzEsInB3ZF9hdXRoX3RpbWUiOjE3NDQwNTkzMTc3NTAsInN\ + jcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyJdLCJzZXNzaW9uX2lkIjoiYXV0aH\ + Nlc3NfcEE4UGl3V0trNmJsSE1qQnVCQnVSRmh2Iiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTMxMDE5Njc2MTIzOTY3O\ + TM3NzcifQ.JOxYfrQSIsHdf-VN1zcOqDOS3tF1HpUYnRVfPKgi65am3sNiniOM1kWTNrKE5-sh4DaZ33G_XniGkhg-9\ + QKF8tspXpZ6BnTNkf-QdGqR_v89AIO8pZgQ7oECLXhnmg-31G-LAHlGfwWedJ6qPVy4dL5zoR0wVyoQ8gYv5QzQC-zd\ + IF7uY-umKKloAAYP_tkKLlQHackjTynN-bpu2mKv55-h7a3hSEPfxvgX0SsvL69xtNsZkwb43bqyP1x_c2ErfYoPyzg\ + rqgMIRZjCw9TI33vISKM_BfQXBiZT_BHkX4s0Jng3ph4vrwvnBsa0HpNxBhQYKb1u9gZXs7XjLZhjZJZsDTQI1V4Wov\ + iAL7ihvZkLxJRtDCOmX7-5BTEsMInFgXUAhCDhQUMwSfLLJuinBS96NGSX5_TfIVMkbCl-HWzdQx7KZAsahNe_CmuYT\ + lZH32NuRn78ohY4eViRyfC8OXVLwoLxxpen2CWlV2dxV5wwXeij_kHt5wOXSGYpSfxz37cK0CaK8K2pwudasxQ0lv2V\ + _Xx_z4UaEOr5wqT_BK1A1HO4HRjvFX2soF-qm4GCSWAbmFZs7DVEClrotxRx2romifRVb8XRia2M6YTmDyIT0YphFgG\ + nnrDIZNKO23uDYT60LVMVYpCCY2RiwxCR9bn7AeGbmi7cpL9aYZ5uJRM"; + + // jwt payload : + // - "https://api.openai.com/auth".user_id = "user-6NxJflAHoEBDJzZl9iXhqbDG" + // - "https://api.openai.com/profile".email = "vocodes2020@gmail.com" + // - "https://api.openai.com/profile".email_verified = true + // - "iat": 1744936471, - issued at claim (5 days ago) + // - "nbf": 1744936471, - not before, don't use before date + // - "exp": 1745800472, - in 5 days, expiry + + let result = lightweight_jwt_parse(token).expect("claim should parse"); + + assert_eq!(result.email, "vocodes2020@gmail.com"); + assert_eq!(result.email_verified, true); + } +} diff --git a/crates/lib/api_clients/openai_sora_client/src/utils/mod.rs b/crates/lib/api_clients/openai_sora_client/src/utils/mod.rs new file mode 100644 index 000000000..8862cb61f --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod lightweight_jwt_parse; \ No newline at end of file