Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Swift

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
test:
runs-on: macos-26
steps:
- uses: actions/checkout@v6
- name: Run package tests
run: swift test

ios:
runs-on: macos-26
steps:
- uses: actions/checkout@v6
- name: Run iOS tests
run: |
simulator_id="$(
xcrun simctl list devices available -j |
python3 -c 'import json, sys; devices = json.load(sys.stdin)["devices"]; print(next(device["udid"] for runtime in devices.values() for device in runtime if device.get("isAvailable") and device["name"].startswith("iPhone")))'
)"
xcodebuild test \
-scheme ICNativeClient \
-destination "platform=iOS Simulator,id=${simulator_id}"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.DS_Store
.build/
.swiftpm/
DerivedData/
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ let reply = try await client.queryRaw(method: "some_query", arg: candidArg)

For signed calls, obtain an `ICAuthSession` through `ICInternetIdentityAuthenticator` or restore one with `ICIdentityStore`.

## Internet Identity Authentication

`ICInternetIdentityAuthenticator.authenticate()` opens the configured native-auth
bridge in `ASWebAuthenticationSession`. Authorization is bounded to 330 seconds
by default and throws `ICClientError.authorizationTimedOut` when that deadline
expires. Task cancellation also cancels the active browser session.

```swift
let session = try await authenticator.authenticate(
timeout: .seconds(330),
prefersEphemeralWebBrowserSession: false
)
```

The normal shared browser session is the default so passkeys and existing
Internet Identity sessions remain available. Set
`prefersEphemeralWebBrowserSession` only for an explicit clean-session test; do
not enable it as the production default.

## Certificate Verification

