All insights
Kotlin MultiplatformEngineering field note

Production-ready OpenID Connect for Kotlin/iOS

How Siere KMP Auth adds Keychain restoration, forced token refresh and a real protected call after ASWebAuthenticationSession completes on iOS.

The system browser returned a valid OpenID Connect callback. The user is signed in. That is where most authentication examples stop.

The application still has to survive termination, restore sensitive credentials without trusting stale identity data, refresh immediately before a protected request, handle a rejected token without looping and delete the persisted session on sign-out.

The Kotlin/JVM implementation uses a hardened loopback callback. The Kotlin/Android implementation connects browser intents to shared protocol code. This iOS companion starts after ASWebAuthenticationSession and PKCE have completed and follows the session into Keychain and a real authenticated request.

The code comes from the iOS sample in Siere KMP Auth. The final path was exercised against a disposable Keycloak server, while mock tests cover the deterministic branches.

The browser session is not the application session

ASWebAuthenticationSession owns the interactive browser operation. It presents the authorization page, watches for the registered callback scheme and returns the complete callback URL to the application.

Once shared Kotlin exchanges that callback, verifies the ID token and produces an AuthSession, the Apple browser object has finished its job. It does not persist the refresh token or restore the signed-in user after the process exits.

Siere exposes that boundary through OidcSessionStore:

interface OidcSessionStore {
    suspend fun read(): ByteArray?
    suspend fun write(value: ByteArray)
    suspend fun clear()
}

The bytes are intentionally opaque to the application. The host does not maintain another token model, extract claims for later or decide whether the stored identity is still valid. It stores sensitive bytes and returns them unchanged.

The cross-platform InMemoryOidcSessionStore is useful for short-lived samples. An iOS application that promises restoration after relaunch needs a Keychain implementation.

Store one opaque value in Keychain

The sample identifies one generic-password item with a service and account:

class IosKeychainOidcSessionStore internal constructor(
    private val service: String,
    private val account: String,
    private val keychain: IosKeychainClient,
) : OidcSessionStore {
    constructor(
        service: String,
        account: String,
    ) : this(service, account, AppleIosKeychainClient)

    override suspend fun read(): ByteArray? =
        keychain.read(service, account)

    override suspend fun write(value: ByteArray) {
        keychain.write(service, account, value)
    }

    override suspend fun clear() {
        keychain.clear(service, account)
    }
}

The production client builds the same base query for every operation:

private fun baseQuery(
    service: String,
    account: String,
) = mutableDictionary().also { query ->
    val serviceValue = cfString(service)
    val accountValue = cfString(account)
    try {
        CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword)
        CFDictionarySetValue(query, kSecAttrService, serviceValue)
        CFDictionarySetValue(query, kSecAttrAccount, accountValue)
    } finally {
        CFRelease(serviceValue)
        CFRelease(accountValue)
    }
}

Using a fixed service and account makes replacement and deletion address the same item. The sample includes the client ID and issuer in the account value so a different local OIDC configuration cannot silently share the credential slot.

New items use this accessibility class:

CFDictionarySetValue(
    attributes,
    kSecAttrAccessible,
    kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
)

The item becomes available after the device has been unlocked once and does not migrate to another device through a backup. That matches the sample's background-restoration requirement without making an OIDC session transferable.

A product with a different threat model may choose another accessibility class. That choice belongs to the application because it depends on background access, device-lock policy and whether restoring after a device transfer is acceptable.

Update, add and delete are separate Keychain paths

Writing cannot assume the item already exists. The sample first attempts SecItemUpdate and adds the item only when Security.framework returns errSecItemNotFound:

when (val status = SecItemUpdate(query, update)) {
    errSecSuccess -> Unit
    errSecItemNotFound -> add(service, account, valueData = data)
    else -> keychainFailure("update", status)
}

Deletion treats an absent item as success:

when (val status = SecItemDelete(query)) {
    errSecSuccess, errSecItemNotFound -> Unit
    else -> keychainFailure("delete", status)
}

