Всички статии
Kotlin MultiplatformИнженерна бележка

Supabase authentication on iOS, Part 1: six flows beyond the adapter

Copyable Kotlin Multiplatform and SwiftUI examples for email auth, restoration, refresh, OAuth callbacks, cancellation and recovery links with Supabase.

Тази статия засега е достъпна на английски.

The Medium article explains the iOS integration boundary: one retained SupabaseClient, PKCE, SwiftUI callback forwarding, and a native test around the real adapter.

This companion guide starts where that article stops. It follows six flows that an application must finish before authentication is ready to ship, with code taken from the sample and its iOS tests.

Siere KMP Auth owns the provider-neutral operations. The application still owns screens, lifecycle, backend calls, incoming links, and the few Supabase-specific account actions that do not belong in a cross-provider API.

Start with one application-owned client

Every flow below uses the same client instance. The iOS sample creates it in IosSupabaseHost, configures the callback once, and retains it for the scene lifetime. The client builder used by that host contains the production and test switches:

private const val IOS_SUPABASE_CALLBACK_SCHEME = "dev.siere.auth.sample"
private const val IOS_SUPABASE_CALLBACK_HOST = "auth-callback"

internal fun createIosSupabaseClient(
    supabaseUrl: String,
    supabasePublishableKey: String,
    testMode: Boolean = false,
): SupabaseClient =
    createSupabaseClient(
        supabaseUrl = supabaseUrl.trim(),
        supabaseKey = requirePublishableSupabaseKey(supabasePublishableKey),
    ) {
        install(Auth) {
            scheme = IOS_SUPABASE_CALLBACK_SCHEME
            host = IOS_SUPABASE_CALLBACK_HOST
            flowType = FlowType.PKCE
            autoLoadFromStorage = !testMode
            autoSaveToStorage = !testMode
            alwaysAutoRefresh = !testMode
        }
    }

The Compose sample wraps that client with the library adapter:

val auth = SiereAuth(SupabaseAuthProvider(client))

Do not construct a second client when a callback arrives. The original instance owns the PKCE transaction, persisted session, refresh state, and auth events observed by the UI.

1. Sign in with email and sign out

The shared API keeps the screen independent of Supabase:

fun run(block: suspend () -> AuthResult<AuthUser>) {
    scope.launch {
        when (val result = block()) {
            is AuthResult.Success -> status = null
            is AuthResult.Failure -> status = result.error.userFacingMessage()
        }
    }
}

ElevatedButton(onClick = { run { auth.signInWithEmail(email, password) } }) {
    Text("Sign in")
}

Sign-out uses the same provider-neutral contract:

scope.launch {
    when (val result = auth.signOut()) {
        is AuthResult.Success -> {
            phoneSession = null
            status = null
        }

        is AuthResult.Failure -> status = result.error.userFacingMessage()
    }
}

The iOS integration test does more than assert the final result. Its mock server verifies that sign-in sends a password grant, sign-out includes the refreshed access token, and the adapter finishes in AuthState.SignedOut with no current session.

That gives us evidence for the adapter. A hosted acceptance test still has to prove that the disposable project's email policy, redirect configuration, and account state behave as expected.

2. Restore the session after relaunch

Session restoration should drive the first screen. The sample does not guess whether a stored session exists. It observes authState:

val authState by auth.authState.collectAsState()

val sessionLabel = when (val state = authState) {
    AuthState.Loading -> "Restoring session…"
    AuthState.SignedOut -> "Signed out"
    is AuthState.SignedIn -> "Signed in as ${state.user.displayName}"
}

The iOS client enables Supabase's storage-backed behavior outside tests:

install(Auth) {
    scheme = IOS_SUPABASE_CALLBACK_SCHEME
    host = IOS_SUPABASE_CALLBACK_HOST
    flowType = FlowType.PKCE
    autoLoadFromStorage = !testMode
    autoSaveToStorage = !testMode
    alwaysAutoRefresh = !testMode
}

