cedros-login-server 0.0.45

Authentication server for cedros-login with email/password, Google OAuth, and Solana wallet sign-in
Documentation
//! Apple OAuth helpers for multi-client verification and token revocation.

use std::collections::HashSet;

use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;

use crate::config::AppleConfig;
use crate::errors::AppError;
use crate::repositories::{CredentialEntity, CredentialRepository, CredentialType};

use super::apple_service::{AppleService, AppleTokenClaims};
use super::note_encryption_service::{NoteEncryptionService, NONCE_SIZE as NOTE_NONCE_SIZE};
use super::settings_service::SettingsService;

const APPLE_AUDIENCE: &str = "https://appleid.apple.com";
const APPLE_REVOKE_URL: &str = "https://appleid.apple.com/auth/revoke";
const APPLE_TOKEN_URL: &str = "https://appleid.apple.com/auth/token";
const APPLE_TOKEN_TIMEOUT_SECS: u64 = 5;
const APPLE_REFRESH_TOKEN_KEY_ID: &str = "apple-oauth-refresh-token";

#[derive(Debug, Clone)]
pub struct VerifiedAppleToken {
    pub claims: AppleTokenClaims,
    pub client_id: String,
}

#[derive(Debug, Clone)]
struct AppleOauthCredentials {
    client_id: String,
    team_id: String,
    key_id: String,
    private_key_pem: String,
}

#[derive(Debug, Serialize)]
struct AppleClientSecretClaims<'a> {
    iss: &'a str,
    iat: i64,
    exp: i64,
    aud: &'static str,
    sub: &'a str,
}

#[derive(Debug, Deserialize)]
struct AppleTokenResponse {
    refresh_token: Option<String>,
}

#[derive(Debug, Deserialize)]
struct AppleErrorResponse {
    error: Option<String>,
    error_description: Option<String>,
}

pub async fn resolve_apple_allowed_client_ids(
    settings: &SettingsService,
    config: &AppleConfig,
) -> Result<Vec<String>, AppError> {
    let mut client_ids = Vec::new();

    if let Some(client_id) = settings.get("auth_apple_client_id").await? {
        let trimmed = client_id.trim();
        if !trimmed.is_empty() {
            client_ids.push(trimmed.to_string());
        }
    } else if let Some(client_id) = &config.client_id {
        client_ids.push(client_id.clone());
    }

    if let Some(from_settings) = settings.get("auth_apple_allowed_client_ids").await? {
        client_ids.extend(parse_client_ids(&from_settings));
    } else {
        client_ids.extend(config.allowed_client_ids.iter().cloned());
    }

    let mut seen = HashSet::new();
    client_ids.retain(|client_id| seen.insert(client_id.clone()));

    if client_ids.is_empty() {
        return Err(AppError::Config(
            "Apple client ID not configured".to_string(),
        ));
    }

    Ok(client_ids)
}

pub async fn verify_apple_id_token_for_allowed_clients(
    apple_service: &AppleService,
    settings: &SettingsService,
    config: &AppleConfig,
    id_token: &str,
) -> Result<VerifiedAppleToken, AppError> {
    let client_ids = resolve_apple_allowed_client_ids(settings, config).await?;
    let mut last_error = AppError::InvalidToken;

    for client_id in client_ids {
        match apple_service.verify_id_token(id_token, &client_id).await {
            Ok(claims) => {
                return Ok(VerifiedAppleToken { claims, client_id });
            }
            Err(error @ AppError::TokenExpired) => return Err(error),
            Err(error) => last_error = error,
        }
    }

    Err(last_error)
}

pub async fn exchange_and_encrypt_refresh_token(
    settings: &SettingsService,
    config: &AppleConfig,
    jwt_secret: &str,
    authorization_code: Option<&str>,
    client_id: &str,
) -> Result<Option<String>, AppError> {
    let Some(authorization_code) = authorization_code.filter(|value| !value.is_empty()) else {
        return Ok(None);
    };

    let Some(credentials) =
        resolve_apple_oauth_credentials(settings, config, client_id).await?
    else {
        tracing::warn!(
            "Apple authorization code received but revocation credentials are not configured"
        );
        return Ok(None);
    };

    let refresh_token =
        exchange_apple_authorization_code(&credentials, authorization_code).await?;
    refresh_token
        .map(|token| encrypt_refresh_token(jwt_secret, &token))
        .transpose()
}

pub async fn revoke_encrypted_refresh_token(
    settings: &SettingsService,
    config: &AppleConfig,
    jwt_secret: &str,
    client_id: &str,
    encrypted_refresh_token: &str,
) -> Result<(), AppError> {
    let Some(credentials) =
        resolve_apple_oauth_credentials(settings, config, client_id).await?
    else {
        tracing::warn!(
            client_id = %client_id,
            "Skipping Apple token revocation because revocation credentials are not configured"
        );
        return Ok(());
    };

    let refresh_token = decrypt_refresh_token(jwt_secret, encrypted_refresh_token)?;
    revoke_apple_refresh_token(&credentials, &refresh_token).await
}

pub fn extract_apple_refresh_token_metadata(
    metadata: &Option<serde_json::Value>,
) -> Option<(String, String)> {
    let object = metadata.as_ref()?.as_object()?;
    let refresh_token = object.get("refreshToken")?.as_str()?.to_string();
    let client_id = object.get("clientId")?.as_str()?.to_string();
    Some((refresh_token, client_id))
}

pub async fn sync_apple_credential(
    credential_repo: &(dyn CredentialRepository + Send + Sync),
    user_id: Uuid,
    encrypted_refresh_token: Option<String>,
    client_id: &str,
) -> Result<(), AppError> {
    let existing_refresh_token = credential_repo
        .find_by_user_and_type(user_id, CredentialType::OauthApple)
        .await?
        .into_iter()
        .find_map(|credential| extract_apple_refresh_token_metadata(&credential.metadata));

    credential_repo
        .delete_by_user_and_type(user_id, CredentialType::OauthApple)
        .await?;

    let mut credential = CredentialEntity::new(
        user_id,
        CredentialType::OauthApple,
        Some("Sign in with Apple".to_string()),
    );

    let refresh_token = encrypted_refresh_token.or_else(|| {
        existing_refresh_token
            .as_ref()
            .map(|(refresh_token, _)| refresh_token.clone())
    });
    let refresh_client_id = refresh_token.as_ref().map(|_| {
        existing_refresh_token
            .as_ref()
            .map(|(_, saved_client_id)| saved_client_id.clone())
            .unwrap_or_else(|| client_id.to_string())
    });

    if let (Some(refresh_token), Some(refresh_client_id)) = (refresh_token, refresh_client_id) {
        credential.metadata = Some(serde_json::json!({
            "refreshToken": refresh_token,
            "clientId": refresh_client_id,
        }));
    }

    let _ = credential_repo.create(credential).await?;
    Ok(())
}

fn parse_client_ids(input: &str) -> Vec<String> {
    input
        .split(',')
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .collect()
}

async fn resolve_apple_oauth_credentials(
    settings: &SettingsService,
    config: &AppleConfig,
    client_id: &str,
) -> Result<Option<AppleOauthCredentials>, AppError> {
    let team_id = resolve_optional_setting(settings, "auth_apple_team_id", config.team_id.clone()).await?;
    let key_id = resolve_optional_setting(settings, "auth_apple_key_id", config.key_id.clone()).await?;
    let private_key_pem = resolve_optional_setting(
        settings,
        "auth_apple_private_key_pem",
        config.private_key_pem.clone(),
    )
    .await?;

    let Some(team_id) = team_id else {
        return Ok(None);
    };
    let Some(key_id) = key_id else {
        return Ok(None);
    };
    let Some(private_key_pem) = private_key_pem else {
        return Ok(None);
    };

    Ok(Some(AppleOauthCredentials {
        client_id: client_id.to_string(),
        team_id,
        key_id,
        private_key_pem,
    }))
}

async fn resolve_optional_setting(
    settings: &SettingsService,
    key: &str,
    fallback: Option<String>,
) -> Result<Option<String>, AppError> {
    Ok(settings
        .get(key)
        .await?
        .filter(|value| !value.trim().is_empty())
        .or(fallback))
}

