Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Firebase Auth in Supabase via the SupabaseConnector #101

Open
benoitletondor opened this issue Jan 12, 2025 · 3 comments
Open

Comments

@benoitletondor
Copy link
Contributor

benoitletondor commented Jan 12, 2025

To connect to Supabase via a Firebase issued JWT, you need to use the createSupabaseClient method of SupabaseClientBuilder:

createSupabaseClient(
    supabaseUrl = BuildConfig.SUPABASE_URL,
    supabaseKey = BuildConfig.SUPABASE_ANON_KEY,
    builder = {
        this.accessToken = {
            Firebase.auth.currentUser?.getIdToken(false)?.await()?.token
        }

        install(Postgrest)
        // install(Auth) <-- This is not possible as you can't use the Auth plugin when using the custom AccessTokenProvider
    },
)

When doing so, you can't use the Auth plugin as you provide an AccessTokenProvider, this is a limitation of the SupabaseClient. Thus, you can't use the SupabaseConnector as it checks for the Auth plugin and crashes without it.

Note that it's easy to bypass limitation by just copy/pasting the SupabaseConnector code and adapting it to my own codebase but it might be something you guys want to offer as a feature here.

@DominicGBauer
Copy link
Contributor

We may support this in future but this is not currently on the roadmap. I will add an example of what could be implemented for future reference if other users come across this.

Adapt the constructor to accept a tokenProvider:

    // Constructor with custom Auth
    public constructor(
        supabaseUrl: String,
        supabaseKey: String,
        powerSyncEndpoint: String,
        tokenProvider: suspend () -> String?,
    ) : this(
        supabaseClient =
            createSupabaseClient(supabaseUrl, supabaseKey) {
                install(Postgrest)
                accessToken = tokenProvider
            },
        powerSyncEndpoint = powerSyncEndpoint,
    )

and remove this check from init

require(supabaseClient.pluginManager.getPluginOrNull(Auth) != null) { "The Auth plugin must be installed on the Supabase client" }

You will then need to adapt the other functions in the class to use your authentication method instead of supabaseClient.auth

@benoitletondor
Copy link
Contributor Author

There are many things not working afterwards though if you remove this check, as a lot of the mechanism for credentials in that class are relying on auth. But I agree this is really easy to customize by copy/pasting the code so maybe it's too specific for this generic implem

@DominicGBauer
Copy link
Contributor

DominicGBauer commented Jan 16, 2025

Here's an example of a connector that could be used without the additional helper functions:

public class SupabaseConnectorWithCustomAuth(
    public val supabaseClient: SupabaseClient,
    public val powerSyncEndpoint: String,
) : PowerSyncBackendConnector() {
    private var errorCode: String? = null

    private object PostgresFatalCodes {
        // Using Regex patterns for Postgres error codes
        private val FATAL_RESPONSE_CODES =
            listOf(
                // Class 22 — Data Exception
                "^22...".toRegex(),
                // Class 23 — Integrity Constraint Violation
                "^23...".toRegex(),
                // INSUFFICIENT PRIVILEGE
                "^42501$".toRegex(),
            )

        fun isFatalError(code: String): Boolean =
            FATAL_RESPONSE_CODES.any { pattern ->
                pattern.matches(code)
            }
    }

    // Constructor with custom Auth
    public constructor(
        supabaseUrl: String,
        supabaseKey: String,
        powerSyncEndpoint: String,
        tokenProvider: suspend () -> String?,
    ) : this(
        supabaseClient =
            createSupabaseClient(supabaseUrl, supabaseKey) {
                install(Postgrest)
                accessToken = tokenProvider
            },
        powerSyncEndpoint = powerSyncEndpoint,
    )

    init {
        require(
            supabaseClient.pluginManager.getPluginOrNull(Postgrest) != null,
        ) { "The Postgrest plugin must be installed on the Supabase client" }

        // This retrieves the error code from the response
        // as this is not accessible in the Supabase client RestException
        // to handle fatal Postgres errors
        supabaseClient.httpClient.httpClient.plugin(HttpSend).intercept { request ->
            val resp = execute(request)
            val response = resp.response
            if (response.status.value == 400) {
                val responseText = response.bodyAsText()

                try {
                    val error = Json { coerceInputValues = true }.decodeFromString<Map<String, String?>>(responseText)
                    errorCode = error["code"]
                } catch (e: Exception) {
                    Logger.e("Failed to parse error response: $e")
                }
            }
            resp
        }
    }

    /**
     * Get credentials for PowerSync.
     */
    override suspend fun fetchCredentials(): PowerSyncCredentials {
        ... handle getting the access token 

        return PowerSyncCredentials(
            endpoint = powerSyncEndpoint,
            token = your_access_token, // Use the access token to authenticate against PowerSync
            userId = your_user_id,
        )
    }

    /**
     * Upload local changes to the app backend (in this case Supabase).
     *
     * This function is called whenever there is data to upload, whether the device is online or offline.
     * If this call throws an error, it is retried periodically.
     */
    override suspend fun uploadData(database: PowerSyncDatabase) {
        val transaction = database.getNextCrudTransaction() ?: return

        var lastEntry: CrudEntry? = null
        try {
            for (entry in transaction.crud) {
                lastEntry = entry

                val table = supabaseClient.from(entry.table)

                when (entry.op) {
                    UpdateType.PUT -> {
                        val data = entry.opData?.toMutableMap() ?: mutableMapOf()
                        data["id"] = entry.id
                        table.upsert(data)
                    }

                    UpdateType.PATCH -> {
                        table.update(entry.opData!!) {
                            filter {
                                eq("id", entry.id)
                            }
                        }
                    }

                    UpdateType.DELETE -> {
                        table.delete {
                            filter {
                                eq("id", entry.id)
                            }
                        }
                    }
                }
            }

            transaction.complete(null)
        } catch (e: Exception) {
            if (errorCode != null && PostgresFatalCodes.isFatalError(errorCode.toString())) {
                /**
                 * Instead of blocking the queue with these errors,
                 * discard the (rest of the) transaction.
                 *
                 * Note that these errors typically indicate a bug in the application.
                 * If protecting against data loss is important, save the failing records
                 * elsewhere instead of discarding, and/or notify the user.
                 */
                Logger.e("Data upload error: ${e.message}")
                Logger.e("Discarding entry: $lastEntry")
                transaction.complete(null)
                return
            }

            Logger.e("Data upload error - retrying last entry: $lastEntry, $e")
            throw e
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants