Почему Apple требует этот метод входа

С 2019 года Apple ввела правило App Store Review Guideline 4.8: если в приложении есть любой сторонний OAuth (Google, Facebook, VK — не важно), значит, обязан быть и Sign in with Apple. Нарушишь — завернут на ревью, без вариантов.

Логика Apple прозрачная: дать пользователю выбор и контроль над своими данными. Для пользователя это удобно — Face ID вместо пароля, скрытый email, никакого постоянного логина. Для разработчика — дополнительный код и несколько неочевидных ловушек, о которых ниже.

Если в приложении нет никаких сторонних входов — Sign in with Apple не нужен. Только своя авторизация — правило не применяется.

Что нужно до написания кода

Платный аккаунт Apple Developer — $99 в год. Без него дальше не идём.

Что ещё понадобится:

  • App ID с включённой capabilities «Sign in with Apple»
  • Service ID (для веб-части и серверной верификации)
  • Приватный ключ (.p8 файл) — скачивается ровно один раз, восстановить нельзя
  • Team ID и Key ID из Developer Console

Если приложение мультиплатформенное (iOS + Android + Web) — серверная верификация токенов обязательна. Доверять клиентской стороне нельзя, Apple прямо это пишет в документации.

Настройка в Apple Developer Console

Заходишь на developer.apple.com → Certificates, Identifiers & Profiles.

App ID. Находишь свой App ID или создаёшь новый. В разделе Capabilities включаешь «Sign in with Apple». Если в проекте несколько таргетов — проверь каждый отдельно.

Service ID. Создаёшь новый identifier типа Services IDs. Identifier обычно выглядит как com.yourcompany.yourapp.service — не совпадает с Bundle ID, это важно. После создания включаешь «Sign in with Apple», жмёшь Configure, указываешь Primary App ID и домен сервера. Невалидный домен — Apple не пропустит.

Приватный ключ. Идёшь в Keys → новый ключ → включаешь «Sign in with Apple» → выбираешь Primary App ID → скачиваешь .p8 файл. Это единственный шанс его скачать. Key ID записываешь сразу — он понадобится для генерации client_secret.

Серверная часть: client_secret и верификация токена

Вот где большинство спотыкается. Apple не выдаёт client_secret как строку — его нужно генерировать самостоятельно в виде JWT, подписанного приватным ключом.

Генерация client_secret

JWT должен содержать:

Header:
{
  "alg": "ES256",
  "kid": "YOUR_KEY_ID"
}

Payload:
{
  "iss": "YOUR_TEAM_ID",
  "iat": <текущее время unix>,
  "exp": <текущее время + не более 15777000 секунд>,
  "aud": "https://appleid.apple.com",
  "sub": "YOUR_BUNDLE_ID_или_SERVICE_ID"
}

Пример на Python:

import jwt
import time

def generate_client_secret(team_id, key_id, private_key, bundle_id):
    headers = {
        "alg": "ES256",
        "kid": key_id
    }
    payload = {
        "iss": team_id,
        "iat": int(time.time()),
        "exp": int(time.time()) + 86400 * 180,
        "aud": "https://appleid.apple.com",
        "sub": bundle_id
    }
    return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)

Приватный ключ берётся из .p8 файла как есть — вместе с заголовками -----BEGIN PRIVATE KEY-----.

Обмен authorization_code на токены

После авторизации на устройстве клиент получает authorization_code. Этот код отправляешь на свой сервер, который делает запрос к Apple:

POST https://appleid.apple.com/auth/token
Content-Type: application/x-www-form-urlencoded

client_id=com.yourcompany.app
&client_secret=<твой JWT>
&code=<authorization_code от клиента>
&grant_type=authorization_code

В ответ получаешь id_token, access_token и refresh_token. Из id_token декодируешь payload и достаёшь:

  • sub — уникальный ID пользователя у Apple (не меняется никогда)
  • email — реальный или скрытый
  • email_verified — bool
  • is_private_email — если Apple скрыл адрес

Клиентская часть на Swift

С iOS 13 всё делается через фреймворк AuthenticationServices.

import AuthenticationServices

class LoginViewController: UIViewController {

    func setupSignInButton() {
        let button = ASAuthorizationAppleIDButton(
            authorizationButtonType: .signIn,
            authorizationButtonStyle: traitCollection.userInterfaceStyle == .dark ? .white : .black
        )
        button.addTarget(self, action: #selector(handleAppleSignIn), for: .touchUpInside)
        view.addSubview(button)
    }

    @objc func handleAppleSignIn() {
        let provider = ASAuthorizationAppleIDProvider()
        let request = provider.createRequest()
        request.requestedScopes = [.fullName, .email]

        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.presentationContextProvider = self
        controller.performRequests()
    }
}

extension LoginViewController: ASAuthorizationControllerDelegate {
    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { return }

        let userId = credential.user           // уникальный ID — сохраняй в Keychain
        let email = credential.email           // nil при повторных входах
        let fullName = credential.fullName     // nil при повторных входах
        let authCode = credential.authorizationCode  // отправь на сервер немедленно

