Это руководство доведет Android-приложение от нуля до вошедшего пользователя и аутентифицированного вызова API через OAuthClient из Versola OAuth SDK. Понадобятся публичный клиент у вашего провайдера и хост под вашим контролем для App Link.
Предварительные требования
minSdk26 иcompileSdk37. Метаданные библиотеки требуютcompileSdkне ниже 37, иначе Gradle останавливает сборку наcheckDebugAarMetadata. Библиотеки не задаютtargetSdk: это решение приложения.- Публичный клиент, зарегистрированный у вашего провайдера, с точным redirect URI, который вы будете использовать. Провайдеры, которым для refresh-токена нужен
offline_access, перечислены на странице Руководства по провайдерам. - Хост под вашим контролем для App Link, где можно отдавать
/.well-known/assetlinks.json.
Установка
Артефакт io.versola:oauth-android лежит в Maven Central. Добавьте в репозитории mavenCentral() и google(), а зависимость — в модуль приложения.
// settings.gradle.kts of the app
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
// build.gradle.kts of the app module
android {
compileSdk = 37
defaultConfig {
minSdk = 26
}
}
dependencies {
implementation("io.versola:oauth-android:<version>")
}
OAuthClient зависит от ядра протокола и модулей DPoP, хранилища, браузера и OkHttp, так что одной зависимости хватает на все. Их типы входят в его публичную поверхность, например SessionState, VersolaError и VersolaRedirectActivity.
Настройка клиента
import versola.oauth.OAuthClient
import versola.oauth.OAuthConfiguration
val oauth = OAuthClient.create(
applicationContext,
OAuthConfiguration(
issuer = "https://id.example.com",
clientId = "acme-app",
redirectUri = "https://app.example.com/oauth",
postLogoutRedirectUri = "https://app.example.com/signed-out",
scopes = setOf("openid", "profile", "offline_access"),
protectedOrigins = setOf("https://api.example.com"),
),
)
| Поле | Значение |
|---|---|
issuer | Издатель провайдера. Discovery выполняется при первой операции, которой он нужен, и кэшируется |
redirectUri | App Link с https, которым вы владеете (предпочтительно), loopback-адрес или схема private-use. Схему private-use SDK принимает с предупреждением в логе при каждом create(), потому что такую же схему может зарегистрировать любое приложение. Открытый http на хост не из loopback отклоняется |
scopes | Scope по умолчанию для login(). openid добавляется, если его нет. Больше ничего не добавляется, в том числе offline_access |
protectedOrigins | Единственные источники, которые когда-либо получают токен: scheme://host[:port], без пути, https (или http на loopback-хост) |
pushedAuthorization | По умолчанию PushedAuthorization.WhenAdvertised: SDK отправляет запрос заранее, если провайдер объявляет PAR-эндпоинт. Остальные значения — Never и Required, см. Запрос авторизации |
tokenBinding | По умолчанию TokenBinding.Bearer. TokenBinding.Dpop() включает DPoP, см. Токены с привязкой к отправителю |
retryPolicy | RetryPolicy.Default: сколько раз и как долго SDK повторяет неудавшийся запрос, см. Ошибки сервера, 429 и Retry-After |
create не обращается к сети и не создает ключей. Повторный вызов с тем же издателем и ID клиента возвращает тот же экземпляр. Другая конфигурация для той же пары отклоняется ошибкой Configuration(InvalidConfiguration). В OAuthConfiguration нет параметра clientSecret, и это сделано намеренно.
Объявите редирект
Приложение объявляет свой redirect URI, заново описав VersolaRedirectActivity в собственном манифесте. Слияние манифестов объединяет это описание с объявлением из библиотеки. Для проверенного App Link:
<activity
android:name="versola.sdk.browser.VersolaRedirectActivity"
android:exported="true"
tools:node="merge">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.example.com" android:path="/oauth" />
</intent-filter>
</activity>
Для атрибута tools:node в корневом элементе манифеста должно быть объявлено пространство имен tools. Хост и путь должны совпадать с redirectUri и быть проверенным App Link вашего приложения. Отдайте на этом хосте /.well-known/assetlinks.json с именем пакета и сертификатом подписи приложения.
Браузерный шаг идет через Auth Tab, если провайдер Custom Tabs на устройстве его поддерживает, иначе через Custom Tab, а в крайнем случае через любой браузер. Auth Tab возвращает редирект тому приложению, которое его открыло. Если в Auth Tab https-адрес редиректа не входит в проверенные App Link приложения, вкладка отвечает ошибкой проверки, и она приходит как Configuration(InvalidConfiguration). То же верно для postLogoutRedirectUri: без проверки вкладка закрывается непроверенной, а logout() сообщает sessionEnded = false.
LoginOptions(ephemeralBrowser = true) просит сессию браузера, которая ничего не делит с браузером пользователя. По умолчанию она выключена: ломает passkeys и единый вход. Работает она по возможности: если браузер провайдера ее не поддерживает, SDK пишет одно предупреждение в лог и открывает общую сессию.
Вход, вызов API, выход
import okhttp3.Request
import versola.oauth.LoginOptions
import versola.oauth.core.VersolaError
suspend fun signInAndCall() {
try {
oauth.login(LoginOptions(scopes = setOf("openid", "profile", "offline_access")))
val request = Request.Builder().url("https://api.example.com/me").build()
oauth.call(request).use { response -> handle(response.code) }
} catch (e: VersolaError.StepUp) {
oauth.stepUp(e) // then repeat the call
}
}
suspend fun signOut() {
val outcome = oauth.logout() // never throws for a server-side failure
// outcome.sessionEnded and outcome.tokenRevoked say which server-side steps worked
}
login() ничего не возвращает. Сессию можно наблюдать через sessionState, а в claims лежат claims проверенного ID-токена. logout() отзывает refresh-токен, завершает сессию у провайдера в браузере и стирает локальное состояние. Безусловно только стирание: два серверных шага выполняются по возможности, и LogoutOutcome говорит, какой из них удался.
Как отправить запрос через PAR и как передать prompt, loginHint, resources или параметры провайдера, описано на странице Запрос авторизации: PAR и дополнительные параметры.
Наблюдение за состоянием сессии
sessionState — холодный Flow<SessionState>, первым значением которого приходит восстановленное состояние. Сохраненный grant восстанавливается как Authenticated, ожидающая авторизация моложе 10 минут — как Authorizing, все остальное — как Unauthenticated.
import versola.oauth.core.SessionState
lifecycleScope.launch {
oauth.sessionState.collect { state ->
if (state == SessionState.Authenticated) showSignedIn()
}
}
Вызов login() в состоянии Authorizing отклоняется ошибкой Configuration(InvalidConfiguration), так что сначала посмотрите на состояние. Семь состояний описаны на странице Состояния сессии и ошибки.
Вызовы API через OkHttp
oauth.call(request) и oauth.authenticatedClient() берут protectedOrigins из конфигурации, но набор можно передать и отдельно для каждого вызова. Чтобы подключить сессию к собственному OkHttpClient:
val client = OkHttpClient.Builder()
.addInterceptor(oauth.interceptor(setOf("https://api.example.com")))
.build()
Перехватчик — прикладной (application interceptor). Он делает следующее:
- Подставляет токен только на перечисленные источники, остальные запросы пропускает нетронутыми.
- На 401 один раз обновляет токен и один раз повторяет запрос.
- Один раз повторяет запрос при вызове DPoP nonce.
- Превращает запрос RFC 9470 в
StepUp.
Перехватчик может бросить только IOException, поэтому в вашем клиенте ошибки приходят как VersolaHttpException. Его свойство error — это VersolaError. oauth.call разворачивает его за вас. authenticatedClient() еще и отключает редиректы, чтобы Location от защищенного источника не мог повторить запрос вместе с токеном на другом источнике. Если вы пользуетесь interceptor в собственном клиенте, редиректы отключите сами.
oauth.accessToken().value — запасной выход для HTTP-стеков, которые не OkHttp. Токен, переданный в код приложения, приложение может залогировать.
Обработка ошибок
На Android тип ошибки — VersolaError, а подсказывает, что делать, ее категория. Четыре категории: Recoverable, Reauthenticate, StepUp и Configuration:
import versola.oauth.core.VersolaError
try {
oauth.login()
} catch (e: VersolaError) {
when (e) {
is VersolaError.Recoverable -> when (val reason = e.reason) {
is VersolaError.Recoverable.Cause.Server -> retryLater(reason.retryAfterMillis) // null: the SDK spent its budget
else -> retryOrAskAgain(reason)
}
is VersolaError.Reauthenticate -> showSignIn(e.reason)
is VersolaError.StepUp -> oauth.stepUp(e) // then repeat the refused call
is VersolaError.Configuration -> throw e // a deployment mistake, not a user problem
}
}
when исчерпывающий. Что значит каждая категория и какие причины в нее попадают, написано на странице Состояния сессии и ошибки. Когда провайдер отвечает 500, 503 или 429, причина в Recoverable — Server: что SDK повторяет до того, как вы увидите ошибку, и полный пример смотрите на странице Ошибки сервера, 429 и Retry-After.
Где хранятся токены и ключи?
На Android refresh-токен, сырой ID-токен и ожидающий запрос лежат в файлах под noBackupFilesDir, обернутых ключом Keystore, так что в резервные копии они не попадают. Refresh-токен в ваш код не отдается никогда.
DPoP включается явно: задайте в конфигурации tokenBinding = TokenBinding.Dpop() (из versola.oauth.core). Он работает, если провайдер объявляет ES256, а иначе SDK использует bearer и пишет одно предупреждение. keyAttestation() сообщает, какого уровня Keystore достиг ключ: StrongBox, TrustedEnvironment или Software. При Dpop(requireHardware = true) устройство, которое не может выполнить это требование, получает ошибку Configuration вместо понижения уровня. Ключ неизвлекаемый, не привязан к присутствию пользователя и заменяется при каждом login().
Нужны ли правила R8 или ProGuard?
Нет. Правил keep для потребителя SDK не требует. Его демо-приложение собирает релизный вариант с R8 (isMinifyEnabled = true, стандартный proguard-android-optimize.txt) и пустым файлом правил, а сериализация обходится без рефлексии. Предупреждение R8 о недостающем классе — баг SDK, а не повод добавлять правила в вашем приложении.
Как тестируется Android SDK?
- Модульные тесты на JVM и Robolectric проверяют правила протокола на заглушках.
- Тесты Keystore на эмуляторе проверяют настоящий Keystore, уровни ключей и обертку GCM для хранилища и DPoP.
- Интеграционные тесты прогоняют
OAuthClientна контейнерах Keycloak 26.4 и Ory Hydra v26.2.0. - Демо-приложение собирает минифицированный релиз и запускает инструментальный smoke-тест на эмуляторе.
Что дальше
- Состояния сессии и ошибки: чего ждет приложение от каждого состояния и каждой категории ошибок.
- Ошибки сервера, 429 и Retry-After: что SDK повторяет, когда провайдер отвечает 500, 503 или 429.
- Руководства по провайдерам: контракт провайдера, различия в
offline_accessи выходе.