Skip to content

Native Swift package for iOS apps #494

Description

@scarlettmueller96

Update: the two smaller API gaps originally filed here have been split out as requested —
#496 (additional parameters on token requests) and #497 (Migration.setTokens not seeding the
snapshot nonce, filed as a bug). This issue now covers only the Swift package.

Summary

The docs say a Swift package for native iOS apps is "still pending". I'd like to help make that
happen, and I have a working proposal in #495 — but I'd rather agree the shape with you than have you
spend review time on something you'd want built differently.

Context: we're currently migrating a production Android app from AppAuth to Lokksmith — the work is
in progress and not released yet — and we'd like to do the same for our native Swift iOS app, which
is what led me here. So this comes from wanting to use Lokksmith in production rather than from
wanting to write a library.

The API isn't reachable from Swift

Even with a framework in hand, lokksmith-core's public surface doesn't survive Objective-C export:

  • create / getOrCreate take a CreateContext builder configured through write-only extension
    properties
    (CreateContext.id, .discoveryUrl, .metadata). Extension properties aren't
    exported as settable members, so a client cannot be created from Swift at all.
  • Lokksmith.Options needs CoroutineScope, HttpClientEngine and SuspendFunction0<Instant>,
    and Objective-C export drops default arguments — so Swift would have to supply all five.
  • Client.key / Client.id are value classes.
  • Client.tokens is a StateFlow; runWithTokens takes a suspend function type, which cannot be
    exported as a parameter at all.
  • Scope is a sealed class; errors are Kotlin exception types, which surface as opaque NSErrors
    without @Throws.

So a hand-written facade is needed regardless — which I think is fine, and arguably better than
exporting core wholesale: the facade can be small and shaped for Swift rather than mirroring Kotlin.

There's no artifact to link

No Package.swift, no XCFramework in the release.

Why Objective-C export rather than Kotlin's native Swift export

Worth being explicit about this, since Swift export is the more modern answer and it would be
reasonable to ask why the proposal doesn't use it.

Kotlin's native Swift export is genuinely nicer where it applies: suspend maps to Swift async,
Flow to AsyncSequence, and there's no Objective-C layer flattening namespaces or renaming enum
entries. We already use it for one of our own internal KMP packages and it's working well so far, so
this isn't scepticism about the feature.

Two reasons we didn't propose it here:

  1. It's still Alpha, with breaking changes expected. Choosing a not-yet-stable export mechanism
    is a very different decision for a published library than for our own internal package, where we
    control both sides and can absorb churn on our own schedule. That call belongs to you, not to a
    contributor — so proposing it felt out of place.
  2. It doesn't produce a distributable artifact yet. As far as I can see, it emits Swift sources
    plus a static library into the Xcode build directory, for a workflow where Xcode drives the Kotlin
    build. That's fine when the consumer builds the Kotlin themselves — it's how our internal package
    works — but a published Swift package has to ship a prebuilt binary that SPM can fetch from a
    release, and I don't think Swift export can do that today. Happy to be corrected.

So Objective-C export plus the XCFramework() DSL and an SPM .binaryTarget looks like the only
route that gets a native iOS app a real dependency today. If Swift export becomes the right answer
later, the facade module is the natural place to switch: its whole job is to be the boundary, so the
public Swift shape could stay similar while the mechanism underneath changes.

What's in #495

A lokksmith-swift module, verified end to end on Kotlin 2.4.10 / Xcode 26.6:

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"
    )
)

let request = LokksmithAuthorizationRequest(redirectUri: "my-app://openid-response")
request.scopes = ["profile", "email"]

// Presents ASWebAuthenticationSession, exchanges the code, validates and persists.
// nil when the user dismissed the browser.
if let tokens = try await client.authorize(request: request) {  }

// Synchronous, no network — for "is the user signed in?"
if client.isAuthenticated {  }

// Refreshes only when needed.
let accessToken = try await client.freshTokens().accessToken.token

Notable points:

  • lokksmith-core stays an implementation dependency and is deliberately not exported, so the
    generated header contains only Lokksmith* declarations. Verified against the built header.
  • LokksmithFailure.kind separates a provider rejection (oAuthRejection) from a transient
    transport failure — the distinction a client app has to act on, and one that is currently
    awkward to recover from a bare NSError.
  • A swiftApiSmokeTest Gradle task type-checks a real Swift sample against the assembled
    XCFramework and is wired into check. Since CI already runs on macos-latest, this guards the
    exported surface and the documented snippets. I confirmed it fails on a deliberate break — worth
    having, because interop regressions (a default argument added, an enum renamed) are invisible from
    Kotlin.
  • Release flow: a packageSwiftArtifact task zips the XCFramework and prints the SHA-256, which I
    verified matches swift package compute-checksum. Wiring that into your release automation is
    your call — I left Package.swift with a placeholder checksum rather than guess at how you'd want
    release-please to update it.

Open questions, all yours to decide

  • Naming. I used LokksmithManager, LokksmithClient, LokksmithTokens. The Lokksmith
    prefix is needed because Objective-C export has no namespaces, but LokksmithManager for the
    thing that is Lokksmith in Kotlin is a compromise. Open to anything.
  • Scope of the facade. I covered create/get/delete client, authorization, refresh, freshTokens,
    reset, end-session and migration. I left out Snapshot, Jwt/Claims and multiple-client
    niceties.
  • Targets. iosArm64 + iosSimulatorArm64, matching core. No iosX64, so Intel Macs can't run
    it in the simulator. Adding it would mean adding the target to core too.
  • iOS 15 minimum, which is just Kotlin/Native's default here.
  • Whether you want this as a module in this repo at all, versus a separate one.

AI disclosure: this issue text, the description of #495 and the module code in it were written
with the help of Claude (Claude Code, Opus 5), with me directing and reviewing. The commit on #495
carries a Co-Authored-By: Claude Opus 5 trailer. The verification claims above were actually
executed rather than asserted — see #495 for what was and wasn't checked.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions