The Google sign-in implementation for Kotlin/JVM desktop established the browser boundary: open the system browser, receive an authorization code on a loopback callback and protect it with PKCE.
Generic OpenID Connect reuses that boundary, then adds provider discovery, signing-key selection, ID-token validation and a session that must remain tied to its original issuer and client.
The client has to decide which metadata to trust, which callback to accept, which signing key to use, whether the token belongs to this application and whether a restored session still matches its configuration.
Siere KMP Auth handles those checks in its JVM OIDC adapter. The consumer supplies a public client registration and provider policy. The library owns the protocol machinery that should not vary between desktop applications.
The API stays small
The application configures one provider:
val auth = SiereAuth(
OidcAuthProvider(
OidcConfiguration(
clientId = "desktop-app",
discoveryUrl =
"https://identity.example/.well-known/openid-configuration",
scopes = setOf("profile", "email", "offline_access"),
providerId = "identity.example",
),
),
)
It then uses the provider-neutral API:
val signIn = auth.signInWithOpenId()
val session = auth.currentSession(forceRefresh = false)
val signOut = auth.signOut()
AuthState carries normalized identity, not credentials. currentSession() returns a point-in-time access token immediately before the application sends it to its own backend.
The adapter is deliberately JVM-only in its first release. Android and iOS have different browser-return and secure-storage primitives; pretending those differences do not exist would produce a weaker API.
OAuth returns authorization; OIDC returns identity
Authorization Code Flow produces an authorization code and then an access token. OpenID Connect Core adds an ID token whose signed claims describe the authentication event and user identity.
That changes the client-side work. Receiving a successful token response does not prove that the ID token:
- Came from the configured issuer
- Targets this client
- Is currently valid
- Uses an allowed signing algorithm
- Matches the browser operation the application started
Siere validates the ID token before it creates AuthUser or writes a session.
Discovery starts the trust chain
The only provider URL supplied directly by the application is discoveryUrl. The OpenID Connect Discovery specification defines the metadata document. Siere reads:
issuer
authorization_endpoint
token_endpoint
jwks_uri
id_token_signing_alg_values_supported
The adapter rejects discovery, issuer, authorization, token and JWKS URLs unless they use HTTPS and contain a host. User information and URL fragments are rejected as well. HTTP is available only when allowInsecureHttpForTesting is true and the host is the numeric loopback address 127.0.0.1 or ::1.
The numeric-host restriction keeps a local test switch from becoming a general cleartext escape hatch. A name that happens to resolve to loopback is not accepted.
HTTP connections use bounded connect and read timeouts, reject redirects and cap response bodies at one megabyte. A provider error becomes a typed AuthError rather than an exception leaking through the shared API.
The current implementation requires the configured discovery document and the returned issuer to be secure, but it does not derive the discovery URL from an issuer string. The application owns that initial configuration and must obtain it from the provider through a trusted channel.
One loopback implementation for Google and OIDC
The Google desktop implementation already used a temporary HTTP listener. Adding another listener inside auth-oidc would have created two security boundaries that could drift.
Siere now keeps PKCE generation and callback handling in a shared internal JVM component. Google and OIDC use the same rules and regression tests.
The OIDC work also hardened Google sign-in
Building the generic provider forced us to audit the loopback boundary again. The result was not limited to auth-oidc. JVM Google sign-in now uses the same hardened callback component.
The hardening pass addressed these cases:
- Require the exact
Hostvalue127.0.0.1:<assigned-port>to reject requests with a forged or rebound host. - Compare the current cryptographic
statein constant time to reject callbacks from another local process. - Reject duplicate query parameter names instead of choosing one
codeorstatevalue. - Accept only
GET /callback. - Atomically accept only the first valid callback when several requests race to complete one sign-in.
- Bound query length, parameter count, names and values.
- Return
no-store,no-cacheandno-referrer, plus restrictive content headers. - Close the listener on success, timeout, cancellation and provider shutdown.
The browser callback still contains the authorization code because that is the native-app flow. It never contains the access token, refresh token or ID token. The code is short-lived, single-use at the provider and bound to the PKCE verifier that remains in the desktop process.
Host validation is one layer rather than a substitute for the others. The server also binds only to the literal loopback address, validates the path and method, checks state, rejects ambiguous parameters and closes after one accepted result.
The listener binds to:
127.0.0.1:<operating-system-assigned-port>
It never binds to all interfaces and does not use localhost. The redirect URI always uses the exact assigned port:
http://127.0.0.1:<port>/callback
The handler validates the HTTP method, path and Host header before looking at the result. It parses a bounded query string, rejects duplicate parameter names, limits parameter name and value sizes, compares state in constant time and atomically claims the first valid callback.
A second request cannot replace the accepted authorization code. An invalid request receives 400 and cannot claim the operation.
Callback responses contain:
Cache-Control: no-store
Pragma: no-cache
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
The response body contains a status message, never credentials. The authorization code remains in the incoming request URL because that is how the browser returns it, but access tokens, refresh tokens and ID tokens do not enter the callback URL.
The listener closes after success, timeout, coroutine cancellation or close(). JvmOidcBrowserFlow serializes browser operations with a mutex, so one provider instance cannot run two callbacks that compete for state.
PKCE, state and nonce have separate jobs
For every authorization request, Siere creates:
- A 32-byte random
state - A 64-byte PKCE verifier and its
S256challenge - A 32-byte random nonce
PKCE protects the authorization code. A process that observes the callback still cannot exchange the code without the verifier.
state rejects callbacks that were not created for the active operation.
The nonce travels through the authorization request and returns inside the signed ID token. Siere compares it after verifying the signature. A valid token from another browser operation cannot satisfy the current one.
additionalAuthorizationParameters supports provider-specific policy such as prompt or an organization hint, but configuration cannot override client_id, redirect_uri, response_type, scope, state, nonce or PKCE fields.
The token exchange has no client secret
The desktop application sends:
grant_type=authorization_code
client_id=<public-client-id>
code=<authorization-code>
redirect_uri=<exact-loopback-uri>
code_verifier=<original-verifier>
OidcConfiguration has no client-secret property. The API makes embedding one through the adapter impossible.
A secret distributed inside a desktop binary is available to anyone who can inspect that binary or its process. RFC 8252 treats native applications as public clients. Providers that require confidential-client authentication need a trusted backend boundary; they are not compatible with this direct public-client flow.
ID-token verification
The adapter supports RS256, RS384, RS512, ES256, ES384 and ES512. It rejects none, algorithms outside that set and algorithms the provider did not advertise when discovery supplies an allow-list.
For each token, it:
- Requires exactly three JWT segments and caps the encoded token size.
- Parses the protected header and selects a compatible signing key.
- Rejects JWKs that do not allow signature verification.
- Requires RSA keys to be at least 2048 bits.
- Requires an EC curve that matches the selected algorithm.
- Verifies the signature over the original encoded header and payload.
- Validates the identity and time claims.
The claims check requires:
iss == discovered issuer
sub is present and non-blank
aud contains the configured client ID
azp == client ID when aud contains multiple values
exp is still valid
iat is not unreasonably in the future
nbf, when present, has passed
nonce matches the current authorization attempt
The configurable clock skew defaults to 30 seconds and cannot exceed five minutes.
When the JWT references a key that is not in the in-memory JWKS cache, the verifier reloads the provider's keys and tries again. This covers normal signing-key rotation. Failure to find a compatible key after that reload rejects the token.
The library does not validate an access token for the consumer's API. Access-token issuer and audience rules belong to the API accepting that token, usually on the backend.
Identity is issuer plus subject
OIDC guarantees the stability of sub within an issuer, not across every provider. Siere therefore maps the normalized UID as:
uid = "${idToken.issuer}|${idToken.subject}"
Profile fields come from standard claims when present:
name or preferred_username
email
email_verified
picture
phone_number
Missing optional profile claims do not invalidate an otherwise valid identity.
Refresh cannot switch the user
currentSession(forceRefresh = true) performs a refresh-token grant. The provider also refreshes automatically when the access token is within 30 seconds of expiry.
Refresh responses vary. Some providers rotate the refresh token; others omit it. Siere keeps the previous refresh token when the response contains no replacement.
An ID token returned during refresh must keep the original issuer and subject. A response that changes either value is rejected. If the provider returns invalid_grant, the stored session is cleared because its refresh token is no longer usable.
The token endpoint may omit a new ID token during refresh. In that case, Siere retains the previously verified identity. It still requires a new access token and a positive lifetime when expires_in is present.
Restoration has to match the configuration
The serialized session includes the client ID and discovery URL. On startup, the provider restores it only when both values match the current OidcConfiguration.
A session created for another client or issuer configuration is deleted rather than exposed through AuthState.
The restored ID token is decoded rather than downloaded and verified again during startup. Its integrity comes from the authenticated session store, and refresh verifies any replacement ID token before accepting it. A custom JvmOidcSessionStore must therefore provide integrity as well as confidentiality.
Restoration publishes either AuthState.SignedIn or AuthState.SignedOut after the asynchronous read completes. Public operations wait for that restoration, which prevents a sign-in or refresh from racing the initial state load.
What encrypted file storage does and does not protect
The default EncryptedFileJvmOidcSessionStore uses:
- A random 256-bit AES key
- A fresh 96-bit nonce for each write
- AES-GCM with a 128-bit authentication tag
- Atomic replacement where supported
- Owner-only file and directory permissions on POSIX file systems
Tampered ciphertext fails authentication and is deleted. Rewriting the same session produces different ciphertext because each write gets a new nonce. Token-bearing toString() output is redacted.
The key and ciphertext live in separate files under ~/.siere-auth. This prevents plaintext disclosure and reduces accidental exposure to other operating-system users. It does not protect against code running as the same user that can read both files.
Applications that require Keychain, Credential Manager, DPAPI or an enterprise vault should implement JvmOidcSessionStore. That implementation receives sensitive plaintext bytes and owns confidentiality, integrity, atomic replacement and deletion.
Local sign-out is intentionally local
signOut() clears the stored Siere session and publishes AuthState.SignedOut.
It does not call an end-session endpoint or clear the system browser's provider cookie. The next sign-in may reuse that browser session. RP-initiated logout varies by provider and remains outside the first OIDC adapter release.
Applications should describe the action as local sign-out unless they add and verify provider-specific logout behavior.
Failure and lifecycle behavior
OAuth errors are mapped into the shared error model:
access_denied -> Cancelled
invalid_grant -> InvalidCredentials
invalid_client -> ProviderDisabled
unauthorized_client -> ProviderDisabled
temporarily_unavailable -> Network
server_error -> Network
Connection failures and HTTP timeouts become AuthError.Network. A browser that cannot be opened becomes AuthError.PopupBlocked. Coroutines still propagate CancellationException; the adapter does not disguise structured cancellation as an authentication error.
close() is idempotent. It closes an active callback listener, cancels the provider scope, clears in-memory tokens and prevents new operations. The application should call it when the authentication owner is disposed.
What the tests cover
The OIDC adapter currently has 22 deterministic tests, and the sample adds four configuration tests.
The suite covers:
- Random PKCE values and correct
S256derivation - Exact method, path, host and state validation
- Duplicate, excessive and oversized callback parameters
- One-time callback acceptance
- Timeout and cancellation cleanup
- Numeric-loopback-only HTTP test configuration
- Reserved authorization parameters
- Discovery, browser and timeout error mapping
- RSA signature and claim validation
- Audience and authorized-party handling
- Nonce mismatch
- JWKS reload during key rotation
- Refresh-token preservation
- Refresh identity continuity
- Clearing an invalid refresh session
- Encrypted storage without plaintext tokens
- Unique ciphertext across writes
- Tamper detection and deletion
- Configuration-bound restoration
The Compose sample adds a manual browser run against a local Keycloak realm. Its UI exercises the external browser path, imported public client, normalized identity, refresh, restart restoration and local sign-out without requiring hosted credentials or billing.
The boundary between library and application
Siere guarantees the mechanics it controls:
- Authorization Code Flow with PKCE
- Fresh state and nonce values
- A hardened one-shot loopback callback
- HTTPS-only production metadata and endpoints
- ID-token signature and claim validation
- Key-rotation retry
- Serialized refresh and identity continuity
- Protected default persistence
- Typed errors and lifecycle cleanup
The consuming application still owns:
- Correct public-client registration
- Redirect policy at the identity provider
- Requested scopes and provider-specific parameters
- Backend validation of access tokens
- Operating-system and process security
- User-facing error copy
- A stronger credential vault when its threat model requires one
- Provider-wide logout if the product promises it
That division keeps the setup small without pretending a client library controls the provider or the operating system.
Run the implementation
Start the disposable provider and Compose application from the repository root:
./gradlew :sample-jvm-oidc:keycloakUp
./gradlew :sample-jvm-oidc:run
Use demo / demo-password, then test refresh, restart restoration and local sign-out. Stop the provider afterward:
./gradlew :sample-jvm-oidc:keycloakDown
The implementation, tests and sample live in Siere KMP Auth. If a provider behaves differently from the OpenID Connect path described here, open an issue with its discovery metadata and the failing protocol step, with credentials and tokens removed.