diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index e37ba7e..c85c876 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -83,61 +83,19 @@ public actor PasskeyAuth { /// - Returns: A PasskeyResponse containing the registration result /// - Throws: Various PasskeyError cases if registration fails public func registerPasskey(displayName: String) async throws -> ( - ASAuthorizationPlatformPublicKeyCredentialRegistration, PasskeyResponse) { - try checkRateLimit() - - guard let presentationContextProvider = presentationContextProvider else { - throw PasskeyError.configurationError("Presentation context provider not set") - } - - guard !isAuthenticating else { - throw PasskeyError.authenticationInProgress - } + ASAuthorizationPlatformPublicKeyCredentialRegistration, PasskeyResponse + ) { + let presentationContextProvider = try prepareAuthentication() defer { self.setAuthenticating(false) } setAuthenticating(true) - guard var urlComponents = URLComponents(string: "\(configuration.baseURL)\(configuration.endpoints.registerChallenge)") else { - throw PasskeyError.invalidURL("Failed to create URL components for registration challenge") - } - - urlComponents.queryItems = [ - URLQueryItem(name: "displayName", value: displayName) - ] - - guard let url = urlComponents.url else { - throw PasskeyError.invalidURL("Failed to create URL from components") - } - - let (data, response) = try await session.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == 429 { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } - - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } - - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - guard let challengeB64 = json?["challenge"] as? String else { - throw PasskeyError.invalidChallenge("Challenge not found in response") - } - - guard let challengeData = challengeB64.base64URLDecoded() else { - throw PasskeyError.invalidChallenge("Failed to decode challenge") - } + let challengeData = try await getChallengeData( + endpoint: \.registerChallenge, + queryItemName: "displayName", + queryItemValue: displayName + ) let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: self.configuration.rpID @@ -146,7 +104,7 @@ public actor PasskeyAuth { let request = provider.createCredentialRegistrationRequest( challenge: challengeData, name: displayName, - userID: UUID().uuidString.data(using: .utf8)! + userID: Data(UUID().uuidString.utf8) ) request.userVerificationPreference = self.configuration.userVerificationPreference @@ -181,52 +139,13 @@ public actor PasskeyAuth { /// - Throws: Various PasskeyError cases if login fails public func loginWithPasskey() async throws -> ( ASAuthorizationPlatformPublicKeyCredentialAssertion, PasskeyResponse) { - try checkRateLimit() - - guard let presentationContextProvider = presentationContextProvider else { - throw PasskeyError.configurationError("Presentation context provider not set") - } - - guard !isAuthenticating else { - throw PasskeyError.authenticationInProgress - } + let presentationContextProvider = try prepareAuthentication() defer { self.setAuthenticating(false) } setAuthenticating(true) - guard let url = URL(string: "\(configuration.baseURL)\(configuration.endpoints.loginChallenge)") else { - throw PasskeyError.invalidURL("Failed to create URL for login challenge") - } - - let (data, response) = try await session.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == 429 { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } - - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } - - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - guard let challengeB64 = json?["challenge"] as? String else { - throw PasskeyError.invalidChallenge("Challenge not found in response") - } - - guard let challengeData = challengeB64.base64URLDecoded() else { - throw PasskeyError.invalidChallenge("Failed to decode challenge") - } + let challengeData = try await getChallengeData(endpoint: \.loginChallenge) let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: configuration.rpID @@ -281,7 +200,7 @@ public actor PasskeyAuth { fileprivate func setAuthenticating(_ value: Bool) { isAuthenticating = value } - + func postRegisterData( credentialID: Data, attestationObject: Data, @@ -289,14 +208,6 @@ public actor PasskeyAuth { ) async throws -> PasskeyResponse { defer { self.setAuthenticating(false) } - guard let url = URL(string: "\(configuration.baseURL)\(configuration.endpoints.registerPasskey)") else { - throw PasskeyError.invalidURL("Failed to create URL for passkey registration") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let body: [String: Any] = [ "attestationResponse": [ "id": credentialID.base64URLEncodedString(), @@ -309,28 +220,7 @@ public actor PasskeyAuth { ] ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == 429 { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } - - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } - - return try JSONDecoder().decode(PasskeyResponse.self, from: data) + return try await post(\.registerPasskey, body: body) } func postLoginData( @@ -340,15 +230,7 @@ public actor PasskeyAuth { signature: Data ) async throws -> PasskeyResponse { defer { self.setAuthenticating(false) } - - guard let url = URL(string: "\(configuration.baseURL)\(configuration.endpoints.loginPasskey)") else { - throw PasskeyError.invalidURL("Failed to create URL for passkey login") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let body: [String: Any] = [ "authenticationResponse": [ "id": credentialID.base64URLEncodedString(), @@ -363,27 +245,123 @@ public actor PasskeyAuth { ] ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == 429 { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } + return try await post(\.loginPasskey, body: body) + } +} - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } +// MARK: Private Methods +extension PasskeyAuth { + /// Performs common preflight checks, such as rate limit and wether we are already authenticating. + /// Returns the presentationContextProvider and a cleanup closure you should call in a `defer`. + private func prepareAuthentication() throws -> PasskeyPresentationContextProvider { + try checkRateLimit() + guard let presentationContextProvider = presentationContextProvider else { + throw PasskeyError.configurationError("Presentation context provider not set") + } + guard !isAuthenticating else { + throw PasskeyError.authenticationInProgress + } + + return presentationContextProvider + } + + /// Performs a URLRequest and enforces shared HTTP handling (status codes, rate limiting, decoding safety) + private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw PasskeyError.networkError(NSError(domain: "", code: -1)) + } + if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { + let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") + .flatMap { TimeInterval($0) } + throw PasskeyError.rateLimit(retryAfter: retryAfter) + } + guard (200...299).contains(httpResponse.statusCode) else { + throw PasskeyError.serverError( + statusCode: httpResponse.statusCode, + message: String(data: data, encoding: .utf8) + ) + } + return (data, httpResponse) + } + + /// Convenience for simple GET requests + private func get( + endpoint endpointKeyPath: KeyPath, + queryItems: [URLQueryItem] = [] + ) async throws -> (Data, HTTPURLResponse) { + let url = try makeUrl(endpoint: endpointKeyPath, queryItems: queryItems) + var request = URLRequest(url: url) + request.httpMethod = "GET" + return try await performRequest(request) + } + + private func getChallengeData( + endpoint endpointKeyPath: KeyPath, + queryItemName name: String, + queryItemValue value: String + ) async throws -> Data { + try await getChallengeData(endpoint: endpointKeyPath, queryItems: [.init(name: name, value: value)]) + } + + private func getChallengeData( + endpoint endpointKeyPath: KeyPath, + queryItems: [URLQueryItem] = [] + ) async throws -> Data { + let (data, _) = try await get(endpoint: endpointKeyPath, queryItems: queryItems) + + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + guard let challengeB64 = json?["challenge"] as? String else { + throw PasskeyError.invalidChallenge("Challenge not found in response") + } + + guard let challengeData = challengeB64.base64URLDecoded() else { + throw PasskeyError.invalidChallenge("Failed to decode challenge") + } + + return challengeData + } + + private func post( + decode: Response.Type = Response.self, + _ endpointKeyPath: KeyPath, + body: [String: Any] + ) async throws -> Response where Response: Decodable { + let url = try makeUrl(endpoint: endpointKeyPath) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, _) = try await performRequest(request) + return try JSONDecoder().decode(Response.self, from: data) + } + + private func makeUrl( + endpoint endpointKeyPath: KeyPath, + queryItems: [URLQueryItem] = [] + ) throws -> URL { + let endpoint = configuration.endpoints[keyPath: endpointKeyPath] + guard let urlBase = URL(string: "\(configuration.baseURL)\(endpoint)") else { + throw PasskeyError.invalidURL("Failed to create URL for \(endpoint)") + } + + guard var urlComponents = URLComponents(url: urlBase, resolvingAgainstBaseURL: false) else { + throw PasskeyError.invalidURL("Failed to create URL components for \(endpoint)") + } + + urlComponents.queryItems = queryItems + + guard let url = urlComponents.url else { + throw PasskeyError.invalidURL("Failed to create URL from components for \(endpoint)") + } + + return url + } +} - return try JSONDecoder().decode(PasskeyResponse.self, from: data) - } +enum HTTPStatusCode: Int { + case tooManyRequests = 429 }