diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..0697a9a --- /dev/null +++ b/.github/workflows/swift.yml @@ -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}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7689f0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.build/ +.swiftpm/ +DerivedData/ diff --git a/README.md b/README.md index 43f737a..dd8e44a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/ICNativeClient/Errors.swift b/Sources/ICNativeClient/Errors.swift index 0c9c0f9..b4cefee 100644 --- a/Sources/ICNativeClient/Errors.swift +++ b/Sources/ICNativeClient/Errors.swift @@ -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) @@ -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: diff --git a/Sources/ICNativeClient/InternetIdentityAuthenticator.swift b/Sources/ICNativeClient/InternetIdentityAuthenticator.swift index ea66894..1b0e76d 100644 --- a/Sources/ICNativeClient/InternetIdentityAuthenticator.swift +++ b/Sources/ICNativeClient/InternetIdentityAuthenticator.swift @@ -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 @@ -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( @@ -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) -> 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, + 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 } @@ -169,5 +214,15 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio } return values } + + private final class AuthorizationAttempt { + var continuation: CheckedContinuation? + var session: ASWebAuthenticationSession? + var timeoutTask: Task? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + } } #endif diff --git a/Tests/ICNativeClientTests/ICNativeClientTests.swift b/Tests/ICNativeClientTests/ICNativeClientTests.swift index 0e16a7e..4e0547e 100644 --- a/Tests/ICNativeClientTests/ICNativeClientTests.swift +++ b/Tests/ICNativeClientTests/ICNativeClientTests.swift @@ -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")