use serde_json::Value;
pub(crate) fn claims(token: &str) -> Option<Value> {
let mut parts = token.split('.');
let (_header, payload, _signature) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
let raw = base64url(payload)?;
serde_json::from_slice(&raw).ok().filter(Value::is_object)
}
pub(crate) fn expires_at(token: &str) -> Option<i64> {
claims(token)?.get("exp")?.as_i64()
}
pub(crate) fn claim<'a>(claims: &'a Value, path: &[&str]) -> Option<&'a str> {
let mut at = claims;
for step in path {
at = at.get(step)?;
}
at.as_str().filter(|found| !found.is_empty())
}
fn base64url(text: &str) -> Option<Vec<u8>> {
fn sextet(byte: u8) -> Option<u32> {
Some(match byte {
b'A'..=b'Z' => u32::from(byte - b'A'),
b'a'..=b'z' => u32::from(byte - b'a') + 26,
b'0'..=b'9' => u32::from(byte - b'0') + 52,
b'-' => 62,
b'_' => 63,
_ => return None,
})
}
let mut out = Vec::with_capacity(text.len() * 3 / 4);
let mut held: u32 = 0;
let mut bits = 0;
for byte in text.bytes() {
held = (held << 6) | sextet(byte)?;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(u8::try_from((held >> bits) & 0xff).expect("masked to one byte"));
}
}
((held & ((1u32 << bits) - 1)) == 0).then_some(out)
}
#[cfg(test)]
fn part(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let mut held = 0u32;
for (at, byte) in chunk.iter().enumerate() {
held |= u32::from(*byte) << (16 - 8 * at);
}
let sextets = chunk.len().saturating_mul(8).div_ceil(6);
for at in 0..sextets {
let index = (held >> (18 - 6 * at)) & 0x3f;
out.push(char::from(ALPHABET[index as usize]));
}
}
out
}
#[cfg(test)]
pub(crate) fn unsigned(payload: &Value) -> String {
format!(
"{}.{}.{}",
part(br#"{"alg":"RS256"}"#),
part(payload.to_string().as_bytes()),
part(b"not a real signature")
)
}
#[cfg(test)]
mod tests {
use super::*;
fn token(payload: &Value) -> String {
unsigned(payload)
}
#[test]
fn the_claims_come_back_whole() {
let made = serde_json::json!({"email": "a@b.c", "exp": 1_789_935_600});
assert_eq!(claims(&token(&made)).unwrap(), made);
assert_eq!(expires_at(&token(&made)), Some(1_789_935_600));
}
#[test]
fn a_nested_claim_is_reachable_by_path() {
let made = serde_json::json!({
"email": "a@b.c",
"https://api.openai.com/auth": {"chatgpt_plan_type": "pro", "empty": ""}
});
let found = claims(&token(&made)).unwrap();
assert_eq!(claim(&found, &["email"]), Some("a@b.c"));
assert_eq!(
claim(
&found,
&["https://api.openai.com/auth", "chatgpt_plan_type"]
),
Some("pro")
);
assert_eq!(claim(&found, &["nothing", "here"]), None);
assert_eq!(
claim(&found, &["https://api.openai.com/auth", "empty"]),
None,
"an empty claim is not an answer"
);
}
#[test]
fn anything_that_is_not_a_token_is_not_read() {
let bad_parts = token(&serde_json::json!("a string, not an object"));
for bad in [
"",
"one.part",
"four.parts.are.wrong",
"aaa.not+base64.ccc",
"aaa.not/base64.ccc",
bad_parts.as_str(),
] {
assert!(claims(bad).is_none(), "{bad} was read as claims");
assert_eq!(expires_at(bad), None, "{bad}");
}
}
#[test]
#[ignore = "needs a signed-in Codex on this machine"]
fn a_real_codex_id_token_reads() {
let Some(home) = std::env::var_os("HOME") else {
return;
};
let path = std::path::Path::new(&home).join(".codex/auth.json");
let Ok(raw) = std::fs::read_to_string(&path) else {
return;
};
let document: Value = serde_json::from_str(&raw).expect("auth.json is JSON");
let token = document["tokens"]["id_token"]
.as_str()
.expect("a signed-in Codex has an id token");
let found = claims(token).expect("the id token reads");
let mut names: Vec<&str> = found
.as_object()
.expect("an object")
.keys()
.map(String::as_str)
.collect();
names.sort_unstable();
assert!(names.contains(&"email"), "claims: {names:?}");
assert!(
names.contains(&"https://api.openai.com/auth"),
"claims: {names:?}"
);
assert!(
claim(
&found,
&["https://api.openai.com/auth", "chatgpt_account_id"]
)
.is_some(),
"the account id claim is where pitboard reads it"
);
assert!(
expires_at(token).is_some(),
"an id token says when it expires"
);
}
#[test]
fn the_decode_is_the_inverse_of_the_encode() {
for len in 0..40usize {
let bytes: Vec<u8> = (0..len)
.map(|i| u8::try_from(i * 7 % 256).unwrap())
.collect();
assert_eq!(base64url(&part(&bytes)), Some(bytes.clone()), "len {len}");
}
}
}