Реалізація механізму Refresh Token в мобільному приложенні
Refresh token — найбільш недооцінена частина auth-системи. Access token закінчується — користувач не повинен це помічати. На практиці половина проблем "приложення розлогинює" відбувається саме від неправильного refresh-механізму.
Три сценарії, які ламають naive-реалізацію
Гонка запитів. Користувач відкриває екран — приложення паралельно запускає три API-виклики. Всі три отримують 401 (access token істік). Всі три запускають refresh. Перший refresh успішно обновляє токени. Другий відправляє вже використаний refresh token — при Refresh Token Rotation сервер його відозиває як підозрілий. Третій те саме. Результат: користувач примусово виходить з системи, хоча access token реально істік щойно.
Refresh в фоні. iOS BackgroundTasks або Android WorkManager запускають синхронізацію даних у фоні. У цей момент основне приложення теж робить refresh. Два паралельних refresh з одним токеном — класична проблема при Rotation.
Істік refresh token. Користувач не відкривав приложення 30 днів. Refresh token теж істік. Приложення робить тихий refresh → отримує 401/400 → повинно коректно перейти на екран логіну, а не зациклитися на безкінечних запитах.
Правильна архітектура
Єдиний джерело правди про токени — TokenRepository (або AuthRepository). Ніякий компонент крім нього не читає та не пише токени напрямки.
Refresh викликається тільки через TokenRepository.getValidAccessToken(). Всередину — mutex або actor-ізоляція:
// Android / Kotlin
class TokenRepository(
private val api: AuthApi,
private val storage: TokenStorage
) {
private val refreshMutex = Mutex()
private var refreshJob: Deferred<String>? = null
suspend fun getValidAccessToken(): String {
val current = storage.getAccessToken()
if (current != null && !current.isExpired()) return current
return refreshMutex.withLock {
// Після отримання лока перепроверяємо — інший потік міг уже обновити
val refreshed = storage.getAccessToken()
if (refreshed != null && !refreshed.isExpired()) return@withLock refreshed
val newTokens = api.refresh(storage.getRefreshToken()
?: throw SessionExpiredException())
storage.saveTokens(newTokens)
newTokens.accessToken
}
}
}
Double-checked locking всередину mutex — обов'язково. Інакше всі потоки, що дочекалися лока, знову роблять refresh.
На iOS з Swift Concurrency — actor:
actor TokenStore {
private var isRefreshing = false
private var waiters: [CheckedContinuation<String, Error>] = []
func getValidToken(refresher: AuthService) async throws -> String {
let stored = storage.accessToken
if let token = stored, !token.isExpired { return token.value }
if isRefreshing {
return try await withCheckedThrowingContinuation { waiters.append($0) }
}
isRefreshing = true
do {
let tokens = try await refresher.refresh(storage.refreshToken)
storage.save(tokens)
waiters.forEach { $0.resume(returning: tokens.accessToken) }
waiters.removeAll()
isRefreshing = false
return tokens.accessToken
} catch {
waiters.forEach { $0.resume(throwing: error) }
waiters.removeAll()
isRefreshing = false
throw error
}
}
}
Зберігання Refresh Token
Refresh token — найчутливіший секрет. Живе довше, дає більше прав (отримати новий access token).
- iOS: Keychain з
kSecAttrAccessibleAfterFirstUnlock(доступний після першої розблокування, включно фонові операції) абоkSecAttrAccessibleWhenUnlocked(тільки при розблокованому екрані, якщо фон не потрібен). - Android: EncryptedSharedPreferences через
MasterKeyз Android Keystore.
Ніколи не логуємо refresh token. Перевіряємо, що Crashlytics, Sentry та інші SDK не захватують HTTP-запити з refresh token у тілі. В OkHttp — кастомний Interceptor з маскуванням sensitive headers/body перед передачею в crashlytics.
Refresh Token Rotation
Якщо сервер підтримує Rotation: кожен успішний refresh повертає новий refresh token, старий інвалідується. Це обмежує вікно атаки при компрометації токена.
Наслідок для мобілки: неможна зберегти "другий" refresh token як резервний. Завжди працюємо з одним, атомарно зберігаємо нову пару після refresh.
Обробка SessionExpired
Коли refresh token істік або відозван — користувачу треба повідомити та перевести на екран логіну. Робимо це через глобальний event bus або Notification/Flow:
// Kotlin / Coroutines
object AuthEvents : MutableSharedFlow<AuthEvent>() // у singleton
// У TokenRepository при 401 на refresh:
AuthEvents.emit(AuthEvent.SessionExpired)
// У Activity/Fragment:
lifecycleScope.launch {
AuthEvents.collect { if (it == AuthEvent.SessionExpired) navigateToLogin() }
}
Не показуємо стандартний системний alert — це наш UX, пояснюємо користувачу, що сесія завершена.
Терміни
Реалізація правильного refresh механізму з mutex/actor-ізоляцією, коректним зберіганням, обробкою SessionExpired та покриттям unit-тестами (включно тест гонки запитів) — 4–8 робочих днів. Якщо добавляється підтримка фонових задач (WorkManager/BackgroundTasks) — ще 2–3 дні.







