kalinjul / kotlin-multiplatform-oidc

Kotlin Multiplatform OpenIDConnect implementation for Android/iOS
https://kalinjul.github.io/kotlin-multiplatform-oidc/
Apache License 2.0
51 stars 16 forks source link
android authorization ios kotlin mobile multiplatform oauth2 openid-connect

Kotlin Multiplatform OIDC

Build Maven Central Snapshot Kotlin Version

Library for using OpenId Connect / OAuth 2.0 in Kotlin Multiplatform (iOS+Android), Android and Xcode projects. This project aims to be a lightweight implementation without sophisticated validation on client side. Simple Desktop support is included via an embedded Webserver that listens for redirects.

The library is designed for kotlin multiplatform, Android-only and iOS only Apps. For iOS only, use the OpenIdConnectClient Swift Package.

You can find the full Api documentation here.

Library dependency versions:

kmp-oidc version kotlin version ktor version
<=0.11.1 1.9.23 2.3.7
0.11.2 2.0.20 2.3.7
0.12.+ 2.0.20 3.0.+

Note that while the library may work with other kotlin/ktor versions, proceed at your own risk.

Dependency

Add the dependency to your commonMain sourceSet (KMP) / Android dependencies (android only):

implementation("io.github.kalinjul.kotlin.multiplatform:oidc-appsupport:<version>")
implementation("io.github.kalinjul.kotlin.multiplatform:oidc-okhttp4:<version>") // optional, android only
implementation("io.github.kalinjul.kotlin.multiplatform:oidc-ktor:<version>") // optional ktor support

Or, for your libs.versions.toml:

[versions]
oidc = "<version>>"
[libraries]
oidc-appsupport = { module = "io.github.kalinjul.kotlin.multiplatform:oidc-appsupport", version.ref = "oidc" }
oidc-okhttp4 = { module = "io.github.kalinjul.kotlin.multiplatform:oidc-okhttp4", version.ref = "oidc" }
oidc-ktor = { module = "io.github.kalinjul.kotlin.multiplatform:oidc-ktor", version.ref = "oidc" }

Using a snapshot version

If you want try a snapshot version, just add maven("https://s01.oss.sonatype.org/content/repositories/snapshots/") to your repositories. See available snapshots.

Compiler options

If you want to run tests, currently (as of kotlin 1.9.22), you need to pass additional linker flags (adjust the path to your Xcode installation):

iosSimulatorArm64().compilations.all {
    kotlinOptions {
        freeCompilerArgs = listOf("-linker-options", "-L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator")
    }
}

Usage

Redirect scheme

For OpenIDConnect/OAuth to work, you have to provide the redirect uri in your Android App's build.gradle:

build.gradle.kts:

android {
    defaultConfig {
        addManifestPlaceholders(
            mapOf("oidcRedirectScheme" to "<uri scheme>")
        )
    }
}

iOS does not require declaring the redirect scheme.

OpenID Configuration (common code)

Create an OpenIdConnectClient:

val client = OpenIdConnectClient(discoveryUri = "<discovery url>") {
    endpoints {
        tokenEndpoint = "<tokenEndpoint>"
        authorizationEndpoint = "<authorizationEndpoint>"
        userInfoEndpoint = null
        endSessionEndpoint = "<endSessionEndpoint>"
    }

    clientId = "<clientId>"
    clientSecret = "<clientSecret>"
    scope = "openid profile"
    codeChallengeMethod = CodeChallengeMethod.S256
    redirectUri = "<redirectUri>"
}

If you provide a Discovery URI, you may skip the endpoint configuration and call discover() on the client to retrieve the endpoint configuration.

Create a Code Auth Flow instance (platform specific)

The Code Auth Flow method is implemented by CodeAuthFlow. You'll need platform specific variants, so we'll use a factory to get an instance.

For Android, you should have a single global instance of [AndroidCodeAuthFlowFactory], preferably using Dependency Injection. You will than need to register your activity in your Activity's onCreate():

class MainActivity : ComponentActivity() {
    // There should only be one instance of this factory.
    // The flow should also be created and started from an
    // Application or ViewModel scope, so it persists Activity.onDestroy() e.g. on low memory
    // and is still able to process redirect results during login.
    val codeAuthFlowFactory = AndroidCodeAuthFlowFactory(useWebView = false)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        codeAuthFlowFactory.registerActivity(this)
    }
}

[!IMPORTANT]
You MUST register your activity using registerActivity() in onCreate() or earlier, as the factory will attach to the ComponentActivity's lifecycle. If you don't use ComponentActivity, you'll need to implement your own Factory.

For the iOS part, you can use IosCodeAuthFlowFactory. Both factories implement CodeAuthFlowFactory and can be provided using Dependency Injection.

For more information, have a look at the KMP sample app.

Authenticate (common code)

Request tokens using code auth flow (this will open the browser for login):

val flow = authFlowFactory.createAuthFlow(client)
val tokens = flow.getAccessToken()

Perform refresh or endSession:

tokens.refresh_token?.let { client.refreshToken(refreshToken = it) }
tokens.id_token?.let { client.endSession(idToken = it) }

Custom headers/url parameters

For most calls (getAccessToken(), refreshToken(), endSession()), you may provide additional configuration for the http call, like headers or parameters using the configure closure parameter:

client.endSession(idToken = idToken) {
    headers.append("X-CUSTOM-HEADER", "value")
    url.parameters.append("custom_parameter", "value")
}
val tokens = flow.getAccessToken(configureAuthUrl = {
    // customize url that is passed to browser for authorization requests
    parameters.append("prompt", "login")
}, configureTokenExchange = {
    // customize token exchange http request
    header("additionalHeaderField", "value")
})

JWT Parsing

We provide simple JWT parsing (without any validation):

val jwt = tokens.id_token?.let { Jwt.parse(it) }
println(jwt?.payload?.aud) // print audience
println(jwt?.payload?.iss) // print issuer
println(jwt?.payload?.additionalClaims?.get("email")) // get claim

Token Store (experimental)

Since persisting tokens is a common task in OpenID Connect Authentication, we provide a TokenStore that uses a Multiplatform Settings Library to persist tokens in Keystore (iOS) / Encrypted Preferences (Android). If you use the TokenStore, you may also make use of TokenRefreshHandler for synchronized token refreshes.

tokenstore.saveTokens(tokens)
val accessToken = tokenstore.getAccessToken()

val refreshHandler = TokenRefreshHandler(tokenStore = tokenstore)
refreshHandler.refreshAndSaveToken(client, oldAccessToken = token) // thread-safe refresh and save new tokens to store

Android implementation is AndroidEncryptedPreferencesSettingsStore, for iOS use IosKeychainTokenStore.

OkHttp support (Android only) (experimental)

val authenticator = OpenIdConnectAuthenticator {
    getAccessToken { tokenStore.getAccessToken() }
    refreshTokens { oldAccessToken -> refreshHandler.refreshAndSaveToken(client, oldAccessToken) }
    onRefreshFailed {
        // provided by app: user has to authenticate again
    }
    buildRequest {
        header("AdditionalHeader", "value") // add custom header to all requests
    }
}

val okHttpClient = OkHttpClient.Builder()
    .authenticator(authenticator)
    .build()

Ktor support (experimental)

    HttpClient(engine) {
        install(Auth) {
            oidcBearer(
                tokenStore = tokenStore,
                refreshHandler = refreshHandler,
                client = client,
            )
        }
    }
}

Because of the way ktor works, you need to tell the client if the token is invalidated outside of ktor's refresh logic, e.g. on logout:

    ktorHttpClient.clearTokens()