`read_state` polling trusts the boundary node response as an update-completion signal and does not verify BLS certificates or certified data roots.
Expand Down
3 changes: 3 additions & 0 deletions Sources/ICNativeClient/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public enum ICClientError: Error, LocalizedError, Equatable {
case invalidIdentity(String)
case invalidPayload
case authorizationFailed(String)
case authorizationTimedOut
case expiredDelegation
case emptyResponse
case invalidResponse(String)
Expand All @@ -27,6 +28,8 @@ public enum ICClientError: Error, LocalizedError, Equatable {
return "Internet Identity returned an invalid payload."
case .authorizationFailed(let message):
return message
case .authorizationTimedOut:
return "Internet Identity authorization timed out. Please try again."
case .expiredDelegation:
return "Internet Identity delegation expired."
case .emptyResponse:
Expand Down
127 changes: 91 additions & 36 deletions Sources/ICNativeClient/InternetIdentityAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import UIKit

@available(iOS 17.4, *)
public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticationPresentationContextProviding {
public static let callbackPath = "/ios-auth-callback"
public nonisolated static let callbackPath = "/ios-auth-callback"
public nonisolated static let defaultAuthorizationTimeout: Duration = .seconds(330)

private let configuration: ICClientConfiguration
private let authOrigin: URL
private let callbackDomain: String
private var activeSession: ASWebAuthenticationSession?
@MainActor private var activeAttempt: AuthorizationAttempt?

public init(configuration: ICClientConfiguration, authOrigin: URL, callbackDomain: String) {
self.configuration = configuration
Expand All @@ -23,7 +24,17 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
}

@MainActor
public func authenticate() async throws -> ICAuthSession {
public func authenticate(
timeout: Duration = ICInternetIdentityAuthenticator.defaultAuthorizationTimeout,
prefersEphemeralWebBrowserSession: Bool = false
) async throws -> ICAuthSession {
guard timeout > .zero else {
throw ICClientError.authorizationFailed("Internet Identity authorization timeout must be positive.")
}
guard activeAttempt == nil else {
throw ICClientError.authorizationFailed("Internet Identity authorization is already in progress.")
}

let privateKey = Curve25519.Signing.PrivateKey()
let state = UUID().uuidString
let url = Self.authorizationURL(
Expand All @@ -35,50 +46,84 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
)
let callback = Self.callbackMatcher(callbackDomain: callbackDomain)

return try await withCheckedThrowingContinuation { continuation in
var didComplete = false
let finish: @MainActor (Result<ICAuthSession, Error>) -> Void = { result in
guard !didComplete else { return }
didComplete = true
self.activeSession = nil
switch result {
case .success(let session):
continuation.resume(returning: session)
case .failure(let error):
continuation.resume(throwing: error)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
if Task.isCancelled {
continuation.resume(throwing: CancellationError())
return
}
}
let session = ASWebAuthenticationSession(url: url, callback: callback) { callbackURL, error in
Task { @MainActor in
if let error {
finish(.failure(error))
return
}
guard let callbackURL else {
finish(.failure(ICClientError.invalidPayload))
return

let attempt = AuthorizationAttempt(continuation: continuation)
let session = ASWebAuthenticationSession(url: url, callback: callback) { [weak self, weak attempt] callbackURL, error in
Task { @MainActor in
guard let self, let attempt else { return }
if let error {
self.finish(attempt, with: .failure(error))
return
}
guard let callbackURL else {
self.finish(attempt, with: .failure(ICClientError.invalidPayload))
return
}
do {
let session = try Self.session(
from: callbackURL,
expectedState: state,
privateKey: privateKey,
configuration: self.configuration
)
self.finish(attempt, with: .success(session))
} catch {
self.finish(attempt, with: .failure(error))
}
}
}
attempt.session = session
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession
activeAttempt = attempt
attempt.timeoutTask = Task { [weak self, weak attempt] in
do {
let session = try Self.session(
from: callbackURL,
expectedState: state,
privateKey: privateKey,
configuration: self.configuration
)
finish(.success(session))
try await Task.sleep(for: timeout)
} catch {
finish(.failure(error))
return
}
guard let self, let attempt else { return }
self.finish(attempt, with: .failure(ICClientError.authorizationTimedOut), cancelSession: true)
}
if !session.start() {
finish(
attempt,
with: .failure(ICClientError.authorizationFailed("Internet Identity could not start."))
)
}
}
session.presentationContextProvider = self
activeSession = session
if !session.start() {
finish(.failure(ICClientError.authorizationFailed("Internet Identity could not start.")))
} onCancel: {
Task { @MainActor [weak self] in
guard let self, let attempt = self.activeAttempt else { return }
self.finish(attempt, with: .failure(CancellationError()), cancelSession: true)
}
}
}

@MainActor
private func finish(
_ attempt: AuthorizationAttempt,
with result: Result<ICAuthSession, Error>,
cancelSession: Bool = false
) {
guard activeAttempt === attempt, let continuation = attempt.continuation else { return }
activeAttempt = nil
attempt.continuation = nil
attempt.timeoutTask?.cancel()
attempt.timeoutTask = nil
if cancelSession {
attempt.session?.cancel()
}
attempt.session = nil
continuation.resume(with: result)
}

public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
Expand Down Expand Up @@ -169,5 +214,15 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
}
return values
}

private final class AuthorizationAttempt {
var continuation: CheckedContinuation<ICAuthSession, Error>?
var session: ASWebAuthenticationSession?
var timeoutTask: Task<Void, Never>?

init(continuation: CheckedContinuation<ICAuthSession, Error>) {
self.continuation = continuation
}
}
}
#endif
138 changes: 138 additions & 0 deletions Tests/ICNativeClientTests/ICNativeClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,144 @@ import XCTest
import ICNativeClient

final class ICNativeClientTests: XCTestCase {
func testAuthorizationTimedOutDescriptionIsRetryable() {
XCTAssertEqual(
ICClientError.authorizationTimedOut.errorDescription,
"Internet Identity authorization timed out. Please try again."
)
}

#if canImport(UIKit)
@available(iOS 17.4, *)
func testAuthorizationDefaultsAndURLPreserveBridgeContract() throws {
XCTAssertEqual(
ICInternetIdentityAuthenticator.defaultAuthorizationTimeout,
.seconds(330)
)

let configuration = testConfiguration()
let privateKey = Curve25519.Signing.PrivateKey()
let url = ICInternetIdentityAuthenticator.authorizationURL(
authOrigin: URL(string: "https://wiki.kinic.xyz")!,
callbackDomain: "wiki.kinic.xyz",
configuration: configuration,
state: "expected-state",
privateKey: privateKey
)
let fragment = try XCTUnwrap(url.fragment)
let fragmentParts = fragment.split(separator: "?", maxSplits: 1)
XCTAssertEqual(fragmentParts.first, "/native-auth")
let query = fragmentParts.count == 2 ? String(fragmentParts[1]) : ""
var queryComponents = URLComponents()
queryComponents.percentEncodedQuery = query
let values = Dictionary(
uniqueKeysWithValues: (queryComponents.queryItems ?? []).compactMap { item in
item.value.map { (item.name, $0) }
}
)

XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "wiki.kinic.xyz")
XCTAssertEqual(values["state"], "expected-state")
XCTAssertEqual(values["callback"], "https://wiki.kinic.xyz/ios-auth-callback")
XCTAssertEqual(values["maxTimeToLive"], ICIdentityBridge.maxTimeToLiveNanos)
XCTAssertEqual(values["identityProvider"], configuration.identityProvider.absoluteString)
XCTAssertNotNil(values["sessionPublicKey"])
}

@available(iOS 17.4, *)
func testCallbackBuildsSessionForExpectedState() throws {
let configuration = testConfiguration()
let privateKey = Curve25519.Signing.PrivateKey()
let payload = identityPayload(sessionPrivateKey: privateKey)
let callbackURL = try makeCallbackURL(
queryItems: [
URLQueryItem(name: "state", value: "expected-state"),
URLQueryItem(
name: "result",
value: ICInternetIdentityAuthenticator.base64URLEncoded(Data(payload.utf8))
),
]
)

let session = try ICInternetIdentityAuthenticator.session(
from: callbackURL,
expectedState: "expected-state",
privateKey: privateKey,
configuration: configuration
)

XCTAssertEqual(session.canisterId, configuration.canisterId)
XCTAssertEqual(session.identityProvider, configuration.identityProvider.absoluteString)
}

@available(iOS 17.4, *)
func testCallbackRejectsMismatchedState() throws {
let callbackURL = try makeCallbackURL(
queryItems: [URLQueryItem(name: "state", value: "unexpected-state")]
)

XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: callbackURL,
expectedState: "expected-state",
privateKey: Curve25519.Signing.PrivateKey(),
configuration: testConfiguration()
)) { error in
XCTAssertEqual(error as? ICClientError, .invalidPayload)
}
}

@available(iOS 17.4, *)
func testCallbackRejectsDuplicateQueryItems() throws {
let callbackURL = try makeCallbackURL(
queryItems: [
URLQueryItem(name: "state", value: "expected-state"),
URLQueryItem(name: "state", value: "expected-state"),
]
)

XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: callbackURL,
expectedState: "expected-state",
privateKey: Curve25519.Signing.PrivateKey(),
configuration: testConfiguration()
)) { error in
XCTAssertEqual(error as? ICClientError, .invalidPayload)
}
}

@available(iOS 17.4, *)
func testCallbackRejectsMalformedPayload() throws {
let callbackURL = try makeCallbackURL(
queryItems: [
URLQueryItem(name: "state", value: "expected-state"),
URLQueryItem(
name: "result",
value: ICInternetIdentityAuthenticator.base64URLEncoded(Data("{".utf8))
),
]
)

XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: callbackURL,
expectedState: "expected-state",
privateKey: Curve25519.Signing.PrivateKey(),
configuration: testConfiguration()
)) { error in
XCTAssertEqual(error as? ICClientError, .invalidPayload)
}
}

private func makeCallbackURL(queryItems: [URLQueryItem]) throws -> URL {
var components = URLComponents(
url: URL(string: "https://wiki.kinic.xyz/ios-auth-callback")!,
resolvingAgainstBaseURL: false
)
components?.queryItems = queryItems
return try XCTUnwrap(components?.url)
}
#endif

func testPrincipalRoundTrip() throws {
let principal = try XCTUnwrap(ICPrincipal.parse("bkyz2-fmaaa-aaaaa-qaaaq-cai"))
XCTAssertEqual(ICPrincipal.text(from: principal), "bkyz2-fmaaa-aaaaa-qaaaq-cai")
Expand Down