Skip to content

Commit 1d5dabc

Browse files
committed
Implement refresh tokens handling
1 parent 5774bfa commit 1d5dabc

7 files changed

Lines changed: 224 additions & 20 deletions

File tree

app/src/main/java/com/example/moontech/application/AppContainer.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.example.moontech.data.store.UserDataStore
1818
import com.example.moontech.services.web.CameraApiService
1919
import com.example.moontech.services.web.CameraApiServiceImpl
2020
import com.example.moontech.services.web.HttpClientFactory
21+
import com.example.moontech.services.web.PersistentCookieStorage
2122
import com.example.moontech.services.web.RoomApiService
2223
import com.example.moontech.services.web.RoomApiServiceImpl
2324
import com.example.moontech.services.web.TokenManager
@@ -39,6 +40,7 @@ interface AppContainer {
3940
val cameraApiService: CameraApiService
4041
val videoServerApiService: VideoServerApiService
4142
val tokenManager: TokenManager
43+
val persistentCookieStorage: PersistentCookieStorage
4244
}
4345

4446
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
@@ -61,9 +63,12 @@ class DefaultAppContainer(context: Context) : AppContainer {
6163
override val roomCameraDataStore: RoomCameraDataStore by lazy {
6264
PreferencesRoomCameraDataStore(context.dataStore)
6365
}
66+
override val tokenManager: TokenManager by lazy {
67+
TokenManager(userDataStore)
68+
}
6469
override val httpClient: HttpClient by lazy {
6570
val baseUrl = context.getString(R.string.base_url)
66-
HttpClientFactory.create(baseUrl, tokenManager)
71+
HttpClientFactory.create(baseUrl, tokenManager, persistentCookieStorage)
6772
}
6873
override val userApiService: UserApiService by lazy {
6974
UserApiServiceImpl(httpClient)
@@ -74,8 +79,8 @@ class DefaultAppContainer(context: Context) : AppContainer {
7479
override val cameraApiService: CameraApiService by lazy {
7580
CameraApiServiceImpl(httpClient)
7681
}
77-
override val tokenManager: TokenManager by lazy {
78-
TokenManager(userDataStore)
82+
override val persistentCookieStorage: PersistentCookieStorage by lazy {
83+
PersistentCookieStorage(context.dataStore)
7984
}
8085
override val videoServerApiService: VideoServerApiService by lazy {
8186
VideoServerApiServiceImpl(httpClient, context)

app/src/main/java/com/example/moontech/data/store/PreferencesDataStoreUtils.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
21
import android.util.Log
32
import androidx.datastore.preferences.core.emptyPreferences
43
import kotlinx.coroutines.flow.Flow
54
import kotlinx.coroutines.flow.catch
5+
import kotlinx.serialization.encodeToString
6+
import kotlinx.serialization.json.Json
67
import java.io.IOException
78

89
private const val TAG = "PreferencesDataStore"
@@ -15,4 +16,17 @@ fun <T> Flow<T>.catching(): Flow<T> {
1516
}
1617
throw it
1718
}
19+
}
20+
21+
inline fun <reified T> Set<String>.editDecodedValues(block: MutableSet<T>.() -> Unit): Set<String> {
22+
return decode<T>()
23+
.toMutableSet().apply { block() }.encodeToString()
24+
}
25+
26+
inline fun <reified T> Set<String>.decode(): Set<T> {
27+
return map { Json.decodeFromString<T>(it) }.toSet()
28+
}
29+
30+
inline fun <reified T> Set<T>.encodeToString(): Set<String> {
31+
return map { Json.encodeToString(it) }.toSet()
1832
}

app/src/main/java/com/example/moontech/data/store/PreferencesUserDataStore.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import catching
99
import com.example.moontech.data.dataclasses.UserData
1010
import kotlinx.coroutines.flow.Flow
1111
import kotlinx.coroutines.flow.map
12+
import kotlinx.serialization.encodeToString
13+
import kotlinx.serialization.json.Json
1214

1315
class PreferencesUserDataStore(private val dataStore: DataStore<Preferences>) : UserDataStore {
1416
companion object {
@@ -20,12 +22,12 @@ class PreferencesUserDataStore(private val dataStore: DataStore<Preferences>) :
2022
.catching()
2123
.map { preferences ->
2224
Log.i(TAG, "fetching ${preferences[USER_DATA_KEY]}")
23-
preferences[USER_DATA_KEY]?.let { UserData(it) }
25+
preferences[USER_DATA_KEY]?.let { Json.decodeFromString<UserData>(it) }
2426
}
2527

2628
override suspend fun save(userData: UserData) {
2729
dataStore.edit { preferences ->
28-
preferences[USER_DATA_KEY] = userData.accessToken
30+
preferences[USER_DATA_KEY] = Json.encodeToString(userData)
2931
}
3032
}
3133

app/src/main/java/com/example/moontech/services/web/HttpClientFactory.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,22 @@ import io.ktor.client.engine.okhttp.OkHttp
55
import io.ktor.client.plugins.auth.Auth
66
import io.ktor.client.plugins.auth.providers.bearer
77
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8+
import io.ktor.client.plugins.cookies.CookiesStorage
9+
import io.ktor.client.plugins.cookies.HttpCookies
810
import io.ktor.client.plugins.defaultRequest
911
import io.ktor.http.ContentType
1012
import io.ktor.http.contentType
13+
import io.ktor.http.encodedPath
1114
import io.ktor.serialization.kotlinx.json.json
1215
import okhttp3.logging.HttpLoggingInterceptor
1316
import java.time.Duration
1417

1518
object HttpClientFactory {
16-
fun create(baseUrl: String, tokenManager: TokenManager): HttpClient {
19+
fun create(
20+
baseUrl: String,
21+
tokenManager: TokenManager,
22+
cookieStorage: CookiesStorage
23+
): HttpClient {
1724
return HttpClient(OkHttp) {
1825
expectSuccess = true
1926
engine {
@@ -34,14 +41,21 @@ object HttpClientFactory {
3441
json()
3542
}
3643

44+
install(HttpCookies) {
45+
storage = cookieStorage
46+
}
47+
3748
install(Auth) {
3849
bearer {
39-
sendWithoutRequest { !it.url.pathSegments.contains("watch") }
50+
sendWithoutRequest {
51+
!it.url.pathSegments.contains("watch") &&
52+
!it.url.encodedPath.startsWith("/room/refreshToken")
53+
}
4054
loadTokens {
4155
tokenManager.loadTokens()
4256
}
4357
refreshTokens {
44-
tokenManager.refreshTokens(oldTokens)
58+
tokenManager.refreshTokens(this)
4559
}
4660
}
4761
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package com.example.moontech.services.web
2+
3+
import androidx.datastore.core.DataStore
4+
import androidx.datastore.preferences.core.Preferences
5+
import androidx.datastore.preferences.core.edit
6+
import androidx.datastore.preferences.core.stringSetPreferencesKey
7+
import decode
8+
import editDecodedValues
9+
import io.ktor.client.plugins.cookies.CookiesStorage
10+
import io.ktor.http.Cookie
11+
import io.ktor.http.Url
12+
import io.ktor.http.hostIsIp
13+
import io.ktor.http.isSecure
14+
import io.ktor.util.toLowerCasePreservingASCIIRules
15+
import kotlinx.coroutines.flow.first
16+
import kotlinx.serialization.Serializable
17+
import kotlinx.serialization.encodeToString
18+
import kotlinx.serialization.json.Json
19+
20+
class PersistentCookieStorage(private val dataStore: DataStore<Preferences>) : CookiesStorage {
21+
private val key = stringSetPreferencesKey("PersistentCookieStorage")
22+
23+
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
24+
val wrappedCookie = cookie.wrap().fillDefaults(requestUrl)
25+
dataStore.edit { preferences ->
26+
val newCookies = preferences[key]?.editDecodedValues<CookieWrapper> {
27+
removeIf { persistedCookie ->
28+
persistedCookie.name == cookie.name && persistedCookie.matches(wrappedCookie)
29+
}
30+
add(wrappedCookie)
31+
} ?: setOf(Json.encodeToString(wrappedCookie))
32+
preferences[key] = newCookies
33+
}
34+
}
35+
36+
override fun close() {
37+
}
38+
39+
override suspend fun get(requestUrl: Url): List<Cookie> {
40+
dataStore.data.first().let { preferences ->
41+
val wrappedCookies = preferences[key]?.decode<CookieWrapper>()
42+
?.filter { cookie -> cookie.matches(requestUrl) }?.toList()
43+
?: listOf()
44+
return wrappedCookies.map(CookieWrapper::unwrap)
45+
}
46+
}
47+
48+
@Serializable
49+
data class CookieWrapper(
50+
val domain: String?,
51+
val name: String,
52+
val path: String?,
53+
val secure: Boolean = true,
54+
val value: String
55+
)
56+
}
57+
58+
internal fun Cookie.wrap(): PersistentCookieStorage.CookieWrapper {
59+
return PersistentCookieStorage.CookieWrapper(
60+
domain = domain,
61+
name = name,
62+
path = path,
63+
secure = secure,
64+
value = value
65+
)
66+
}
67+
68+
internal fun PersistentCookieStorage.CookieWrapper.fillDefaults(requestUrl: Url): PersistentCookieStorage.CookieWrapper {
69+
var result = this
70+
71+
if (result.path?.startsWith("/") != true) {
72+
result = result.copy(path = requestUrl.encodedPath)
73+
}
74+
75+
if (result.domain.isNullOrBlank()) {
76+
result = result.copy(domain = requestUrl.host)
77+
}
78+
79+
return result
80+
}
81+
82+
internal fun PersistentCookieStorage.CookieWrapper.unwrap(): Cookie {
83+
return Cookie(
84+
name = name,
85+
domain = domain,
86+
path = path,
87+
secure = secure,
88+
value = value
89+
)
90+
}
91+
92+
internal fun PersistentCookieStorage.CookieWrapper.matches(requestUrl: Url): Boolean {
93+
val domain = domain?.toLowerCasePreservingASCIIRules()?.trimStart('.')
94+
?: error("Domain field should have the default value")
95+
96+
val path = with(path) {
97+
val current = path ?: error("Path field should have the default value")
98+
if (current.endsWith('/')) current else "$path/"
99+
}
100+
101+
val host = requestUrl.host.toLowerCasePreservingASCIIRules()
102+
val requestPath = let {
103+
val pathInRequest = requestUrl.encodedPath
104+
if (pathInRequest.endsWith('/')) pathInRequest else "$pathInRequest/"
105+
}
106+
107+
if (host != domain && (hostIsIp(host) || !host.endsWith(".$domain"))) {
108+
return false
109+
}
110+
111+
if (path != "/" &&
112+
requestPath != path &&
113+
!requestPath.startsWith(path)
114+
) {
115+
return false
116+
}
117+
118+
return !(secure && !requestUrl.protocol.isSecure())
119+
}
120+
121+
internal fun PersistentCookieStorage.CookieWrapper.matches(cookie: PersistentCookieStorage.CookieWrapper): Boolean {
122+
val domain = domain?.toLowerCasePreservingASCIIRules()?.trimStart('.')
123+
?: error("Domain field should have the default value")
124+
125+
val path = with(path) {
126+
val current = path ?: error("Path field should have the default value")
127+
if (current.endsWith('/')) current else "$path/"
128+
}
129+
130+
val host = cookie.domain?.toLowerCasePreservingASCIIRules()?.trimStart('.')
131+
?: error("Domain field should have the default value")
132+
val requestPath = with(cookie.path) {
133+
val current = this ?: error("Path field should have the default value")
134+
if (current.endsWith('/')) current else "$this/"
135+
}
136+
if (host != domain && (hostIsIp(host) || !host.endsWith(".$domain"))) {
137+
return false
138+
}
139+
140+
if (path != "/" &&
141+
requestPath != path &&
142+
!requestPath.startsWith(path)
143+
) {
144+
return false
145+
}
146+
return true
147+
}

app/src/main/java/com/example/moontech/services/web/RoomApiServiceImpl.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,21 @@ class RoomApiServiceImpl(private val httpClient: HttpClient): RoomApiService {
3030
}
3131

3232
override suspend fun watchRoom(request: WatchRequest, accessToken: String?): Result<WatchedRoom> {
33-
return httpClient.postResult("$endpoint/watch") {
34-
setBody(request)
35-
accessToken?.let {
36-
this.headers.append("Authorization", "Bearer $accessToken")
33+
if (accessToken == null) {
34+
return httpClient.getResult("$endpoint/watch") {
35+
setBody(request)
36+
}
37+
}
38+
val tokenResponse = httpClient.postResult<RoomTokenResponse>("$endpoint/refreshToken/${request.roomName}") {
39+
this.headers.append("Authorization", "Bearer $accessToken")
40+
}
41+
tokenResponse.onSuccess {
42+
return httpClient.postResult("$endpoint/watch") {
43+
setBody(request)
44+
this.headers.append("Authorization", "Bearer ${it.accessToken}")
3745
}
3846
}
47+
return Result.failure(Exception("Access denied: unauthorized"))
3948
}
4049

4150
override suspend fun getRoomToken(request: RoomTokenRequest): Result<RoomTokenResponse> {
Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package com.example.moontech.services.web
22

3+
import com.example.moontech.data.dataclasses.UserData
34
import com.example.moontech.data.store.UserDataStore
45
import io.ktor.client.plugins.auth.providers.BearerTokens
6+
import io.ktor.client.plugins.auth.providers.RefreshTokensParams
7+
import io.ktor.client.request.header
8+
import io.ktor.client.statement.request
59
import kotlinx.coroutines.flow.first
610

711
class TokenManager(private val userDataStore: UserDataStore) {
@@ -10,13 +14,22 @@ class TokenManager(private val userDataStore: UserDataStore) {
1014
return userData?.accessToken?.let { BearerTokens(it, it) }
1115
}
1216

13-
suspend fun refreshTokens(oldTokens: BearerTokens?): BearerTokens? {
14-
val userData = userDataStore.userData.first()
15-
// here we treat access token as refresh token
16-
if (userData == null || oldTokens?.refreshToken == userData.accessToken) {
17-
userDataStore.clear()
18-
return null
17+
suspend fun refreshTokens(params: RefreshTokensParams): BearerTokens? {
18+
val userData = userDataStore.userData.first() ?: return null
19+
val response = params.client.postResult<UserData>("/user/refreshToken") {
20+
header("Authorization", "Bearer ${params.oldTokens?.accessToken ?: userData.accessToken}")
1921
}
20-
return BearerTokens(userData.accessToken, userData.accessToken)
22+
response.fold(
23+
onSuccess = {
24+
userDataStore.save(it)
25+
return BearerTokens(it.accessToken, it.accessToken)
26+
},
27+
onFailure = {
28+
if (!params.response.request.url.encodedPath.startsWith("/room/watch")) {
29+
userDataStore.clear()
30+
}
31+
return null
32+
}
33+
)
2134
}
2235
}

0 commit comments

Comments
 (0)