The native test signs in with one client, creates a second adapter over the same injected session store, and verifies that the new adapter reaches SignedIn without another network request.

That test proves restoration logic, not iOS process persistence. Before release, sign in on a simulator or device, terminate the application, launch it again, and verify that the UI moves from Loading to SignedIn without showing the sign-in form in between.

3. Refresh before an authenticated backend call

Automatic refresh is useful, but a sensitive backend call should not depend on a token that is close to expiry. The sample exposes the explicit path:

when (val result = auth.currentSession(forceRefresh = true)) {
    is AuthResult.Success -> {
        status = "Fresh session ready for ${result.value.user.uid}"
    }

    is AuthResult.Failure -> {
        status = result.error.userFacingMessage()
    }
}

In production, put the refresh beside the call that needs the token:

suspend fun loadPrivateProfile(
    auth: SiereAuth,
    api: ProfileApi,
): Profile {
    val session = when (val result = auth.currentSession(forceRefresh = true)) {
        is AuthResult.Success -> result.value
        is AuthResult.Failure -> throw AuthenticationRequired(result.error)
    }

    return api.getProfile(
        bearerToken = session.accessToken,
    )
}

The iOS test asserts that this operation sends the refresh_token grant and that the following sign-out request uses the new access token. This catches a subtle failure mode where refresh succeeds remotely but the application continues using a cached token.

Do not store the token separately in a view model. Ask the auth owner for the session at the point of use so refresh, restoration, and sign-out all update one source of truth.

4. Handle OAuth success and user cancellation

The UI starts Google sign-in through the same shared API:

when (val result = auth.signInWithGoogle()) {
    is AuthResult.Success -> {
        status = "Signed in as ${result.value.user.displayName}"
    }

    is AuthResult.Failure -> {
        status = when (result.error) {
            is AuthError.Cancelled -> "Sign-in cancelled"
            else -> result.error.userFacingMessage()
        }
    }
}

The success test captures the authorization URL opened by the adapter and sends the callback through the real iOS host:

val authorizationUrl = launchedUrl.await()
assertTrue("provider=google" in authorizationUrl)

assertTrue(
    host.handleOpenUrl(
        "dev.siere.auth.sample://auth-callback?code=test-code",
    ),
)

It then verifies a PKCE token exchange and the returned Google user.

Cancellation takes a different callback but the same route:

assertTrue(
    host.handleOpenUrl(
        "dev.siere.auth.sample://auth-callback" +
            "?error=access_denied" +
            "&error_code=access_denied" +
            "&error_description=cancelled",
    ),
)

The native test expects AuthError.Cancelled and asserts that no token request was made. The zero-request assertion prevents the application from retrying a cancellation as a failed authorization-code exchange.

5. Route warm-start and cold-start callbacks

SwiftUI forwards every matching application URL to the retained host:

@main
struct SiereAuthSampleApp: App {
    private let configuration = SupabaseHostConfiguration.fromEnvironment()

    var body: some Scene {
        WindowGroup {
            ComposeView(supabaseHost: configuration?.host)
                .ignoresSafeArea()
                .onOpenURL { url in
                    _ = configuration?.host.handleOpenUrl(
                        url: url.absoluteString
                    )
                }
        }
    }
}

The Kotlin host rejects unrelated URLs before Supabase sees them:

fun handleOpenUrl(url: String): Boolean {
    val nativeUrl = NSURL.URLWithString(url) ?: return false

    if (
        nativeUrl.scheme != IOS_SUPABASE_CALLBACK_SCHEME ||
        nativeUrl.host != IOS_SUPABASE_CALLBACK_HOST
    ) {
        return false
    }

    client.handleDeeplinks(nativeUrl)
    return true
}

The router test proves exact scheme-and-host filtering. The OAuth tests prove that an accepted URL completes or cancels the suspended sign-in operation.

