Skip to content
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -496,8 +497,15 @@ interface Auth : MainPlugin<AuthConfig>, 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)
}
}

}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
/**
* The configuration for [Auth]
*/
expect class AuthConfig() : CustomSerializationConfig, AuthConfigDefaults
expect class AuthConfig() : CustomSerializationConfig, AuthConfigDefaults, AuthDependentPluginConfig

/**
* The default values for the [AuthConfig]
Expand Down Expand Up @@ -103,6 +103,16 @@
@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

}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
Expand All @@ -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)
}
Expand All @@ -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 })
Expand All @@ -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)
Expand All @@ -232,7 +233,8 @@ internal class AuthImpl(
createdConfig.providerId?.let {
put("provider_id", it)
}
}).body()
})
.body()
}

override suspend fun updateUser(
Expand All @@ -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<UserInfo>()
Expand All @@ -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))
})
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<UserSession>()
importSession(session, source = SessionSource.SignIn(OTP))
}
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}) {
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}

Expand All @@ -35,33 +54,76 @@ 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()
}
}
}

}

/**
* Creates a [AuthenticatedSupabaseApi] with the given [baseUrl]. Requires [Auth] to authenticate requests
* 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 <C> SupabaseClient.authenticatedSupabaseApi(
plugin: MainPlugin<C>,
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)
fun SupabaseClient.authenticatedSupabaseApi(
resolveUrl: (path: String) -> String,
parseErrorResponse: (suspend (response: HttpResponse) -> RestException)? = null,
config: AuthenticatedApiConfig
) =
AuthenticatedSupabaseApi(resolveUrl, parseErrorResponse, this, config)
Original file line number Diff line number Diff line change
@@ -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 ", "")

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.github.jan.supabase.auth.exception

//TODO: Add actual message and docs
class TokenExpiredException: Exception("The token has expired")
Loading
Loading