diff --git a/.changeset/sixty-baboons-yawn.md b/.changeset/sixty-baboons-yawn.md new file mode 100644 index 00000000..cd7dcb08 --- /dev/null +++ b/.changeset/sixty-baboons-yawn.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": minor +--- + +Add TokenSource implementation for use with token servers diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb47049c..15169c9d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ dagger = "2.46" groupie = "2.9.0" junit-lib = "4.13.2" junit-jupiter = "5.5.0" +jwtdecode = "2.0.2" klaxon = "5.5" kotlinx-serialization = "1.5.0" leakcanaryAndroid = "2.8.1" @@ -49,6 +50,7 @@ dagger-lib = { module = "com.google.dagger:dagger", version.ref = "dagger" } dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } groupie = { module = "com.github.lisawray.groupie:groupie", version.ref = "groupie" } groupie-viewbinding = { module = "com.github.lisawray.groupie:groupie-viewbinding", version.ref = "groupie" } +jwtdecode = { module = "com.auth0.android:jwtdecode", version.ref = "jwtdecode" } klaxon = { module = "com.beust:klaxon", version.ref = "klaxon" } noise = { module = "com.github.paramsen:noise", version.ref = "noise" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" } diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index c94576ef..666def54 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -149,6 +149,7 @@ dependencies { implementation libs.okhttp.coroutines api libs.audioswitch implementation libs.klaxon + implementation libs.jwtdecode implementation libs.androidx.annotation implementation libs.androidx.core diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/WebModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/WebModule.kt index f8470adc..7c0af14d 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/WebModule.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/WebModule.kt @@ -43,7 +43,7 @@ internal object WebModule { @Named(InjectionNames.OVERRIDE_OKHTTP) okHttpClientOverride: OkHttpClient?, ): OkHttpClient { - return okHttpClientOverride ?: OkHttpClient() + return okHttpClientOverride ?: globalOkHttpClient } @Provides @@ -79,3 +79,8 @@ internal object WebModule { } } } + +/** + * Singleton okhttpclient to avoid recreating each time a new Room is created. + */ +internal val globalOkHttpClient by lazy { OkHttpClient() } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 1db8bbbe..98d2ed40 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1270,6 +1270,10 @@ internal constructor( -> { LKLog.v { "invalid value for data packet" } } + + LivekitModels.DataPacket.ValueCase.ENCRYPTED_PACKET -> { + // TODO + } } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt index 8b05c7f9..e7159e66 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt @@ -796,6 +796,10 @@ constructor( LivekitRtc.SignalResponse.MessageCase.MEDIA_SECTIONS_REQUIREMENT -> { // TODO } + + LivekitRtc.SignalResponse.MessageCase.SUBSCRIBED_AUDIO_CODEC_UPDATE -> { + // TODO + } } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/CachingTokenSource.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/CachingTokenSource.kt new file mode 100644 index 00000000..f2a9bd1e --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/CachingTokenSource.kt @@ -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 + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/CustomTokenSource.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/CustomTokenSource.kt new file mode 100644 index 00000000..086bd65c --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/CustomTokenSource.kt @@ -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) + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/EndpointTokenSource.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/EndpointTokenSource.kt new file mode 100644 index 00000000..af6a28b6 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/EndpointTokenSource.kt @@ -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, +) : 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 = 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 + + @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() + + 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(bodyStr) + } catch (e: Exception) { + } + + if (tokenResponse == null) { + // snake_case decoding failed, try camelCase decoding for v1 back compatibility + try { + tokenResponse = camelCaseJson.decodeFromString(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) + } + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/LiteralTokenSource.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/LiteralTokenSource.kt new file mode 100644 index 00000000..7f82131b --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/LiteralTokenSource.kt @@ -0,0 +1,26 @@ +/* + * 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 LiteralTokenSource( + val serverUrl: String, + val participantToken: String, +) : FixedTokenSource { + override suspend fun fetch(): TokenSourceResponse { + return TokenSourceResponse(serverUrl, participantToken) + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenPayload.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenPayload.kt new file mode 100644 index 00000000..f24319b2 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenPayload.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024-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 com.auth0.android.jwt.JWT +import java.util.Date + +/** + * Decodes a LiveKit connection token and grabs relevant information from it. + * + * https://docs.livekit.io/home/get-started/authentication/ + */ +data class TokenPayload(val token: String) { + + val jwt = JWT(token) + val issuer: String? + get() = jwt.issuer + + val subject: String? + get() = jwt.subject + + /** + * Date specifying the time + * [after which this token is invalid](https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-25#section-4.1.4). + */ + val expiresAt: Date? + get() = jwt.expiresAt + + /** + * Date specifying the time + * [before which this token is invalid](https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-25#section-4.1.5). + */ + val notBefore: Date? + get() = jwt.notBefore + + val issuedAt: Date? + get() = jwt.issuedAt + + // Claims are parsed through GSON each time and potentially costly. + // Cache them with lazy delegates. + + /** Display name for the participant, equivalent to [io.livekit.android.room.participant.Participant.name] */ + val name: String? + by lazy(LazyThreadSafetyMode.NONE) { jwt.claims["name"]?.asString() } + + /** Unique identity of the user, equivalent to [io.livekit.android.room.participant.Participant.identity] */ + val identity: String? + by lazy(LazyThreadSafetyMode.NONE) { subject ?: jwt.claims["identity"]?.asString() } + + /** The metadata of the participant */ + val metadata: String? + by lazy(LazyThreadSafetyMode.NONE) { jwt.claims["metadata"]?.asString() } + + /** Key/value attributes attached to the participant */ + @Suppress("UNCHECKED_CAST") + val attributes: Map? + by lazy(LazyThreadSafetyMode.NONE) { jwt.claims["attributes"]?.asObject(Map::class.java) as? Map } + + /** + * Room related permissions. + */ + val video: VideoGrants? + by lazy(LazyThreadSafetyMode.NONE) { jwt.claims["video"]?.asObject(VideoGrants::class.java) } +} + +data class VideoGrants( + /** + * The name of the room. + */ + val room: String?, + /** + * Permission to join a room + */ + val roomJoin: Boolean?, + val canPublish: Boolean?, + val canPublishData: Boolean?, + /** + * The list of sources this participant can publish from. + */ + val canPublishSources: List?, + val canSubscribe: Boolean?, +) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt new file mode 100644 index 00000000..1a15b007 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt @@ -0,0 +1,195 @@ +/* + * 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 android.annotation.SuppressLint +import kotlinx.serialization.Serializable +import java.net.URL + +/** + * The options for a token request. + * + * When making a custom request against a token server, this is converted into + * [TokenSourceRequest] through [toRequest]. + */ +data class TokenRequestOptions( + val roomName: String? = null, + val participantName: String? = null, + val participantIdentity: String? = null, + val participantMetadata: String? = null, + val participantAttributes: Map? = null, + val agentName: String? = null, + val agentMetadata: String? = null, +) + +/** + * Converts a [TokenRequestOptions] to [TokenSourceRequest], a JSON serializable request body. + */ +fun TokenRequestOptions.toRequest(): TokenSourceRequest { + val agents = if (agentName != null || agentMetadata != null) { + listOf( + RoomAgentDispatch( + agentName = agentName, + metadata = agentMetadata, + ), + ) + } else { + null + } + return TokenSourceRequest( + roomName = roomName, + participantName = participantName, + participantIdentity = participantIdentity, + participantMetadata = participantMetadata, + participantAttributes = participantAttributes, + roomConfig = RoomConfiguration( + agents = agents, + ), + ) +} + +/** + * The JSON serializable format of the request sent to standard LiveKit token servers. + * + * Equivalent to [livekit.LivekitTokenSource.TokenSourceRequest] + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class TokenSourceRequest( + val roomName: String?, + val participantName: String?, + val participantIdentity: String?, + val participantMetadata: String?, + val participantAttributes: Map?, + val roomConfig: RoomConfiguration?, +) + +/** + * @see livekit.LivekitRoom.RoomConfiguration + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class RoomConfiguration( + val name: String? = null, + val emptyTimeout: Int? = null, + val departureTimeout: Int? = null, + val maxParticipants: Int? = null, + val metadata: String? = null, + // egress is omitted due to complexity of serialization here. + val minPlayoutDelay: Int? = null, + val maxPlayoutDelay: Int? = null, + val syncStreams: Int? = null, + val agents: List? = null, +) + +/** + * @see livekit.LivekitAgentDispatch.RoomAgentDispatch + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class RoomAgentDispatch( + val agentName: String? = null, + val metadata: String? = null, +) + +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class TokenSourceResponse( + /** + * The server url to connect with the associated [participantToken]. + */ + val serverUrl: String, + /** + * The JWT token used to connect to the room. + * + * Specific details of the payload may be examined with [TokenPayload] + * (such as the permissions, metadata, etc.) + */ + val participantToken: String, + /** + * The room name. + * + * Note: Not required to be sent by the server response. + */ + val roomName: String? = null, + /** + * The participant name. + * + * Note: Not required to be sent by the server response. + */ + val participantName: String? = null, +) + +interface TokenSource { + companion object { + /** + * Creates a [FixedTokenSource] that immediately returns with the supplied [serverUrl] and [participantToken]. + * + * @see cached + * @see CachingFixedTokenSource + */ + fun fromLiteral(serverUrl: String, participantToken: String): FixedTokenSource = LiteralTokenSource(serverUrl, participantToken) + + /** + * Creates a custom [ConfigurableTokenSource] that executes [block] to fetch the credentials. + * + * @see cached + * @see CachingConfigurableTokenSource + */ + fun fromCustom(block: suspend (options: TokenRequestOptions) -> TokenSourceResponse): ConfigurableTokenSource = CustomTokenSource(block) + + /** + * Creates a [ConfigurableTokenSource] that fetches from a given [url] using the standard token server format. + * + * @see cached + * @see CachingConfigurableTokenSource + */ + fun fromEndpoint(url: URL, method: String = "POST", headers: Map = emptyMap()): ConfigurableTokenSource = EndpointTokenSourceImpl( + url = url, + method = method, + headers = headers, + ) + + /** + * Creates a [ConfigurableTokenSource] that fetches from a sandbox token server for credentials, + * which supports quick prototyping/getting started types of use cases. + * + * Note: This token provider is **insecure** and should **not** be used in production. + * + * @see cached + * @see CachingConfigurableTokenSource + */ + fun fromSandboxTokenServer(sandboxId: String, options: SandboxTokenServerOptions = SandboxTokenServerOptions()): ConfigurableTokenSource = SandboxTokenSource( + sandboxId = sandboxId, + options = options, + ) + } +} + +/** + * A non-configurable token source that does not take any options. + */ +interface FixedTokenSource : TokenSource { + suspend fun fetch(): TokenSourceResponse +} + +/** + * A configurable token source takes in a [TokenRequestOptions] when requesting credentials. + */ +interface ConfigurableTokenSource : TokenSource { + suspend fun fetch(options: TokenRequestOptions = TokenRequestOptions()): TokenSourceResponse +} diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt index 9bbb47df..3d2d0bba 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-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. @@ -51,6 +51,5 @@ abstract class BaseTest { ) } - @OptIn(ExperimentalCoroutinesApi::class) fun runTest(testBody: suspend TestScope.() -> Unit) = coroutineRule.scope.runTest(testBody = testBody) } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt index 11006701..7c5654b1 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt @@ -40,7 +40,7 @@ import org.junit.Before import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) abstract class MockE2ETest : BaseTest() { diff --git a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt index 7ea56a6b..c63a85a0 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt @@ -19,8 +19,15 @@ package io.livekit.android.proto import io.livekit.android.room.RegionSettings import io.livekit.android.room.participant.ParticipantPermission import io.livekit.android.rpc.RpcError +import io.livekit.android.token.RoomAgentDispatch +import io.livekit.android.token.RoomConfiguration +import io.livekit.android.token.TokenSourceRequest +import io.livekit.android.token.TokenSourceResponse +import livekit.LivekitAgentDispatch import livekit.LivekitModels +import livekit.LivekitRoom import livekit.LivekitRtc +import livekit.LivekitTokenSource import livekit.org.webrtc.SessionDescription import org.junit.Assert.assertTrue import org.junit.Test @@ -75,6 +82,23 @@ class ProtoConverterTest( mapping = mapOf("sdp" to "description"), whitelist = listOf("id"), ), + ProtoConverterTestCase( + LivekitTokenSource.TokenSourceRequest::class.java, + TokenSourceRequest::class.java, + ), + ProtoConverterTestCase( + LivekitTokenSource.TokenSourceResponse::class.java, + TokenSourceResponse::class.java, + ), + ProtoConverterTestCase( + LivekitRoom.RoomConfiguration::class.java, + RoomConfiguration::class.java, + whitelist = listOf("egress"), + ), + ProtoConverterTestCase( + LivekitAgentDispatch.RoomAgentDispatch::class.java, + RoomAgentDispatch::class.java, + ), ) @JvmStatic @@ -91,9 +115,10 @@ class ProtoConverterTest( .map { it.name } .filter { it.isNotBlank() } .filter { it[0].isLowerCase() } - .map { it.slice(0 until it.indexOf('_')) } + .map { it.slice(0 until it.indexOf('_')) } // Internally fields may have underscores attached to them. .filter { it.isNotBlank() } .filterNot { whitelist.contains(it) } + .filterNot { it == "bitField0" } // Internal field not related to the declared protobuf structure .map { mapping[it] ?: it } .toSet() val fields = sdkClass.declaredFields diff --git a/livekit-android-test/src/test/java/io/livekit/android/token/CachingTokenSourceTest.kt b/livekit-android-test/src/test/java/io/livekit/android/token/CachingTokenSourceTest.kt new file mode 100644 index 00000000..581967b8 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/token/CachingTokenSourceTest.kt @@ -0,0 +1,132 @@ +/* + * 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.test.BaseTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +// JWTPayload requires Android Base64 implementation, so robolectric runner needed. +@RunWith(RobolectricTestRunner::class) +class CachingTokenSourceTest : BaseTest() { + + @Test + fun tokenIsValid() { + val tokenResponse = TokenSourceResponse( + "wss://www.example.com", + TokenPayloadTest.TEST_TOKEN, + ) + + assertTrue(tokenResponse.hasValidToken(date = Date(5000000000000))) + } + + @Test + fun tokenBeforeNbfIsInvalid() { + val tokenResponse = TokenSourceResponse( + "wss://www.example.com", + TokenPayloadTest.TEST_TOKEN, + ) + + assertTrue(tokenResponse.hasValidToken(date = Date(0))) + } + + @Test + fun tokenAfterExpIsInvalid() { + val tokenResponse = TokenSourceResponse( + "wss://www.example.com", + TokenPayloadTest.TEST_TOKEN, + ) + + assertTrue(tokenResponse.hasValidToken(date = Date(9999999990000))) + } + + @Test + fun cachedValidTokenOnlyFetchedOnce() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse().setBody( + """{ + "serverUrl": "wss://www.example.com", + "roomName": "room-name", + "participantName": "participant-name", + "participantToken": "${TokenPayloadTest.TEST_TOKEN}" +}""", + ), + ) + + val source = TokenSource + .fromEndpoint(server.url("/").toUrl()) + .cached() + + val firstResponse = source.fetch() + val secondResponse = source.fetch() + + assertEquals(firstResponse, secondResponse) + assertEquals(1, server.requestCount) + } + + @Test + fun cachedInvalidTokenRefetched() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse().setBody( + """{ + "serverUrl": "wss://www.example.com", + "roomName": "room-name", + "participantName": "participant-name", + "participantToken": "${EXPIRED_TOKEN}" +}""", + ), + ) + server.enqueue( + MockResponse().setBody( + """{ + "serverUrl": "wss://www.example.com", + "roomName": "room-name", + "participantName": "participant-name", + "participantToken": "${TokenPayloadTest.TEST_TOKEN}" +}""", + ), + ) + + val source = TokenSource + .fromEndpoint(server.url("/").toUrl()) + .cached() + + val firstResponse = source.fetch() + val secondResponse = source.fetch() + + assertNotEquals(firstResponse, secondResponse) + assertEquals(2, server.requestCount) + } + + companion object { + // Token with an nbf and exp of 0 seconds. + const val EXPIRED_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjlhMzJiZTg2NzkyZTM3Nm" + + "I3ZTBlMmIyNjVjMjY1YTA5In0.eyJpYXQiOjAsIm5iZiI6MCwiZXhwIjowfQ.8oV9K-CeULScAjFIK2O7sxEGUD7" + + "su3kCQv3Q8rhk0Hg_AuzQixJfz2Pt0rJUwLWhF0mSlcYMUKdR0yp12RfrdA" + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/token/TokenPayloadTest.kt b/livekit-android-test/src/test/java/io/livekit/android/token/TokenPayloadTest.kt new file mode 100644 index 00000000..1179c0da --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/token/TokenPayloadTest.kt @@ -0,0 +1,81 @@ +/* + * 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 junit.framework.Assert.assertEquals +import junit.framework.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date + +// JWTPayload requires Android Base64 implementation, so robolectric runner needed. +@RunWith(RobolectricTestRunner::class) +class TokenPayloadTest { + companion object { + // Test JWT created for test purposes only. + // Does not actually auth against anything. + // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000) + // Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000) + const val TEST_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImRiY2UzNm" + + "JkNjBjZDI5NWM2ODExNTBiMGU2OGFjNGU5In0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo" + + "5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.sYQ-blJC16BL" + + "ltZduvvkOqoa7PBBbYQh2p50ofRfVjZw6XIPgMo-oXXBI49J4IOsOKjzK_VeHlchxUitdIPtkg" + + // Test JWT created for test purposes only. + // Does not actually auth against anything. + // Filled with various dummy data. + const val FULL_TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJh" + + "YmNkZWZnIiwiZXhwIjozMzI1NDI4MjgwNCwic3ViIjoiaWRlbnRpdHkiLCJuYW1lIjoibmFtZ" + + "SIsIm1ldGFkYXRhIjoibWV0YWRhdGEiLCJzaGEyNTYiOiJnZmVkY2JhIiwicm9vbVByZXNldC" + + "I6InJvb21QcmVzZXQiLCJhdHRyaWJ1dGVzIjp7ImtleSI6InZhbHVlIn0sInJvb21Db25maWc" + + "iOnsibmFtZSI6Im5hbWUiLCJlbXB0eV90aW1lb3V0IjoxLCJkZXBhcnR1cmVfdGltZW91dCI6" + + "MiwibWF4X3BhcnRpY2lwYW50cyI6MywiZWdyZXNzIjp7InJvb20iOnsicm9vbV9uYW1lIjoib" + + "mFtZSJ9fSwibWluX3BsYXlvdXRfZGVsYXkiOjQsIm1heF9wbGF5b3V0X2RlbGF5Ijo1LCJzeW" + + "5jX3N0cmVhbXMiOnRydWV9LCJ2aWRlbyI6eyJyb29tIjoicm9vbV9uYW1lIiwicm9vbUpvaW4" + + "iOnRydWUsImNhblB1Ymxpc2giOnRydWUsImNhblB1Ymxpc2hTb3VyY2VzIjpbImNhbWVyYSIs" + + "Im1pY3JvcGhvbmUiXX0sInNpcCI6eyJhZG1pbiI6dHJ1ZX19.kFgctvUje5JUxwPCNSvFri-g" + + "0b0AEG6hiZS-xQ3SAI4" + } + + @Test + fun decode() { + val payload = TokenPayload(TEST_TOKEN) + + assertEquals(Date(1234567890000), payload.notBefore) + assertEquals(Date(9876543210000), payload.expiresAt) + } + + @Test + fun fullTestDecode() { + val payload = TokenPayload(FULL_TEST_TOKEN) + + assertEquals("identity", payload.subject) + assertEquals("identity", payload.identity) + assertEquals("name", payload.name) + assertEquals("metadata", payload.metadata) + assertEquals("value", payload.attributes?.get("key")) + + val videoGrants = payload.video + assertEquals("room_name", videoGrants?.room) + assertEquals(listOf("camera", "microphone"), videoGrants?.canPublishSources) + assertEquals(true, videoGrants?.roomJoin) + assertEquals(true, videoGrants?.canPublish) + assertNull(videoGrants?.canPublishData) + assertNull(videoGrants?.canSubscribe) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt b/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt new file mode 100644 index 00000000..bfb9e48a --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt @@ -0,0 +1,191 @@ +/* + * 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.test.BaseTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +// JWTPayload requires Android Base64 implementation, so robolectric runner needed. +@RunWith(RobolectricTestRunner::class) +class TokenSourceTest : BaseTest() { + + @Test + fun testLiteral() = runTest { + val source = TokenSource.fromLiteral("https://www.example.com", "token") + + val response = source.fetch() + + assertEquals("https://www.example.com", response.serverUrl) + assertEquals("token", response.participantToken) + } + + @Test + fun testCustom() = runTest { + var wasCalled = false + val requestOptions = TokenRequestOptions( + roomName = "room_name", + ) + val source = TokenSource.fromCustom { options -> + wasCalled = true + assertEquals(requestOptions, options) + return@fromCustom TokenSourceResponse("https://www.example.com", "token") + } + + val response = source.fetch(requestOptions) + + assertTrue(wasCalled) + assertEquals("https://www.example.com", response.serverUrl) + assertEquals("token", response.participantToken) + } + + @Test + fun testEndpoint() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse().setBody( + """{ + "server_url": "wss://www.example.com", + "room_name": "room-name", + "participant_name": "participant-name", + "participant_token": "token" +}""", + ), + ) + + val source = TokenSource.fromEndpoint( + server.url("/").toUrl(), + method = "POST", + headers = mapOf("hello" to "world"), + ) + val options = TokenRequestOptions( + roomName = "room-name", + participantName = "participant-name", + participantIdentity = "participant-identity", + participantMetadata = "participant-metadata", + agentName = "agent-name", + agentMetadata = "agent-metadata", + ) + + val response = source.fetch(options) + assertEquals("wss://www.example.com", response.serverUrl) + assertEquals("token", response.participantToken) + assertEquals("participant-name", response.participantName) + assertEquals("room-name", response.roomName) + + val request = server.takeRequest() + + assertEquals("POST", request.method) + assertEquals("world", request.headers["hello"]) + + val requestBody = request.body.readUtf8() + + println(requestBody) + + val json = Json.parseToJsonElement(requestBody).jsonObject.toMap() + + // Check sends snake_case + assertEquals("room-name", json["room_name"]?.jsonPrimitive?.content) + + val roomConfig = json["room_config"]?.jsonObject + val agents = roomConfig?.get("agents")?.jsonArray + assertEquals(1, agents?.size) + + val agent = agents?.get(0)?.jsonObject + assertEquals("agent-name", agent?.get("agent_name")?.jsonPrimitive?.content) + assertEquals("agent-metadata", agent?.get("metadata")?.jsonPrimitive?.content) + } + + @Test + fun testCamelCaseCompatibility() = runTest { + // V1 token server sends back camelCase keys, ensure we can handle those. + val server = MockWebServer() + server.enqueue( + MockResponse().setBody( + """{ + "serverUrl": "wss://www.example.com", + "roomName": "room-name", + "participantName": "participant-name", + "participantToken": "token" +}""", + ), + ) + + val source = TokenSource.fromEndpoint(server.url("/").toUrl()) + + val response = source.fetch() + assertEquals("wss://www.example.com", response.serverUrl) + assertEquals("token", response.participantToken) + assertEquals("participant-name", response.participantName) + assertEquals("room-name", response.roomName) + } + + @Test + fun testMissingKeysDefaultNull() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse().setBody( + """{ + "server_url": "wss://www.example.com", + "participant_token": "token" +}""", + ), + ) + + val source = TokenSource.fromEndpoint(server.url("/").toUrl()) + + val response = source.fetch() + assertEquals("wss://www.example.com", response.serverUrl) + assertEquals("token", response.participantToken) + assertNull(response.participantName) + assertNull(response.roomName) + } + + @Ignore("For manual testing only.") + @Test + fun testTokenServer() = runTest { + val source = TokenSource.fromSandboxTokenServer( + "", // Put sandboxId here to test manually. + ) + val options = TokenRequestOptions( + roomName = "room-name", + participantName = "participant-name", + participantIdentity = "participant-identity", + participantMetadata = "participant-metadata", + agentName = "agent-name", + agentMetadata = "agent-metadata", + ) + + val response = source.fetch(options) + println(response) + + assertTrue(response.hasValidToken()) + } +} diff --git a/protocol b/protocol index 9bd0fc7c..4c05a332 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 9bd0fc7c95be67400c6086216d79f39cac791e5c +Subproject commit 4c05a3325ec35760bee1c0bfe57b7011604a124f