Skip to content

Usage

The main entry point is the Lokksmith class, which manages and persists authentication clients and their state across your application. To obtain an instance, use the platform-specific createLokksmith() function. The function has an optional options argument for tweaking the behaviour of Lokksmith. Please read the source code documentation for details.

Note

It is recommended to create a single shared Lokksmith instance, ideally provided via dependency injection.

val lokksmith = createLokksmith()

Info

The factory function differs slightly per platform:

  • On Android it also requires the current Context.
  • On Desktop it requires a dataDirectory specifying where Lokksmith stores its data, for example createLokksmith(dataDirectory = DataDirectory.Default("my-app")).
  • On Web it optionally accepts handleRedirectOnStartup (default true) to automatically complete an auth flow after the redirect; see Web.

Use getOrCreate() to retrieve an existing client by its unique key, or create a new one if it does not exist. This key is independent of the OAuth client ID, but you may use the same value if it suits your use case. Defining distinct keys allows you to manage multiple authentication clients within your application.

val client = lokksmith.getOrCreate("my-key") {
    id = "my-client-id"
    discoveryUrl = "https://example.com/.well-known/openid-configuration"
}

Note

Many functions of Lokksmith are suspending and must be run in a Coroutine, like getOrCreate() in this case.

Once you have a client instance, it is time to call the Authorization Code Flow to obtain the tokens.

val authFlow = client.authorizationCodeFlow(
    AuthorizationCodeFlow.Request( // (1)!
        redirectUri = "my-app://openid-response"
    )
)

val initiation = authFlow.prepare()
  1. See code documentation of AuthorizationCodeFlow.Request for more details.

The next step is to call the request URL from the Initiation object and pass the returned response to the auth flow.

// Open system browser with initiation.requestUrl, pass response to auth flow

authFlow.onResponse(response)

Note

See platform-specific implementation details below.

In a best case scenario the client is now authenticated and received the tokens.

You can now access the tokens through client.tokens, which is a Coroutine Flow, or client.runWithTokens().

Note

The tokens Flow does not automatically refresh tokens when they expire. Use runWithTokens() to ensure fresh tokens when required.

Singleton

A singleton Lokksmith instance must be provided during application startup as soon as possible via SingletonLokksmithProvider. This provider ensures that platform-specific response handling, which is decoupled from the initiation of an auth flow and is launched by the system implicitly, is able to retrieve the Lokksmith instance. On Android this should be executed in the Application class, for example.

SingletonLokksmithProvider.set(
    lokksmith = createLokksmith(),
    coroutineScope = MainScope(),
)

Provider-specific token request parameters

Some providers require a vendor-specific parameter on requests to the token endpoint. Set additionalTokenRequestParameters on the client options to have it sent with both the authorization code exchange and every token refresh:

val client = lokksmith.getOrCreate(
    key = "my-key",
    options = Client.Options(
        additionalTokenRequestParameters = mapOf("httpStatusCodes" to "true"),
    ),
) {
    id = "my-client-id"
    discoveryUrl = "https://example.com/.well-known/openid-configuration"
}

From Swift, the same options are set on LokksmithClientOptions and passed where the client is obtained:

let options = LokksmithClientOptions()
options.additionalTokenRequestParameters = ["httpStatusCodes": "true"]

let client = try await lokksmith.getOrCreateClient(
    key: "main",
    configuration: .companion.discovery(
        clientId: "my-client-id",
        discoveryUrl: "https://example.com/.well-known/openid-configuration"
    ),
    options: options
)

Options are not persisted with the client, so pass the same options every time it is read back with client(key:options:).

The example above is SAP Customer Data Cloud, which answers token endpoint errors with HTTP 200 and an error body unless httpStatusCodes=true is sent. Without it, Lokksmith cannot tell an OAuth error such as invalid_grant apart from a malformed response — which is the difference between "the session is dead" and "retry later".

Warning

Known OAuth and OIDC parameters are rejected with an IllegalArgumentException, so this cannot be used to override grant_type, client_id, code, code_verifier, redirect_uri or refresh_token. This is checked when the options are constructed and again when a request is sent, and the client keeps a defensive copy of the map, so mutating it afterwards has no effect.

