Skip to content

Commit 9ebf1ca

Browse files
committed
more testing
1 parent 703c58b commit 9ebf1ca

File tree

7 files changed

+222
-28
lines changed

7 files changed

+222
-28
lines changed

livekit-android-sdk/src/main/java/io/livekit/android/token/CachingTokenSource.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ private val defaultValidator: TokenValidator = { options, response ->
120120
/**
121121
* Validates whether the JWT token is still valid.
122122
*/
123-
fun TokenSourceResponse.hasValidToken(tolerance: Duration = 60.seconds): Boolean {
123+
fun TokenSourceResponse.hasValidToken(tolerance: Duration = 60.seconds, date: Date = Date()): Boolean {
124124
try {
125125
val jwt = JWTPayload(participantToken)
126126
val now = Date()
@@ -130,7 +130,7 @@ fun TokenSourceResponse.hasValidToken(tolerance: Duration = 60.seconds): Boolean
130130
val isBefore = nbf != null && now.before(nbf)
131131
val hasExpired = expiresAt != null && now.after(Date(expiresAt.time + tolerance.inWholeMilliseconds))
132132

133-
return isBefore || hasExpired
133+
return !isBefore && !hasExpired
134134
} catch (e: Exception) {
135135
LKLog.i(e) { "Could not validate existing token" }
136136
return false

livekit-android-sdk/src/main/java/io/livekit/android/token/EndpointTokenSource.kt

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,15 @@ internal class EndpointTokenSourceImpl(
3939
override val headers: Map<String, String>,
4040
) : EndpointTokenSource
4141

42-
internal class SandboxTokenSource(sandboxId: String) : EndpointTokenSource {
43-
override val url: URL = URL("https://cloud-api.livekit.io/api/sandbox/connection-details")
44-
override val headers: Map<String, String> = mapOf("X-Sandbox-ID" to sandboxId)
42+
data class SandboxTokenServerOptions(
43+
val baseUrl: String? = null,
44+
)
45+
46+
internal class SandboxTokenSource(sandboxId: String, options: SandboxTokenServerOptions) : EndpointTokenSource {
47+
override val url: URL = URL("${options.baseUrl ?: "https://cloud-api.livekit.io"}/api/v2/sandbox/connection-details")
48+
override val headers: Map<String, String> = mapOf(
49+
"X-Sandbox-ID" to sandboxId,
50+
)
4551
}
4652

4753
internal interface EndpointTokenSource : ConfigurableTokenSource {
@@ -60,18 +66,21 @@ internal interface EndpointTokenSource : ConfigurableTokenSource {
6066
try {
6167
val okHttpClient = globalOkHttpClient
6268

63-
val encodeJson = Json {
69+
val snakeCaseJson = Json {
6470
namingStrategy = JsonNamingStrategy.SnakeCase
6571
ignoreUnknownKeys = true
6672
}
67-
val decodeJson = Json {
73+
74+
// v1 token server returns camelCase keys
75+
val camelCaseJson = Json {
6876
ignoreUnknownKeys = true
6977
}
70-
val body = encodeJson.encodeToString(options)
78+
val body = snakeCaseJson.encodeToString(options)
7179

7280
val request = Request.Builder()
7381
.url(url)
7482
.method(method, body.toRequestBody())
83+
.addHeader("Content-Type", "application/json")
7584
.apply {
7685
headers.forEach { (key, value) ->
7786
addHeader(key, value)
@@ -83,18 +92,26 @@ internal interface EndpointTokenSource : ConfigurableTokenSource {
8392
call.enqueue(
8493
object : Callback {
8594
override fun onResponse(call: Call, response: Response) {
86-
val body = response.body
87-
if (body == null) {
95+
val bodyStr = response.body?.string()
96+
if (bodyStr == null) {
8897
continuation.resumeWithException(NullPointerException("No response returned from server"))
8998
return
9099
}
91100

92-
val tokenResponse: TokenSourceResponse
101+
var tokenResponse: TokenSourceResponse? = null
93102
try {
94-
tokenResponse = decodeJson.decodeFromString<TokenSourceResponse>(body.string())
103+
tokenResponse = snakeCaseJson.decodeFromString<TokenSourceResponse>(bodyStr)
95104
} catch (e: Exception) {
96-
continuation.resumeWithException(e)
97-
return
105+
}
106+
107+
if (tokenResponse == null) {
108+
// snake_case decoding failed, try camelCase decoding for v1 back compatibility
109+
try {
110+
tokenResponse = camelCaseJson.decodeFromString<TokenSourceResponse>(bodyStr)
111+
} catch (e: Exception) {
112+
continuation.resumeWithException(IllegalArgumentException("Failed to decode response from token server", e))
113+
return
114+
}
98115
}
99116

100117
continuation.resume(tokenResponse)

livekit-android-sdk/src/main/java/io/livekit/android/token/JWTPayload.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ class JWTPayload(token: String) {
3232

3333
/**
3434
* Date specifying the time
35-
* [before which this token is invalid](https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-25#section-4.1.5)
36-
* .
35+
* [before which this token is invalid](https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-25#section-4.1.5).
3736
*/
3837
val notBefore: Date?
3938

livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ data class RoomAgentDispatch(
5252
val metadata: String,
5353
)
5454

55+
@SuppressLint("UnsafeOptInUsageError")
5556
@Serializable
5657
data class TokenSourceResponse(val serverUrl: String, val participantToken: String)
5758

@@ -64,25 +65,43 @@ interface TokenSource {
6465
fun fromLiteral(serverUrl: String, participantToken: String): FixedTokenSource = LiteralTokenSource(serverUrl, participantToken)
6566

6667
/**
67-
* Creates a [ConfigurableTokenSource] that executes [block] to fetch the [TokenSourceResponse].
68+
* Creates a custom [ConfigurableTokenSource] that executes [block] to fetch the credentials..
6869
*/
6970
fun fromCustom(block: suspend (options: TokenRequestOptions) -> TokenSourceResponse): ConfigurableTokenSource = CustomTokenSource(block)
7071

72+
/**
73+
* Creates a [ConfigurableTokenSource] that fetches from a given [url] using the standard token server format.
74+
*/
7175
fun fromEndpoint(url: URL, method: String = "POST", headers: Map<String, String> = emptyMap()): ConfigurableTokenSource = EndpointTokenSourceImpl(
7276
url = url,
7377
method = method,
7478
headers = headers,
7579
)
80+
81+
/**
82+
* Creates a [ConfigurableTokenSource] that fetches from a sandbox token server for credentials,
83+
* which supports quick prototyping/getting started types of use cases.
84+
*
85+
* Note: This token provider is **insecure** and should **not** be used in production.
86+
*/
87+
fun fromSandboxTokenServer(sandboxId: String, options: SandboxTokenServerOptions = SandboxTokenServerOptions()): ConfigurableTokenSource = SandboxTokenSource(
88+
sandboxId = sandboxId,
89+
options = options,
90+
)
91+
7692
}
7793
}
7894

7995
/**
80-
* A non-configurable token source that
96+
* A non-configurable token source that does not take any options.
8197
*/
8298
interface FixedTokenSource : TokenSource {
8399
suspend fun fetch(): TokenSourceResponse
84100
}
85101

102+
/**
103+
* A configurable token source takes in a [TokenRequestOptions] when requesting credentials.
104+
*/
86105
interface ConfigurableTokenSource : TokenSource {
87-
suspend fun fetch(options: TokenRequestOptions): TokenSourceResponse
106+
suspend fun fetch(options: TokenRequestOptions = TokenRequestOptions()): TokenSourceResponse
88107
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package io.livekit.android.token
2+
3+
import io.livekit.android.test.BaseTest
4+
import kotlinx.coroutines.ExperimentalCoroutinesApi
5+
import okhttp3.mockwebserver.MockResponse
6+
import okhttp3.mockwebserver.MockWebServer
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Assert.assertNotEquals
9+
import org.junit.Assert.assertTrue
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import org.robolectric.RobolectricTestRunner
13+
import java.util.Date
14+
15+
@OptIn(ExperimentalCoroutinesApi::class)
16+
// JWTPayload requires Android Base64 implementation, so robolectric runner needed.
17+
@RunWith(RobolectricTestRunner::class)
18+
class CachingTokenSourceTest : BaseTest() {
19+
20+
@Test
21+
fun tokenIsValid() {
22+
val tokenResponse = TokenSourceResponse(
23+
"wss://www.example.com",
24+
JWTPayloadTest.TEST_TOKEN,
25+
)
26+
27+
assertTrue(tokenResponse.hasValidToken(date = Date(5000000000000)))
28+
}
29+
30+
@Test
31+
fun tokenBeforeNbfIsInvalid() {
32+
val tokenResponse = TokenSourceResponse(
33+
"wss://www.example.com",
34+
JWTPayloadTest.TEST_TOKEN,
35+
)
36+
37+
assertTrue(tokenResponse.hasValidToken(date = Date(0)))
38+
}
39+
40+
@Test
41+
fun tokenAfterExpIsInvalid() {
42+
val tokenResponse = TokenSourceResponse(
43+
"wss://www.example.com",
44+
JWTPayloadTest.TEST_TOKEN,
45+
)
46+
47+
assertTrue(tokenResponse.hasValidToken(date = Date(9999999990000)))
48+
}
49+
50+
@Test
51+
fun cachedValidTokenOnlyFetchedOnce() = runTest {
52+
53+
val server = MockWebServer()
54+
server.enqueue(
55+
MockResponse().setBody(
56+
"""{
57+
"serverUrl": "wss://www.example.com",
58+
"roomName": "room-name",
59+
"participantName": "participant-name",
60+
"participantToken": "${JWTPayloadTest.TEST_TOKEN}"
61+
}""",
62+
),
63+
)
64+
65+
val source = TokenSource
66+
.fromEndpoint(server.url("/").toUrl())
67+
.cached()
68+
69+
70+
val firstResponse = source.fetch()
71+
val secondResponse = source.fetch()
72+
73+
assertEquals(firstResponse, secondResponse)
74+
assertEquals(1, server.requestCount)
75+
}
76+
77+
@Test
78+
fun cachedInvalidTokenRefetched() = runTest {
79+
80+
val server = MockWebServer()
81+
server.enqueue(
82+
MockResponse().setBody(
83+
"""{
84+
"serverUrl": "wss://www.example.com",
85+
"roomName": "room-name",
86+
"participantName": "participant-name",
87+
"participantToken": "${EXPIRED_TOKEN}"
88+
}""",
89+
),
90+
)
91+
server.enqueue(
92+
MockResponse().setBody(
93+
"""{
94+
"serverUrl": "wss://www.example.com",
95+
"roomName": "room-name",
96+
"participantName": "participant-name",
97+
"participantToken": "${JWTPayloadTest.TEST_TOKEN}"
98+
}""",
99+
),
100+
)
101+
102+
val source = TokenSource
103+
.fromEndpoint(server.url("/").toUrl())
104+
.cached()
105+
106+
val firstResponse = source.fetch()
107+
val secondResponse = source.fetch()
108+
109+
assertNotEquals(firstResponse, secondResponse)
110+
assertEquals(2, server.requestCount)
111+
}
112+
113+
companion object {
114+
// Token with an nbf and exp of 0 seconds.
115+
const val EXPIRED_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjlhMzJiZTg2NzkyZTM3Nm" +
116+
"I3ZTBlMmIyNjVjMjY1YTA5In0.eyJpYXQiOjAsIm5iZiI6MCwiZXhwIjowfQ.8oV9K-CeULScAjFIK2O7sxEGUD7" +
117+
"su3kCQv3Q8rhk0Hg_AuzQixJfz2Pt0rJUwLWhF0mSlcYMUKdR0yp12RfrdA"
118+
}
119+
}

livekit-android-test/src/test/java/io/livekit/android/token/JWTPayloadTest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import java.util.Date
2626
@RunWith(RobolectricTestRunner::class)
2727
class JWTPayloadTest {
2828
companion object {
29+
// Test JWT created for test purposes only.
30+
// Does not actually auth against anything.
31+
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
32+
// Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
2933
const val TEST_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImRiY2UzNm" +
3034
"JkNjBjZDI5NWM2ODExNTBiMGU2OGFjNGU5In0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo" +
3135
"5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.sYQ-blJC16BL" +

livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@ import okhttp3.mockwebserver.MockResponse
2525
import okhttp3.mockwebserver.MockWebServer
2626
import org.junit.Assert.assertEquals
2727
import org.junit.Assert.assertTrue
28+
import org.junit.Ignore
2829
import org.junit.Test
30+
import org.junit.runner.RunWith
31+
import org.robolectric.RobolectricTestRunner
32+
import java.util.Date
2933

3034
@OptIn(ExperimentalCoroutinesApi::class)
35+
// JWTPayload requires Android Base64 implementation, so robolectric runner needed.
36+
@RunWith(RobolectricTestRunner::class)
3137
class TokenSourceTest : BaseTest() {
3238

3339
@Test
@@ -68,7 +74,7 @@ class TokenSourceTest : BaseTest() {
6874
"serverUrl": "wss://www.example.com",
6975
"roomName": "room-name",
7076
"participantName": "participant-name",
71-
"participantToken": "$TEST_TOKEN"
77+
"participantToken": "token"
7278
}""",
7379
),
7480
)
@@ -101,9 +107,13 @@ class TokenSourceTest : BaseTest() {
101107

102108
val response = source.fetch(options)
103109
assertEquals("wss://www.example.com", response.serverUrl)
104-
assertEquals(TEST_TOKEN, response.participantToken)
110+
assertEquals("token", response.participantToken)
105111

106112
val request = server.takeRequest()
113+
114+
assertEquals("POST", request.method)
115+
assertEquals("world", request.headers["hello"])
116+
107117
val requestBody = request.body.readUtf8()
108118

109119
println(requestBody)
@@ -114,12 +124,38 @@ class TokenSourceTest : BaseTest() {
114124
assertEquals("room-name", json["room_name"]?.jsonPrimitive?.content)
115125
}
116126

117-
companion object {
118-
// Test JWT created for test purposes only.
119-
// Does not actually auth against anything.
120-
const val TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTkyMzczNDcsImlkZW50aXR5IjoiaW1tdX" +
121-
"RhYmxlLXZhdWx0IiwiaXNzIjoiQVBJWFJ1eG1WM2ZBWlFMIiwibmJmIjoxNzU5MjM2NDQ3LCJzdWIiOiJpbW11dGFibGUtdmF1bHQi" +
122-
"LCJ2aWRlbyI6eyJjYW5VcGRhdGVPd25NZXRhZGF0YSI6dHJ1ZSwicm9vbSI6ImhlbGxvIiwicm9vbUpvaW4iOnRydWUsInJvb21MaX" +
123-
"N0Ijp0cnVlfX0.Oy2vTdWyOP3n7swwMhM2zHA_uB9S75iaDTT-IN-eWss"
127+
128+
@Ignore("For manual testing only.")
129+
@Test
130+
fun testTokenServer() = runTest {
131+
val source = TokenSource.fromSandboxTokenServer(
132+
"", // Put sandboxId here to test manually.
133+
)
134+
val options = TokenRequestOptions(
135+
roomName = "room-name",
136+
participantName = "participant-name",
137+
participantIdentity = "participant-identity",
138+
participantMetadata = "participant-metadata",
139+
roomConfiguration = RoomConfiguration(
140+
name = "room-name",
141+
emptyTimeout = 10,
142+
departureTimeout = 10,
143+
maxParticipants = 100,
144+
metadata = "room-metadata",
145+
minPlayoutDelay = 1,
146+
maxPlayoutDelay = 1,
147+
syncStreams = 1,
148+
agents = RoomAgentDispatch(
149+
agentName = "agent-name",
150+
metadata = "agent-metadata",
151+
),
152+
),
153+
)
154+
155+
val response = source.fetch(options)
156+
println(response)
157+
158+
assertTrue(response.hasValidToken())
124159
}
160+
125161
}

0 commit comments

Comments
 (0)