Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Check warning

Code scanning / detekt

Public properties require documentation. Warning

The property requireValidSession is missing documentation.

}

/**
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 ", "")

Check warning

Code scanning / detekt

Property is unused and should be removed. Warning

Private property authHeader is unused.

}

}
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")

Check warning

Code scanning / detekt

Public classes, interfaces and objects require documentation. Warning

TokenExpiredException is missing required documentation.
Loading
Loading