        // Сохраняй fullName до отправки на сервер, не после
    }

    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        if let authError = error as? ASAuthorizationError {
            switch authError.code {
            case .canceled:
                break  // пользователь отменил сам — не показывай ошибку
            default:
                // покажи сообщение об ошибке
                break
            }
        }
    }
}

extension LoginViewController: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return view.window!
    }
}

Проверка состояния учётной записи при запуске

При каждом старте приложения нужно проверять, не отозвал ли пользователь доступ через Настройки:

let provider = ASAuthorizationAppleIDProvider()
provider.getCredentialState(forUserID: savedUserId) { state, error in
    switch state {
    case .authorized:
        break  // всё хорошо
    case .revoked:
        // пользователь отозвал — разлогинивай
        break
    case .notFound:
        // userId не найден — нужен повторный вход
        break
    default:
        break
    }
}

Если пропустить эту проверку — пользователь отзовёт доступ, а приложение продолжит считать его авторизованным.

Подводные камни, о которых обычно не предупреждают

Имя приходит ровно один раз. fullName доступен только при первом входе. При всех последующих — nil. Если не сохранил сразу (до ответа сервера, а не после) — данные потеряны. Пользователю придётся идти в Настройки → Apple ID → Приложения с Apple ID, удалять твоё приложение и входить заново. Это плохой UX и недовольный пользователь.

Решение: сразу при получении credential сохраняй fullName в UserDefaults или Keychain, параллельно отправляй на сервер. Не жди ответа сервера перед сохранением.

authorization_code живёт 5 минут. Код, который пришёл с устройства, нужно обменять у Apple не позднее чем через 5 минут. Если сервер тормозит — получишь invalid_grant. Не храни код, отправляй сразу.

Скрытый email — это рабочий адрес. Адреса вида xxx@privaterelay.appleid.com реально принимают письма, пока пользователь не отключил пересылку. Проблема бывает с жёсткими email-валидаторами — там попадаются нестандартные символы. Упрощай регекс или используй либу.

Кнопка — только официальная. Apple строго требует использовать ASAuthorizationAppleIDButton. Нельзя рисовать свою кнопку, менять цвет логотипа, произвольно менять текст. Ревьюеры смотрят на это — завернут. Из разрешённого: выбор светлой или тёмной темы и один из допустимых вариантов текста.

Симулятор — не для всего. Базовый флоу на симуляторе проверить можно, но Face ID и Touch ID не работают. Для нормального тестирования нужно реальное устройство с залогиненным iCloud.

Server-to-Server уведомления. С iOS 14 Apple умеет слать POST на твой сервер при событиях consent-revoked или account-delete. Если не настроить этот endpoint в Service ID → Configure → Server-to-Server Notifications — на сервере будут висеть живые сессии удалённых аккаунтов. Сервер должен отвечать 200, иначе Apple будет ретраить.

Разные Bundle ID для dev и prod. Если у тебя два Bundle ID (com.yourcompany.app и com.yourcompany.app.dev), нужны отдельные App ID и отдельные ключи. client_secret генерируется с конкретным sub, и они не взаимозаменяемы. Путаница здесь — самая частая причина 401 при верификации токена.

Как тестировать

Создай отдельный тестовый Apple ID через appleid.apple.com — не используй основной аккаунт разработчика. Для Sandbox-тестирования в App Store Connect есть раздел Sandbox Testers.

Проверяй эти сценарии вручную:

  • Первый вход (имя и email приходят)
  • Повторный вход (имя и email nil, userId тот же)
  • Отзыв доступа через Настройки
  • Вход со скрытым email
  • Отмена пользователем (должна обрабатываться без показа ошибки)
  • Запуск приложения после отзыва доступа

Для unit-тестов серверной части: покрывай логику парсинга id_token с фиксированными данными. Реальные токены Apple не замокать, но структуру проверить можно.

Стоит ли делать самому

Если команда крепко держит iOS-разработку и проект не слишком сложный — Sign in with Apple реализуется за 2-3 дня. Документация Apple достаточно полная, хотя местами устаревшая (некоторые скриншоты консоли не совпадают с реальностью).

Если проект мультиплатформенный, с кастомной аутентификацией или legacy-кодом — разумнее отдать задачу тем, кто уже прошёл через эти грабли. В REEXY (r3xy.ru) регулярно делают интеграции такого рода: от OAuth до сторонних API и Telegram-ботов. Интеграция сервисов — от 1 500 ₽, конкретная цена зависит от объёма.

Самое важное, что стоит понять: Sign in with Apple — это не просто кнопка. Это серверная логика, хранение ключей, обработка событий отзыва и проверка состояния сессии при каждом запуске. Сделать только клиентскую часть и не дописать сервер — значит оставить бомбу замедленного действия. У половины пользователей в какой-то момент протухнут сессии или потеряются имена при первом входе. Лучше один раз сделать правильно.