I had already implemented authentication for Kotlin/JS and Kotlin/Wasm. Every time, the shared authentication API stopped being shared as soon as I reached the platform-specific details.
Desktop Google sign-in was the point where I got tired of solving the same problem inside individual applications.
I wanted one authentication API for Android, iOS, JVM, JS and Wasm, while still allowing each target to use the flow that makes sense on that platform. That became Siere KMP Auth, our open-source authentication library for Kotlin Multiplatform.
This article covers one of the harder parts behind that library: opening Google authentication in the system browser, receiving the result in a Kotlin/JVM desktop application and turning it into a persistent Firebase session.
The API I wanted to use
Application code should not need to know how to run a local callback server or exchange an OAuth authorization code.
With Siere KMP Auth, the JVM setup looks like this:
val provider = FirebaseAuthProvider(
googleAuthConfig = JvmGoogleAuthConfig(
clientId = desktopClientId,
),
)
val auth = SiereAuth(provider)
Signing in uses the same shared operation as the other targets:
when (val result = auth.signInWithGoogle()) {
is AuthResult.Success -> {
println("Signed in as ${result.value.displayName}")
}
is AuthResult.Failure -> {
println("Sign-in failed: ${result.error}")
}
}
The JVM implementation handles the browser, callback listener, state validation, PKCE exchange and Firebase credential conversion.
Getting there took more work than the public API suggests.
The browser opened, but nothing was listening
I expected the browser part to be straightforward. Open Google, let the user authenticate, receive the credential and pass it to Firebase.
Well, the browser opened.
After signing in, Safari redirected to something like:
http://127.0.0.1:64896/callback
Then it displayed:
Safari Can’t Connect to the Server
That page explained the first problem. Opening the browser is only half of a desktop OAuth flow. The desktop application must also run a temporary local HTTP server to receive the authorization response.
Before you start
This guide does not cover creating a Firebase project, enabling Google authentication or configuring the OAuth consent screen. Firebase and Google already document those steps.
You will need:
- A Firebase project with Google authentication enabled
- The Firebase web configuration for that project
- A Google OAuth client with its application type set to Desktop app
- The downloaded OAuth client JSON file
Google treats installed applications as clients that cannot keep secrets. The downloaded desktop client JSON may contain a client_secret, but a distributed desktop application cannot keep that value confidential. Google explains this limitation in its installed-application OAuth documentation.
The complete desktop flow
The implementation has ten steps:
- Generate a random OAuth state value.
- Generate a PKCE verifier and SHA-256 challenge.
- Start an HTTP server on
127.0.0.1using a random available port. - Open the Google authorization URL in the system browser.
- Let the user authenticate.
- Receive the authorization code on
/callback. - Verify the callback state.
- Exchange the code for Google tokens.
- Create a Firebase Google credential.
- Sign in to Firebase.
There is no embedded WebView and no copy-and-paste step. The user authenticates in their normal browser, and the browser returns the result directly to the desktop application.
Start a loopback server
Google recommends a loopback redirect for macOS, Windows and Linux desktop applications.
The JDK already includes a small HTTP server that is enough for this callback:
val address = InetSocketAddress(
InetAddress.getByName("127.0.0.1"),
0,
)
val server = HttpServer.create(address, 0)
server.createContext("/callback") { exchange ->
handleGoogleCallback(exchange)
}
server.start()
val redirectUri = URI(
"http://127.0.0.1:${server.address.port}/callback",
)
Passing 0 as the port asks the operating system to choose an available one.
The server binds explicitly to 127.0.0.1 because localhost can introduce name-resolution and firewall behaviour that the flow does not need. Google also recommends the literal loopback address for desktop applications.
The server should exist for one authentication attempt and stop as soon as the callback is received, cancelled or timed out.
Protect the authorization code with state and PKCE
A callback does not become trustworthy merely because it uses 127.0.0.1.
Each authorization request needs:
- A cryptographically random
state - A new PKCE verifier
- An S256 PKCE challenge derived from that verifier
val state = randomUrlSafe(byteCount = 32)
val verifier = randomUrlSafe(byteCount = 64)
val verifierHash = MessageDigest
.getInstance("SHA-256")
.digest(verifier.toByteArray(StandardCharsets.US_ASCII))
val challenge = Base64
.getUrlEncoder()
.withoutPadding()
.encodeToString(verifierHash)
The authorization request includes the challenge and state:
client_id=<desktop-client-id>
redirect_uri=http://127.0.0.1:<port>/callback
response_type=code
scope=openid email profile
code_challenge=<challenge>
code_challenge_method=S256
state=<state>
prompt=select_account
The application then opens that request in the system browser:
Desktop.getDesktop().browse(authorizationUri)
Validate the callback
Once the user finishes authentication, Google redirects the browser to the loopback server.
The callback handler accepts only:
- A
GETrequest - The exact
/callbackpath - A matching state value
- The first valid callback for the current operation
val parameters = parseQuery(exchange.requestURI.rawQuery)
val validMethod = exchange.requestMethod == "GET"
val validPath = exchange.requestURI.path == "/callback"
val validState = parameters["state"]
?.constantTimeEquals(expectedState) == true
val accepted = validMethod &&
validPath &&
validState &&
!callback.isCompleted
If the callback is valid, the server returns a small message to the browser:
Google authorization received. You can return to the application.
That message confirms only that the application received the authorization code. Firebase sign-in still has to succeed.
The desktop process must remain alive while the user is in the browser. During my first live attempt, the callback server timed out before I completed the Google flow. The browser reached 127.0.0.1, but nothing was listening anymore.
The final implementation puts a deadline on the callback and stops the server when the operation succeeds, fails or is cancelled.
Exchange the authorization code
The callback returns a short-lived authorization code. The application exchanges it at Google’s token endpoint:
POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded
The form body contains:
client_id=<desktop-client-id>
client_secret=<desktop-client-secret, when required>
code=<authorization-code>
code_verifier=<original-pkce-verifier>
grant_type=authorization_code
redirect_uri=http://127.0.0.1:<port>/callback
The redirect_uri must match the value used in the authorization request exactly.
Google documents client_secret as optional for the desktop token exchange, so Siere KMP Auth omits it by default. Our first live client still responded with:
invalid_request: client_secret is missing
For that client, we read the value from the downloaded desktop OAuth JSON and included it in the exchange. JvmGoogleAuthConfig therefore accepts an optional clientSecret for compatibility:
val googleConfig = JvmGoogleAuthConfig(
clientId = desktopClientId,
clientSecret = desktopClientSecret,
)
That value is still not confidential inside a distributed desktop application. PKCE binds the authorization code to the verifier, state rejects unrelated callbacks, and the listener accepts the result only through its temporary loopback address.
Pass the Google credential to Firebase
The successful token response contains a Google ID token and, depending on the request, an access token.
Those tokens can be converted into a Firebase credential:
val credential = GoogleAuthProvider.credential(
idToken = tokens.idToken,
accessToken = tokens.accessToken,
)
val firebaseUser = Firebase.auth
.signInWithCredential(credential)
.user
Firebase documents the same mechanism for manually handled Google sign-in: obtain a Google ID token, create a GoogleAuthProvider credential and call signInWithCredential. Firebase’s Google authentication guide shows the equivalent web flow.
One more JVM problem: Dispatchers.Main
Once Google OAuth succeeded, the request reached Firebase and failed with:
Module with the Main dispatcher is missing
The JVM Firebase bridge assumed that Dispatchers.Main existed.
That assumption works on Android, where the platform provides the main dispatcher. It is unsafe for a headless JVM process and makes testing more difficult.
We fixed it by injecting platform dispatchers:
interface DispatcherProvider {
val main: CoroutineDispatcher
val default: CoroutineDispatcher
val io: CoroutineDispatcher
val unconfined: CoroutineDispatcher
}
The desktop Firebase bridge can now use the correct dispatcher without hard-coding Android coroutine behaviour. Tests can supply a TestDispatcherProvider, while Android and iOS remain free to expose the dispatchers available on their platforms.
The fix touched both GitLive layers used on JVM:
- The GitLive Firebase Kotlin SDK
- The underlying GitLive Firebase Java SDK
At the time of writing, the upstream Java SDK still describes Firebase Auth as minimal. The credential path we needed was missing, so we implemented it in the Siere forks and tested the Kotlin and Java layers together. We still treat those bridge builds as pre-release until the changes land upstream.
A login is not complete until it survives a restart
A desktop application has more work after Firebase returns the first user. It must survive a restart, refresh an expired token, link another credential without switching accounts, sign out cleanly and stop pending browser work when the user denies access or closes the application.
The JVM bridge now persists the Firebase user and refresh token through the consumer-provided FirebasePlatform storage. Recreating the application restores that session. getIdToken(true) forces a refresh, while an expired token refreshes before the caller receives it.
Google linking keeps the current Firebase UID and replaces the stored session only after Firebase accepts the new credential.
The browser flow treats denial as cancellation, puts a deadline on the callback and releases the local server when the calling coroutine or provider is cancelled. Those cases have regression tests because they are the ones most likely to leave a desktop application waiting forever.
The sample uses a file-backed store to prove restart restoration. A production application should store session data in operating-system credential storage rather than a plain file.
What we verified
The final live run completed the complete flow:
- The system browser opened
- Google authentication succeeded
- The loopback callback was received
- The authorization code was exchanged
- Firebase accepted the Google credential
- The Firebase user was returned
- A Firebase ID token was retrieved
- Sign-out succeeded
The same implementation passed 30 JVM adapter tests and 24 tests in the lower Firebase Java bridge. They cover restart restoration, forced and expired-token refresh, Google linking, sign-out, denial, timeout and cancellation.
No API keys, OAuth files or raw tokens are stored in the repository. During live verification, token output was limited to its length and a SHA-256 digest. That proved a token was returned without leaking it into the logs.
Use it or inspect the implementation
Siere KMP Auth is open source on GitHub. The public API keeps authentication consistent across Kotlin Multiplatform targets, while the platform implementations handle browser flows, native SDKs and session storage.
This first article focused on Google OAuth and the loopback callback. The next part will cover the less visible work required for a production JVM session: persistence, token refresh, credential linking, cancellation and failure testing.
If your team is building a Kotlin Multiplatform application and authentication is blocking a target, you can open an issue or talk to Siere Soft.