Skip to main content

better_auth_api/plugins/oauth/providers/
apple.rs

1use std::time::Duration;
2
3use better_auth_core::{AuthError, AuthResult};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use super::{
8    OAuthCallbackUserPayload, OAuthProvider, OAuthUserInfo, OAuthUserInfoRequest,
9    defaults::ProviderKind,
10};
11use crate::plugins::oauth::{generic_profile::decode_claims, id_token, oidc::OidcVerifier};
12
13mod client_secret;
14
15pub use client_secret::{AppleClientSecretError, AppleClientSecretSigner};
16
17const ISSUER: &str = "https://appleid.apple.com";
18const JWKS_URL: &str = "https://appleid.apple.com/auth/keys";
19
20/// Audience selection and client-secret generation for Sign in with Apple.
21#[derive(Clone, Default)]
22pub struct AppleOptions {
23    /// Explicit accepted audiences. An omitted or empty list uses the bundle or client identifiers.
24    pub audience: Option<Vec<String>>,
25    /// Accepted bundle identifier when an explicit audience list is absent or empty.
26    pub app_bundle_identifier: Option<String>,
27    /// Additional accepted client identifiers after the primary `OAuthProvider::client_id`.
28    pub additional_client_ids: Vec<String>,
29    /// Signs the client secret for authorization-code exchange and token refresh.
30    ///
31    /// When set, a generated secret replaces `OAuthProvider::client_secret`
32    /// in token requests, and the configured secret may be empty.
33    pub client_secret_signer: Option<AppleClientSecretSigner>,
34    #[cfg(test)]
35    pub(in crate::plugins::oauth) jwks_url: Option<String>,
36}
37
38impl AppleOptions {
39    pub(in crate::plugins::oauth) fn audiences(&self, client_id: &str) -> Vec<String> {
40        if let Some(audience) = self.audience.as_ref().filter(|values| !values.is_empty()) {
41            audience.clone()
42        } else if let Some(bundle) = self
43            .app_bundle_identifier
44            .as_ref()
45            .filter(|value| !value.is_empty())
46        {
47            vec![bundle.clone()]
48        } else {
49            std::iter::once(client_id.to_owned())
50                .chain(self.additional_client_ids.iter().cloned())
51                .collect()
52        }
53    }
54
55    pub(in crate::plugins::oauth) async fn verify(
56        &self,
57        client_id: &str,
58        token: &str,
59        nonce: Option<&str>,
60    ) -> AuthResult<Value> {
61        let jwks_url = JWKS_URL;
62        #[cfg(test)]
63        let jwks_url = self.jwks_url.as_deref().unwrap_or(jwks_url);
64        let verifier = OidcVerifier::with_policy(
65            jwks_url
66                .parse()
67                .map_err(|error: url::ParseError| AuthError::internal(error.to_string()))?,
68            ISSUER.into(),
69            self.audiences(client_id),
70            None,
71            Some(Duration::from_secs(3600)),
72        )
73        .map_err(|error| AuthError::internal(error.to_string()))?;
74        let claims = verifier
75            .verify(token, None)
76            .await
77            .map_err(|_| id_token::invalid())?;
78        if let Some(nonce) = nonce.filter(|value| !value.is_empty()) {
79            let claim = claims.get("nonce").and_then(Value::as_str);
80            let hashed = hex::encode(Sha256::digest(nonce.as_bytes()));
81            if claim != Some(nonce) && claim != Some(hashed.as_str()) {
82                return Err(id_token::invalid());
83            }
84        }
85        Ok(claims)
86    }
87}
88
89impl OAuthProvider {
90    /// Configure Apple authorization, token grants, and direct ID-token verification.
91    ///
92    /// `client_secret` is a static Apple client secret. Pass an empty string
93    /// when `AppleOptions::client_secret_signer` generates the secret.
94    /// Direct ID-token sign-in needs neither.
95    pub fn apple(client_id: &str, client_secret: &str, options: AppleOptions) -> Self {
96        Self {
97            kind: ProviderKind::Apple(options),
98            ..Self::custom(
99                client_id,
100                client_secret,
101                "https://appleid.apple.com/auth/authorize",
102                "https://appleid.apple.com/auth/token",
103            )
104        }
105    }
106
107    pub(in crate::plugins::oauth) fn apple_options(&self) -> Option<&AppleOptions> {
108        match &self.kind {
109            ProviderKind::Apple(options) => Some(options),
110            _ => None,
111        }
112    }
113
114    /// Report whether the provider has a static or generated client secret.
115    pub(in crate::plugins::oauth) fn has_client_secret(&self) -> bool {
116        !self.client_secret.is_empty()
117            || self
118                .apple_options()
119                .is_some_and(|options| options.client_secret_signer.is_some())
120    }
121
122    /// Return a generated client secret that replaces the static secret.
123    pub(in crate::plugins::oauth) fn generated_client_secret(&self) -> AuthResult<Option<String>> {
124        self.apple_options()
125            .and_then(|options| options.client_secret_signer.as_ref())
126            .map(|signer| signer.client_secret(&self.client_id))
127            .transpose()
128    }
129}
130
131pub(in crate::plugins::oauth) fn profile(
132    request: &OAuthUserInfoRequest,
133    verified: Option<Value>,
134) -> AuthResult<Option<Value>> {
135    let claims = match verified {
136        Some(claims) => claims,
137        None => {
138            let Some(token) = request
139                .id_token
140                .as_deref()
141                .filter(|token| !token.is_empty())
142            else {
143                return Ok(None);
144            };
145            decode_claims(token)
146                .ok_or_else(|| AuthError::internal("Invalid Apple ID-token claims"))?
147        }
148    };
149    enrich_profile(claims, request.user.as_ref()).map(Some)
150}
151
152fn enrich_profile(mut claims: Value, user: Option<&OAuthCallbackUserPayload>) -> AuthResult<Value> {
153    let name = if let Some(name) = user.and_then(|user| user.name.as_ref()) {
154        format!(
155            "{} {}",
156            name.first_name.as_deref().unwrap_or_default(),
157            name.last_name.as_deref().unwrap_or_default()
158        )
159        .trim_matches(crate::plugins::helpers::oauth_scope_whitespace)
160        .to_owned()
161    } else {
162        claims
163            .get("name")
164            .and_then(Value::as_str)
165            .unwrap_or_default()
166            .to_owned()
167    };
168    let _ = claims
169        .as_object_mut()
170        .ok_or_else(|| AuthError::internal("Apple claims must be an object"))?
171        .insert("name".into(), name.into());
172    Ok(claims)
173}
174
175pub(super) fn decode_profile(profile: Value) -> Result<OAuthUserInfo, String> {
176    let verified = match profile.get("email_verified") {
177        Some(Value::Bool(value)) => *value,
178        Some(Value::String(value)) => value == "true",
179        _ => false,
180    };
181    Ok(OAuthUserInfo {
182        id: profile
183            .get("sub")
184            .and_then(Value::as_str)
185            .ok_or("missing Apple subject")?
186            .into(),
187        name: Some(
188            profile
189                .get("name")
190                .and_then(Value::as_str)
191                .unwrap_or_default()
192                .to_owned(),
193        )
194        .into(),
195        email: super::defaults::profile_email(&profile)?,
196        image: None,
197        email_verified: Some(verified).into(),
198        additional_fields: Default::default(),
199    })
200}