From 69d86994e976d6a876d142051b99965a3b314564 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Mon, 24 Aug 2026 15:07:17 +0100 Subject: [PATCH] fix: treat TokenSource JWTs without exp as expired CachingTokenSource.hasValidToken treated a missing exp as still valid, so TokenSourceCached never refetched. Require exp and keep nbf optional, matching client-sdk-js#2057. --- .changeset/token-source-require-exp.md | 5 +++ .../android/token/CachingTokenSource.kt | 6 ++-- .../android/token/CachingTokenSourceTest.kt | 31 ++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 .changeset/token-source-require-exp.md diff --git a/.changeset/token-source-require-exp.md b/.changeset/token-source-require-exp.md new file mode 100644 index 000000000..bd99054ed --- /dev/null +++ b/.changeset/token-source-require-exp.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +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. 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 index 32ba37b46..2ecefa1cc 100644 --- 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 @@ -151,11 +151,13 @@ fun TokenSourceResponse.hasValidToken(tolerance: Duration = 60.seconds, date: Da try { val jwt = TokenPayload(participantToken) val now = Date() - val expiresAt = jwt.expiresAt + // First-party minters always set exp. A signed JWT with no exp would + // otherwise stay cached forever (livekit/client-sdk-js#2057). + val expiresAt = jwt.expiresAt ?: return false val nbf = jwt.notBefore val isBefore = nbf != null && now.before(nbf) - val hasExpired = expiresAt != null && now.after(Date(expiresAt.time + tolerance.inWholeMilliseconds)) + val hasExpired = now.after(Date(expiresAt.time + tolerance.inWholeMilliseconds)) return !isBefore && !hasExpired } catch (e: Exception) { 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 index 581967b80..0ca02cd61 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit, Inc. + * Copyright 2025-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -63,6 +64,26 @@ class CachingTokenSourceTest : BaseTest() { assertTrue(tokenResponse.hasValidToken(date = Date(9999999990000))) } + @Test + fun tokenWithoutExpIsInvalid() { + val tokenResponse = TokenSourceResponse( + "wss://www.example.com", + NO_EXP_TOKEN, + ) + + assertFalse(tokenResponse.hasValidToken()) + } + + @Test + fun tokenWithExpOnlyIsValid() { + val tokenResponse = TokenSourceResponse( + "wss://www.example.com", + EXP_ONLY_TOKEN, + ) + + assertTrue(tokenResponse.hasValidToken()) + } + @Test fun cachedValidTokenOnlyFetchedOnce() = runTest { val server = MockWebServer() @@ -128,5 +149,13 @@ class CachingTokenSourceTest : BaseTest() { const val EXPIRED_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjlhMzJiZTg2NzkyZTM3Nm" + "I3ZTBlMmIyNjVjMjY1YTA5In0.eyJpYXQiOjAsIm5iZiI6MCwiZXhwIjowfQ.8oV9K-CeULScAjFIK2O7sxEGUD7" + "su3kCQv3Q8rhk0Hg_AuzQixJfz2Pt0rJUwLWhF0mSlcYMUKdR0yp12RfrdA" + + // Dummy HS256 JWTs for cache-validity only (signature is not verified). + // NO_EXP: {"sub":"identity"} with no exp. + // EXP_ONLY: same identity plus exp 9876543210 (Fri Dec 22 2282). + const val NO_EXP_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJpZGVudGl0eSJ9." + + "ytOOgz0Ly2PUjItxXAXFRIE9sgZnOKPzQVSovM7uH84" + const val EXP_ONLY_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiJpZGVudGl0eSIsImV4cCI6OTg3NjU0MzIxMH0.Im7BMAH9ZdnkdPb7oTny_kEiVmdzep3Bl-SNwqAruMw" } }