These parameters are constant for the lifetime of the client. Values that need to vary per request are not covered by this option.

Platform-specific configuration

Some request values legitimately differ per platform. The most common is the redirect URI: a multiplatform app often registers a different URI per platform — for example a verified App Link / Universal Link such as https://app.example.com/redirect on Android and iOS, but a same-origin URL like https://example.com/redirect on Web. On Desktop the redirect URI is ignored and replaced by the loopback URL, so its value does not matter there.

Keep your shared flow code identical by delegating the platform-dependent value to an expect/actual declaration:

commonMain
expect val redirectUri: String
androidMain / iosMain
actual val redirectUri = "https://app.example.com/redirect"
wasmJsMain
actual val redirectUri = "https://example.com/redirect"
jvmMain (Desktop)
actual val redirectUri = "http://localhost/callback" // ignored; replaced by the loopback URL

Then build the request from shared code as usual:

commonMain
val authFlow = client.authorizationCodeFlow(
    AuthorizationCodeFlow.Request(redirectUri = redirectUri)
)

Tip

If more than the redirect URI differs between platforms, delegate the construction of the whole AuthorizationCodeFlow.Request (and EndSessionFlow.Request) to an expect/actual function instead of just the URI.

Platform implementations

Calling the system browser and handling the authentication response on mobile platforms involves multiple steps, including managing process death and app recreation. To ensure a seamless user experience, it is essential to persist and restore authentication state as needed. Lokksmith provides platform-specific implementations that abstract these complexities, making it easier to integrate secure authentication flows in your application.

Compose Multiplatform

The AuthFlowLauncher shown here works on Android, iOS, Desktop and Web.

Once you receive the Initiation object, use AuthFlowLauncher to start the authentication flow from your Composable. For example:

val uiState by viewModel.uiState.collectAsStateWithLifecycle() // (1)!
val authFlowLauncher = rememberAuthFlowLauncher()

LaunchedEffect(uiState.initiation) {
    uiState.initiation?.let { initiation ->
        authFlowLauncher.launch(initiation)    
    }
}
  1. data class UiState(val initiation: Initiation? = null)

Tip

launch accepts an optional options argument that allows you to customize Lokksmith's behavior. For example, on Android, you can choose between authentication using a Custom Tab or an Auth Tab.

You can either use authFlowLauncher.result to observe the current state of the process and update the user interface accordingly or use Client.authFlowResult(1) from your business logic (e.g. ViewModel) to pass the same result state to your UI state.

  1. See AuthFlowResultProvider

iOS

To launch an authentication flow from the iOS platform code of a Kotlin Multiplatform application, use launchAuthFlow():

lokksmith.launchAuthFlow(initiation)

Native iOS apps

Native iOS apps written in Swift use the Lokksmith Swift package described in Installation. It exposes a Swift-facing API rather than the Kotlin one: suspending functions become async, and the browser flow, code exchange and token persistence happen in a single call.

Create one LokksmithManager for the lifetime of the app and share it:

import Lokksmith

let lokksmith = LokksmithManager()

let client = try await lokksmith.getOrCreateClient(
    key: "main",
    configuration: .companion.discovery(
        clientId: "my-client-id",
        discoveryUrl: "https://example.com/.well-known/openid-configuration"
    )
)

authorize presents an ASWebAuthenticationSession, exchanges the authorization code, validates the tokens and persists them. It returns nil when the user dismisses the browser:

let request = LokksmithAuthorizationRequest(redirectUri: "my-app://openid-response")
request.scopes = ["profile", "email"]
request.prompts = [.login] // optional, forces re-authentication

if let tokens = try await client.authorize(request: request) {
    print(tokens.accessToken.token)
}

Note

Objective-C interop does not carry Kotlin default arguments, so optional request values are set as properties rather than passed to the initializer.

Reading the current tokens does not suspend and never triggers a network request, which makes it suitable for answering "is the user signed in?" synchronously:

if client.isAuthenticated { … }
let subject = client.tokens?.idToken.subject

On the request path, use freshTokens(). It refreshes only when the access token or the ID token is expired or about to expire:

let accessToken = try await client.freshTokens().accessToken.token

