Repository: supabase-community/supabase-kt (kotlin)

Issue:
Fix Request: Prevent sending expired tokens to Realtime channel
# Fix Request

To minimize the number of InvalidJWToken errors on the Realtime backend, we must check if the token is valid before pushing it.

Reference PR: https://github.com/supabase/realtime-js/pull/437

Pull request description:
## What kind of change does this PR introduce?

Feature (closes #804)

## What is the new behavior?

Expired tokens will no longer be sent to realtime.

Based off on #807

Patch:
diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeChannelImpl.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeChannelImpl.kt
index a03d05cea..71197593b 100644
--- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeChannelImpl.kt
+++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeChannelImpl.kt
@@ -40,15 +40,14 @@ internal class RealtimeChannelImpl(
     private val _status = MutableStateFlow(RealtimeChannel.Status.UNSUBSCRIBED)
     override val status = _status.asStateFlow()
     override val realtime: Realtime = realtimeImpl
-    private val accessToken = suspend {
-        realtimeImpl.config.accessToken(supabaseClient) ?: realtimeImpl.accessToken
-    }
     override val supabaseClient = realtimeImpl.supabaseClient
 
     private val broadcastUrl = realtimeImpl.broadcastUrl()
     private val subTopic = topic.replaceFirst(Regex("^realtime:", RegexOption.IGNORE_CASE), "")
     private val httpClient = realtimeImpl.supabaseClient.httpClient
 
+    private suspend fun accessToken() = realtimeImpl.config.accessToken(supabaseClient) ?: realtimeImpl.accessToken
+
     @OptIn(SupabaseInternal::class)
     override suspend fun subscribe(blockUntilSubscribed: Boolean) {
         if(realtimeImpl.status.value != Realtime.Status.CONNECTED) {
diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt
index 8b578b8fa..28994f4bd 100644
--- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt
+++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt
@@ -17,6 +17,7 @@ import io.github.jan.supabase.realtime.websocket.RealtimeWebsocket
 import io.ktor.client.statement.HttpResponse
 import io.ktor.http.URLProtocol
 import io.ktor.http.path
+import io.ktor.util.decodeBase64String
 import kotlinx.atomicfu.atomic
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -30,7 +31,13 @@ import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import kotlinx.datetime.Clock
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
 import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import kotlin.io.encoding.ExperimentalEncodingApi
 
 @PublishedApi internal class RealtimeImpl(override val supabaseClient: SupabaseClient, override val config: Realtime.Config) : Realtime {
 
@@ -167,8 +174,20 @@ import kotlinx.serialization.json.buildJsonObject
         }
     }
 
+    @OptIn(ExperimentalEncodingApi::class)
     override suspend fun setAuth(token: String?) {
         val newToken = token ?: config.accessToken(supabaseClient)
+
+        if(newToken != null) {
+            val parsed = Json.decodeFromString<JsonObject>(newToken.split(".")[1].decodeBase64String())
+            val exp = parsed["exp"]?.jsonPrimitive?.longOrNull ?: error("No exp found in token")
+            val now = Clock.System.now().epochSeconds
+            val diff = exp - now
+            if(diff < 0) {
+                Realtime.logger.w { "Token is expired. Not sending it to realtime." }
+                return
+            }
+        }
         this.accessToken = newToken
         scope.launch {
             subscriptions.values.filter { it.status.value == RealtimeChannel.Status.SUBSCRIBED }.forEach { it.updateAuth(accessToken) }


Test patch:
diff --git a/Realtime/src/commonTest/kotlin/RealtimeTest.kt b/Realtime/src/commonTest/kotlin/RealtimeTest.kt
index 655c885ed..5ff705e64 100644
--- a/Realtime/src/commonTest/kotlin/RealtimeTest.kt
+++ b/Realtime/src/commonTest/kotlin/RealtimeTest.kt
@@ -1,11 +1,15 @@
 import io.github.jan.supabase.realtime.Realtime
+import io.github.jan.supabase.realtime.RealtimeImpl
 import io.github.jan.supabase.realtime.RealtimeMessage
 import io.github.jan.supabase.realtime.realtime
+import io.ktor.util.encodeBase64
 import kotlinx.coroutines.test.runTest
+import kotlinx.datetime.Clock
 import kotlinx.serialization.json.buildJsonObject
 import kotlinx.serialization.json.put
 import kotlin.test.Test
 import kotlin.test.assertEquals
+import kotlin.test.assertNull
 
 class RealtimeTest {
 
@@ -51,4 +55,46 @@ class RealtimeTest {
         }
     }
 
+    @Test
+    fun testSendingExpiredToken() {
+        runTest {
+            createTestClient(
+                wsHandler = { i, _ ->
+
+                },
+                supabaseHandler = {
+                    it.realtime as RealtimeImpl
+                    val expiredToken = generateToken(Clock.System.now().epochSeconds - 10)
+                    it.realtime.setAuth(expiredToken)
+                    assertNull((it.realtime as RealtimeImpl).accessToken)
+                }
+            )
+        }
+    }
+
+    @Test
+    fun testSendingValidToken() {
+        runTest {
+            createTestClient(
+                wsHandler = { i, _ ->
+
+                },
+                supabaseHandler = {
+                    it.realtime as RealtimeImpl
+                    val token = generateToken(Clock.System.now().epochSeconds + 10)
+                    it.realtime.setAuth(token)
+                    assertEquals(token, (it.realtime as RealtimeImpl).accessToken)
+                }
+            )
+        }
+    }
+
+    private fun generateToken(exp: Long) = buildString {
+        append("test.")
+        append(buildJsonObject {
+            put("exp", exp)
+        }.toString().encodeBase64())
+        append(".test")
+    }
+
 }
\ No newline at end of file