
Lightweight OpenID Connect authentication supporting Authorization Code Flow with PKCE, discovery, token exchange/refresh, local and provider logout, secure token storage, and provider-specific customization.
Lightweight OpenID Connect (OIDC) authentication library for Kotlin Multiplatform.
kmp-oidc is currently a pre-stable 0.2.0 release. It already supports a working browser-based OIDC flow on Android and iOS, but the API may still change before 1.0.0.
implementation("io.github.worker432:kmp-oidc:0.2.0")Android-only artifact:
implementation("io.github.worker432:kmp-oidc-android:0.2.0")Current project properties:
This repository already contains maven-publish configuration. For local verification:
./gradlew :auth-core:publishToMavenLocalIf you use publishToMavenLocal, add mavenLocal() to your repositories.
Before wiring the library into your app, make sure your OIDC client is configured at the provider side.
You need:
Example:
These values are client-specific. They do not come from discovery metadata.
Add the multiplatform artifact to commonMain:
commonMain.dependencies {
implementation("io.github.worker432:kmp-oidc:0.2.0")
}If you want to use the library in a regular Android application, use the Android artifact:
dependencies {
implementation("io.github.worker432:kmp-oidc-android:0.2.0")
}If you are consuming the library from mavenLocal() during development:
repositories {
mavenLocal()
mavenCentral()
google()
}val config = AuthConfig(
clientId = "kmp-oidc-sdk",
issuer = "https://issuer.example.com/realms/demo",
redirectUri = "myapp://callback",
logoutRedirectUri = "myapp://logout",
scopes = listOf(
"openid",
"profile",
"email",
"offline_access"
),
storageName = "auth_tokens"
)What these fields mean:
At a high level, client integration looks like this:
val authClient = AuthClientFactory.create(
config = config,
dependencies = platformDependencies
)PlatformDependencies is platform-specific.
Android:
val platformDependencies = PlatformDependencies(
context = applicationContext,
activity = this
)iOS:
val platformDependencies = PlatformDependencies()<uses-permission android:name="android.permission.INTERNET" />Your activity must be able to receive the login and logout callbacks.
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myapp"
android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myapp"
android:host="logout" />
</intent-filter>
</activity>These values must match:
class MainActivity : ComponentActivity() {
private var redirectUrl by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
redirectUrl = intent?.dataString
setContent {
App(
dependencies = PlatformDependencies(
context = applicationContext,
activity = this
),
redirectUrl = redirectUrl
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
redirectUrl = intent.dataString
}
}The important parts here:
In your Compose layer or screen logic:
LaunchedEffect(redirectUrl) {
val url = redirectUrl ?: return@LaunchedEffect
authClient.handleRedirect(url)
}scope.launch {
val result = authClient.login()
}On Android, login() usually returns AuthResult.Started because the browser flow continues outside the app. After the provider redirects back, call handleRedirect(url).
scope.launch {
when (val result = authClient.getValidAccessToken()) {
is TokenResult.Success -> {
val token = result.accessToken
}
TokenResult.NeedLogin -> {
authClient.login()
}
is TokenResult.Failure -> Unit
}
}If your local IdP runs on plain http, Android 9+ blocks cleartext traffic by default.
For emulator-based local development, either:
Example:
<application
android:usesCleartextTraffic="true" />This is only for local development. Production should use https.
Add your redirect scheme to Info.plist. If your redirectUri is myapp://callback and your logoutRedirectUri is myapp://logout, the scheme is just myapp.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>If your local IdP uses plain http, iOS may require App Transport Security exceptions during development.
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>This is only for local development. Production should use https.
This is what a minimal local-development setup can look like:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
</dict>
</plist>val platformDependencies = PlatformDependencies()val authClient = AuthClientFactory.create(
config = config,
dependencies = PlatformDependencies()
)scope.launch {
val result = authClient.login()
}The default iOS integration uses ASWebAuthenticationSession, and this is the main difference from Android.
That means:
Because of that, iOS usually does not need the same manual redirect wiring as Android.
scope.launch {
when (val result = authClient.getValidAccessToken()) {
is TokenResult.Success -> {
val token = result.accessToken
}
TokenResult.NeedLogin -> {
authClient.login()
}
is TokenResult.Failure -> Unit
}
}when (val result = authClient.login()) {
AuthResult.Started -> {
// Typical Android path:
// browser opened and redirect will be delivered later.
}
AuthResult.Success -> {
// Typical iOS path:
// ASWebAuthenticationSession completed and tokens are already stored.
}
AuthResult.AccessDenied -> {
// Provider returned access_denied
}
AuthResult.Cancelled -> Unit
is AuthResult.Failure -> {
// Redirect, discovery, browser, storage, or token error
}
}Android:
authClient.handleRedirect(redirectUrl)iOS:
This is the minimum shape of a client integration:
val authClient = AuthClientFactory.create(
config = AuthConfig(
clientId = "kmp-oidc-sdk",
issuer = "http://10.0.2.2:8080/realms/kmp",
redirectUri = "myapp://callback",
logoutRedirectUri = "myapp://logout",
scopes = listOf("openid", "profile", "email", "offline_access"),
storageName = "auth_tokens"
),
dependencies = platformDependencies
)For Android emulator-based local development, 10.0.2.2 is the usual host alias for services running on the development machine.
when (val result = authClient.getValidAccessToken()) {
is TokenResult.Success -> {
val accessToken = result.accessToken
}
TokenResult.NeedLogin -> {
authClient.login()
}
is TokenResult.Failure -> {
// Refresh or storage failure
}
}If the access token is expired and a refresh token is available, the library tries to refresh it automatically.
Local logout:
authClient.logout(LogoutMode.LOCAL_ONLY)Provider logout:
authClient.logout(LogoutMode.LOCAL_AND_PROVIDER)Provider logout uses end_session_endpoint when it is available in discovery metadata.
If your provider needs extra query or form parameters, use IdpCustomization.
val config = AuthConfig(
clientId = "client-id",
issuer = "https://issuer.example.com/realms/demo",
redirectUri = "myapp://callback",
logoutRedirectUri = "myapp://logout",
customization = IdpCustomization(
authorizationParameters = mapOf(
"prompt" to "login"
),
tokenParameters = mapOf(),
logoutParameters = mapOf()
)
)This is useful for providers that expect extra parameters in authorize, token, or logout requests.
Current compatibility status:
| Provider | Status | Notes |
|---|---|---|
| Keycloak | Tested | Verified in the sample app on Android and iOS |
| Other OIDC providers | Not verified yet | The library follows standard OIDC flows, but they have not been verified in this repository yet |
interface AuthClient {
suspend fun login(): AuthResult
suspend fun handleRedirect(url: String): AuthResult
suspend fun getValidAccessToken(): TokenResult
suspend fun logout(mode: LogoutMode = LogoutMode.LOCAL_ONLY): AuthResult
}Licensed under the Apache License 2.0.
Lightweight OpenID Connect (OIDC) authentication library for Kotlin Multiplatform.
kmp-oidc is currently a pre-stable 0.2.0 release. It already supports a working browser-based OIDC flow on Android and iOS, but the API may still change before 1.0.0.
implementation("io.github.worker432:kmp-oidc:0.2.0")Android-only artifact:
implementation("io.github.worker432:kmp-oidc-android:0.2.0")Current project properties:
This repository already contains maven-publish configuration. For local verification:
./gradlew :auth-core:publishToMavenLocalIf you use publishToMavenLocal, add mavenLocal() to your repositories.
Before wiring the library into your app, make sure your OIDC client is configured at the provider side.
You need:
Example:
These values are client-specific. They do not come from discovery metadata.
Add the multiplatform artifact to commonMain:
commonMain.dependencies {
implementation("io.github.worker432:kmp-oidc:0.2.0")
}If you want to use the library in a regular Android application, use the Android artifact:
dependencies {
implementation("io.github.worker432:kmp-oidc-android:0.2.0")
}If you are consuming the library from mavenLocal() during development:
repositories {
mavenLocal()
mavenCentral()
google()
}val config = AuthConfig(
clientId = "kmp-oidc-sdk",
issuer = "https://issuer.example.com/realms/demo",
redirectUri = "myapp://callback",
logoutRedirectUri = "myapp://logout",
scopes = listOf(
"openid",
"profile",
"email",
"offline_access"
),
storageName = "auth_tokens"
)What these fields mean:
At a high level, client integration looks like this:
val authClient = AuthClientFactory.create(
config = config,
dependencies = platformDependencies
)PlatformDependencies is platform-specific.
Android:
val platformDependencies = PlatformDependencies(
context = applicationContext,
activity = this
)iOS:
val platformDependencies = PlatformDependencies()<uses-permission android:name="android.permission.INTERNET" />Your activity must be able to receive the login and logout callbacks.
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myapp"
android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myapp"
android:host="logout" />
</intent-filter>
</activity>These values must match:
class MainActivity : ComponentActivity() {
private var redirectUrl by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
redirectUrl = intent?.dataString
setContent {
App(
dependencies = PlatformDependencies(
context = applicationContext,
activity = this
),
redirectUrl = redirectUrl
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
redirectUrl = intent.dataString
}
}The important parts here:
In your Compose layer or screen logic:
LaunchedEffect(redirectUrl) {
val url = redirectUrl ?: return@LaunchedEffect
authClient.handleRedirect(url)
}scope.launch {
val result = authClient.login()
}On Android, login() usually returns AuthResult.Started because the browser flow continues outside the app. After the provider redirects back, call handleRedirect(url).
scope.launch {
when (val result = authClient.getValidAccessToken()) {
is TokenResult.Success -> {
val token = result.accessToken
}
TokenResult.NeedLogin -> {
authClient.login()
}
is TokenResult.Failure -> Unit
}
}If your local IdP runs on plain http, Android 9+ blocks cleartext traffic by default.
For emulator-based local development, either:
Example:
<application
android:usesCleartextTraffic="true" />This is only for local development. Production should use https.
Add your redirect scheme to Info.plist. If your redirectUri is myapp://callback and your logoutRedirectUri is myapp://logout, the scheme is just myapp.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>If your local IdP uses plain http, iOS may require App Transport Security exceptions during development.
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>This is only for local development. Production should use https.
This is what a minimal local-development setup can look like:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
</dict>
</plist>val platformDependencies = PlatformDependencies()val authClient = AuthClientFactory.create(
config = config,
dependencies = PlatformDependencies()
)scope.launch {
val result = authClient.login()
}The default iOS integration uses ASWebAuthenticationSession, and this is the main difference from Android.
That means:
Because of that, iOS usually does not need the same manual redirect wiring as Android.
scope.launch {
when (val result = authClient.getValidAccessToken()) {
is TokenResult.Success -> {
val token = result.accessToken
}
TokenResult.NeedLogin -> {
authClient.login()
}
is TokenResult.Failure -> Unit
}
}when (val result = authClient.login()) {
AuthResult.Started -> {
// Typical Android path:
// browser opened and redirect will be delivered later.
}
AuthResult.Success -> {
// Typical iOS path:
// ASWebAuthenticationSession completed and tokens are already stored.
}
AuthResult.AccessDenied -> {
// Provider returned access_denied
}
AuthResult.Cancelled -> Unit
is AuthResult.Failure -> {
// Redirect, discovery, browser, storage, or token error
}
}Android:
authClient.handleRedirect(redirectUrl)iOS:
This is the minimum shape of a client integration:
val authClient = AuthClientFactory.create(
config = AuthConfig(
clientId = "kmp-oidc-sdk",
issuer = "http://10.0.2.2:8080/realms/kmp",
redirectUri = "myapp://callback",
logoutRedirectUri = "myapp://logout",
scopes = listOf("openid", "profile", "email", "offline_access"),
storageName = "auth_tokens"
),
dependencies = platformDependencies
)For Android emulator-based local development, 10.0.2.2 is the usual host alias for services running on the development machine.
when (val result = authClient.getValidAccessToken()) {
is TokenResult.Success -> {
val accessToken = result.accessToken
}
TokenResult.NeedLogin -> {
authClient.login()
}
is TokenResult.Failure -> {
// Refresh or storage failure
}
}If the access token is expired and a refresh token is available, the library tries to refresh it automatically.
Local logout:
authClient.logout(LogoutMode.LOCAL_ONLY)Provider logout:
authClient.logout(LogoutMode.LOCAL_AND_PROVIDER)Provider logout uses end_session_endpoint when it is available in discovery metadata.
If your provider needs extra query or form parameters, use IdpCustomization.
val config = AuthConfig(
clientId = "client-id",
issuer = "https://issuer.example.com/realms/demo",
redirectUri = "myapp://callback",
logoutRedirectUri = "myapp://logout",
customization = IdpCustomization(
authorizationParameters = mapOf(
"prompt" to "login"
),
tokenParameters = mapOf(),
logoutParameters = mapOf()
)
)This is useful for providers that expect extra parameters in authorize, token, or logout requests.
Current compatibility status:
| Provider | Status | Notes |
|---|---|---|
| Keycloak | Tested | Verified in the sample app on Android and iOS |
| Other OIDC providers | Not verified yet | The library follows standard OIDC flows, but they have not been verified in this repository yet |
interface AuthClient {
suspend fun login(): AuthResult
suspend fun handleRedirect(url: String): AuthResult
suspend fun getValidAccessToken(): TokenResult
suspend fun logout(mode: LogoutMode = LogoutMode.LOCAL_ONLY): AuthResult
}Licensed under the Apache License 2.0.