Those tests do not prove operating-system delivery. Run both acceptance paths against a disposable project:

  1. Start OAuth while the application is visible and return to it.
  2. Start OAuth, terminate the application before redirect, and complete the browser flow.

For the second path, verify that SwiftUI creates the scene, the same configuration creates the host, .onOpenURL receives the opening URL, and restoration settles on the authenticated user. Test it with the final URL scheme or universal-link entitlements, not only with the mock router.

6. Finish password reset and email confirmation

The provider-neutral API can request a reset email:

when (val result = auth.sendPasswordReset(email)) {
    is AuthResult.Success -> {
        status = "Check your email for the reset link"
    }

    is AuthResult.Failure -> {
        status = result.error.userFacingMessage()
    }
}

That is only step one. The application must receive the link, show a new-password screen, and update the authenticated recovery user. This completion step is intentionally Supabase-specific, so it belongs beside the application-owned client rather than in SiereAuth:

class IosSupabaseAccountActions(
    private val client: SupabaseClient,
) {
    suspend fun finishPasswordReset(newPassword: String) {
        require(newPassword.length >= 8)

        client.auth.updateUser {
            password = newPassword
        }
    }

    suspend fun resendEmailConfirmation(email: String) {
        client.auth.resendEmail(
            type = OtpType.Email.SIGNUP,
            email = email,
        )
    }
}

This helper is an application extension to add beside IosSupabaseHost; it is not a second library adapter. The sample's shared screen currently covers reset-email initiation, while the product decides password rules, recovery navigation, success messages, and whether confirmation must block access.

If recovery needs its own screen, request the email with a distinct allowed redirect such as:

client.auth.resetPasswordForEmail(
    email = email,
    redirectUrl =
        "dev.siere.auth.sample://auth-callback/password-recovery",
)

Forward that URL to client.handleDeeplinks first, then route the path to the recovery screen and wait for the recovery session before calling updateUser.

The shared reset method deliberately has no Supabase-specific redirect parameter. An application that needs this path should make the reset request through its retained Supabase client.

Email confirmation uses the same callback pipe. With confirmation enabled in the project, sign-up sends the email, the link returns through the registered redirect, and the resulting auth state drives the UI. A resend button can call resendEmailConfirmation from the application-specific helper above.

Add every callback to the project's redirect allow list. If a customized email template should respect the redirect supplied by the application, use Supabase's redirect template variable rather than hard-coding the Site URL. Production delivery also needs custom SMTP; the hosted default sender is intended for limited testing.

See Supabase's documentation for native mobile deep linking, redirect URLs, password reset, and resending email confirmation.

Turn the checklist into release evidence

The repository provides deterministic native evidence for:

  • Email sign-in and sign-out
  • Restoration through the Supabase session manager
  • A forced refresh followed by an authenticated request
  • OAuth success and cancellation
  • Callback filtering and forwarding
  • Password-reset initiation with the iOS redirect

Run it with:

./gradlew :sample:iosSimulatorArm64Test

Then use a disposable hosted project for the parts a mock server cannot prove:

  • Terminate and relaunch the application with a saved session.
  • Complete OAuth from an active application and from a cold start.
  • Cancel the provider screen and confirm that no error retry begins.
  • Force refresh immediately before a real protected backend call.
  • Open reset and confirmation emails on the test device.
  • Complete the password change, sign out, and sign in with the new password.
  • Verify the confirmation resend, expiry, duplicate-use, and invalid-link screens.

The source is in Siere KMP Auth on GitHub. Start with SampleApp.kt, IosSupabase.kt, iOSApp.swift, and IosSupabaseIntegrationTest.kt; together they show the shared UI, retained native host, SwiftUI bridge, and executable evidence.

If your team is building a Kotlin Multiplatform application and authentication is blocking a target, talk to Siere Soft.

Да поговорим?

hello@sieresoft.com · Отговаряме до един работен ден, обикновено по-бързо.