async fn exchange_apple_authorization_code(
    credentials: &AppleOauthCredentials,
    authorization_code: &str,
) -> Result<Option<String>, AppError> {
    let client_secret = build_client_secret(credentials)?;
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(APPLE_TOKEN_TIMEOUT_SECS))
        .build()
        .map_err(|error| AppError::Internal(anyhow::anyhow!("Failed to build Apple client: {}", error)))?;

    let response = client
        .post(APPLE_TOKEN_URL)
        .form(&[
            ("client_id", credentials.client_id.as_str()),
            ("client_secret", client_secret.as_str()),
            ("code", authorization_code),
            ("grant_type", "authorization_code"),
        ])
        .send()
        .await
        .map_err(|error| AppError::Internal(anyhow::anyhow!("Apple token exchange failed: {}", error)))?;

    if !response.status().is_success() {
        let error = response
            .json::<AppleErrorResponse>()
            .await
            .ok()
            .and_then(|payload| payload.error_description.or(payload.error))
            .unwrap_or_else(|| "Apple token exchange failed".to_string());
        return Err(AppError::Validation(error));
    }

    Ok(response
        .json::<AppleTokenResponse>()
        .await
        .map_err(|error| AppError::Internal(anyhow::anyhow!("Invalid Apple token response: {}", error)))?
        .refresh_token)
}

async fn revoke_apple_refresh_token(
    credentials: &AppleOauthCredentials,
    refresh_token: &str,
) -> Result<(), AppError> {
    let client_secret = build_client_secret(credentials)?;
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(APPLE_TOKEN_TIMEOUT_SECS))
        .build()
        .map_err(|error| AppError::Internal(anyhow::anyhow!("Failed to build Apple client: {}", error)))?;

    let response = client
        .post(APPLE_REVOKE_URL)
        .form(&[
            ("client_id", credentials.client_id.as_str()),
            ("client_secret", client_secret.as_str()),
            ("token", refresh_token),
            ("token_type_hint", "refresh_token"),
        ])
        .send()
        .await
        .map_err(|error| AppError::Internal(anyhow::anyhow!("Apple token revocation failed: {}", error)))?;

    if response.status().is_success() {
        return Ok(());
    }

    let error = response
        .json::<AppleErrorResponse>()
        .await
        .ok()
        .and_then(|payload| payload.error_description.or(payload.error))
        .unwrap_or_else(|| "Apple token revocation failed".to_string());

    Err(AppError::Validation(error))
}

fn build_client_secret(credentials: &AppleOauthCredentials) -> Result<String, AppError> {
    let mut header = Header::new(Algorithm::ES256);
    header.kid = Some(credentials.key_id.clone());

    let now = Utc::now();
    let claims = AppleClientSecretClaims {
        iss: &credentials.team_id,
        iat: now.timestamp(),
        exp: (now + Duration::minutes(5)).timestamp(),
        aud: APPLE_AUDIENCE,
        sub: &credentials.client_id,
    };

    let encoding_key =
        EncodingKey::from_ec_pem(credentials.private_key_pem.as_bytes()).map_err(|error| {
            AppError::Config(format!("Invalid APPLE_PRIVATE_KEY_PEM: {}", error))
        })?;

    encode(&header, &claims, &encoding_key)
        .map_err(|error| AppError::Internal(anyhow::anyhow!("Failed to sign Apple client secret: {}", error)))
}

fn encrypt_refresh_token(jwt_secret: &str, refresh_token: &str) -> Result<String, AppError> {
    let encryption_service =
        NoteEncryptionService::new(&derive_refresh_token_key(jwt_secret), APPLE_REFRESH_TOKEN_KEY_ID)?;
    let encrypted = encryption_service.encrypt(refresh_token.as_bytes())?;
    let mut payload = encrypted.nonce;
    payload.extend(encrypted.ciphertext);
    Ok(BASE64_STANDARD.encode(payload))
}

fn decrypt_refresh_token(jwt_secret: &str, encrypted_refresh_token: &str) -> Result<String, AppError> {
    let encryption_service =
        NoteEncryptionService::new(&derive_refresh_token_key(jwt_secret), APPLE_REFRESH_TOKEN_KEY_ID)?;
    let payload = BASE64_STANDARD
        .decode(encrypted_refresh_token)
        .map_err(|error| AppError::Validation(format!("Invalid encrypted Apple token: {}", error)))?;
    if payload.len() <= NOTE_NONCE_SIZE {
        return Err(AppError::Validation(
            "Invalid encrypted Apple token payload".to_string(),
        ));
    }
    let (nonce, ciphertext) = payload.split_at(NOTE_NONCE_SIZE);
    let plaintext = encryption_service.decrypt(ciphertext, nonce)?;
    String::from_utf8(plaintext)
        .map_err(|error| AppError::Internal(anyhow::anyhow!("Invalid Apple refresh token bytes: {}", error)))
}

fn derive_refresh_token_key(jwt_secret: &str) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(b"cedros:apple_refresh_token:v1");
    hasher.update(jwt_secret.as_bytes());
    hasher.finalize().into()
}