Почему 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 — это не просто кнопка. Это серверная логика, хранение ключей, обработка событий отзыва и проверка состояния сессии при каждом запуске. Сделать только клиентскую часть и не дописать сервер — значит оставить бомбу замедленного действия. У половины пользователей в какой-то момент протухнут сессии или потеряются имена при первом входе. Лучше один раз сделать правильно.