Repository: supabase-community/supabase-kt (kotlin)

Issue:
[Bug]: Arbitrary hash parameters cause hard to catch exception (e.g. expired OTP link)
### General Info

- [x] I checked for similar bug report
- [x] I am using the latest version
- [x] I checked the [troubleshooting](https://github.com/supabase-community/supabase-kt/blob/master/TROUBLESHOOTING.md) page for similar problems

### Version(s)

3.1.4

### Kotlin Target(s) and their respective versions

WasmJs, Js (didn't test)

### What happened? (include your code)

Any hash parameters string that does not lead to creating a session object will cause a crash. This includes the common case of clicking on an expired OTP link. Examples:

- http://localhost:8080/#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired
- http://localhost:8080/#xxx=yyy

It causes a WebAssembly.Exception on wasmJs without any details on where it originates. The exception is caused by the Auth plugin itself inside of its authScope, which is hard to catch.

Code reference: Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/AuthExtensions.kt::Auth.parseSessionFromFragment

Suggested fixes:
- Only create Session from parameters when possible
- Handle expired OTP and notify user
    - auth.sessionStatus seem like a good place
- Possibly there are other supabase related hash parameters that could be handled as well?

Additionally I suggest:
- Consume only used parameters, leave room for custom parameters that can be read by other parts of the application

### Steps To Reproduce (optional)

1. Install the Auth plugin in the supbase client
2. Open the app with non-session-creating hash parameters.

### Relevant log output (optional)

```shell

```

Pull request:
## What kind of change does this PR introduce?

Bug fix (closes #910)

## What is the current behavior?

Exception on non-session hash parameters and all other hash parameters get removed

## What is the new behavior?

This has been fixed.

Patch:
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 731c1882c..d8c46184a 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
@@ -411,6 +411,17 @@ interface Auth : MainPlugin<AuthConfig>, CustomSerializationPlugin {
 
     companion object : SupabasePluginProvider<AuthConfig, Auth> {
 
+        internal val HASH_PARAMETERS = listOf(
+            "access_token",
+            "refresh_token",
+            "expires_in",
+            "expires_at",
+            "token_type",
+            "type",
+            "provider_refresh_token",
+            "provider_token"
+        )
+
         override val key = "auth"
 
         override val logger: SupabaseLogger = SupabaseClient.createLogger("Supabase-Auth")
diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/UrlUtils.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/UrlUtils.kt
new file mode 100644
index 000000000..986a540ab
--- /dev/null
+++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/UrlUtils.kt
@@ -0,0 +1,48 @@
+package io.github.jan.supabase.auth
+
+import io.github.jan.supabase.annotations.SupabaseInternal
+import io.github.jan.supabase.auth.status.SessionSource
+import io.github.jan.supabase.auth.user.UserSession
+import io.github.jan.supabase.buildUrl
+import io.github.jan.supabase.logging.d
+import io.ktor.client.request.HttpRequestBuilder
+import kotlinx.coroutines.launch
+
+@SupabaseInternal
+fun Auth.parseFragmentAndImportSession(fragment: String, onSessionSuccess: (UserSession) -> Unit = {}) {
+    Auth.logger.d { "Parsing deeplink fragment $fragment" }
+    val session = try {
+        parseSessionFromFragment(fragment)
+    } catch(e: IllegalArgumentException) {
+        Auth.logger.d(e) { "Received invalid session fragment. Ignoring." }
+        return
+    }
+    this as AuthImpl
+    authScope.launch {
+        val user = retrieveUser(session.accessToken)
+        val newSession = session.copy(user = user)
+        onSessionSuccess(newSession)
+        importSession(newSession, source = SessionSource.External)
+    }
+}
+
+@SupabaseInternal
+fun HttpRequestBuilder.redirectTo(url: String) {
+    this.url.parameters["redirect_to"] = url
+}
+
+internal fun consumeHashParameters(parameters: List<String>, url: String): String {
+    return buildUrl(url) {
+        fragment = fragment.split("&").filter {
+            it.split("=").first() !in parameters
+        }.joinToString("&")
+    }
+}
+
+internal fun consumeUrlParameter(parameters: List<String>, url: String): String {
+    return buildUrl(url) {
+        parameters.forEach { parameter ->
+            this.parameters.remove(parameter)
+        }
+    }
+}
\ No newline at end of file
diff --git a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Utils.kt b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Utils.kt
index a3d7c1779..4bc4dc6a3 100644
--- a/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Utils.kt
+++ b/Auth/src/commonMain/kotlin/io/github/jan/supabase/auth/Utils.kt
@@ -1,30 +1,7 @@
 package io.github.jan.supabase.auth
 
 import io.github.jan.supabase.SupabaseClient
-import io.github.jan.supabase.annotations.SupabaseInternal
-import io.github.jan.supabase.auth.status.SessionSource
 import io.github.jan.supabase.auth.user.UserSession
-import io.github.jan.supabase.logging.d
-import io.ktor.client.request.HttpRequestBuilder
-import kotlinx.coroutines.launch
-
-@SupabaseInternal
-fun Auth.parseFragmentAndImportSession(fragment: String, onSessionSuccess: (UserSession) -> Unit = {}) {
-    Auth.logger.d { "Parsing deeplink fragment $fragment" }
-    val session = parseSessionFromFragment(fragment)
-    this as AuthImpl
-    authScope.launch {
-        val user = retrieveUser(session.accessToken)
-        val newSession = session.copy(user = user)
-        onSessionSuccess(newSession)
-        importSession(newSession, source = SessionSource.External)
-    }
-}
-
-@SupabaseInternal
-fun HttpRequestBuilder.redirectTo(url: String) {
-    this.url.parameters["redirect_to"] = url
-}
 
 internal fun invalidArg(message: String): Nothing = throw IllegalArgumentException(message)
 
diff --git a/Auth/src/jsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt b/Auth/src/jsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt
index c0b73f5cb..3638a3091 100644
--- a/Auth/src/jsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt
+++ b/Auth/src/jsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt
@@ -23,7 +23,7 @@ actual fun Auth.setupPlatform() {
         }
         Auth.logger.d { "Found hash: $afterHash" }
         parseFragmentAndImportSession(afterHash) {
-            val newURL = window.location.href.split("#")[0];
+            val newURL = consumeHashParameters(Auth.HASH_PARAMETERS, window.location.href)
             window.history.replaceState(null, window.document.title, newURL);
         }
     }
@@ -36,7 +36,7 @@ actual fun Auth.setupPlatform() {
             val session = exchangeCodeForSession(code)
             importSession(session, source = SessionSource.External)
         }
-        val newURL = window.location.href.split("?")[0];
+        val newURL = consumeUrlParameter(listOf("code"), window.location.href)
         window.history.replaceState(null, window.document.title, newURL);
     }
 
diff --git a/Auth/src/wasmJsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt b/Auth/src/wasmJsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt
index 6a8491ef1..e58829982 100644
--- a/Auth/src/wasmJsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt
+++ b/Auth/src/wasmJsMain/kotlin/io/github/jan/supabase/auth/setupPlatform.kt
@@ -21,7 +21,7 @@ actual fun Auth.setupPlatform() {
         }
         Auth.logger.d { "Found hash: $afterHash" }
         parseFragmentAndImportSession(afterHash) {
-            val newURL = window.location.href.split("#")[0];
+            val newURL = consumeHashParameters(Auth.HASH_PARAMETERS, window.location.href)
             window.history.replaceState(null, window.document.title, newURL);
         }
     }
@@ -34,7 +34,7 @@ actual fun Auth.setupPlatform() {
             val session = exchangeCodeForSession(code)
             importSession(session)
         }
-        val newURL = window.location.href.split("?")[0];
+        val newURL = consumeUrlParameter(listOf("code"), window.location.href)
         window.history.replaceState(null, window.document.title, newURL);
     }
 
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 431bff73d..9b86223b1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,7 +8,7 @@ pluginManagement {
 }
 
 plugins {
-    id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0")
+    id("org.gradle.toolchains.foojay-resolver-convention") version("0.10.0")
 }
 
 // Main Modules


Test patch:
diff --git a/Auth/src/commonTest/kotlin/UrlUtilsTest.kt b/Auth/src/commonTest/kotlin/UrlUtilsTest.kt
new file mode 100644
index 000000000..a6f90985f
--- /dev/null
+++ b/Auth/src/commonTest/kotlin/UrlUtilsTest.kt
@@ -0,0 +1,39 @@
+import io.github.jan.supabase.auth.consumeHashParameters
+import io.github.jan.supabase.auth.consumeUrlParameter
+import io.github.jan.supabase.auth.redirectTo
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.url
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class UrlUtilsTest {
+
+    @Test
+    fun testConsumeHashParameters() {
+        val url = "https://example.com/#test=123&state=abc&code=xyz"
+        val newUrl = consumeHashParameters(listOf("test", "state"), url)
+        val expectedUrl = "https://example.com/#code=xyz"
+        assertEquals(expectedUrl, newUrl)
+    }
+
+    @Test
+    fun testConsumeUrlParameter() {
+        val url = "https://example.com/?test=123&state=abc&code=xyz"
+        val newUrl = consumeUrlParameter(listOf("test", "state"), url)
+        val expectedUrl = "https://example.com/?code=xyz"
+        assertEquals(expectedUrl, newUrl)
+    }
+
+    @Test
+    fun testRedirectTo() {
+        val url = "https://example.com/"
+        val redirectTo = "https://redirect.com"
+        val newUrl = HttpRequestBuilder().apply {
+            url(url)
+            redirectTo(redirectTo)
+        }.url.toString()
+        val expectedUrl = "https://example.com/?redirect_to=https%3A%2F%2Fredirect.com"
+        assertEquals(expectedUrl, newUrl)
+    }
+
+}
\ No newline at end of file