This makes sign-out idempotent. Calling it twice does not turn a clean signed-out state into an error.

Kotlin/Native interop also makes Core Foundation ownership visible. Values created with CFStringCreateWithCString, CFDataCreate and CFDictionaryCreateMutable are released in finally blocks. The result returned by SecItemCopyMatching is released after its bytes are copied.

These details are tedious, but hiding them behind IosKeychainClient keeps the storage contract testable and the rest of the authentication code free from C interop.

Restoration revalidates identity

Reading bytes from Keychain does not produce AuthState.SignedIn by itself.

OidcAuthProvider checks that the serialized session belongs to the configured client and issuer. It performs discovery again and verifies the stored ID-token signature, issuer, audience and time claims before publishing the restored user.

That protects the application from several bad shortcuts:

  • Treating serialized display data as authenticated identity
  • Restoring credentials created for another client configuration
  • Accepting an expired or invalid stored ID token
  • Keeping malformed credentials forever

Invalid credentials are cleared. A transient network or storage failure leaves potentially recoverable credentials in place and reports a signed-out runtime state.

The Compose screen observes that process instead of guessing from the presence of a file or Keychain item:

val state = auth.authState.collectAsState()

Text(
    when (val current = state.value) {
        AuthState.Loading -> "Restoring session…"
        AuthState.SignedOut -> "Signed out"
        is AuthState.SignedIn ->
            "Signed in as ${current.user.displayName ?: current.user.uid}"
    },
)

The first meaningful screen state therefore follows verified restoration, not local-storage optimism.

Refresh beside the protected request

An access token copied into a view model can outlive refresh, restoration or sign-out. The sample asks SiereAuth for a point-in-time session immediately before the request:

internal suspend fun callProtectedEndpoint(
    auth: SiereAuth,
): AuthenticatedCallResult =
    authenticatedBackendCall(
        forceRefreshBeforeCall = true,
        currentSession = auth::currentSession,
    ) { accessToken ->
        val response = protectedClient.get(
            "http://127.0.0.1:8080/realms/siere/" +
                "protocol/openid-connect/userinfo",
        ) {
            bearerAuth(accessToken)
        }

        response.bodyAsText()
        SampleBackendResponse(
            statusCode = response.status.value,
            successMessage =
                "Protected userinfo call accepted a freshly refreshed token",
        )
    }

Keycloak's userinfo endpoint is the disposable protected resource in this sample. A product backend still has to validate the access token's signature, issuer, audience and authorization claims for its own resource.

The refresh happens immediately before the bearer token is attached. The application never stores a second copy for later use.

Retry one unauthorized response, not an authentication loop

Some calls do not need an eager refresh. They can use the current session and recover once when the server returns HTTP 401.

The shared sample helper makes that policy explicit:

val first = executeBackendRequest(
    initial.value.accessToken,
    request,
)
val shouldRetry =
    !forceRefreshBeforeCall &&
        first is AuthenticatedCallResult.HttpFailure &&
        first.statusCode == HTTP_UNAUTHORIZED

if (shouldRetry) {
    when (val refreshed = currentSession(true)) {
        is AuthResult.Failure ->
            AuthenticatedCallResult.AuthFailure(refreshed.error)

        is AuthResult.Success ->
            executeBackendRequest(refreshed.value.accessToken, request)
    }
} else {
    first
}

There are two rules here:

  1. A cached token may receive one forced-refresh retry after 401.
  2. A request that already forced refresh does not refresh and retry again.

The second rule prevents a rejected credential, disabled account or server-side policy failure from becoming an unbounded refresh loop.

Network I/O failures become a separate result. Coroutine cancellation is not caught as a network error, so closing the provider or leaving the screen still cancels the request normally.

Test storage semantics without pretending to test Keychain

The deterministic Kotlin/Native test injects an in-memory IosKeychainClient and creates two stores over it:

