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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ by default and throws `ICClientError.authorizationTimedOut` when that deadline
expires. Task cancellation also cancels the active browser session.

```swift
let authenticator = ICInternetIdentityAuthenticator(
configuration: configuration,
callbackDomain: "app.example.com",
callbackPath: "/native-auth-callback"
)
let session = try await authenticator.authenticate(
timeout: .seconds(330),
prefersEphemeralWebBrowserSession: false
Expand Down
45 changes: 37 additions & 8 deletions Sources/ICNativeClient/InternetIdentityAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import UIKit

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

private let configuration: ICClientConfiguration
private let callbackDomain: String
private let callbackPath: String
@MainActor private var activeAttempt: AuthorizationAttempt?

public init(configuration: ICClientConfiguration, callbackDomain: String) {
public init(configuration: ICClientConfiguration, callbackDomain: String, callbackPath: String) {
self.configuration = configuration
self.callbackDomain = callbackDomain
self.callbackPath = callbackPath
}

@MainActor
Expand All @@ -37,11 +38,16 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
let requestID = UUID().uuidString
let url = try Self.authorizationURL(
callbackDomain: callbackDomain,
callbackPath: callbackPath,
configuration: configuration,
state: state,
requestID: requestID,
privateKey: privateKey
)
let callbackMatcher = try Self.callbackMatcher(
callbackDomain: callbackDomain,
callbackPath: callbackPath
)

return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
Expand All @@ -53,7 +59,7 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
let attempt = AuthorizationAttempt(continuation: continuation)
let session = ASWebAuthenticationSession(
url: url,
callback: Self.callbackMatcher(callbackDomain: callbackDomain)
callback: callbackMatcher
) { [weak self, weak attempt] callbackURL, error in
Task { @MainActor in
guard let self, let attempt else { return }
Expand All @@ -69,6 +75,7 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
let authSession = try Self.session(
from: callbackURL,
callbackDomain: self.callbackDomain,
callbackPath: self.callbackPath,
expectedState: state,
expectedRequestID: requestID,
privateKey: privateKey,
Expand Down Expand Up @@ -110,6 +117,7 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio

public static func authorizationURL(
callbackDomain: String,
callbackPath: String,
configuration: ICClientConfiguration,
state: String,
requestID: String,
Expand Down Expand Up @@ -141,7 +149,10 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
var fragment = URLComponents()
fragment.queryItems = [
URLQueryItem(name: "message", value: message),
URLQueryItem(name: "callback", value: callbackURL(callbackDomain: callbackDomain).absoluteString),
URLQueryItem(
name: "callback",
value: try callbackURL(callbackDomain: callbackDomain, callbackPath: callbackPath).absoluteString
),
URLQueryItem(name: "state", value: state),
]
guard let encodedFragment = fragment.percentEncodedQuery else {
Expand All @@ -160,17 +171,35 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio
return url
}

public static func callbackURL(callbackDomain: String) -> URL {
URL(string: "https://\(callbackDomain)\(callbackPath)")!
public static func callbackURL(callbackDomain: String, callbackPath: String) throws -> URL {
guard callbackPath.hasPrefix("/"),
!callbackPath.hasPrefix("//"),
let url = URL(string: "https://\(callbackDomain)\(callbackPath)"),
url.scheme == "https",
url.host == callbackDomain,
url.port == nil,
url.user == nil,
url.password == nil,
url.path == callbackPath,
url.query == nil,
url.fragment == nil else {
throw ICClientError.invalidPayload
}
return url
}

public static func callbackMatcher(callbackDomain: String) -> ASWebAuthenticationSession.Callback {
.https(host: callbackDomain, path: callbackPath)
public static func callbackMatcher(
callbackDomain: String,
callbackPath: String
) throws -> ASWebAuthenticationSession.Callback {
_ = try callbackURL(callbackDomain: callbackDomain, callbackPath: callbackPath)
return .https(host: callbackDomain, path: callbackPath)
}

public static func session(
from callbackURL: URL,
callbackDomain: String,
callbackPath: String,
expectedState: String,
expectedRequestID: String,
privateKey: Curve25519.Signing.PrivateKey,
Expand Down
57 changes: 54 additions & 3 deletions Tests/ICNativeClientTests/ICNativeClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import XCTest
import ICNativeClient

final class ICNativeClientTests: XCTestCase {
private let callbackPath = "/native-auth-callback"

func testAuthorizationTimedOutDescriptionIsRetryable() {
XCTAssertEqual(
ICClientError.authorizationTimedOut.errorDescription,
Expand All @@ -31,6 +33,7 @@ final class ICNativeClientTests: XCTestCase {
}
let url = try ICInternetIdentityAuthenticator.authorizationURL(
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
configuration: configuration,
state: "expected-state",
requestID: "request-1",
Expand All @@ -54,7 +57,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertTrue(percentEncodedFragment.contains("%2B"))
XCTAssertFalse(percentEncodedFragment.contains("+"))
XCTAssertEqual(values["state"], "expected-state")
XCTAssertEqual(values["callback"], "https://wiki.kinic.xyz/ios-auth-callback")
XCTAssertEqual(values["callback"], "https://wiki.kinic.xyz/native-auth-callback")
let message = try XCTUnwrap(values["message"]?.data(using: .utf8))
let request = try XCTUnwrap(JSONSerialization.jsonObject(with: message) as? [String: Any])
let params = try XCTUnwrap(request["params"] as? [String: Any])
Expand Down Expand Up @@ -85,6 +88,7 @@ final class ICNativeClientTests: XCTestCase {
let session = try ICInternetIdentityAuthenticator.session(
from: callbackURL,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand All @@ -108,6 +112,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: callbackURL,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand All @@ -117,6 +122,43 @@ final class ICNativeClientTests: XCTestCase {
}
}

@available(iOS 17.4, *)
func testCallbackRejectsOldPath() throws {
let privateKey = Curve25519.Signing.PrivateKey()
let callbackURL = try makeCallbackURL(
path: "/ios-auth-callback",
fragmentItems: [
URLQueryItem(
name: "message",
value: delegationResponse(requestID: "request-1", sessionPrivateKey: privateKey)
),
URLQueryItem(name: "state", value: "expected-state"),
]
)

XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: callbackURL,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
configuration: testConfiguration()
)) { error in
XCTAssertEqual(error as? ICClientError, .invalidPayload)
}
}

@available(iOS 17.4, *)
func testCallbackURLRejectsInvalidPath() {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.callbackURL(
callbackDomain: "wiki.kinic.xyz",
callbackPath: "native-auth-callback"
)) { error in
XCTAssertEqual(error as? ICClientError, .invalidPayload)
}
}

@available(iOS 17.4, *)
func testCallbackRejectsDuplicateFragmentItems() throws {
let privateKey = Curve25519.Signing.PrivateKey()
Expand All @@ -131,6 +173,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: callbackURL,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand All @@ -151,6 +194,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: callbackURL,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand All @@ -174,6 +218,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: invalidBase64,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand All @@ -193,6 +238,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: excessiveLifetime,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand All @@ -216,6 +262,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: wrongLeaf,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand All @@ -233,6 +280,7 @@ final class ICNativeClientTests: XCTestCase {
XCTAssertThrowsError(try ICInternetIdentityAuthenticator.session(
from: wrongTarget,
callbackDomain: "wiki.kinic.xyz",
callbackPath: callbackPath,
expectedState: "expected-state",
expectedRequestID: "request-1",
privateKey: privateKey,
Expand Down Expand Up @@ -286,9 +334,12 @@ final class ICNativeClientTests: XCTestCase {
return String(decoding: try! JSONSerialization.data(withJSONObject: response), as: UTF8.self)
}

private func makeCallbackURL(fragmentItems: [URLQueryItem]) throws -> URL {
private func makeCallbackURL(
path: String = "/native-auth-callback",
fragmentItems: [URLQueryItem]
) throws -> URL {
var components = URLComponents(
url: URL(string: "https://wiki.kinic.xyz/ios-auth-callback")!,
url: URL(string: "https://wiki.kinic.xyz\(path)")!,
resolvingAgainstBaseURL: false
)
var fragment = URLComponents()
Expand Down