Skip to content

Commit e378345

Browse files
authored
fix: treat TokenSource JWTs without exp as expired (#1008)
CachingTokenSource.hasValidToken treated a missing exp as still valid, so TokenSourceCached never refetched. Require exp and keep nbf optional, matching client-sdk-js#2057.
1 parent 4fdea28 commit e378345

3 files changed

Lines changed: 39 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": patch
3+
---
4+
5+
Fix CachingTokenSource treating a JWT with no exp as still valid, so a token that never expires stays cached forever. Require exp; keep nbf optional.

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,13 @@ fun TokenSourceResponse.hasValidToken(tolerance: Duration = 60.seconds, date: Da
151151
try {
152152
val jwt = TokenPayload(participantToken)
153153
val now = Date()
154-
val expiresAt = jwt.expiresAt
154+
// First-party minters always set exp. A signed JWT with no exp would
155+
// otherwise stay cached forever (livekit/client-sdk-js#2057).
156+
val expiresAt = jwt.expiresAt ?: return false
155157
val nbf = jwt.notBefore
156158

157159
val isBefore = nbf != null && now.before(nbf)
158-
val hasExpired = expiresAt != null && now.after(Date(expiresAt.time + tolerance.inWholeMilliseconds))
160+
val hasExpired = now.after(Date(expiresAt.time + tolerance.inWholeMilliseconds))
159161

160162
return !isBefore && !hasExpired
161163
} catch (e: Exception) {

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2025 LiveKit, Inc.
2+
* Copyright 2025-2026 LiveKit, Inc.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
2121
import okhttp3.mockwebserver.MockResponse
2222
import okhttp3.mockwebserver.MockWebServer
2323
import org.junit.Assert.assertEquals
24+
import org.junit.Assert.assertFalse
2425
import org.junit.Assert.assertNotEquals
2526
import org.junit.Assert.assertTrue
2627
import org.junit.Test
@@ -63,6 +64,26 @@ class CachingTokenSourceTest : BaseTest() {
6364
assertTrue(tokenResponse.hasValidToken(date = Date(9999999990000)))
6465
}
6566

67+
@Test
68+
fun tokenWithoutExpIsInvalid() {
69+
val tokenResponse = TokenSourceResponse(
70+
"wss://www.example.com",
71+
NO_EXP_TOKEN,
72+
)
73+
74+
assertFalse(tokenResponse.hasValidToken())
75+
}
76+
77+
@Test
78+
fun tokenWithExpOnlyIsValid() {
79+
val tokenResponse = TokenSourceResponse(
80+
"wss://www.example.com",
81+
EXP_ONLY_TOKEN,
82+
)
83+
84+
assertTrue(tokenResponse.hasValidToken())
85+
}
86+
6687
@Test
6788
fun cachedValidTokenOnlyFetchedOnce() = runTest {
6889
val server = MockWebServer()
@@ -128,5 +149,13 @@ class CachingTokenSourceTest : BaseTest() {
128149
const val EXPIRED_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjlhMzJiZTg2NzkyZTM3Nm" +
129150
"I3ZTBlMmIyNjVjMjY1YTA5In0.eyJpYXQiOjAsIm5iZiI6MCwiZXhwIjowfQ.8oV9K-CeULScAjFIK2O7sxEGUD7" +
130151
"su3kCQv3Q8rhk0Hg_AuzQixJfz2Pt0rJUwLWhF0mSlcYMUKdR0yp12RfrdA"
152+
153+
// Dummy HS256 JWTs for cache-validity only (signature is not verified).
154+
// NO_EXP: {"sub":"identity"} with no exp.
155+
// EXP_ONLY: same identity plus exp 9876543210 (Fri Dec 22 2282).
156+
const val NO_EXP_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJpZGVudGl0eSJ9." +
157+
"ytOOgz0Ly2PUjItxXAXFRIE9sgZnOKPzQVSovM7uH84"
158+
const val EXP_ONLY_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
159+
"eyJzdWIiOiJpZGVudGl0eSIsImV4cCI6OTg3NjU0MzIxMH0.Im7BMAH9ZdnkdPb7oTny_kEiVmdzep3Bl-SNwqAruMw"
131160
}
132161
}

0 commit comments

Comments
 (0)