diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Auth.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Auth.kt index 0b4d5ce44..09ebc0732 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Auth.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Auth.kt @@ -1,6 +1,7 @@ package io.github.jan.supabase.auth import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.SupabaseClientBuilder import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.auth.admin.AdminApi @@ -496,8 +497,15 @@ interface Auth : MainPlugin, CustomSerializationPlugin { const val API_VERSION = 1 override fun createConfig(init: AuthConfig.() -> Unit) = AuthConfig().apply(init) + override fun create(supabaseClient: SupabaseClient, config: AuthConfig): Auth = AuthImpl(supabaseClient, config) + override fun setup(builder: SupabaseClientBuilder, config: AuthConfig) { + if(config.checkSessionOnRequest) { + builder.networkInterceptors.add(SessionNetworkInterceptor) + } + } + } } diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthConfig.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthConfig.kt index 1b4d5a738..7a18957d7 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthConfig.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthConfig.kt @@ -12,7 +12,7 @@ import kotlin.time.Duration.Companion.seconds /** * The configuration for [Auth] */ -expect class AuthConfig() : CustomSerializationConfig, AuthConfigDefaults +expect class AuthConfig() : CustomSerializationConfig, AuthConfigDefaults, AuthDependentPluginConfig /** * The default values for the [AuthConfig] @@ -103,6 +103,16 @@ open class AuthConfigDefaults : MainConfig() { @SupabaseExperimental var urlLauncher: UrlLauncher = UrlLauncher.DEFAULT + /** + * Whether to check if the current session is expired on an authenticated request and possibly try to refresh it. + * + * **Note: This option is experimental and is a fail-safe for when the auto refresh fails. This option may be removed without notice.** + */ + @SupabaseExperimental + var checkSessionOnRequest: Boolean = true + + var requireValidSession: Boolean = false + } /** diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthDependentPluginConfig.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthDependentPluginConfig.kt new file mode 100644 index 000000000..3807edc86 --- /dev/null +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthDependentPluginConfig.kt @@ -0,0 +1,16 @@ +package io.github.jan.supabase.auth + +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.auth.user.UserSession + +/** + * TODO + */ +interface AuthDependentPluginConfig { + + /** + * Whether to require a valid [UserSession] in the [Auth] plugin to make any request with this plugin. The [SupabaseClient.supabaseKey] cannot be used as fallback. + */ + var requireValidSession: Boolean + +} diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthImpl.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthImpl.kt index d4168377f..7105d65ac 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthImpl.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthImpl.kt @@ -87,8 +87,9 @@ internal class AuthImpl( override val codeVerifierCache = config.codeVerifierCache ?: createDefaultCodeVerifierCache() @OptIn(SupabaseInternal::class) - internal val api = supabaseClient.authenticatedSupabaseApi(this) - override val admin: AdminApi = AdminApiImpl(this) + internal val userApi = supabaseClient.authenticatedSupabaseApi(this) + internal val publicApi = supabaseClient.authenticatedSupabaseApi(this, requireSession = false) + override val admin: AdminApi = AdminApiImpl(publicApi) override val mfa: MfaApi = MfaApiImpl(this) var sessionJob: Job? = null override val isAutoRefreshRunning: Boolean @@ -142,7 +143,7 @@ internal class AuthImpl( }, redirectUrl, config) override suspend fun signInAnonymously(data: JsonObject?, captchaToken: String?) { - val response = api.postJson("signup", buildJsonObject { + val response = publicApi.postJson("signup", buildJsonObject { data?.let { put("data", it) } captchaToken?.let(::putCaptchaToken) }) @@ -166,7 +167,7 @@ internal class AuthImpl( val automaticallyOpen = ExternalAuthConfigDefaults().apply(config).automaticallyOpenUrl val fetchUrl: suspend (String?) -> String = { redirectTo: String? -> val url = getOAuthUrl(provider, redirectTo, "user/identities/authorize", config) - val response = api.rawRequest(url) { + val response = userApi.rawRequest(url) { method = HttpMethod.Get parameter("skip_http_redirect", true) } @@ -193,12 +194,12 @@ internal class AuthImpl( config: (IDToken.Config) -> Unit ) { val body = IDToken.Config(idToken = idToken, provider = provider, linkIdentity = true).apply(config) - val result = api.postJson("token?grant_type=id_token", body) + val result = userApi.postJson("token?grant_type=id_token", body) importSession(result.safeBody(), source = SessionSource.UserIdentitiesChanged(result.safeBody())) } override suspend fun unlinkIdentity(identityId: String, updateLocalUser: Boolean) { - api.delete("user/identities/$identityId") + userApi.delete("user/identities/$identityId") if (updateLocalUser) { val session = currentSessionOrNull() ?: return val newUser = session.user?.copy(identities = session.user.identities?.filter { it.identityId != identityId }) @@ -222,7 +223,7 @@ internal class AuthImpl( } val codeChallenge: String? = preparePKCEIfEnabled() - return api.postJson("sso", buildJsonObject { + return publicApi.postJson("sso", buildJsonObject { redirectUrl?.let { put("redirect_to", it) } createdConfig.captchaToken?.let(::putCaptchaToken) codeChallenge?.let(::putCodeChallenge) @@ -232,7 +233,8 @@ internal class AuthImpl( createdConfig.providerId?.let { put("provider_id", it) } - }).body() + }) + .body() } override suspend fun updateUser( @@ -246,7 +248,7 @@ internal class AuthImpl( putJsonObject(supabaseJson.encodeToJsonElement(updateBuilder).jsonObject) codeChallenge?.let(::putCodeChallenge) }.toString() - val response = api.putJson("user", body) { + val response = userApi.putJson("user", body) { redirectUrl?.let { url.parameters.append("redirect_to", it) } } val userInfo = response.safeBody() @@ -262,7 +264,7 @@ internal class AuthImpl( } private suspend fun resend(type: String, body: JsonObjectBuilder.() -> Unit) { - api.postJson("resend", buildJsonObject { + userApi.postJson("resend", buildJsonObject { put("type", type) putJsonObject(buildJsonObject(body)) }) @@ -297,19 +299,19 @@ internal class AuthImpl( captchaToken?.let(::putCaptchaToken) codeChallenge?.let(::putCodeChallenge) }.toString() - api.postJson("recover", body) { + publicApi.postJson("recover", body) { redirectUrl?.let { url.encodedParameters.append("redirect_to", it) } } } override suspend fun reauthenticate() { - api.get("reauthenticate") + userApi.get("reauthenticate") } override suspend fun signOut(scope: SignOutScope) { if (currentSessionOrNull() != null) { try { - api.post("logout") { + userApi.post("logout") { parameter("scope", scope.name.lowercase()) } } catch(e: RestException) { @@ -339,7 +341,7 @@ internal class AuthImpl( captchaToken?.let(::putCaptchaToken) additionalData() } - val response = api.postJson("verify", body) + val response = publicApi.postJson("verify", body) val session = response.body() importSession(session, source = SessionSource.SignIn(OTP)) } @@ -371,7 +373,7 @@ internal class AuthImpl( } override suspend fun retrieveUser(jwt: String): UserInfo { - val response = api.get("user") { + val response = userApi.get("user") { headers["Authorization"] = "Bearer $jwt" } val body = response.bodyAsText() @@ -394,7 +396,7 @@ internal class AuthImpl( require(codeVerifier != null) { "No code verifier stored. Make sure to use `getOAuthUrl` for the OAuth Url to prepare the PKCE flow." } - val session = api.postJson("token?grant_type=pkce", buildJsonObject { + val session = publicApi.postJson("token?grant_type=pkce", buildJsonObject { put("auth_code", code) put("code_verifier", codeVerifier) }) { @@ -414,7 +416,7 @@ internal class AuthImpl( val body = buildJsonObject { put("refresh_token", refreshToken) } - val response = api.postJson("token?grant_type=refresh_token", body) { + val response = publicApi.postJson("token?grant_type=refresh_token", body) { headers.remove("Authorization") } return response.safeBody("Auth#refreshSession") diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthenticatedSupabaseApi.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthenticatedSupabaseApi.kt index aca2fd113..7a685f3c5 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthenticatedSupabaseApi.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthenticatedSupabaseApi.kt @@ -1,31 +1,50 @@ @file:Suppress("UndocumentedPublicClass", "UndocumentedPublicFunction") package io.github.jan.supabase.auth +import io.github.jan.supabase.OSInformation import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.annotations.SupabaseInternal +import io.github.jan.supabase.auth.exception.SessionRequiredException +import io.github.jan.supabase.auth.exception.TokenExpiredException import io.github.jan.supabase.exceptions.RestException +import io.github.jan.supabase.logging.e import io.github.jan.supabase.network.SupabaseApi +import io.github.jan.supabase.plugins.MainConfig import io.github.jan.supabase.plugins.MainPlugin import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.bearerAuth import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpStatement +import kotlin.time.Clock + +@SupabaseInternal +data class AuthenticatedApiConfig( + val jwtToken: String? = null, + val defaultRequest: (HttpRequestBuilder.() -> Unit)? = null, + val requireSession: Boolean +) @OptIn(SupabaseInternal::class) class AuthenticatedSupabaseApi @SupabaseInternal constructor( resolveUrl: (path: String) -> String, parseErrorResponse: (suspend (response: HttpResponse) -> RestException)? = null, - private val defaultRequest: (HttpRequestBuilder.() -> Unit)? = null, supabaseClient: SupabaseClient, - private val jwtToken: String? = null // Can be configured plugin-wide. By default, all plugins use the token from the current session + config: AuthenticatedApiConfig ): SupabaseApi(resolveUrl, parseErrorResponse, supabaseClient) { + private val defaultRequest = config.defaultRequest + private val jwtToken = config.jwtToken + private val requireSession = config.requireSession + override suspend fun rawRequest(url: String, builder: HttpRequestBuilder.() -> Unit): HttpResponse { - val accessToken = supabaseClient.resolveAccessToken(jwtToken) ?: error("No access token available") + val builder = HttpRequestBuilder().apply(builder) + val accessToken = supabaseClient.resolveAccessToken(jwtToken, keyAsFallback = !requireSession) + ?: throw SessionRequiredException() + checkAccessToken(accessToken) return super.rawRequest(url) { bearerAuth(accessToken) - builder() defaultRequest?.invoke(this) + this } } @@ -35,14 +54,42 @@ class AuthenticatedSupabaseApi @SupabaseInternal constructor( url: String, builder: HttpRequestBuilder.() -> Unit ): HttpStatement { + val accessToken = supabaseClient.resolveAccessToken(jwtToken, keyAsFallback = !requireSession) + ?: throw SessionRequiredException() + checkAccessToken(accessToken) return super.prepareRequest(url) { - val jwtToken = jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth)?.currentAccessTokenOrNull() ?: supabaseClient.supabaseKey - bearerAuth(jwtToken) + bearerAuth(accessToken) builder() defaultRequest?.invoke(this) } } + private suspend fun checkAccessToken(token: String?) { + val currentSession = supabaseClient.auth.currentSessionOrNull() + val now = Clock.System.now() + val sessionExistsAndExpired = + token == currentSession?.accessToken && currentSession != null && currentSession.expiresAt < now + val autoRefreshEnabled = supabaseClient.auth.config.alwaysAutoRefresh + if (sessionExistsAndExpired && autoRefreshEnabled) { + val autoRefreshRunning = supabaseClient.auth.isAutoRefreshRunning + Auth.logger.e { + """ + Authenticated request attempted with expired access token. This should not happen. Please report this issue. Trying to refresh session before... + Auto refresh running: $autoRefreshRunning + OS: ${OSInformation.CURRENT} + Session: $currentSession + """.trimIndent() + } + + try { + supabaseClient.auth.refreshCurrentSession() + } catch (e: Exception) { + Auth.logger.e(e) { "Failed to refresh session" } + throw TokenExpiredException() + } + } + } + } /** @@ -50,18 +97,33 @@ class AuthenticatedSupabaseApi @SupabaseInternal constructor( * All requests will be resolved relative to this url */ @SupabaseInternal -fun SupabaseClient.authenticatedSupabaseApi(baseUrl: String, parseErrorResponse: (suspend (response: HttpResponse) -> RestException)? = null) = authenticatedSupabaseApi({ baseUrl + it }, parseErrorResponse) +fun SupabaseClient.authenticatedSupabaseApi( + baseUrl: String, + parseErrorResponse: (suspend (response: HttpResponse) -> RestException)? = null, + config: AuthenticatedApiConfig +) = + authenticatedSupabaseApi({ baseUrl + it }, parseErrorResponse, config) /** * Creates a [AuthenticatedSupabaseApi] for the given [plugin]. Requires [Auth] to authenticate requests * All requests will be resolved using the [MainPlugin.resolveUrl] function */ @SupabaseInternal -fun SupabaseClient.authenticatedSupabaseApi(plugin: MainPlugin<*>, defaultRequest: (HttpRequestBuilder.() -> Unit)? = null) = authenticatedSupabaseApi(plugin::resolveUrl, plugin::parseErrorResponse, defaultRequest, plugin.config.jwtToken) +fun SupabaseClient.authenticatedSupabaseApi( + plugin: MainPlugin, + defaultRequest: (HttpRequestBuilder.() -> Unit)? = null, + requireSession: Boolean = plugin.config.requireValidSession +): AuthenticatedSupabaseApi where C : MainConfig, C : AuthDependentPluginConfig = + authenticatedSupabaseApi(plugin::resolveUrl, plugin::parseErrorResponse, AuthenticatedApiConfig(defaultRequest = defaultRequest, requireSession = requireSession)) /** * Creates a [AuthenticatedSupabaseApi] with the given [resolveUrl] function. Requires [Auth] to authenticate requests * All requests will be resolved using this function */ @SupabaseInternal -fun SupabaseClient.authenticatedSupabaseApi(resolveUrl: (path: String) -> String, parseErrorResponse: (suspend (response: HttpResponse) -> RestException)? = null, defaultRequest: (HttpRequestBuilder.() -> Unit)? = null, jwtToken: String? = null) = AuthenticatedSupabaseApi(resolveUrl, parseErrorResponse, defaultRequest, this, jwtToken) \ No newline at end of file +fun SupabaseClient.authenticatedSupabaseApi( + resolveUrl: (path: String) -> String, + parseErrorResponse: (suspend (response: HttpResponse) -> RestException)? = null, + config: AuthenticatedApiConfig +) = + AuthenticatedSupabaseApi(resolveUrl, parseErrorResponse, this, config) \ No newline at end of file diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/SessionNetworkInterceptor.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/SessionNetworkInterceptor.kt new file mode 100644 index 000000000..bb9b1a120 --- /dev/null +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/SessionNetworkInterceptor.kt @@ -0,0 +1,15 @@ +package io.github.jan.supabase.auth + +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.network.NetworkInterceptor +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.http.HttpHeaders + +object SessionNetworkInterceptor: NetworkInterceptor.Before { + + override suspend fun call(builder: HttpRequestBuilder, supabase: SupabaseClient) { + val authHeader = builder.headers[HttpHeaders.Authorization]?.replace("Bearer ", "") + + } + +} \ No newline at end of file diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/admin/AdminApi.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/admin/AdminApi.kt index bca33394c..546a1173d 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/admin/AdminApi.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/admin/AdminApi.kt @@ -2,7 +2,7 @@ package io.github.jan.supabase.auth.admin import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.auth.Auth -import io.github.jan.supabase.auth.AuthImpl +import io.github.jan.supabase.auth.AuthenticatedSupabaseApi import io.github.jan.supabase.auth.SignOutScope import io.github.jan.supabase.auth.user.UserInfo import io.github.jan.supabase.auth.user.UserMfaFactor @@ -99,9 +99,7 @@ interface AdminApi { } @PublishedApi -internal class AdminApiImpl(val gotrue: Auth) : AdminApi { - - val api = (gotrue as AuthImpl).api +internal class AdminApiImpl(val api: AuthenticatedSupabaseApi) : AdminApi { override suspend fun signOut(jwt: String, scope: SignOutScope) { api.post("logout") { diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/exception/SessionRequiredException.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/exception/SessionRequiredException.kt new file mode 100644 index 000000000..4529dbc9c --- /dev/null +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/exception/SessionRequiredException.kt @@ -0,0 +1,6 @@ +package io.github.jan.supabase.auth.exception + +/** + * An exception thrown when trying to perform a request that requires a valid session while no user is logged in. + */ +class SessionRequiredException: Exception("You need to be logged in to perform this request") \ No newline at end of file diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/exception/TokenExpiredException.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/exception/TokenExpiredException.kt new file mode 100644 index 000000000..2e6b99564 --- /dev/null +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/exception/TokenExpiredException.kt @@ -0,0 +1,4 @@ +package io.github.jan.supabase.auth.exception + +//TODO: Add actual message and docs +class TokenExpiredException: Exception("The token has expired") \ No newline at end of file diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/mfa/MfaApi.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/mfa/MfaApi.kt index fe9259a4a..a89bc931d 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/mfa/MfaApi.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/mfa/MfaApi.kt @@ -128,7 +128,7 @@ internal class MfaApiImpl( override val verifiedFactors: List get() = auth.currentUserOrNull()?.factors?.filter(UserMfaFactor::isVerified) ?: emptyList() - val api = auth.api + val api = auth.userApi override suspend fun enroll(factorType: FactorType, friendlyName: String?, config: Config.() -> Unit): MfaFactor { val result = api.postJson("factors", buildJsonObject { diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/DefaultAuthProvider.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/DefaultAuthProvider.kt index 3cfcfa2b0..13fce1827 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/DefaultAuthProvider.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/DefaultAuthProvider.kt @@ -59,7 +59,7 @@ sealed interface DefaultAuthProvider : AuthProvider { val encodedCredentials = encodeCredentials(config) val gotrue = supabaseClient.auth as AuthImpl val url = "token?grant_type=$grantType" - val response = gotrue.api.postJson(url, encodedCredentials) { + val response = gotrue.publicApi.postJson(url, encodedCredentials) { redirectUrl?.let { redirectTo(it) } } response.body().also { @@ -87,7 +87,7 @@ sealed interface DefaultAuthProvider : AuthProvider { Phone -> "signup" IDToken -> "token?grant_type=id_token" } - val response = gotrue.api.postJson(url, buildJsonObject { + val response = gotrue.publicApi.postJson(url, buildJsonObject { putJsonObject(body) if (codeChallenge != null) { putCodeChallenge(codeChallenge) diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/OTP.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/OTP.kt index 3875f0d62..b1d6e1b86 100644 --- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/OTP.kt +++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/providers/builtin/OTP.kt @@ -82,7 +82,7 @@ data object OTP: AuthProvider { supabaseClient.auth.codeVerifierCache.saveCodeVerifier(codeVerifier) codeChallenge = generateCodeChallenge(codeVerifier) } - (supabaseClient.auth as AuthImpl).api.postJson("otp", buildJsonObject { + (supabaseClient.auth as AuthImpl).publicApi.postJson("otp", buildJsonObject { putJsonObject(body) codeChallenge?.let { put("code_challenge", it) diff --git a/Postgrest/src/commonMain/kotlin/io/github/jan/supabase/postgrest/Postgrest.kt b/Postgrest/src/commonMain/kotlin/io/github/jan/supabase/postgrest/Postgrest.kt index 960ef4e75..8f60628a7 100644 --- a/Postgrest/src/commonMain/kotlin/io/github/jan/supabase/postgrest/Postgrest.kt +++ b/Postgrest/src/commonMain/kotlin/io/github/jan/supabase/postgrest/Postgrest.kt @@ -2,6 +2,7 @@ package io.github.jan.supabase.postgrest import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.SupabaseSerializer +import io.github.jan.supabase.auth.AuthDependentPluginConfig import io.github.jan.supabase.exceptions.HttpRequestException import io.github.jan.supabase.logging.SupabaseLogger import io.github.jan.supabase.plugins.CustomSerializationConfig @@ -101,7 +102,8 @@ interface Postgrest : MainPlugin, CustomSerializationPlugin { data class Config( var defaultSchema: String = "public", var propertyConversionMethod: PropertyConversionMethod = PropertyConversionMethod.CAMEL_CASE_TO_SNAKE_CASE, - ): MainConfig(), CustomSerializationConfig { + override var requireValidSession: Boolean = false, + ): MainConfig(), CustomSerializationConfig, AuthDependentPluginConfig { override var serializer: SupabaseSerializer? = null diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/Realtime.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/Realtime.kt index cb2a0cb50..eb13755c1 100644 --- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/Realtime.kt +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/Realtime.kt @@ -4,6 +4,7 @@ import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.SupabaseClientBuilder import io.github.jan.supabase.SupabaseSerializer import io.github.jan.supabase.annotations.SupabaseInternal +import io.github.jan.supabase.auth.AuthDependentPluginConfig import io.github.jan.supabase.auth.resolveAccessToken import io.github.jan.supabase.logging.SupabaseLogger import io.github.jan.supabase.logging.w @@ -141,7 +142,8 @@ interface Realtime : MainPlugin, CustomSerializationPlugin { var connectOnSubscribe: Boolean = true, @property:SupabaseInternal var websocketFactory: RealtimeWebsocketFactory? = null, var disconnectOnNoSubscriptions: Boolean = true, - ): MainConfig(), CustomSerializationConfig { + override var requireValidSession: Boolean = false, + ): MainConfig(), CustomSerializationConfig, AuthDependentPluginConfig { /** * A custom access token provider. If this is set, the [SupabaseClient] will not be used to resolve the access token. diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt index 6774a80e7..9c9136ca8 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/Storage.kt @@ -3,6 +3,7 @@ package io.github.jan.supabase.storage import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.SupabaseSerializer import io.github.jan.supabase.annotations.SupabaseInternal +import io.github.jan.supabase.auth.AuthDependentPluginConfig import io.github.jan.supabase.auth.authenticatedSupabaseApi import io.github.jan.supabase.bodyOrNull import io.github.jan.supabase.collections.AtomicMutableMap @@ -120,8 +121,9 @@ interface Storage : MainPlugin, CustomSerializationPlugin { data class Config( var transferTimeout: Duration = 120.seconds, @PublishedApi internal var resumable: Resumable = Resumable(), - override var serializer: SupabaseSerializer? = null - ) : MainConfig(), CustomSerializationConfig { + override var serializer: SupabaseSerializer? = null, + override var requireValidSession: Boolean = false, + ) : MainConfig(), CustomSerializationConfig, AuthDependentPluginConfig { /** * @param cache the cache for caching resumable upload urls 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 061c6c156..c97d1e6bb 100644 --- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt +++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt @@ -18,6 +18,11 @@ import kotlinx.coroutines.CoroutineDispatcher */ interface SupabaseClient { + /** + * The configuration for the Supabase Client. + */ + val config: SupabaseClientConfig + /** * The supabase url with either a http or https scheme. */ @@ -93,7 +98,7 @@ interface SupabaseClient { } internal class SupabaseClientImpl( - config: SupabaseClientConfig, + override val config: SupabaseClientConfig, ) : SupabaseClient { override val accessToken: AccessTokenProvider? = config.accessToken @@ -117,11 +122,7 @@ internal class SupabaseClientImpl( @OptIn(SupabaseInternal::class) override val httpClient = KtorSupabaseHttpClient( - supabaseKey, - config.networkConfig.httpConfigOverrides, - config.networkConfig.requestTimeout.inWholeMilliseconds, - config.networkConfig.httpEngine, - config.osInformation + this ) override val pluginManager = PluginManager(config.plugins.toList().associate { (key, value) -> 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 fa2efedd1..9cb9c746d 100644 --- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt +++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt @@ -3,6 +3,7 @@ package io.github.jan.supabase import io.github.jan.supabase.annotations.SupabaseDsl import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.logging.LogLevel +import io.github.jan.supabase.network.NetworkInterceptor import io.github.jan.supabase.plugins.PluginManager import io.github.jan.supabase.plugins.SupabasePlugin import io.github.jan.supabase.plugins.SupabasePluginProvider @@ -95,6 +96,12 @@ class SupabaseClientBuilder @PublishedApi internal constructor(private val supab */ var osInformation: OSInformation? = OSInformation.CURRENT + /** + * A list of [NetworkInterceptor]s. Used for modifying requests or handling responses. + */ + @SupabaseInternal + var networkInterceptors = mutableListOf() + private val httpConfigOverrides = mutableListOf() private val plugins = mutableMapOf() @@ -124,7 +131,8 @@ class SupabaseClientBuilder @PublishedApi internal constructor(private val supab useHTTPS = useHTTPS, httpEngine = httpEngine, httpConfigOverrides = httpConfigOverrides, - requestTimeout = requestTimeout + requestTimeout = requestTimeout, + interceptors = networkInterceptors ), defaultSerializer = defaultSerializer, coroutineDispatcher = coroutineDispatcher, diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt index 3e6983ef1..f5de8ff15 100644 --- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt +++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt @@ -1,11 +1,12 @@ package io.github.jan.supabase import io.github.jan.supabase.logging.LogLevel +import io.github.jan.supabase.network.NetworkInterceptor import io.ktor.client.engine.HttpClientEngine import kotlinx.coroutines.CoroutineDispatcher import kotlin.time.Duration -internal data class SupabaseClientConfig( +data class SupabaseClientConfig( val supabaseUrl: String, val supabaseKey: String, val defaultLogLevel: LogLevel, @@ -17,9 +18,10 @@ internal data class SupabaseClientConfig( val osInformation: OSInformation? ) -internal data class SupabaseNetworkConfig( +data class SupabaseNetworkConfig( val useHTTPS: Boolean, val httpEngine: HttpClientEngine?, val httpConfigOverrides: List, + val interceptors: List, val requestTimeout: Duration ) diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt index 2db17767e..be0f5f523 100644 --- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt +++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt @@ -2,7 +2,6 @@ package io.github.jan.supabase.network import io.github.jan.supabase.BuildConfig -import io.github.jan.supabase.OSInformation import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.exceptions.HttpRequestException @@ -11,7 +10,6 @@ import io.github.jan.supabase.logging.e import io.github.jan.supabase.supabaseJson import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.plugins.HttpTimeout @@ -40,15 +38,22 @@ typealias HttpRequestOverride = HttpRequestBuilder.() -> Unit */ @OptIn(SupabaseInternal::class) class KtorSupabaseHttpClient @SupabaseInternal constructor( - private val supabaseKey: String, - modifiers: List.() -> Unit> = listOf(), - private val requestTimeout: Long, - engine: HttpClientEngine? = null, - private val osInformation: OSInformation? + private val supabase: SupabaseClient ): SupabaseHttpClient() { + private val supabaseKey = supabase.supabaseKey + private val osInformation = supabase.config.osInformation + + private val networkConfig = supabase.config.networkConfig + private val requestTimeout = networkConfig.requestTimeout + private val engine = networkConfig.httpEngine + private val modifiers = networkConfig.httpConfigOverrides + + private val beforeInterceptors = networkConfig.interceptors.filterIsInstance().toTypedArray() + private val afterInterceptors = networkConfig.interceptors.filterIsInstance().toTypedArray() + init { - SupabaseClient.LOGGER.d { "Creating KtorSupabaseHttpClient with request timeout $requestTimeout ms, HttpClientEngine: $engine" } + SupabaseClient.LOGGER.d { "Creating KtorSupabaseHttpClient with request timeout $requestTimeout, HttpClientEngine: $engine" } } @SupabaseInternal @@ -63,11 +68,11 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( } val endPoint = request.url.encodedPath SupabaseClient.LOGGER.d { "Starting ${request.method.value} request to endpoint $endPoint" } - + callBeforeInterceptors(request) val response = try { httpClient.request(url, builder) } catch(e: HttpRequestTimeoutException) { - SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint timed out after $requestTimeout ms" } + SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint timed out after $requestTimeout" } throw e } catch(e: CancellationException) { SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint was cancelled"} @@ -76,6 +81,7 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( SupabaseClient.LOGGER.e(e) { "${request.method.value} request to endpoint $endPoint failed with exception ${e.message}" } throw HttpRequestException(e.message ?: "", request) } + callAfterInterceptors(response) val responseTime = (response.responseTime.timestamp - response.requestTime.timestamp).milliseconds SupabaseClient.LOGGER.d { "${request.method.value} request to endpoint $endPoint successfully finished in $responseTime" } return response @@ -92,7 +98,7 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( val response = try { httpClient.prepareRequest(url, builder) } catch(e: HttpRequestTimeoutException) { - SupabaseClient.LOGGER.e { "Request timed out after $requestTimeout ms on url $url" } + SupabaseClient.LOGGER.e { "Request timed out after $requestTimeout on url $url" } throw e } catch(e: CancellationException) { SupabaseClient.LOGGER.e { "Request was cancelled on url $url" } @@ -106,6 +112,18 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( fun close() = httpClient.close() + private fun callBeforeInterceptors(requestBuilder: HttpRequestBuilder) { + beforeInterceptors.forEach { + it.call(requestBuilder, supabase) + } + } + + private fun callAfterInterceptors(response: HttpResponse) { + afterInterceptors.forEach { + it.call(response, supabase) + } + } + private fun HttpClientConfig<*>.applyDefaultConfiguration(modifiers: List.() -> Unit>) { install(DefaultRequest) { headers { @@ -124,7 +142,7 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( json(supabaseJson) } install(HttpTimeout) { - requestTimeoutMillis = requestTimeout + requestTimeoutMillis = requestTimeout.inWholeMilliseconds } modifiers.forEach { it.invoke(this) } } diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/NetworkInterceptor.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/NetworkInterceptor.kt new file mode 100644 index 000000000..0ef5ee258 --- /dev/null +++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/NetworkInterceptor.kt @@ -0,0 +1,23 @@ +package io.github.jan.supabase.network + +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.annotations.SupabaseInternal +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.statement.HttpResponse + +@SupabaseInternal +sealed interface NetworkInterceptor { + + fun interface Before: NetworkInterceptor { + + suspend fun call(builder: HttpRequestBuilder, supabase: SupabaseClient) + + } + + fun interface After: NetworkInterceptor { + + suspend fun call(response: HttpResponse, supabase: SupabaseClient) + + } + +} \ No newline at end of file