Android — Versola Docs
/docs
0.1.0versola.ioGitHub

Android

Подключите Versola OAuth SDK к Android-приложению из Maven Central, объявите редирект через App Link, войдите, вызовите API через OkHttp и разберите ошибки.

Это руководство доведет Android-приложение от нуля до вошедшего пользователя и аутентифицированного вызова API через OAuthClient из Versola OAuth SDK. Понадобятся публичный клиент у вашего провайдера и хост под вашим контролем для App Link.

Предварительные требования

  • minSdk 26 и compileSdk 37. Метаданные библиотеки требуют 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 выполняется при первой операции, которой он нужен, и кэшируется
redirectUriApp Link с https, которым вы владеете (предпочтительно), loopback-адрес или схема private-use. Схему private-use SDK принимает с предупреждением в логе при каждом create(), потому что такую же схему может зарегистрировать любое приложение. Открытый http на хост не из loopback отклоняется
scopesScope по умолчанию для login(). openid добавляется, если его нет. Больше ничего не добавляется, в том числе offline_access
protectedOriginsЕдинственные источники, которые когда-либо получают токен: scheme://host[:port], без пути, https (или http на loopback-хост)
pushedAuthorizationПо умолчанию PushedAuthorization.WhenAdvertised: SDK отправляет запрос заранее, если провайдер объявляет PAR-эндпоинт. Остальные значения — Never и Required, см. Запрос авторизации
tokenBindingПо умолчанию TokenBinding.Bearer. TokenBinding.Dpop() включает DPoP, см. Токены с привязкой к отправителю
retryPolicyRetryPolicy.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-тест на эмуляторе.

Что дальше