Skip to content
Open
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
284 changes: 131 additions & 153 deletions Sources/PasskeyAuth/PasskeyAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -281,22 +200,14 @@ public actor PasskeyAuth {
fileprivate func setAuthenticating(_ value: Bool) {
isAuthenticating = value
}

func postRegisterData(
credentialID: Data,
attestationObject: Data,
clientDataJSON: Data
) 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(),
Expand All @@ -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(
Expand All @@ -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(),
Expand All @@ -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<PasskeyEndpoints, String>,
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<PasskeyEndpoints, String>,
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<PasskeyEndpoints, String>,
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<Response>(
decode: Response.Type = Response.self,
_ endpointKeyPath: KeyPath<PasskeyEndpoints, String>,
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<PasskeyEndpoints, String>,
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
}