better_auth_api/plugins/oauth/providers/
apple.rs1use 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#[derive(Clone, Default)]
22pub struct AppleOptions {
23 pub audience: Option<Vec<String>>,
25 pub app_bundle_identifier: Option<String>,
27 pub additional_client_ids: Vec<String>,
29 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 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 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 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}