Skip to main content

better_auth_api/plugins/oauth/providers/apple/
client_secret.rs

1//! Sign in with Apple client-secret generation.
2//!
3//! Apple's token endpoint accepts an ES256 JWT as the OAuth `client_secret`.
4//! [`AppleClientSecretSigner`] signs that JWT with the developer's private key
5//! and renews it before it expires, so applications do not rotate a static
6//! secret by hand.
7
8use std::fmt;
9use std::sync::{Arc, Mutex, PoisonError};
10use std::time::Duration;
11
12use better_auth_core::{AuthError, AuthResult};
13use josekit::JoseError;
14use josekit::jws::alg::ecdsa::EcdsaJwsSigner;
15use josekit::jws::{ES256, JwsHeader};
16use josekit::jwt::{self, JwtPayload};
17use serde_json::{Map, Value};
18
19const AUDIENCE: &str = "https://appleid.apple.com";
20/// Apple rejects client secrets that expire more than 15,777,000 seconds after issue.
21const MAX_LIFETIME_SECS: i64 = 15_777_000;
22/// The shortest lifetime keeps every secret usable beyond the renewal margin.
23const MIN_LIFETIME_SECS: i64 = 600;
24const DEFAULT_LIFETIME_SECS: i64 = 3600;
25/// A cached secret is renewed when less than this many seconds remain.
26const RENEWAL_MARGIN_SECS: i64 = 300;
27
28/// Rejected Sign in with Apple client-secret configuration.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
30pub enum AppleClientSecretError {
31    /// The Apple Developer Team ID is empty.
32    #[error("Apple team ID must not be empty")]
33    MissingTeamId,
34    /// The Sign in with Apple key ID is empty.
35    #[error("Apple key ID must not be empty")]
36    MissingKeyId,
37    /// The private key is not a PKCS #8 PEM key that can sign ES256.
38    #[error("Apple private key must be a PKCS #8 PEM key for ES256")]
39    InvalidPrivateKey,
40    /// The requested lifetime is outside the range that Apple accepts.
41    #[error("Apple client secret lifetime must be between 600 and 15777000 seconds")]
42    InvalidLifetime,
43}
44
45/// Generates and renews Sign in with Apple client secrets.
46///
47/// Each secret is an ES256 JWT with the Team ID as `iss`, the OAuth client ID
48/// as `sub`, and `https://appleid.apple.com` as `aud`. Secrets last one hour by
49/// default. A cached secret is reused until less than five minutes remain.
50/// Clones share the same cache.
51#[derive(Clone)]
52pub struct AppleClientSecretSigner {
53    inner: Arc<Signer>,
54}
55
56struct Signer {
57    team_id: String,
58    key_id: String,
59    key: EcdsaJwsSigner,
60    lifetime_secs: i64,
61    cache: Mutex<Option<CachedSecret>>,
62}
63
64struct CachedSecret {
65    client_id: String,
66    value: String,
67    issued_at: i64,
68    expires_at: i64,
69}
70
71impl AppleClientSecretSigner {
72    /// Create a signer from the Team ID, the key ID, and the `.p8` private key.
73    ///
74    /// `private_key_pem` is the PKCS #8 PEM file that Apple issues for a
75    /// Sign in with Apple key. The key is checked with a trial signature.
76    pub fn new(
77        team_id: &str,
78        key_id: &str,
79        private_key_pem: &[u8],
80    ) -> Result<Self, AppleClientSecretError> {
81        let team_id = team_id.trim();
82        if team_id.is_empty() {
83            return Err(AppleClientSecretError::MissingTeamId);
84        }
85        let key_id = key_id.trim();
86        if key_id.is_empty() {
87            return Err(AppleClientSecretError::MissingKeyId);
88        }
89        // Loading checks the PEM structure and requires the P-256 curve.
90        let key = ES256
91            .signer_from_pem(private_key_pem)
92            .map_err(|_| AppleClientSecretError::InvalidPrivateKey)?;
93        Signer::new(
94            team_id.to_owned(),
95            key_id.to_owned(),
96            key,
97            DEFAULT_LIFETIME_SECS,
98        )
99        .map(|signer| Self {
100            inner: Arc::new(signer),
101        })
102    }
103
104    /// Return a signer whose secrets remain valid for `lifetime`.
105    ///
106    /// The lifetime must be between 10 minutes and 15,777,000 seconds (about
107    /// six months). Fractions of a second are ignored. The returned signer
108    /// starts with an empty cache.
109    pub fn with_lifetime(self, lifetime: Duration) -> Result<Self, AppleClientSecretError> {
110        let lifetime_secs = i64::try_from(lifetime.as_secs())
111            .ok()
112            .filter(|seconds| (MIN_LIFETIME_SECS..=MAX_LIFETIME_SECS).contains(seconds))
113            .ok_or(AppleClientSecretError::InvalidLifetime)?;
114        let signer = Signer::new(
115            self.inner.team_id.clone(),
116            self.inner.key_id.clone(),
117            self.inner.key.clone(),
118            lifetime_secs,
119        )?;
120        Ok(Self {
121            inner: Arc::new(signer),
122        })
123    }
124
125    /// Return a valid client secret for `client_id`.
126    ///
127    /// The signer reuses its cached secret for the same client ID until less
128    /// than five minutes of validity remain. Otherwise it signs a new secret.
129    pub fn client_secret(&self, client_id: &str) -> AuthResult<String> {
130        self.client_secret_at(client_id, chrono::Utc::now().timestamp())
131    }
132
133    pub(in crate::plugins::oauth) fn client_secret_at(
134        &self,
135        client_id: &str,
136        now: i64,
137    ) -> AuthResult<String> {
138        let mut cache = self
139            .inner
140            .cache
141            .lock()
142            .unwrap_or_else(PoisonError::into_inner);
143        if let Some(cached) = cache.as_ref().filter(|cached| {
144            cached.client_id == client_id
145                && cached.issued_at <= now
146                && cached.expires_at - now > RENEWAL_MARGIN_SECS
147        }) {
148            return Ok(cached.value.clone());
149        }
150        let value = self
151            .inner
152            .sign(client_id, now)
153            .map_err(|_| AuthError::internal("Failed to sign the Apple client secret"))?;
154        *cache = Some(CachedSecret {
155            client_id: client_id.to_owned(),
156            value: value.clone(),
157            issued_at: now,
158            expires_at: now + self.inner.lifetime_secs,
159        });
160        Ok(value)
161    }
162}
163
164impl fmt::Debug for AppleClientSecretSigner {
165    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166        formatter
167            .debug_struct("AppleClientSecretSigner")
168            .field("team_id", &self.inner.team_id)
169            .field("key_id", &self.inner.key_id)
170            .field("lifetime_secs", &self.inner.lifetime_secs)
171            .finish_non_exhaustive()
172    }
173}
174
175impl Signer {
176    fn new(
177        team_id: String,
178        key_id: String,
179        key: EcdsaJwsSigner,
180        lifetime_secs: i64,
181    ) -> Result<Self, AppleClientSecretError> {
182        let signer = Self {
183            team_id,
184            key_id,
185            key,
186            lifetime_secs,
187            cache: Mutex::new(None),
188        };
189        // A trial signature proves that the loaded key can sign ES256.
190        let _ = signer
191            .sign("key-check", 0)
192            .map_err(|_| AppleClientSecretError::InvalidPrivateKey)?;
193        Ok(signer)
194    }
195
196    fn sign(&self, client_id: &str, issued_at: i64) -> Result<String, JoseError> {
197        // Apple documents a header with only `alg` and `kid`.
198        let mut header = JwsHeader::new();
199        header.set_key_id(&self.key_id);
200        let mut claims = Map::new();
201        let _ = claims.insert("iss".into(), Value::from(self.team_id.as_str()));
202        let _ = claims.insert("iat".into(), Value::from(issued_at));
203        let _ = claims.insert("exp".into(), Value::from(issued_at + self.lifetime_secs));
204        let _ = claims.insert("aud".into(), Value::from(AUDIENCE));
205        let _ = claims.insert("sub".into(), Value::from(client_id));
206        let payload = JwtPayload::from_map(claims)?;
207        jwt::encode_with_signer(&payload, &header, &self.key)
208    }
209}