-
Notifications
You must be signed in to change notification settings - Fork 120
feat: TokenSource implementation #769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
717c8af
TokenSource implementation
davidliu 7da8c54
more testing
davidliu c22e538
spotless
davidliu e86f809
fix naming to match protobuf
davidliu 00336ef
changeset
davidliu c901325
add in room name and participant name values to response
davidliu a63f17c
Add invalidate and cachedResponse functions to CachingTokenSource
davidliu 1aae59d
spotless
davidliu 8243cc8
remove roomconfig from token request options and replace with user fa…
davidliu 07e88e1
Expose more details in TokenPayload
davidliu e1c9f81
fix tests
davidliu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "client-sdk-android": minor | ||
| --- | ||
|
|
||
| Add TokenSource implementation for use with token servers |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
161 changes: 161 additions & 0 deletions
161
livekit-android-sdk/src/main/java/io/livekit/android/token/CachingTokenSource.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| /* | ||
| * Copyright 2025 LiveKit, Inc. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package io.livekit.android.token | ||
|
|
||
| import io.livekit.android.util.LKLog | ||
| import java.util.Date | ||
| import kotlin.time.Duration | ||
| import kotlin.time.Duration.Companion.seconds | ||
|
|
||
| abstract class BaseCachingTokenSource( | ||
| private val store: TokenStore, | ||
| private val validator: TokenValidator, | ||
| ) { | ||
|
|
||
| /** | ||
| * The entrypoint for the caching store; subclasses should call this from their fetch methods. | ||
| * | ||
| * If a new token is needed, [fetchFromSource] will be called. | ||
| */ | ||
| internal suspend fun fetchImpl(options: TokenRequestOptions?): TokenSourceResponse { | ||
| val cached = store.retrieve() | ||
|
|
||
| if (cached != null && cached.options == options && validator.invoke(cached.options, cached.response)) { | ||
| return cached.response | ||
| } | ||
|
|
||
| val response = fetchFromSource(options) | ||
| store.store(options, response) | ||
| return response | ||
| } | ||
|
|
||
| /** | ||
| * Implement this to fetch the [TokenSourceResponse] from the token source. | ||
| */ | ||
| abstract suspend fun fetchFromSource(options: TokenRequestOptions?): TokenSourceResponse | ||
|
|
||
| /** | ||
| * Invalidate the cached credentials, forcing a fresh fetch on the next request. | ||
| */ | ||
| suspend fun invalidate() { | ||
| store.clear() | ||
| } | ||
|
|
||
| /** | ||
| * Get the cached credentials if one exists. | ||
| */ | ||
| suspend fun cachedResponse(): TokenSourceResponse? { | ||
| return store.retrieve()?.response | ||
| } | ||
| } | ||
|
|
||
| class CachingFixedTokenSource( | ||
| private val source: FixedTokenSource, | ||
| store: TokenStore, | ||
| validator: TokenValidator, | ||
| ) : BaseCachingTokenSource(store, validator), FixedTokenSource { | ||
| override suspend fun fetchFromSource(options: TokenRequestOptions?): TokenSourceResponse { | ||
| return source.fetch() | ||
| } | ||
|
|
||
| override suspend fun fetch(): TokenSourceResponse { | ||
| return fetchImpl(null) | ||
| } | ||
| } | ||
|
|
||
| class CachingConfigurableTokenSource( | ||
| private val source: ConfigurableTokenSource, | ||
| store: TokenStore, | ||
| validator: TokenValidator, | ||
| ) : BaseCachingTokenSource(store, validator), ConfigurableTokenSource { | ||
| override suspend fun fetchFromSource(options: TokenRequestOptions?): TokenSourceResponse { | ||
| return source.fetch(options ?: TokenRequestOptions()) | ||
| } | ||
|
|
||
| override suspend fun fetch(options: TokenRequestOptions): TokenSourceResponse { | ||
| return fetchImpl(options) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Wraps the token store with a cache so that it reuses the token as long as it is valid. | ||
| */ | ||
| fun FixedTokenSource.cached( | ||
| store: TokenStore = InMemoryTokenStore(), | ||
| validator: TokenValidator = defaultValidator, | ||
| ) = CachingFixedTokenSource(this, store, validator) | ||
|
|
||
| /** | ||
| * Wraps the token store with a cache so that it reuses the token as long as it is valid. | ||
| * | ||
| * If the request options passed to [ConfigurableTokenSource.fetch] change, a new token | ||
| * will be fetched. | ||
| */ | ||
| fun ConfigurableTokenSource.cached( | ||
| store: TokenStore = InMemoryTokenStore(), | ||
| validator: TokenValidator = defaultValidator, | ||
| ) = CachingConfigurableTokenSource(this, store, validator) | ||
|
|
||
| typealias TokenValidator = (options: TokenRequestOptions?, response: TokenSourceResponse) -> Boolean | ||
|
|
||
| interface TokenStore { | ||
| suspend fun retrieve(): Item? | ||
| suspend fun store(options: TokenRequestOptions?, response: TokenSourceResponse) | ||
| suspend fun clear() | ||
|
|
||
| data class Item( | ||
| val options: TokenRequestOptions?, | ||
| val response: TokenSourceResponse, | ||
| ) | ||
| } | ||
|
|
||
| internal class InMemoryTokenStore() : TokenStore { | ||
| var item: TokenStore.Item? = null | ||
| override suspend fun retrieve(): TokenStore.Item? = item | ||
|
|
||
| override suspend fun store(options: TokenRequestOptions?, response: TokenSourceResponse) { | ||
| item = TokenStore.Item(options, response) | ||
| } | ||
|
|
||
| override suspend fun clear() { | ||
| item = null | ||
| } | ||
| } | ||
|
|
||
| private val defaultValidator: TokenValidator = { options, response -> | ||
| response.hasValidToken() | ||
| } | ||
|
|
||
| /** | ||
| * Validates whether the JWT token is still valid. | ||
| */ | ||
| fun TokenSourceResponse.hasValidToken(tolerance: Duration = 60.seconds, date: Date = Date()): Boolean { | ||
| try { | ||
| val jwt = TokenPayload(participantToken) | ||
| val now = Date() | ||
| val expiresAt = jwt.expiresAt | ||
| val nbf = jwt.notBefore | ||
|
|
||
| val isBefore = nbf != null && now.before(nbf) | ||
| val hasExpired = expiresAt != null && now.after(Date(expiresAt.time + tolerance.inWholeMilliseconds)) | ||
|
|
||
| return !isBefore && !hasExpired | ||
| } catch (e: Exception) { | ||
| LKLog.i(e) { "Could not validate existing token" } | ||
| return false | ||
| } | ||
| } | ||
23 changes: 23 additions & 0 deletions
23
livekit-android-sdk/src/main/java/io/livekit/android/token/CustomTokenSource.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /* | ||
| * Copyright 2025 LiveKit, Inc. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package io.livekit.android.token | ||
|
|
||
| internal class CustomTokenSource(val block: suspend (options: TokenRequestOptions) -> TokenSourceResponse) : ConfigurableTokenSource { | ||
| override suspend fun fetch(options: TokenRequestOptions): TokenSourceResponse { | ||
| return block(options) | ||
| } | ||
| } |
131 changes: 131 additions & 0 deletions
131
livekit-android-sdk/src/main/java/io/livekit/android/token/EndpointTokenSource.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| /* | ||
| * Copyright 2025 LiveKit, Inc. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package io.livekit.android.token | ||
|
|
||
| import io.livekit.android.dagger.globalOkHttpClient | ||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||
| import kotlinx.serialization.ExperimentalSerializationApi | ||
| import kotlinx.serialization.decodeFromString | ||
| import kotlinx.serialization.encodeToString | ||
| import kotlinx.serialization.json.Json | ||
| import kotlinx.serialization.json.JsonNamingStrategy | ||
| import okhttp3.Call | ||
| import okhttp3.Callback | ||
| import okhttp3.Request | ||
| import okhttp3.RequestBody.Companion.toRequestBody | ||
| import okhttp3.Response | ||
| import java.io.IOException | ||
| import java.net.URL | ||
| import kotlin.coroutines.resume | ||
| import kotlin.coroutines.resumeWithException | ||
|
|
||
| internal class EndpointTokenSourceImpl( | ||
| override val url: URL, | ||
| override val method: String, | ||
| override val headers: Map<String, String>, | ||
| ) : EndpointTokenSource | ||
|
|
||
| data class SandboxTokenServerOptions( | ||
| val baseUrl: String? = null, | ||
| ) | ||
|
|
||
| internal class SandboxTokenSource(sandboxId: String, options: SandboxTokenServerOptions) : EndpointTokenSource { | ||
| override val url: URL = URL("${options.baseUrl ?: "https://cloud-api.livekit.io"}/api/v2/sandbox/connection-details") | ||
| override val headers: Map<String, String> = mapOf( | ||
| "X-Sandbox-ID" to sandboxId, | ||
| ) | ||
| } | ||
|
|
||
| internal interface EndpointTokenSource : ConfigurableTokenSource { | ||
| /** The url to fetch the token from */ | ||
| val url: URL | ||
|
|
||
| /** The HTTP method to use (defaults to "POST") */ | ||
| val method: String | ||
| get() = "POST" | ||
|
|
||
| /** Additional HTTP headers to include with the request */ | ||
| val headers: Map<String, String> | ||
|
|
||
| @OptIn(ExperimentalSerializationApi::class) | ||
| override suspend fun fetch(options: TokenRequestOptions): TokenSourceResponse = suspendCancellableCoroutine { continuation -> | ||
| try { | ||
| val okHttpClient = globalOkHttpClient | ||
|
|
||
| val snakeCaseJson = Json { | ||
| namingStrategy = JsonNamingStrategy.SnakeCase | ||
| ignoreUnknownKeys = true | ||
| explicitNulls = false | ||
| } | ||
|
|
||
| // v1 token server returns camelCase keys | ||
| val camelCaseJson = Json { | ||
| ignoreUnknownKeys = true | ||
| explicitNulls = false | ||
| } | ||
| val body = snakeCaseJson.encodeToString(options.toRequest()) | ||
|
|
||
| val request = Request.Builder() | ||
| .url(url) | ||
| .method(method, body.toRequestBody()) | ||
| .addHeader("Content-Type", "application/json") | ||
| .apply { | ||
| headers.forEach { (key, value) -> | ||
| addHeader(key, value) | ||
| } | ||
| } | ||
| .build() | ||
davidliu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| val call = okHttpClient.newCall(request) | ||
| call.enqueue( | ||
| object : Callback { | ||
| override fun onResponse(call: Call, response: Response) { | ||
| val bodyStr = response.body?.string() | ||
| if (bodyStr == null) { | ||
| continuation.resumeWithException(NullPointerException("No response returned from server")) | ||
| return | ||
| } | ||
|
|
||
| var tokenResponse: TokenSourceResponse? = null | ||
| try { | ||
| tokenResponse = snakeCaseJson.decodeFromString<TokenSourceResponse>(bodyStr) | ||
| } catch (e: Exception) { | ||
| } | ||
|
|
||
| if (tokenResponse == null) { | ||
| // snake_case decoding failed, try camelCase decoding for v1 back compatibility | ||
| try { | ||
| tokenResponse = camelCaseJson.decodeFromString<TokenSourceResponse>(bodyStr) | ||
| } catch (e: Exception) { | ||
| continuation.resumeWithException(IllegalArgumentException("Failed to decode response from token server", e)) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| continuation.resume(tokenResponse) | ||
| } | ||
|
|
||
| override fun onFailure(call: Call, e: IOException) { | ||
| continuation.resumeWithException(e) | ||
| } | ||
| }, | ||
| ) | ||
| } catch (e: Exception) { | ||
| continuation.resumeWithException(e) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.