a06673b
use base64::Engine;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::Deserialize;
use crate::config::OAuthProviderConfig;
use crate::error::AuthError;
use crate::features::oauth::types::{OAuthProvider, ProviderIdentity};
#[derive(Debug, Deserialize)]
struct AppleTokenResp {
access_token: Option<String>,
refresh_token: Option<String>,
expires_in: Option<i64>,
id_token: String,
}
#[derive(Debug, Deserialize)]
struct AppleClaims {
sub: String,
email: Option<String>,
email_verified: Option<bool>,
}
fn b64_decode(input: &str) -> Result<Vec<u8>, AuthError> {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(input)
.map_err(|e| AuthError::Internal(format!("apple b64: {e}")))
}
/// Apple returns an RSA public key set at https://appleid.apple.com/auth/keys.
#[derive(Debug, Deserialize)]
struct AppleKey {
kid: String,
n: String,
e: String,
}
#[derive(Debug, Deserialize)]
struct AppleKeys {
keys: Vec<AppleKey>,
}
async fn fetch_key_for_kid(kid: &str) -> Result<DecodingKey, AuthError> {
let url = "https://appleid.apple.com/auth/keys";
let resp: AppleKeys = reqwest::Client::new()
.get(url)
.send()
.await
.map_err(|e| AuthError::Internal(format!("apple keys: {e}")))?
.error_for_status()
.map_err(|e| AuthError::Internal(format!("apple keys status: {e}")))?
.json()
.await
.map_err(|e| AuthError::Internal(format!("apple keys parse: {e}")))?;
let k = resp
.keys
.into_iter()
.find(|k| k.kid == kid)
.ok_or_else(|| AuthError::Internal("apple signing key not found".into()))?;
let n = b64_decode(&k.n)?;
let e = b64_decode(&k.e)?;
DecodingKey::from_rsa_components(
std::str::from_utf8(&n).map_err(|e| AuthError::Internal(format!("apple n utf8: {e}")))?,
std::str::from_utf8(&e).map_err(|e| AuthError::Internal(format!("apple e utf8: {e}")))?,
)
.map_err(|e| AuthError::Internal(format!("apple decoding key: {e}")))
}
fn apple_kid_from_token(token: &str) -> Result<String, AuthError> {
let mut parts = token.split('.');
let _header = parts
.next()
.ok_or_else(|| AuthError::BadRequest("apple id_token malformed".into()))?;
let payload = parts
.next()
.ok_or_else(|| AuthError::BadRequest("apple id_token malformed".into()))?;
let _ = parts.next();
let bytes = b64_decode(payload)?;
let v: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| AuthError::Internal(format!("apple id_token json: {e}")))?;
let kid = v
.get("kid")
.and_then(|x| x.as_str())
.ok_or_else(|| AuthError::BadRequest("apple id_token missing kid".into()))?;
Ok(kid.to_string())
}
/// Build an ES256-signed `client_secret` JWT for Apple's `/auth/token`
/// endpoint. Apple requires:
/// header: alg=ES256, kid=<key id>
/// payload: iss=<team_id>, iat, exp, aud=https://appleid.apple.com, sub=<client_id>
/// The signing key is supplied via the provider config's `extra` map under
/// `es256_private_key` (PEM, PKCS#8).
fn apple_client_secret(cfg: &OAuthProviderConfig) -> Result<String, AuthError> {
let pem = cfg.extra.get("es256_private_key").ok_or_else(|| {
AuthError::Internal("apple provider missing es256_private_key in extra".into())
})?;
let kid = cfg
.extra
.get("key_id")
.ok_or_else(|| AuthError::Internal("apple provider missing key_id in extra".into()))?;
let team = cfg
.extra
.get("team_id")
.ok_or_else(|| AuthError::Internal("apple provider missing team_id in extra".into()))?;
let key = EncodingKey::from_ec_pem(pem.as_bytes())
.map_err(|e| AuthError::Internal(format!("apple es256 key: {e}")))?;
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(kid.clone());
let claims = serde_json::json!({
"iss": team,
"iat": now,
"exp": now + 60 * 60, // 1 hour
"aud": "https://appleid.apple.com",
"sub": cfg.client_id,
});
encode(&header, &claims, &key)
.map_err(|e| AuthError::Internal(format!("apple client_secret encode: {e}")))
}
/// Exchange an Apple authorization code for tokens. Decode the `id_token`
/// (RS256) to extract `sub` / `email` / `email_verified`.
pub async fn fetch_apple_identity(
code: &str,
code_verifier: &str,
cfg: &OAuthProviderConfig,
client: &reqwest::Client,
) -> Result<ProviderIdentity, AuthError> {
let client_secret = apple_client_secret(cfg)?;
let token: AppleTokenResp = client
.post(&cfg.token_url)
.form(&[
("code", code),
("client_id", &cfg.client_id),
("client_secret", &client_secret),
("grant_type", "authorization_code"),
("code_verifier", code_verifier),
])
.send()
.await
.map_err(|e| AuthError::Internal(format!("apple token: {e}")))?
.error_for_status()
.map_err(|e| AuthError::Internal(format!("apple token status: {e}")))?
.json()
.await
.map_err(|e| AuthError::Internal(format!("apple token parse: {e}")))?;
let kid = apple_kid_from_token(&token.id_token)?;
let decoding = fetch_key_for_kid(&kid).await?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_audience(std::slice::from_ref(&cfg.client_id));
validation.set_issuer(&["https://appleid.apple.com"]);
let data = decode::<AppleClaims>(&token.id_token, &decoding, &validation)
.map_err(|e| AuthError::Internal(format!("apple id_token decode: {e}")))?;
let claims = data.claims;
Ok(ProviderIdentity {
provider: OAuthProvider::Apple,
provider_user_id: claims.sub,
provider_username: String::new(),
email: claims.email,
email_verified: claims.email_verified.unwrap_or(false),
access_token: token.access_token.unwrap_or_default(),
refresh_token: token.refresh_token,
expires_at: token
.expires_in
.map(|secs| time::OffsetDateTime::now_utc() + time::Duration::seconds(secs)),
scopes: cfg.scopes.clone(),
})
}