use std::sync::Mutex;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::Serialize;
pub const APPLE_AUDIENCE: &str = "https://appleid.apple.com";
pub const APPLE_MAX_TTL_SECONDS: i64 = 15_777_000;
pub const DEFAULT_TOKEN_TTL_SECONDS: i64 = 60 * 60;
pub const DEFAULT_REFRESH_MARGIN_SECONDS: i64 = 5 * 60;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ClientSecretError {
#[error("invalid p8 private key: {0}")]
InvalidKey(String),
#[error("ttl out of range (1..={max} seconds): got {got}", max = APPLE_MAX_TTL_SECONDS)]
TtlOutOfRange { got: i64 },
#[error("refresh margin must be in 0..ttl_seconds: margin={margin} ttl={ttl}")]
InvalidRefreshMargin { margin: i64, ttl: i64 },
#[error("jwt sign: {0}")]
Sign(String),
}
#[derive(Debug, Serialize)]
struct AppleClaims<'a> {
iss: &'a str,
iat: i64,
exp: i64,
aud: &'a str,
sub: &'a str,
}
#[derive(Clone)]
struct Cached {
token: String,
expires_at: i64,
}
pub struct AppleClientSecret {
team_id: String,
key_id: String,
client_id: String,
encoding_key: EncodingKey,
ttl_seconds: i64,
refresh_margin_seconds: i64,
cache: Mutex<Option<Cached>>,
}
impl AppleClientSecret {
pub fn from_p8_pem(
team_id: impl Into<String>,
key_id: impl Into<String>,
client_id: impl Into<String>,
p8_pem: &[u8],
) -> Result<Self, ClientSecretError> {
let encoding_key = EncodingKey::from_ec_pem(p8_pem)
.map_err(|e| ClientSecretError::InvalidKey(e.to_string()))?;
Ok(Self {
team_id: team_id.into(),
key_id: key_id.into(),
client_id: client_id.into(),
encoding_key,
ttl_seconds: DEFAULT_TOKEN_TTL_SECONDS,
refresh_margin_seconds: DEFAULT_REFRESH_MARGIN_SECONDS,
cache: Mutex::new(None),
})
}
pub fn with_ttl_seconds(mut self, ttl: i64) -> Result<Self, ClientSecretError> {
if ttl <= 0 || ttl > APPLE_MAX_TTL_SECONDS {
return Err(ClientSecretError::TtlOutOfRange { got: ttl });
}
if self.refresh_margin_seconds >= ttl {
return Err(ClientSecretError::InvalidRefreshMargin {
margin: self.refresh_margin_seconds,
ttl,
});
}
self.ttl_seconds = ttl;
*self.cache.lock().expect("cache mutex") = None;
Ok(self)
}
pub fn with_refresh_margin_seconds(mut self, margin: i64) -> Result<Self, ClientSecretError> {
if margin < 0 || margin >= self.ttl_seconds {
return Err(ClientSecretError::InvalidRefreshMargin {
margin,
ttl: self.ttl_seconds,
});
}
self.refresh_margin_seconds = margin;
Ok(self)
}
pub fn team_id(&self) -> &str {
&self.team_id
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn client_id(&self) -> &str {
&self.client_id
}
pub fn ttl_seconds(&self) -> i64 {
self.ttl_seconds
}
pub fn refresh_margin_seconds(&self) -> i64 {
self.refresh_margin_seconds
}
pub fn current(&self, now: i64) -> Result<String, ClientSecretError> {
{
let cache = self.cache.lock().expect("cache mutex");
if let Some(c) = cache.as_ref() {
if c.expires_at.saturating_sub(self.refresh_margin_seconds) > now {
return Ok(c.token.clone());
}
}
}
let exp = now.saturating_add(self.ttl_seconds);
let claims = AppleClaims {
iss: &self.team_id,
iat: now,
exp,
aud: APPLE_AUDIENCE,
sub: &self.client_id,
};
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(self.key_id.clone());
let token = jsonwebtoken::encode(&header, &claims, &self.encoding_key)
.map_err(|e| ClientSecretError::Sign(e.to_string()))?;
*self.cache.lock().expect("cache mutex") = Some(Cached {
token: token.clone(),
expires_at: exp,
});
Ok(token)
}
pub fn invalidate(&self) {
*self.cache.lock().expect("cache mutex") = None;
}
pub fn cache_expires_at(&self) -> Option<i64> {
self.cache
.lock()
.expect("cache mutex")
.as_ref()
.map(|c| c.expires_at)
}
}
impl std::fmt::Debug for AppleClientSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppleClientSecret")
.field("team_id", &self.team_id)
.field("key_id", &self.key_id)
.field("client_id", &self.client_id)
.field("encoding_key", &"<redacted>")
.field("ttl_seconds", &self.ttl_seconds)
.field("refresh_margin_seconds", &self.refresh_margin_seconds)
.field(
"cache_expires_at",
&self
.cache
.lock()
.expect("cache mutex")
.as_ref()
.map(|c| c.expires_at),
)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
use p256::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
use serde::Deserialize;
fn fixed_keypair() -> (String, String) {
let mut bytes = [0u8; 32];
bytes[31] = 0x42;
let sk = p256::SecretKey::from_slice(&bytes).expect("valid P-256 scalar");
let p8 = sk
.to_pkcs8_pem(LineEnding::LF)
.expect("P-256 PKCS#8 PEM encode")
.to_string();
let pubpem = sk
.public_key()
.to_public_key_pem(LineEnding::LF)
.expect("P-256 SPKI PEM encode");
(p8, pubpem)
}
fn make_secret() -> AppleClientSecret {
let (p8, _) = fixed_keypair();
AppleClientSecret::from_p8_pem(
"TEAM123ABC",
"KEYID45678",
"com.example.signin",
p8.as_bytes(),
)
.expect("valid p8 PEM parses")
}
fn validation_no_exp() -> Validation {
let mut v = Validation::new(Algorithm::ES256);
v.set_audience(&[APPLE_AUDIENCE]);
v.set_issuer(&["TEAM123ABC"]);
v.set_required_spec_claims(&["iss", "iat", "exp", "aud", "sub"]);
v.validate_exp = false;
v.validate_nbf = false;
v
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct DecodedClaims {
iss: String,
iat: i64,
exp: i64,
aud: String,
sub: String,
}
#[test]
fn from_p8_pem_rejects_garbage() {
let err = AppleClientSecret::from_p8_pem("T", "K", "C", b"not a PEM").unwrap_err();
assert!(matches!(err, ClientSecretError::InvalidKey(_)));
}
#[test]
fn from_p8_pem_accepts_pkcs8_p256() {
let _ = make_secret();
}
#[test]
fn defaults_match_documented_constants() {
let s = make_secret();
assert_eq!(s.team_id(), "TEAM123ABC");
assert_eq!(s.key_id(), "KEYID45678");
assert_eq!(s.client_id(), "com.example.signin");
assert_eq!(s.ttl_seconds(), DEFAULT_TOKEN_TTL_SECONDS);
assert_eq!(s.refresh_margin_seconds(), DEFAULT_REFRESH_MARGIN_SECONDS);
assert!(s.cache_expires_at().is_none());
}
#[test]
fn with_ttl_rejects_out_of_range() {
let s = make_secret();
assert!(matches!(
s.with_ttl_seconds(0).unwrap_err(),
ClientSecretError::TtlOutOfRange { got: 0 }
));
let s = make_secret();
assert!(matches!(
s.with_ttl_seconds(-1).unwrap_err(),
ClientSecretError::TtlOutOfRange { got: -1 }
));
let s = make_secret();
assert!(matches!(
s.with_ttl_seconds(APPLE_MAX_TTL_SECONDS + 1).unwrap_err(),
ClientSecretError::TtlOutOfRange { .. }
));
}
#[test]
fn with_ttl_accepts_one_and_max() {
let s = make_secret()
.with_refresh_margin_seconds(0)
.unwrap()
.with_ttl_seconds(1)
.unwrap();
assert_eq!(s.ttl_seconds(), 1);
let s = make_secret().with_ttl_seconds(APPLE_MAX_TTL_SECONDS).unwrap();
assert_eq!(s.ttl_seconds(), APPLE_MAX_TTL_SECONDS);
}
#[test]
fn with_ttl_rejects_when_existing_margin_would_consume_it() {
let err = make_secret().with_ttl_seconds(300).unwrap_err();
assert!(matches!(err, ClientSecretError::InvalidRefreshMargin { .. }));
}
#[test]
fn with_refresh_margin_rejects_out_of_range() {
let s = make_secret();
assert!(matches!(
s.with_refresh_margin_seconds(-1).unwrap_err(),
ClientSecretError::InvalidRefreshMargin { margin: -1, .. }
));
let s = make_secret();
assert!(matches!(
s.with_refresh_margin_seconds(DEFAULT_TOKEN_TTL_SECONDS)
.unwrap_err(),
ClientSecretError::InvalidRefreshMargin { .. }
));
}
#[test]
fn current_mints_jwt_with_expected_header() {
let secret = make_secret();
let token = secret.current(1_700_000_000).unwrap();
let header = decode_header(&token).expect("decode header");
assert_eq!(header.alg, Algorithm::ES256);
assert_eq!(header.kid.as_deref(), Some("KEYID45678"));
assert_eq!(header.typ.as_deref(), Some("JWT"));
}
#[test]
fn current_mints_jwt_with_expected_claims() {
let secret = make_secret();
let (_, pubpem) = fixed_keypair();
let now = 1_700_000_000;
let token = secret.current(now).unwrap();
let key = DecodingKey::from_ec_pem(pubpem.as_bytes()).expect("decoding key");
let data =
decode::<DecodedClaims>(&token, &key, &validation_no_exp()).expect("verify ES256");
assert_eq!(data.claims.iss, "TEAM123ABC");
assert_eq!(data.claims.aud, APPLE_AUDIENCE);
assert_eq!(data.claims.sub, "com.example.signin");
assert_eq!(data.claims.iat, now);
assert_eq!(data.claims.exp, now + DEFAULT_TOKEN_TTL_SECONDS);
}
#[test]
fn current_signature_does_not_verify_under_wrong_key() {
let secret = make_secret();
let token = secret.current(1_700_000_000).unwrap();
let mut bytes = [0u8; 32];
bytes[31] = 0x43;
let other = p256::SecretKey::from_slice(&bytes).unwrap();
let other_pub = other
.public_key()
.to_public_key_pem(LineEnding::LF)
.unwrap();
let key = DecodingKey::from_ec_pem(other_pub.as_bytes()).unwrap();
let err =
decode::<DecodedClaims>(&token, &key, &validation_no_exp()).expect_err("wrong key");
assert!(
format!("{err}").to_lowercase().contains("signature"),
"{err}"
);
}
#[test]
fn current_returns_same_token_inside_window() {
let secret = make_secret();
let t1 = secret.current(1_700_000_000).unwrap();
let t2 = secret.current(1_700_000_001).unwrap();
assert_eq!(t1, t2, "cached token should be reused");
assert_eq!(
secret.cache_expires_at(),
Some(1_700_000_000 + DEFAULT_TOKEN_TTL_SECONDS)
);
}
#[test]
fn current_re_mints_when_within_refresh_margin() {
let secret = make_secret(); let now_a = 1_700_000_000;
let t1 = secret.current(now_a).unwrap();
let now_b = now_a + 3300;
let t2 = secret.current(now_b).unwrap();
assert_ne!(t1, t2, "expected fresh JWT inside refresh margin");
assert_eq!(
secret.cache_expires_at(),
Some(now_b + DEFAULT_TOKEN_TTL_SECONDS)
);
}
#[test]
fn current_serves_cache_right_up_to_refresh_margin_edge() {
let secret = make_secret(); let now_a = 1_700_000_000;
let t1 = secret.current(now_a).unwrap();
let t2 = secret.current(now_a + 3299).unwrap();
assert_eq!(t1, t2);
}
#[test]
fn invalidate_forces_re_mint() {
let secret = make_secret();
let t1 = secret.current(1_700_000_000).unwrap();
secret.invalidate();
assert!(secret.cache_expires_at().is_none());
let t2 = secret.current(1_700_000_001).unwrap();
assert_ne!(t1, t2);
}
#[test]
fn with_ttl_invalidates_cache() {
let secret = make_secret();
let _ = secret.current(1_700_000_000).unwrap();
let secret = secret.with_ttl_seconds(7200).unwrap();
assert!(secret.cache_expires_at().is_none());
}
#[test]
fn debug_redacts_encoding_key() {
let dbg = format!("{:?}", make_secret());
assert!(dbg.contains("AppleClientSecret"));
assert!(dbg.contains("team_id: \"TEAM123ABC\""));
assert!(dbg.contains("encoding_key: \"<redacted>\""));
assert!(!dbg.to_lowercase().contains("begin private key"));
}
#[test]
fn is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AppleClientSecret>();
}
}