
Implements OAuth PKCE flow for Android and iOS, facilitating secure authentication by managing URL schemes, OAuth clients, and handling sign-in callbacks across both platforms.
A Kotlin Multiplatform library for Android and iOS that implements the OAuth PKCE flow.
⚠️ Work in progress. The API is still settling and may change before a 1.0.0 release.
PKCEFlow (shared) orchestrates the flow: it builds the sign-in URL, exchanges the returned
authorization code for access/refresh tokens, and exposes progress as a StateFlow, authState
(NOT_STARTED → WAITING_FOR_AUTHORIZATION_CODE → EXCHANGING_AUTHORIZATION_CODE → FINISHED).
Keep one instance around — typically an app-wide singleton.WebAuthSession opens the system browser to the sign-in URL and reports the redirect back to
PKCEFlow. Each platform has its own: on Android a Chrome
Auth Tab (with a Custom Tab
fallback on older browsers), on iOS ASWebAuthenticationSession. You give PKCEFlow a
WebAuthSessionFactory that builds the right one per platform.For the runtime internals — how the completion handler gets wired up and how each platform hands the redirect back (with sequence diagrams) — see ARCHITECTURE.md.
Register a custom URL scheme for your app (e.g. exampleapp://) on both platforms, and create
an OAuth client that supports PKCE on your server (we use
Doorkeeper). You'll need its client id and a
redirect URI that uses your scheme, e.g. exampleapp://oauth.
Add the dependency, plus the repository for its krypt-csprng transitive dependency:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://repo.repsy.io/mvn/chrynan/public") // krypt-csprng (transitive dependency)
}
}# gradle/libs.versions.toml
[libraries]
oauth-core = { module = "com.collectiveidea.oauth:oauth-core", version = "0.3.0" }// shared module build.gradle.kts
kotlin.sourceSets.commonMain.dependencies {
implementation(libs.oauth.core)
}Build one PKCEFlow and hold it as a singleton. Everything but the WebAuthSessionFactory and the
Ktor engine is shared; those two are supplied by each platform.
fun createPKCEFlow(
webAuthSessionFactory: WebAuthSessionFactory, // platform-provided (see below)
httpClientEngine: HttpClientEngine, // OkHttp on Android, Darwin on iOS
applicationScope: CoroutineScope, // app-lifetime; launches the token exchange
): PKCEFlow {
val baseUrl = "https://api.example.com/" // everything before "oauth/…", with a trailing slash
val oauthService = OAuthServiceImpl(
httpClient = HttpClient(httpClientEngine) { installJsonOAuth(baseUrl) },
clientId = "your-oauth-client-id",
)
return PKCEFlow(
webAuthSessionFactory = webAuthSessionFactory,
oauthService = oauthService,
oauthBaseUrl = baseUrl,
redirectUrl = "exampleapp://oauth",
applicationScope = applicationScope,
ioDispatcher = Dispatchers.IO,
)
}AndroidWebAuthSession registers an Activity Result launcher, so it must be built by the hosting
Activity before it is STARTED — it can't be an app-wide singleton. Use the provided
CurrentActivityWebAuthSession: hold it as the singleton, give PKCEFlow its factory, and
bindTo(...) the current Activity from onCreate.
// App singletons (e.g. in your DI graph):
val webAuthSession = CurrentActivityWebAuthSession()
val pkceFlow = createPKCEFlow(
webAuthSessionFactory = webAuthSession.factory,
httpClientEngine = OkHttp.create(),
applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
)class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before the Activity is STARTED. (Resolve the singletons however your app does DI.)
webAuthSession.bindTo(this)
// ...
}
}When the browser supports an Auth Tab (Chrome 137+, i.e. Android 8+), the redirect is captured and
delivered directly — nothing else is needed. To keep working on older devices/browsers that fall
back to a Custom Tab, register the redirect scheme and forward the redirect from onNewIntent:
<!-- AndroidManifest.xml -->
<activity android:name=".MainActivity" android:exported="true">
<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="exampleapp" />
</intent-filter>
</activity>override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val url = intent.data?.toString()
if (url != null && url.startsWith("exampleapp://oauth")) {
pkceFlow.continueSignInWithCallbackOrError(url, null)
}
}If the Activity is recreated while a sign-in is in flight (e.g. under memory pressure),
CurrentActivityWebAuthSession rebinds the recreated Activity and the redelivered result still
completes — which is why PKCEFlow must outlive the Activity (hence the singleton). If the whole
process is killed the PKCE verifier is lost, so PKCEFlow finishes with an error and the user
restarts sign-in.
IosWebAuthSession's constructor matches WebAuthSessionFactory, so pass it as the factory.
ASWebAuthenticationSession delivers the callback directly — nothing else is needed.
val pkceFlow = createPKCEFlow(
webAuthSessionFactory = ::IosWebAuthSession,
httpClientEngine = Darwin.create(),
applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
)Start sign-in (which opens the browser) and observe authState — for example, from a shared view
model:
fun signIn() = pkceFlow.startSignIn()
init {
scope.launch {
pkceFlow.authState.collect { state ->
when (state.state) {
PKCEFlow.PKCEAuthState.State.FINISHED -> {
val tokens = state.tokenResponse
if (tokens != null) {
// Persist the tokens and continue into the app.
} else {
// Sign-in failed — show state.errorMessage.
}
pkceFlow.resetState()
}
else -> {
// NOT_STARTED / WAITING_FOR_AUTHORIZATION_CODE / EXCHANGING_AUTHORIZATION_CODE —
// optionally reflect progress in the UI.
}
}
}
}
}A Kotlin Multiplatform library for Android and iOS that implements the OAuth PKCE flow.
⚠️ Work in progress. The API is still settling and may change before a 1.0.0 release.
PKCEFlow (shared) orchestrates the flow: it builds the sign-in URL, exchanges the returned
authorization code for access/refresh tokens, and exposes progress as a StateFlow, authState
(NOT_STARTED → WAITING_FOR_AUTHORIZATION_CODE → EXCHANGING_AUTHORIZATION_CODE → FINISHED).
Keep one instance around — typically an app-wide singleton.WebAuthSession opens the system browser to the sign-in URL and reports the redirect back to
PKCEFlow. Each platform has its own: on Android a Chrome
Auth Tab (with a Custom Tab
fallback on older browsers), on iOS ASWebAuthenticationSession. You give PKCEFlow a
WebAuthSessionFactory that builds the right one per platform.For the runtime internals — how the completion handler gets wired up and how each platform hands the redirect back (with sequence diagrams) — see ARCHITECTURE.md.
Register a custom URL scheme for your app (e.g. exampleapp://) on both platforms, and create
an OAuth client that supports PKCE on your server (we use
Doorkeeper). You'll need its client id and a
redirect URI that uses your scheme, e.g. exampleapp://oauth.
Add the dependency, plus the repository for its krypt-csprng transitive dependency:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://repo.repsy.io/mvn/chrynan/public") // krypt-csprng (transitive dependency)
}
}# gradle/libs.versions.toml
[libraries]
oauth-core = { module = "com.collectiveidea.oauth:oauth-core", version = "0.3.0" }// shared module build.gradle.kts
kotlin.sourceSets.commonMain.dependencies {
implementation(libs.oauth.core)
}Build one PKCEFlow and hold it as a singleton. Everything but the WebAuthSessionFactory and the
Ktor engine is shared; those two are supplied by each platform.
fun createPKCEFlow(
webAuthSessionFactory: WebAuthSessionFactory, // platform-provided (see below)
httpClientEngine: HttpClientEngine, // OkHttp on Android, Darwin on iOS
applicationScope: CoroutineScope, // app-lifetime; launches the token exchange
): PKCEFlow {
val baseUrl = "https://api.example.com/" // everything before "oauth/…", with a trailing slash
val oauthService = OAuthServiceImpl(
httpClient = HttpClient(httpClientEngine) { installJsonOAuth(baseUrl) },
clientId = "your-oauth-client-id",
)
return PKCEFlow(
webAuthSessionFactory = webAuthSessionFactory,
oauthService = oauthService,
oauthBaseUrl = baseUrl,
redirectUrl = "exampleapp://oauth",
applicationScope = applicationScope,
ioDispatcher = Dispatchers.IO,
)
}AndroidWebAuthSession registers an Activity Result launcher, so it must be built by the hosting
Activity before it is STARTED — it can't be an app-wide singleton. Use the provided
CurrentActivityWebAuthSession: hold it as the singleton, give PKCEFlow its factory, and
bindTo(...) the current Activity from onCreate.
// App singletons (e.g. in your DI graph):
val webAuthSession = CurrentActivityWebAuthSession()
val pkceFlow = createPKCEFlow(
webAuthSessionFactory = webAuthSession.factory,
httpClientEngine = OkHttp.create(),
applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
)class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before the Activity is STARTED. (Resolve the singletons however your app does DI.)
webAuthSession.bindTo(this)
// ...
}
}When the browser supports an Auth Tab (Chrome 137+, i.e. Android 8+), the redirect is captured and
delivered directly — nothing else is needed. To keep working on older devices/browsers that fall
back to a Custom Tab, register the redirect scheme and forward the redirect from onNewIntent:
<!-- AndroidManifest.xml -->
<activity android:name=".MainActivity" android:exported="true">
<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="exampleapp" />
</intent-filter>
</activity>override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val url = intent.data?.toString()
if (url != null && url.startsWith("exampleapp://oauth")) {
pkceFlow.continueSignInWithCallbackOrError(url, null)
}
}If the Activity is recreated while a sign-in is in flight (e.g. under memory pressure),
CurrentActivityWebAuthSession rebinds the recreated Activity and the redelivered result still
completes — which is why PKCEFlow must outlive the Activity (hence the singleton). If the whole
process is killed the PKCE verifier is lost, so PKCEFlow finishes with an error and the user
restarts sign-in.
IosWebAuthSession's constructor matches WebAuthSessionFactory, so pass it as the factory.
ASWebAuthenticationSession delivers the callback directly — nothing else is needed.
val pkceFlow = createPKCEFlow(
webAuthSessionFactory = ::IosWebAuthSession,
httpClientEngine = Darwin.create(),
applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
)Start sign-in (which opens the browser) and observe authState — for example, from a shared view
model:
fun signIn() = pkceFlow.startSignIn()
init {
scope.launch {
pkceFlow.authState.collect { state ->
when (state.state) {
PKCEFlow.PKCEAuthState.State.FINISHED -> {
val tokens = state.tokenResponse
if (tokens != null) {
// Persist the tokens and continue into the app.
} else {
// Sign-in failed — show state.errorMessage.
}
pkceFlow.resetState()
}
else -> {
// NOT_STARTED / WAITING_FOR_AUTHORIZATION_CODE / EXCHANGING_AUTHORIZATION_CODE —
// optionally reflect progress in the UI.
}
}
}
}
}