Привет! Меня зовут Лена, я занимаюсь iOS-разработкой в KTS.
В большинстве случаев мобильное приложение должно уметь авторизовывать пользователя для доступа к данным, и зачастую это должно происходить с помощью сторонних сервисов. В таких случаях используется OAuth2.0 — протокол, позволяющий авторизоваться в другом сервисе без необходимости передавать логин и пароль в приложение и давать ограниченный набор доступов к ресурсам сервиса.
В мобильных приложениях используется Authorization Code Flow with Proof Key for Code Exchange (PKCE). Подробнее о выборе flow читайте в нашей предыдущей статье. Эта статья является продолжением.
Сегодня мы рассмотрим вариант реализации OAuth-авторизации с помощью библиотеки AppAuth-iOS. Она одна из самых популярных и довольна проста в использовании. Весь код из статьи доступен в Github.
Что будет в статье:
???? Базовая настройка
В качестве стороннего сервиса мы взяли OAuth Github. Общая настройка аналогична настройке android-приложения из предыдущей статьи: первым делом зарегистрируем приложение OAuth в Github.
При регистрации установите CALLBACK_URL
для вашего приложения на сервисе. На этот URL будет происходить перенаправление после авторизации, и ваше приложение будет его перехватывать.
В качестве CALLBACK_URL
будем использовать ru.kts.oauth://github.com/callback. Не забывайте использовать кастомную схему ru.kts.oauth, чтобы только ваше приложение могло перехватить редирект.
После регистрации OAuth-приложения в Github у вас должны быть доступны client_id
и client_secret
, который нужно сгенерировать. Сохраните их.
Теперь нужно понять, на какой URL переходить для авторизации на веб-странице Github, и по какому обменивать код на токен. Ответ лежит в документации по Github OAuth.
URL для авторизации: https://github.com/login/oauth/authorize
URL для обмена токена: https://github.com/login/oauth/access_token
Для авторизации нужно определить скоупы, к которым Github предоставит доступ. Представим, что нам в приложении нужны доступ к информации пользователя и его репозиториям: user
, repo
.
После настройки приложения OAuth Github мы должны иметь следующие данные, которые добавим в проект в качестве свойств конфигурации:
struct AuthConfiguration {
static let baseUrl = "https://github.com/"
static let authUri = "login/oauth/authorize"
static let tokenUri = "login/oauth/access_token"
static let endSessionUri = "logout"
static let scopes = ["user", "repo"]
static let clientId = "..."
static let clientSecret = "..."
static let callbackUrl = "ru.kts.oauth://github.com/callback"
static let logoutCallbackUrl = "ru.kts.oauth://github.com/logout_callback"
}
Обратите внимание, что callbackUrl
и logoutCallbackUrl
имеют кастомную схему “ru.kts.oauth”. Чтобы приложение могло перехватить редирект, необходимо прописать эту схему в Info.plist
(URL Schemes, URLIdentifier):
Подключаем библиотеку AppAuth через cocoapods. В работе с Github OAuth есть один нюанс — Github возвращает ответ в формате xml, хотя спецификация OAuth требует ответ в формате json. В библиотеке AppAuth нет возможности добавить кастомный заголовок запроса для указания формата ответа, поэтому форкнем ее и добавим заголовки в исходный код.
Подключение библиотеки AppAuth
Теперь в Podfile пишем следующую строку, указывая наш репо:
pod 'AppAuth', :git => 'git@github.com:elenakacharmina/AppAuth-iOS.git'
Выполняем команду в терминале:
pod install
Переходим к реализации авторизации.
???? Авторизация
Наша задача:
создать метод, внутри которого сформируем запрос авторизации и вызовем метод библиотеки AppAuth
реализовать перехват редиректа для завершения процесса
1. Создаем сессию авторизации. Для начала в AppDelegate объявим переменную типа OIDExternalUserAgentSession, которая будет удерживать текущий сеанс авторизации и будет использоваться при перехвате редиректа, чтобы продолжить процесс авторизации (реализация ниже):
class AppDelegate: UIResponder, UIApplicationDelegate {
var currentAuthorizationFlow: OIDExternalUserAgentSession?
}
Все классы/протоколы библиотеки AppAuth имеют префикс OID.
2. Создаем конфигурацию авторизации. В библиотеке AppAuth представлена сущность OIDAuthorizationService, которая позволяет выполнять запросы, связанные с OAuth.
Запросы представлены сущностями OIDAuthorizationRequest
(запрос авторизации), OIDTokenRequest
(запрос обновления токена), OIDEndSessionRequest
(запрос логаута). Каждый запрос требует в инициализаторе конфигурацию OIDServiceConfiguration
— набор endpoint uri. Мы создадим общую константу для всех запросов и вынесем ее в новый класс репозиторий OAuthRepository:
class OAuthRepository {
private let configuration = OIDServiceConfiguration(
authorizationEndpoint: URL(string: AuthConfiguration.baseUrl + AuthConfiguration.authUri)!,
tokenEndpoint: URL(string: AuthConfiguration.baseUrl + AuthConfiguration.tokenUri)!,
issuer: nil,
registrationEndpoint: nil,
endSessionEndpoint: URL(string: AuthConfiguration.baseUrl + AuthConfiguration.endSessionUri)!)
}
В нашем случае поля issuer
(The OpenID Connect issuer) и registrationEndpoint
(uri регистрации) не используются.
3. Создаем запрос авторизации. Вся логика работа с данными, связанная с OAuth, будет располагаться в OAuthRepository, разместим в нем метод login.
Формируем запрос:
let request = OIDAuthorizationRequest(
configuration: configuration,
clientId: AuthConfiguration.clientId,
clientSecret: AuthConfiguration.clientSecret,
scopes: AuthConfiguration.scopes,
redirectURL: URL(string: AuthConfiguration.callbackUrl)!,
responseType: OIDResponseTypeCode,
additionalParameters: nil)
Поле
responseType
сообщает о том, в каком виде мы хотим получить ответ после авторизации. Другие возможные значения —OIDResponseTypeToken
,OIDResponseTypeIDToken
. Github всегда возвращает только код, поэтому используем значениеOIDResponseTypeCode
.additionalParameters
— словарь, ключи которого будут добавлены в URL запроса в виде дополнительныхquery
-параметров. Они могут понадобиться в некоторых сервисах OAuth, но в нашем примере мы их не используем
4. Выполняем запрос авторизации. Следующий шаг — вызов метода библиотеки AppAuth для выполнения запроса.
Если нет задачи открывать страницы в приложении в SafariVC как авторизованный пользователь, можно обойтись стандартным методом из документации библиотеки AppAuth. Под капотом метод использует OIDExternalUserAgentIOS
с отдельной сессией ASWebAuthenticationSession
, которая затем не шарит куки с другими SafariViewController
. Нам это не подходит.
Чтобы сессия шарилась с SavariVC
, мы реализуем кастомный OIDExternalUserAgentIOSSafariViewController
, который реализует интерфейс OIDExternalUserAgent
из библиотеки. Потом это позволит открывать страницы в SafariViewController
без авторизации.
Пример реализации можно взять по ссылке и добавить в проект, а подробнее про варианты UserAgent
можно прочитать в статье.
5. Создаем OIDExternalUserAgentIOSSafariViewController:
let agent = OIDExternalUserAgentIOSSafariViewController(presentingViewController: viewController)
Теперь воспользуемся методом авторизации с кастомным externalUserAgent
:
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request,
externalUserAgent: agent) { [weak self] authState, error in
// реализация ниже
}
В результате вызова этого метода откроется SafariVC со страницей авторизации, а по ее успешному окончанию сервис перенаправит нас на url, указанный в поле redirectURL запроса. В данном случае — “ru.kts.oauth://github.com/callback”.
В параметрах редирект uri будет содержаться интересующий нас code
. Этот редирект необходимо перехватить, а затем обменять код на токен.
6. Перехватываем редирект авторизации и получаем токен. Для перехвата редиректа реализуем в sceneDelegate
метод scene(_, openURLContexts:)
, для версий ios ниже 13 — application(_:, open url: URL, options:)
в AppDelegate:
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
if let authorizationFlow = appDelegate.currentAuthorizationFlow,
let url = URLContexts.first?.url,
authorizationFlow.resumeExternalUserAgentFlow(with: url) {
appDelegate.currentAuthorizationFlow = nil
}
}
Здесь происходит проверка наличия текущего flow авторизации в приложении и вызывается метод resumeExternalUserAgentFlow
, который проверяет: совпадает ли данный uri с указанным при запросе. Если метод возвращает true, SafariVC скрывается и запускается процесс обмена кода на токен. Обмен кода на токен происходит под капотом AppAuth.
7. Сохраняем токены. В результате обмена кода на токен мы попадаем в обработчик метода OIDAuthState.authState
, который мы использовали в предыдущем шаге. Необходимо проверить отсутствие ошибки и сохранить полученный токен. Для простоты access и refresh токены сохраняем в UserDefaults:
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request,
externalUserAgent: agent)
{ [weak self] authState, error in
if error == nil {
let tokenModel = TokenModel(access: authState?.lastTokenResponse?.accessToken,
refresh: authState?.lastTokenResponse?.refreshToken)
self?.userDefaultsHelper.setToken(value: tokenModel)
}
}
На реальном проекте это небезопасно, лучше использовать keychain.
7. Тестируем авторизацию.
Запускаем код.
У нас уже реализована авторизация OAuth и получение токена:
Чтобы проверить валидность полученного токена, по кнопке пушим новый экран, на котором пытаемся получить данные с https://api.github.com/user при помощи токена.
Чтобы убедиться, что сессия авторизации шарит куки с SafariVC, откроем в нем страницу Github-пользователя по кнопке:
???? Обновление токена
В Github OAuth refresh-токен не используется, но во многих других сервисах access-токен протухает. Поэтому привожу пример обновления токена.
1. Формируем запрос обновления токена. Для начала нужно сформировать OIDTokenRequest:
func refreshToken() {
guard let refreshToken = userDefaultsHelper.getToken()?.refresh else { return }
let requestRefresh = OIDTokenRequest(
configuration: configuration,
grantType: OIDGrantTypeRefreshToken,
authorizationCode: nil,
redirectURL: nil,
clientID: AuthConfiguration.clientId,
clientSecret: AuthConfiguration.clientSecret,
scope: nil,
refreshToken: refreshToken,
codeVerifier: nil,
additionalParameters: nil)
}
В запросе кроме уже известных параметров, которые мы видели при авторизации, необходимо передать в параметр grantType
значение OIDGrantTypeRefreshToken
и сохраненный refresh-токен. В неиспользуемые параметры передаем nil.
2. Выполняем обновление токена. Вызываем метод OIDAuthorizationService.perform
, при успешном обновлении сохраняем новые значения access- и refresh-токенов:
func refreshToken() {
guard let refreshToken = userDefaultsHelper.getToken()?.refresh else { return }
let requestRefresh = OIDTokenRequest(
configuration: configuration,
grantType: OIDGrantTypeRefreshToken,
authorizationCode: nil,
redirectURL: nil,
clientID: AuthConfiguration.clientId,
clientSecret: AuthConfiguration.clientSecret,
scope: nil,
refreshToken: refreshToken,
codeVerifier: nil,
additionalParameters: nil)
OIDAuthorizationService.perform(requestRefresh) { [weak self] tokenResponse, error in
if error == nil {
let tokenModel = TokenModel(access: tokenResponse?.accessToken,
refresh: tokenResponse?.refreshToken)
self?.userDefaultsHelper.setToken(value: tokenModel)
}
}
}
Эту реализацию можно проверить, если создать приложение Github Apps вместо OAuth и заменить clientId и clientSecret в AuthConfiguration.
???? Логаут
При использовании OAuth-авторизации недостаточно просто очистить сохраненный токен из хранилища. Куки в SafariVC при этом не очищаются и при попытке повторной авторизации пользователь автоматически зайдет в сервис под предыдущим аккаунтом без возможности ввести другой логин/пароль.
Поэтому для реализации логаута тоже необходимо сформировать запрос в OIDEndSessionRequest…
let request = OIDEndSessionRequest(configuration: configuration,
idTokenHint: accessToken,
postLogoutRedirectURL: URL(string: AuthConfiguration.logoutCallbackUrl)!,
additionalParameters: nil)
…и пробросить его в метод OIDAuthorizationService.present:
let agent = OIDExternalUserAgentIOSSafariViewController(presentingViewController: viewController)
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.currentAuthorizationFlow = OIDAuthorizationService.present(request, externalUserAgent: agent) { [weak self] (response, error) in
if let error = error {
completion(.failure(CustomError.logoutError))
} else {
self?.userDefaultsHelper.clearToken()
}
}
В GitHub OAuth нет редиректа после логаута, поэтому пользователю придется закрывать окно самостоятельно.
Это вызовет ошибку в callback, поэтому в финальной версии проверки на наличие ошибки нет, а просто происходит очистка сохраненных токенов:
appDelegate.currentAuthorizationFlow = OIDAuthorizationService.present(request, externalUserAgent: agent) { [weak self] (response, error) in
self?.userDefaultsHelper.clearToken()
}
???? Выводы
Весь код проекта расположен в github.
Мы реализовали все необходимые методы для поддержки OAuth: авторизация, обновление токена и логаут. Использование библиотеки помогает довольно просто интегрировать OAuth в приложение. Однако в некоторых сервисах — как Github OAuth — есть свои особенности использования, которые требуют кастомных решений.
А как вы реализуете OAuth в приложениях? Был ли опыт работы с AppAuth? С какими проблемами OAuth сталкивались на практике?