diff --git a/README.md b/README.md index 70ea8b6..4f83a55 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/ICNativeClient/InternetIdentityAuthenticator.swift b/Sources/ICNativeClient/InternetIdentityAuthenticator.swift index 2d099cc..6cc55b6 100644 --- a/Sources/ICNativeClient/InternetIdentityAuthenticator.swift +++ b/Sources/ICNativeClient/InternetIdentityAuthenticator.swift @@ -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 @@ -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 @@ -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 } @@ -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, @@ -110,6 +117,7 @@ public final class ICInternetIdentityAuthenticator: NSObject, ASWebAuthenticatio public static func authorizationURL( callbackDomain: String, + callbackPath: String, configuration: ICClientConfiguration, state: String, requestID: String, @@ -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 { @@ -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, diff --git a/Tests/ICNativeClientTests/ICNativeClientTests.swift b/Tests/ICNativeClientTests/ICNativeClientTests.swift index 3ffca5e..9ded19f 100644 --- a/Tests/ICNativeClientTests/ICNativeClientTests.swift +++ b/Tests/ICNativeClientTests/ICNativeClientTests.swift @@ -6,6 +6,8 @@ import XCTest import ICNativeClient final class ICNativeClientTests: XCTestCase { + private let callbackPath = "/native-auth-callback" + func testAuthorizationTimedOutDescriptionIsRetryable() { XCTAssertEqual( ICClientError.authorizationTimedOut.errorDescription, @@ -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", @@ -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]) @@ -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, @@ -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, @@ -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() @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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()