Repository: supabase-community/supabase-kt
Pull request #688 (2024-08-12 14:25:48)

Add support for third party auth and add Slack OIDC provider
## What kind of change does this PR introduce?

Feature

## What is the current behavior?

No support for third party auth and missing the Slack OIDC provider.

## What is the new behavior?

The provider has been added and there is a new option in the `SupabaseClientBuilder`:
```kotlin
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
    accessToken = {
          //fetch the third party token
         "my-token"
    }
}
```
If this property is not null, the `Auth` plugin will not be available and this custom provider function will be used for auth.

## Additional context

Check https://github.com/supabase/supabase-js/pull/1004.



Patch:
diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AccessToken.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AccessToken.kt
new file mode 100644
index 00000000..0a3a4a53
--- /dev/null
+++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AccessToken.kt
@@ -0,0 +1,36 @@
+package io.github.jan.supabase.gotrue
+
+import io.github.jan.supabase.SupabaseClient
+import io.github.jan.supabase.annotations.SupabaseInternal
+import io.github.jan.supabase.plugins.MainConfig
+import io.github.jan.supabase.plugins.MainPlugin
+
+/**
+ * Returns the access token used for requests. The token is resolved in the following order:
+ * 1. [jwtToken] if not null
+ * 2. [SupabaseClient.resolveAccessToken] if not null
+ * 3. [Auth.currentAccessTokenOrNull] if the Auth plugin is installed
+ * 4. [SupabaseClient.supabaseKey] if [keyAsFallback] is true
+ */
+@SupabaseInternal
+suspend fun SupabaseClient.resolveAccessToken(
+    jwtToken: String? = null,
+    keyAsFallback: Boolean = true
+): String? {
+    val key = if(keyAsFallback) supabaseKey else null
+    return jwtToken ?: accessToken?.invoke()
+    ?: pluginManager.getPluginOrNull(Auth)?.currentAccessTokenOrNull() ?: key
+}
+
+/**
+ * Returns the access token used for requests. The token is resolved in the following order:
+ * 1. [MainConfig.jwtToken] if not null
+ * 2. [SupabaseClient.resolveAccessToken] if not null
+ * 3. [Auth.currentAccessTokenOrNull] if the Auth plugin is installed
+ * 4. [SupabaseClient.supabaseKey] if [keyAsFallback] is true
+ */
+@SupabaseInternal
+suspend fun <C : MainConfig> SupabaseClient.resolveAccessToken(
+    plugin: MainPlugin<C>,
+    keyAsFallback: Boolean = true
+) = resolveAccessToken(plugin.config.jwtToken, keyAsFallback)
\ No newline at end of file
diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt
index b62c9333..71c77e60 100644
--- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt
+++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt
@@ -86,6 +86,10 @@ internal class AuthImpl(
     override val pluginKey: String
         get() = Auth.key
 
+    init {
+        if(supabaseClient.accessToken != null) error("The Auth plugin is not available when using a custom access token provider. Please uninstall the Auth plugin.")
+    }
+
     override fun init() {
         setupPlatform()
         if (config.autoLoadFromStorage) {
diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthenticatedSupabaseApi.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthenticatedSupabaseApi.kt
index e7151684..424afc34 100644
--- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthenticatedSupabaseApi.kt
+++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthenticatedSupabaseApi.kt
@@ -20,11 +20,13 @@ class AuthenticatedSupabaseApi @SupabaseInternal constructor(
     private val jwtToken: String? = null // Can be configured plugin-wide. By default, all plugins use the token from the current session
 ): SupabaseApi(resolveUrl, parseErrorResponse, supabaseClient) {
 
-    override suspend fun rawRequest(url: String, builder: HttpRequestBuilder.() -> Unit): HttpResponse = super.rawRequest(url) {
-        val jwtToken = jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth)?.currentAccessTokenOrNull() ?: supabaseClient.supabaseKey
-        bearerAuth(jwtToken)
-        builder()
-        defaultRequest?.invoke(this)
+    override suspend fun rawRequest(url: String, builder: HttpRequestBuilder.() -> Unit): HttpResponse {
+        val accessToken = supabaseClient.resolveAccessToken(jwtToken) ?: error("No access token available")
+        return super.rawRequest(url) {
+            bearerAuth(accessToken)
+            builder()
+            defaultRequest?.invoke(this)
+        }
     }
 
     suspend fun rawRequest(builder: HttpRequestBuilder.() -> Unit): HttpResponse = rawRequest("", builder)
diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/Providers.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/Providers.kt
index 93df6386..54f19f1b 100644
--- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/Providers.kt
+++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/Providers.kt
@@ -55,6 +55,12 @@ data object Slack : OAuthProvider() {
 
 }
 
+data object SlackOIDC : OAuthProvider() {
+
+    override val name = "slack_oidc"
+
+}
+
 data object Twitch : OAuthProvider() {
 
     override val name = "twitch"
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 747bd4cd..6b1c171e 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
@@ -3,7 +3,7 @@ package io.github.jan.supabase.realtime
 import io.github.jan.supabase.annotations.SupabaseInternal
 import io.github.jan.supabase.collections.AtomicMutableList
 import io.github.jan.supabase.decodeIfNotEmptyOrDefault
-import io.github.jan.supabase.gotrue.Auth
+import io.github.jan.supabase.gotrue.resolveAccessToken
 import io.github.jan.supabase.logging.d
 import io.github.jan.supabase.logging.e
 import io.github.jan.supabase.logging.w
@@ -17,7 +17,6 @@ import io.ktor.http.headers
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.first
-import kotlinx.datetime.Clock
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.JsonObject
 import kotlinx.serialization.json.buildJsonObject
@@ -61,9 +60,7 @@ internal class RealtimeChannelImpl(
         }
         _status.value = RealtimeChannel.Status.SUBSCRIBING
         Realtime.logger.d { "Subscribing to channel $topic" }
-        val currentJwt = realtimeImpl.config.jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth)?.currentSessionOrNull()?.let {
-            if(it.expiresAt > Clock.System.now()) it.accessToken else null
-        }
+        val currentJwt = supabaseClient.resolveAccessToken(realtimeImpl, keyAsFallback = false)
         val postgrestChanges = clientChanges.toList()
         val joinConfig = RealtimeJoinPayload(RealtimeJoinConfig(broadcastJoinConfig, presenceJoinConfig, postgrestChanges, isPrivate))
         val joinConfigObject = buildJsonObject {
diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt
index 7f355371..a4c7da74 100644
--- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt
+++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt
@@ -3,7 +3,7 @@ package io.github.jan.supabase.storage
 import io.github.jan.supabase.SupabaseClient
 import io.github.jan.supabase.exceptions.HttpRequestException
 import io.github.jan.supabase.exceptions.RestException
-import io.github.jan.supabase.gotrue.Auth
+import io.github.jan.supabase.gotrue.resolveAccessToken
 import io.github.jan.supabase.storage.resumable.ResumableClient
 import io.ktor.client.plugins.HttpRequestTimeoutException
 import io.ktor.utils.io.ByteReadChannel
@@ -314,8 +314,8 @@ sealed interface BucketApi {
  * **Authentication: Bearer <your_access_token>**
  * @param path The path to download
  */
-fun BucketApi.authenticatedRequest(path: String): Pair<String, String> {
+suspend fun BucketApi.authenticatedRequest(path: String): Pair<String, String> {
     val url = authenticatedUrl(path)
-    val token = supabaseClient.storage.config.jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth)?.currentAccessTokenOrNull() ?: supabaseClient.supabaseKey
+    val token = supabaseClient.resolveAccessToken(supabaseClient.storage) ?: error("No access token available")
     return token to url
 }
\ No newline at end of file
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/AccessTokenProvider.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/AccessTokenProvider.kt
new file mode 100644
index 00000000..7d0afd14
--- /dev/null
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/AccessTokenProvider.kt
@@ -0,0 +1,10 @@
+package io.github.jan.supabase
+
+/**
+ * Optional function for using a third-party authentication system with
+ * Supabase. The function should return an access token or ID token (JWT) by
+ * obtaining it from the third-party auth client library. Note that this
+ * function may be called concurrently and many times. Use memoization and
+ * locking techniques if this is not supported by the client libraries.
+ */
+typealias AccessTokenProvider = suspend () -> String
\ No newline at end of file
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt
index e875e0c9..f9bbd39d 100644
--- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt
@@ -39,7 +39,7 @@ sealed interface SupabaseClient {
     val pluginManager: PluginManager
 
     /**
-     * The http client used to interact with the supabase api
+     * The http client used to interact with the Supabase api
      */
     val httpClient: KtorSupabaseHttpClient
 
@@ -53,6 +53,12 @@ sealed interface SupabaseClient {
      */
     val defaultSerializer: SupabaseSerializer
 
+    /**
+     * The custom access token provider used to provide custom access tokens for requests. Configured within the [SupabaseClientBuilder]
+     */
+    @SupabaseInternal
+    val accessToken: AccessTokenProvider?
+
     /**
      * Releases all resources held by the [httpClient] and all plugins the [pluginManager]
      */
@@ -88,6 +94,7 @@ internal class SupabaseClientImpl(
     requestTimeout: Long,
     httpEngine: HttpClientEngine?,
     override val defaultSerializer: SupabaseSerializer,
+    override val accessToken: AccessTokenProvider?,
 ) : SupabaseClient {
 
     init {
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt
index 023282ac..282ea829 100644
--- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt
@@ -66,6 +66,19 @@ class SupabaseClientBuilder @PublishedApi internal constructor(private val supab
      */
     var defaultSerializer: SupabaseSerializer = KotlinXSerializer(Json { ignoreUnknownKeys = true })
 
+    /**
+     * Optional function for using a third-party authentication system with
+     * Supabase. The function should return an access token or ID token (JWT) by
+     * obtaining it from the third-party auth client library. Note that this
+     * function may be called concurrently and many times. Use memoization and
+     * locking techniques if this is not supported by the client libraries.
+     *
+     * When set, the Auth plugin from `auth-kt` cannot be used.
+     * Create another client if you wish to use Supabase Auth and third-party
+     * authentications concurrently in the same application.
+     */
+    var accessToken: AccessTokenProvider? = null
+
     private val httpConfigOverrides = mutableListOf<HttpClientConfig<*>.() -> Unit>()
     private val plugins = mutableMapOf<String, ((SupabaseClient) -> SupabasePlugin<*>)>()
 
@@ -95,7 +108,8 @@ class SupabaseClientBuilder @PublishedApi internal constructor(private val supab
             useHTTPS,
             requestTimeout.inWholeMilliseconds,
             httpEngine,
-            defaultSerializer
+            defaultSerializer,
+            accessToken
         )
     }
 


Test patch:
diff --git a/GoTrue/src/commonTest/kotlin/AccessTokenTest.kt b/GoTrue/src/commonTest/kotlin/AccessTokenTest.kt
new file mode 100644
index 00000000..2c962fa7
--- /dev/null
+++ b/GoTrue/src/commonTest/kotlin/AccessTokenTest.kt
@@ -0,0 +1,73 @@
+import io.github.jan.supabase.gotrue.Auth
+import io.github.jan.supabase.gotrue.auth
+import io.github.jan.supabase.gotrue.minimalSettings
+import io.github.jan.supabase.gotrue.resolveAccessToken
+import io.github.jan.supabase.testing.createMockedSupabaseClient
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class AccessTokenTest {
+
+    @Test
+    fun testAccessTokenWithJwtToken() {
+        runTest {
+            val client = createMockedSupabaseClient(
+                configuration = {
+                    install(Auth) {
+                        minimalSettings()
+                    }
+                }
+            )
+            client.auth.importAuthToken("myAuth") //this should be ignored as per plugin tokens override the used access token
+            assertEquals("myJwtToken", client.resolveAccessToken("myJwtToken"))
+        }
+    }
+
+    @Test
+    fun testAccessTokenWithKeyAsFallback() {
+        runTest {
+            val client = createMockedSupabaseClient(supabaseKey = "myKey")
+            assertEquals("myKey", client.resolveAccessToken())
+        }
+    }
+
+    @Test
+    fun testAccessTokenWithoutKey() {
+        runTest {
+            val client = createMockedSupabaseClient()
+            assertNull(client.resolveAccessToken(keyAsFallback = false))
+        }
+    }
+
+    @Test
+    fun testAccessTokenWithCustomAccessToken() {
+        runTest {
+            val client = createMockedSupabaseClient(
+                configuration = {
+                    accessToken = {
+                        "myCustomToken"
+                    }
+                }
+            )
+            assertEquals("myCustomToken", client.resolveAccessToken())
+        }
+    }
+
+    @Test
+    fun testAccessTokenWithAuth() {
+        runTest {
+            val client = createMockedSupabaseClient(
+                configuration = {
+                    install(Auth) {
+                        minimalSettings()
+                    }
+                }
+            )
+            client.auth.importAuthToken("myAuth")
+            assertEquals("myAuth", client.resolveAccessToken())
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/GoTrue/src/commonTest/kotlin/AuthTest.kt b/GoTrue/src/commonTest/kotlin/AuthTest.kt
index 582158d5..58414953 100644
--- a/GoTrue/src/commonTest/kotlin/AuthTest.kt
+++ b/GoTrue/src/commonTest/kotlin/AuthTest.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.test.runTest
 import kotlinx.serialization.json.buildJsonObject
 import kotlin.test.Test
 import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
 import kotlin.test.assertIs
 import kotlin.test.assertNull
 
@@ -46,6 +47,22 @@ class AuthTest {
         }
     }
 
+    @Test
+    fun testErrorWhenUsingAccessToken() {
+        runTest {
+            assertFailsWith<IllegalStateException> {
+                createMockedSupabaseClient(
+                    configuration = {
+                        accessToken = {
+                            "myToken"
+                        }
+                        install(Auth)
+                    }
+                )
+            }
+        }
+    }
+
     @Test
     fun testSavingSessionToStorage() {
         runTest {
diff --git a/test-common/src/commonTest/kotlin/SupabaseClientTest.kt b/test-common/src/commonTest/kotlin/SupabaseClientTest.kt
index aed85d33..50a89685 100644
--- a/test-common/src/commonTest/kotlin/SupabaseClientTest.kt
+++ b/test-common/src/commonTest/kotlin/SupabaseClientTest.kt
@@ -30,6 +30,20 @@ class SupabaseClientTest {
         }
     }
 
+    @Test
+    fun testAccessTokenProvider() {
+        runTest {
+            val client = createMockedSupabaseClient(
+                configuration = {
+                    accessToken = {
+                        "myToken"
+                    }
+                }
+            )
+            assertEquals("myToken", client.accessToken?.invoke())
+        }
+    }
+
     @Test
     fun testDefaultLogLevel() {
         createMockedSupabaseClient(