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 000000000..0a3a4a537 --- /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 SupabaseClient.resolveAccessToken( + plugin: MainPlugin, + 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 b62c93338..71c77e602 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 e7151684e..424afc341 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 93df63865..54f19f1bf 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/GoTrue/src/commonTest/kotlin/AccessTokenTest.kt b/GoTrue/src/commonTest/kotlin/AccessTokenTest.kt new file mode 100644 index 000000000..2c962fa7f --- /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 582158d59..584149532 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 { + createMockedSupabaseClient( + configuration = { + accessToken = { + "myToken" + } + install(Auth) + } + ) + } + } + } + @Test fun testSavingSessionToStorage() { runTest { 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 747bd4cd8..6b1c171e3 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 7f3553716..a4c7da748 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 ** * @param path The path to download */ -fun BucketApi.authenticatedRequest(path: String): Pair { +suspend fun BucketApi.authenticatedRequest(path: String): Pair { 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 000000000..7d0afd14b --- /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 e875e0c9a..f9bbd39de 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 023282acf..282ea8290 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.() -> Unit>() private val plugins = mutableMapOf SupabasePlugin<*>)>() @@ -95,7 +108,8 @@ class SupabaseClientBuilder @PublishedApi internal constructor(private val supab useHTTPS, requestTimeout.inWholeMilliseconds, httpEngine, - defaultSerializer + defaultSerializer, + accessToken ) } diff --git a/test-common/src/commonTest/kotlin/SupabaseClientTest.kt b/test-common/src/commonTest/kotlin/SupabaseClientTest.kt index aed85d335..50a896852 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(