use std::fmt;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use better_auth_core::{AuthError, AuthResult};
use josekit::JoseError;
use josekit::jws::alg::ecdsa::EcdsaJwsSigner;
use josekit::jws::{ES256, JwsHeader};
use josekit::jwt::{self, JwtPayload};
use serde_json::{Map, Value};
const AUDIENCE: &str = "https://appleid.apple.com";
const MAX_LIFETIME_SECS: i64 = 15_777_000;
const MIN_LIFETIME_SECS: i64 = 600;
const DEFAULT_LIFETIME_SECS: i64 = 3600;
const RENEWAL_MARGIN_SECS: i64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AppleClientSecretError {
#[error("Apple team ID must not be empty")]
MissingTeamId,
#[error("Apple key ID must not be empty")]
MissingKeyId,
#[error("Apple private key must be a PKCS #8 PEM key for ES256")]
InvalidPrivateKey,
#[error("Apple client secret lifetime must be between 600 and 15777000 seconds")]
InvalidLifetime,
}
#[derive(Clone)]
pub struct AppleClientSecretSigner {
inner: Arc<Signer>,
}
struct Signer {
team_id: String,
key_id: String,
key: EcdsaJwsSigner,
lifetime_secs: i64,
cache: Mutex<Option<CachedSecret>>,
}
struct CachedSecret {
client_id: String,
value: String,
issued_at: i64,
expires_at: i64,
}
impl AppleClientSecretSigner {
pub fn new(
team_id: &str,
key_id: &str,
private_key_pem: &[u8],
) -> Result<Self, AppleClientSecretError> {
let team_id = team_id.trim();
if team_id.is_empty() {
return Err(AppleClientSecretError::MissingTeamId);
}
let key_id = key_id.trim();
if key_id.is_empty() {
return Err(AppleClientSecretError::MissingKeyId);
}
let key = ES256
.signer_from_pem(private_key_pem)
.map_err(|_| AppleClientSecretError::InvalidPrivateKey)?;
Signer::new(
team_id.to_owned(),
key_id.to_owned(),
key,
DEFAULT_LIFETIME_SECS,
)
.map(|signer| Self {
inner: Arc::new(signer),
})
}
pub fn with_lifetime(self, lifetime: Duration) -> Result<Self, AppleClientSecretError> {
let lifetime_secs = i64::try_from(lifetime.as_secs())
.ok()
.filter(|seconds| (MIN_LIFETIME_SECS..=MAX_LIFETIME_SECS).contains(seconds))
.ok_or(AppleClientSecretError::InvalidLifetime)?;
let signer = Signer::new(
self.inner.team_id.clone(),
self.inner.key_id.clone(),
self.inner.key.clone(),
lifetime_secs,
)?;
Ok(Self {
inner: Arc::new(signer),
})
}
pub fn client_secret(&self, client_id: &str) -> AuthResult<String> {
self.client_secret_at(client_id, chrono::Utc::now().timestamp())
}
pub(in crate::plugins::oauth) fn client_secret_at(
&self,
client_id: &str,
now: i64,
) -> AuthResult<String> {
let mut cache = self
.inner
.cache
.lock()
.unwrap_or_else(PoisonError::into_inner);
if let Some(cached) = cache.as_ref().filter(|cached| {
cached.client_id == client_id
&& cached.issued_at <= now
&& cached.expires_at - now > RENEWAL_MARGIN_SECS
}) {
return Ok(cached.value.clone());
}
let value = self
.inner
.sign(client_id, now)
.map_err(|_| AuthError::internal("Failed to sign the Apple client secret"))?;
*cache = Some(CachedSecret {
client_id: client_id.to_owned(),
value: value.clone(),
issued_at: now,
expires_at: now + self.inner.lifetime_secs,
});
Ok(value)
}
}
impl fmt::Debug for AppleClientSecretSigner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AppleClientSecretSigner")
.field("team_id", &self.inner.team_id)
.field("key_id", &self.inner.key_id)
.field("lifetime_secs", &self.inner.lifetime_secs)
.finish_non_exhaustive()
}
}
impl Signer {
fn new(
team_id: String,
key_id: String,
key: EcdsaJwsSigner,
lifetime_secs: i64,
) -> Result<Self, AppleClientSecretError> {
let signer = Self {
team_id,
key_id,
key,
lifetime_secs,
cache: Mutex::new(None),
};
let _ = signer
.sign("key-check", 0)
.map_err(|_| AppleClientSecretError::InvalidPrivateKey)?;
Ok(signer)
}
fn sign(&self, client_id: &str, issued_at: i64) -> Result<String, JoseError> {
let mut header = JwsHeader::new();
header.set_key_id(&self.key_id);
let mut claims = Map::new();
let _ = claims.insert("iss".into(), Value::from(self.team_id.as_str()));
let _ = claims.insert("iat".into(), Value::from(issued_at));
let _ = claims.insert("exp".into(), Value::from(issued_at + self.lifetime_secs));
let _ = claims.insert("aud".into(), Value::from(AUDIENCE));
let _ = claims.insert("sub".into(), Value::from(client_id));
let payload = JwtPayload::from_map(claims)?;
jwt::encode_with_signer(&payload, &header, &self.key)
}
}