To react to token changes, for example to mirror the access token somewhere, observe them and cancel the returned handle when you are done:

let observation = client.observeTokens { tokens in
    // Called on the main thread, starting with the current value.
}
// later
observation.cancel()

Warning

Errors cross the Objective-C boundary as NSError. Unwrap LokksmithFailure to find out what went wrong. The distinction that matters is oAuthRejection, which means the provider rejected the grant and the session is dead, versus transport, which is transient and should not sign the user out:

do {
    let tokens = try await client.freshTokens()
} catch let error as NSError {
    let failure = error.userInfo["KotlinException"] as? LokksmithFailure
    switch failure?.kind {
    case .oAuthRejection: try await client.resetTokens()
    case .transport:      break // keep the session, retry later
    default:              break
    }
}

Local sign-out is resetTokens(), which discards the persisted tokens but keeps the client and its provider configuration. endSession(request:) additionally runs the provider's RP-initiated logout flow, when one is advertised.

Desktop

On Desktop, Lokksmith implements the loopback redirect described in RFC 8252 ("OAuth 2.0 for Native Apps"). When you prepare an auth flow, Lokksmith starts a temporary HTTP server bound to 127.0.0.1 on an ephemeral port and uses http://127.0.0.1:<port>/callback as the redirect URI. The redirectUri you pass to AuthorizationCodeFlow.Request (or EndSessionFlow.Request) is therefore ignored and replaced by this loopback URL.

Warning

Because the port is chosen at runtime, your OpenID provider must allow loopback redirect URIs (http://127.0.0.1 / http://localhost) with an arbitrary port, as recommended by RFC 8252. No custom URI scheme or manifest configuration is required on Desktop.

Use the Compose rememberAuthFlowLauncher() exactly as described under Compose Multiplatform. It opens the system browser, waits for the redirect on the loopback server, and completes the flow.

The Desktop behaviour can be customized via DesktopOptions when creating the instance:

val lokksmith = createLokksmith(
    dataDirectory = DataDirectory.Default("my-app"),
    desktop = DesktopOptions(
        redirectPath = "/callback",  // (1)!
        redirectTimeout = 5.minutes, // (2)!
        // browserLauncher, authorizationResponseHtml, endSessionResponseHtml, ...
    ),
)
  1. Path of the loopback redirect URI.
  2. How long to wait for the redirect before the flow times out.

Tip

See the Demo for a complete, runnable Desktop example, including how to test the flow against a local OpenID provider.

Web

On the Web (Kotlin/Wasm in the browser) an auth flow is completed via a full-page redirect: the browser navigates to the OpenID provider and is redirected back to a same-origin URL, which reloads and restarts the whole application. The redirectUri you pass to AuthorizationCodeFlow.Request (or EndSessionFlow.Request) must therefore be a URL on your app's own origin, for example https://myapp.example/. Custom URI schemes do not work in the browser.

Because the redirect restarts the app, there is no in-memory state to resume from. Lokksmith reads the response from the current URL on startup for you: by default createLokksmith() completes a pending flow automatically (controlled by the handleRedirectOnStartup argument). The result is then observed through common code via Client.authFlowResult or client.tokens, exactly as on the other platforms.

Use the Compose rememberAuthFlowLauncher() exactly as described under Compose Multiplatform to start the flow. From non-Compose code you can launch it directly, which navigates the current document to the request URL:

lokksmith.launchAuthFlow(initiation)

Manual redirect handling

To control when the redirect is processed, create the instance with createLokksmith(handleRedirectOnStartup = false) and call lokksmith.completeAuthFlowFromRedirect() yourself, for example during application startup.

Security

On the Web, Lokksmith persists its state — including tokens — in the browser's localStorage. The state is encrypted, but the encryption key is stored in localStorage too, so on this platform encryption is obfuscation rather than strong protection: any script on the same origin can read both. A cross-site scripting (XSS) vulnerability can therefore expose tokens. Apply a strong Content Security Policy and the usual XSS defenses.

Note

rememberAuthFlowLauncher().result is not restored after the full-page reload. Observe Client.authFlowResult (for example from your ViewModel) to react to the completed result.