val keychain = InMemoryIosKeychainClient()
val first = IosKeychainOidcSessionStore(
    TEST_SERVICE,
    TEST_ACCOUNT,
    keychain,
)
val second = IosKeychainOidcSessionStore(
    TEST_SERVICE,
    TEST_ACCOUNT,
    keychain,
)

first.write(original)
assertContentEquals(original, second.read())

second.write(replacement)
assertContentEquals(replacement, first.read())

second.clear()
assertNull(first.read())

This covers opaque byte preservation, replacement, store recreation and idempotent deletion without depending on shared machine state.

We initially tried to call the real Security.framework implementation from the Kotlin/Native command-line test executable. It returned OSStatus -25291 (errSecNotAvailable) because the test runner is not a signed iOS application with an application Keychain context.

Mocking that boundary is the honest unit test. Installing the signed application and terminating its process is the acceptance test for real Keychain behavior.

Test the refresh sequence separately

The authenticated-call test records both session requests and bearer tokens:

assertEquals(listOf(false, true), refreshRequests)
assertEquals(listOf("cached-token", "fresh-token"), tokens)
assertIs<AuthenticatedCallResult.Success>(result)

Another test starts with forceRefreshBeforeCall = true, returns 401 and verifies exactly one session request and one backend request. That assertion protects the no-loop rule.

These tests do not need Keycloak. They describe application policy around a provider-neutral AuthSession, so they run quickly and fail at the exact boundary that changed.

Run the signed application acceptance path

Start the disposable Keycloak server from the Siere KMP Auth repository:

./gradlew :sample-jvm-oidc:keycloakUp

Open sample/iosApp/iosApp.xcodeproj, run the signed application in an iOS Simulator and choose Local OIDC. The fixture credentials are:

username: demo
password: demo-password

Use this sequence rather than stopping at the first signed-in label:

  1. Sign in through the system authentication session.
  2. Terminate the application process.
  3. Launch it and confirm it restores Signed in as Siere Demo without opening the browser.
  4. Select Call protected endpoint and confirm that Keycloak accepts the freshly refreshed token.
  5. Sign out.
  6. Terminate and launch the application again.
  7. Confirm it remains signed out.

Stop the disposable server afterward:

./gradlew :sample-jvm-oidc:keycloakDown

The loopback HTTP configuration and local-network exception exist for this disposable test. Production discovery and token endpoints must use HTTPS.

What we verified

The final acceptance run used a signed sample application on an iPhone 15 Plus Simulator with iOS 17.5 and the disposable Keycloak 26.7.3 server.

The app completed browser sign-in, restored the session from the real iOS Keychain after termination, forced a refresh immediately before the userinfo request and received a successful protected response. Sign-out deleted the stored session, and another terminate-and-launch cycle remained signed out.

The automated iOS suite covers browser completion, user cancellation, coroutine cancellation on the main dispatcher, Keychain-store semantics and both authenticated-call retry branches. The final Xcode integration build, focused Kotlin formatting checks and static analysis also passed.

The source is in Siere KMP Auth. Start with IosOidc.kt, IosKeychainOidcSessionStore.kt, AuthenticatedBackendCall.kt and their corresponding tests.

The application owns the last security boundary

Siere KMP Auth verifies the protocol invariants shared by every target: discovery, PKCE, callback state, issuer, signed ID tokens, refresh identity and restoration validity.

The consuming iOS application still owns:

  • Keychain accessibility and device-transfer policy
  • The service and account identifiers used for credential separation
  • When a backend call requires eager refresh
  • Whether one 401 refresh retry is appropriate for that endpoint
  • Resource-server validation and authorization
  • User-facing recovery after cancellation, invalid credentials or network failure
  • Acceptance testing on supported iOS versions and real devices

That boundary lets the library remain provider-neutral while the application makes the decisions that depend on its data, backend and threat model.

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

Ready to talk?

hello@sieresoft.com · Reply within one working day, usually less.