Skip to main content

Command Palette

Search for a command to run...

Creating a Passkey Client for iOS

Updated
20 min read

Now that we’ve set up our passkey server, it’s time to bring passwordless authentication to life on the client side, starting with iOS. In this article, we’ll walk through how to create a passkey enabled login experience using Swift, Face ID/Touch ID, and Apple’s AuthenticationServices framework. You’ll learn how to register, authenticate, and handle credentials securely. No passwords required.

Overview

This project will create a client iOS application that talks to a restful server to handle Passkey authentication.

Setting up the Project

Create a new Xcode project. When prompted, you can use either SwiftUI or UIKit. My UI code example is SwiftUI for simplicity, but the meat of this article is the Passkey related code, and that will work great with either UI framework.

Creating a Simple Login Screen

Once you've created the bones of a project, we'll start with a basic login screen UI. This isn't the best it could be, but gets the job done:

import SwiftUI

struct LoginView: View {

    @StateObject var authService = AuthService()

    @State private var emailAddress = ""
    @State private var loginStatus = ""
    @State private var isLoading = false
    @FocusState private var isTextFieldFocused: Bool

    var body: some View {
        ZStack {
            VStack {

                Spacer()

                TextField("Email Address", text: $emailAddress)
                    .focused($isTextFieldFocused)
                    .autocorrectionDisabled()
                    .autocapitalization(.none)
                    .textContentType(.emailAddress)
                    .onSubmit {
                        isTextFieldFocused = false
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .frame(height: 44)
                    .border(.separator, width: 1)
                    .padding()

                Text(authService.authStatus.displayMessage)

                Spacer()

                VStack(spacing: 20) {
                    Button {
                        signUp()
                    } label: {
                        Text("Sign Up")
                    }
                    .frame(maxWidth: .infinity)
                    .frame(height: 50)
                    .foregroundColor(.white)
                    .background(.blue)
                    .padding(.horizontal)

                    Button {
                        login()
                    } label: {
                        Text("Log In")
                    }
                    .frame(maxWidth: .infinity)
                    .frame(height: 50)
                    .foregroundColor(.white)
                    .background(Color(.systemCyan))
                    .padding(.horizontal)
                    .padding(.bottom)
                }
            }

            if isLoading {
                ProgressView()
            }
        }
        .alert(authService.authErrorTitle,
               isPresented: $authService.isShowingAuthError, actions: {
            Button("Ok", role: .cancel) { }
        }, message: {
            Text(authService.authErrorMessage)
        })
    }

    private func signUp() {
        Task {
            await authService.signUp(username: emailAddress)
        }
    }

    private func login() {
        Task {
            await authService.login(username: emailAddress)
        }
    }
}

#Preview {
    LoginView()
}

Here's a quick run down on the screen:

  • There is a TextField for username/email input. I'm also using a FocusState so the keyboard will be dismissed when the return button is tapped since the buttons are at the bottom of the screen and will be covered up

  • I've also got a Text label in the mix that gets updated with a status as we progress through the flow

  • This screen can also show an error alert if anything goes wrong.

A Quick Note about ngrok

ngrok is a tool that creates an http or https endpoint and routes the traffic to your local machine. You're going to need this to make calls to your local server during testing. You can download ngrok for free here. Once you get it set up, all you have to do is run ./ngrok http 8080 in Terminal and you'll be able to connect to your local server with the URL it provides. The https version of the URL will be your relying party, and the base URL for connecting to your backend server. Don't forget to update those values if you turn ngrok off and back on again, because the URL will change.

Setting up an Associated Domain

Once you have ngrok running, you'll need to use that https url in a couple places in your Xcode project. Open your project file, select your app's target and select the Signing & Capabilties tab. Add an Associated Domains capability, and then add a domain like this:

webcredentials:7c42-2600-1700-3e41-88b0-b052-7126-dc42-525f.ngrok-free.app?mode=developer

When you've got a real url set up and are ready for production, you'll remove the ?mode=developer part, but it's going to allow us to reach our url for testing purposes. Here's a link if you'd like to read more about supporting associated domains on iOS.

We're also going to use the ngrok url as our relying party in AuthService, which is coming up next. Also note, each time you kill and restart ngrok, you're going to need to update both places in your iOS code with the new url. You'll also need to update the environment variables on your Vapor server and run it again.

Setting up AuthService

Before we get into the heavy lifting, here's an enum I created to help keep track of progress as the app moves through the steps of Passkey creation and login. We'll display the status in the Text label on the login screen.

enum AuthStatus: Equatable, Hashable {
    case signUpInProgress
    case signUpSuccess
    case loginInProgress
    case loginSuccess
    case loggedOut

    var displayMessage: String {
        switch self {
        case .signUpInProgress:     "Sign up in progress..."
        case .signUpSuccess:        "Signed up successfully"
        case .loginInProgress:      "Logging in..."
        case .loginSuccess:         "Logged in!"
        case .loggedOut:            "Logged out"
        }
    }
}

We can start with this basic outline of the file:

// 1. Class setup
@MainActor
class AuthService: NSObject, ObservableObject {

    enum PasskeyError: Error {
        case signup
        case login
    }

    // 2. Relying party url created by ngrok
    static let relyingParty = "7c42-2600-1700-3e41-88b0-b052-7126-dc42-525f.ngrok-free.app"

    // 3. Published properties for the LoginView
    @Published var authErrorTitle = ""
    @Published var authErrorMessage = ""
    @Published var isShowingAuthError = false
    @Published var authStatus = AuthStatus.loggedOut

    private var username = ""
    private var userId = ""
    private var preferImmediatelyAvailableCredentials = true

    // MARK: - Sign Up / Registration

    func signUp(username: String) async {
        authStatus = .signUpInProgress
    }

    // MARK: - Log in

    func login(username: String) async {
        authStatus = .loginInProgress
        self.username = username
    }
}

This is just a basic outline of a file. I'll give a quick overview and then we'll start implementing everything:

  1. I'm using ObservableObject and publishing some values to be observed by the UI. If you're targeting iOS 17 and up, you can use the @Observable macro instead. Everything in this article will work with iOS 16+ since that's what I'm supporting in my apps at the time of writing.

  2. Here's our relying party, using the domain created for us by ngrok. You'll eventually replace this with your real url in a production app

  3. We're publishing some properties that will drive the UI

You can also see I've got some placeholder functions for the overall purpose of AuthService, which is handling sign ups (Passkey creation), and logging in with an existing passkey.

Making Calls to the Passkey Server

You can use your preferred method for making API calls, but if you just want to get this all working, here's my code for making GET and POST requests. It's a simplified version of what I've been using for years, and for our purposes now, it'll just work. This code takes a generic type T and decodes the response from the server into that type, or throws an error.

//
//  URLSession+Example.swift
//  PasskeyClient
//

import Foundation

enum SessionError: Error {
    case badResponse
    case requestFailed(String)
}

/// Object to represent empty response bodies from API calls that conform to Decodable.
public struct EmptyResponse: Decodable { }

extension URLSession {
    func get<T: Decodable>(endpoint: String,
                           queryParams: [String: String] = [:],
                           decodingType: T.Type) async throws -> T {
        var request = request(endpoint: endpoint, queryParams: queryParams)
        request.httpMethod = "GET"
        return try await fetchAndDecode(request: request)
    }

    @discardableResult
    func post<T: Decodable>(endpoint: String,
                            queryParams: [String: String] = [:],
                            postBody: Data,
                            decodingType: T.Type) async throws -> T {
        var request = request(endpoint: endpoint, queryParams: queryParams)
        request.httpMethod = "POST"
        request.httpBody = postBody
        return try await fetchAndDecode(request: request)
    }

    private func request(endpoint: String,
                         queryParams: [String: String]) -> URLRequest {
        let baseUrlString = "https://\(AuthService.relyingParty)"
        let baseUrl = URL(string: baseUrlString)!
        let fullUrl = URL(string: endpoint, relativeTo: baseUrl)!
        var request = URLRequest(url: fullUrl)

        if !queryParams.isEmpty {
            if let url = request.url {
                if var components = URLComponents(url: url, resolvingAgainstBaseURL: true) {
                    let queryItems = queryParams.compactMap { URLQueryItem(name: $0.key, value: $0.value) }
                    components.queryItems = queryItems
                    if let newURL = components.url {
                        request = URLRequest(url: newURL)
                    }
                }
            }
        }

        defaultHeaders.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) }
        return request
    }

    private func fetchAndDecode<T: Decodable>(request: URLRequest) async throws -> T {
        let (data, response) = try await data(for: request)

        guard let response = response as? HTTPURLResponse else {
            throw SessionError.badResponse
        }

        switch response.statusCode {
        case (200...299):
            var data = data
            if data.isEmpty {
                data = "{}".data(using: .utf8) ?? Data()
            }

            return try JSONDecoder().decode(T.self, from: data)

        default:
            let errorMessage = String(data: data, encoding: .utf8) ?? ""
            throw SessionError.requestFailed(errorMessage)
        }
    }

    private var defaultHeaders: [String: String] {
        return [
            "Content-Type": "application/json",
            "Accept": "application/json"
        ]
    }
}

Creating a Passkey

First we've got to create a Passkey. If you're building on my vapor Passkey server article, you can enter any username or email address or whatever into the TextField and tap the Sign Up button. It's going to call this function in AuthService:

func signUp(username: String) async {
        authStatus = .signUpInProgress

        do {
            // 1. Generate a challenge
            let challengeResponse = try await URLSession.shared.get(endpoint: "/signup",
                                                                    queryParams: ["username": username],
                                                                    decodingType: ChallengeResponse.self)

            // 2. Save the userId -- we'll need it later                                                                    
            userId = challengeResponse.userId

            // 3. Trigger iOS to prompt the user to create a Passkey
            try startSignUpAuthorization(with: challengeResponse)
        } catch {
            // 4. Something went wrong, kick the error up to the UI
            authErrorTitle = "Sign Up Error"
            authErrorMessage = "\(error)"
            isShowingAuthError = true
        }
    }

Here's what we're adding to the login function:

  1. We've got to call our first server endpoint with the username supplied by the user. The server will make sure a Passkey doesn't already exist for this user, create a challenge, and save it to the database. That challenge and the user's id will be returned to us. In a production app, you'd already have a user and user id, but this is a simplified flow focusing on the Passkey parts.

  2. Save the userId locally, because we're going to need it again later in the process.

  3. Here we start the flow in iOS that's going to prompt the user to create a Passkey. For most users, they'll get a prompt to allow the Passkey and be presented with a Face ID authentication (see screenshot below).

  4. If anything goes wrong, we're going to bubble the error state back up to the UI so you know what's going on.

The ChallengeResponse struct

Here's how ChallengeResponse is built. I'm using CustomStringConvertible to generate a nicer log output for debugging.

struct ChallengeResponse: Decodable, CustomStringConvertible {
    let challenge: Challenge
    let userId: String

    var description: String {
        return """
            challenge: \(challenge)
            userId: \(userId)
        """
    }
}

struct Challenge: Decodable, CustomStringConvertible {
    let attestation: String
    let challenge: String
    let params: [PublicKeyCredParams]
    let relyingParty: RelyingParty
    let timeout: Int
    let user: AuthUser

    var description: String {
        return """
            attestation: \(attestation)
            challenge: \(challenge)
            params: \(params)
            relyingParty: \(relyingParty)
            timeout: \(timeout)
            user: \(user)
        """
    }

    enum CodingKeys: String, CodingKey {
        case attestation
        case challenge
        case params = "pubKeyCredParams"
        case relyingParty = "rp"
        case timeout
        case user
    }
}

struct PublicKeyCredParams: Decodable, CustomStringConvertible {
    let alg: Int
    let type: String

    var description: String {
        return "alg: \(alg), type: \(type)"
    }
}

struct RelyingParty: Decodable, CustomStringConvertible {
    let id: String
    let name: String

    var description: String {
        return "id: \(id), name: \(name)"
    }
}

struct AuthUser: Decodable, CustomStringConvertible {
    let displayName: String
    let id: String
    let name: String

    var description: String {
        return "id: \(id), name: \(name), displayName: \(displayName)"
    }
}

Generating a Passkey on iOS with ASAuthorizationController

/Users/jday/Documents/WhiteRockStudios/blog/passkeys/iOS screenshots/iOS allow Face ID.png

/Users/jday/Documents/WhiteRockStudios/blog/passkeys/iOS screenshots/iOS save Passkey.png

Once you've got ChallengeResponse and its properties set up, we're ready to continue. From step 3 above, we call startSignUpAuthorization and pass in our newly created challenge. We're going to create a request and pass that into ASAuthorizationController. This will bring up the system Passkey screen in the screenshot above. This will prompt the user to allow biometrics and use them to create a Passkey. Most users at this point will see Face ID, but Touch ID would also work. Once that is finished, we'll get a callback from ASAuthorizationControllerDelegate, which AuthService must conform to and implement.

Note: You can enable biometrics in the iOS Simulator with the enroll option in the Features > Face ID menu. You'll see that you can also complete Face ID prompts here (and there's a handy keyboard shortcut).

Let's walk through startSignUpAuthorization first:

private func startSignUpAuthorization(with challenge: ChallengeResponse) throws {
    guard let challengeData = challenge.challenge.challenge.data(using: .utf8),
          let userIdData = challenge.challenge.user.id.data(using: .utf8) else {

        throw PasskeyError.signup
    }

    // 1. Create the request using our relying party and challenge data
    let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: AuthService.relyingParty)
    let assertionKeyRequest = platformProvider.createCredentialRegistrationRequest(challenge: challengeData,
                                                                                   name: challenge.challenge.user.name,
                                                                                   userID: userIdData)

    // 2. Establish AuthController as the delegate and send the request to create a Passkey
    let authController = ASAuthorizationController(authorizationRequests: [assertionKeyRequest])
    authController.delegate = self
    authController.presentationContextProvider = self
    authController.performRequests()
}

So what exactly is going on above?

  1. After guarding to make sure we've got all the challenge data inputs required, we create an assertion request using the AuthenticationServices framework in iOS.

  2. The request gets passed to ASAuthorizationController to prompt the user to allow the Passkey to be created. Our AuthService class will be the controller's delegate to handle the result. We also provide a presentation context. At the end of your file, add the below chunk of code.

extension AuthService: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        ASPresentationAnchor()
    }
}

Ok, back to the ASAuthorizationControllerDelegate conformance. You'll need to implement the two functions below. One is for error handling, and the other handles the actual responses from ASAuthorizationController. The same delegate callback function handles registration, signing in (credential assertion), and also other types of login like Sign in with Apple. Here's the first step in getting this implementation ready:

extension AuthService: ASAuthorizationControllerDelegate {
    func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
        print("\(#function) error: \(error)")
    }

    func authorizationController(controller: ASAuthorizationController,
                                 didCompleteWithAuthorization authorization: ASAuthorization) {
        switch authorization.credential {
        case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration:
            print("\(#function) got a credential registration: \(credentialRegistration)")

            Task {
                do {
                    try await makeCredential(userId: userId,
                                             registration: credentialRegistration)
                } catch {
                    authErrorTitle = "Error Registering Credentials"
                    authErrorMessage = "Unable to register at this time. Reason: \(error)"
                    isShowingAuthError = true
                }
            }

        case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
            // we are not ready for this yet
            break

       case let credential as ASPasswordCredential:
             // Handle other authentication cases, such as Sign in with Apple. We are not doing anything with this.
             print("\(#function) got a password credential: \(credential)")

        default:
            print("something went wrong: \(authorization)")
        }
    }
}

All we're really doing above is making sure the controller is handling a credential registration request, by making sure the authorization credential is of type ASAuthorizationPlatformPublicKeyCredentialRegistration. If so, we're going to take that credential registration and send it to our server in the makeCredential function. There, we'll create a POST request and send that up to our /makeCredential endpoint. If all goes well there, we've completed the registration process and can now use our Passkey to sign in. Here's what that implementation looks like:

private func makeCredential(userId: String,
                                registration: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws {
    self.userId = userId

    guard let attestationObject = registration.rawAttestationObject else {
            authErrorTitle = "Login Error"
            authErrorMessage = "Unable to find attestation object"
            isShowingAuthError = true
        return
    }

    let payload = attestationPayload(userId: userId,
                                     registration: registration,
                                     attestation: attestationObject)
    try await URLSession.shared.post(endpoint: "/makeCredential",
                                     queryParams: ["userId": userId],
                                     postBody: JSONSerialization.data(withJSONObject: payload),
                                     decodingType: EmptyResponse.self)

    authStatus = .signUpSuccess
}

private func attestationPayload(userId: String,
                                registration: ASAuthorizationPlatformPublicKeyCredentialRegistration,
                                attestation: Data) -> [String: Any] {

    let clientDataJSON = registration.rawClientDataJSON
    let credentialID = registration.credentialID

    return [
        "rawId": credentialID.base64EncodedString(),
        "id": registration.credentialID.base64URLEncode(),
        "authenticatorAttachment": "platform", // Optional parameter
        "clientExtensionResults": [String: Any](), // Optional parameter
        "type": "public-key",
        "response": [
            "attestationObject": attestation.base64EncodedString(),
            "clientDataJSON": clientDataJSON.base64EncodedString()
        ],
        userId: userId
    ]
}

So what's going on here? We're basically just building a POST payload out of the credential response we got back from ASAuthenticationController and sending that to the server. It returns as empty 200 response if everything goes well. The attestationPayload function builds up that payload, making sure all of the fields are encoded correctly. As mentioned before, there's heavy use of base64 and base64 url encoded strings in the WebAuthn flow. If you're interested in learning more about how WebAuthn works, you can really just google any of those terms or ask your favorite LLM, and go as deep into the details as you'd like.

A quick note about ASPresenationAnchor above

There's no need to provide a window or try to hook into your UI somehow. Other implementations you'll find online here are often misunderstood and overcomplicated. By just calling ASPresentationAnchor(), iOS will do the right thing.

Signing in with a Passkey

Now we're ready to use the Log In button in our iOS app, and validate our newly created Passkey. Our button calls AuthService.login, which gets implemented like this:

func login(username: String) async {
    authStatus = .loginInProgress
    self.username = username

    do {
        let options = try await URLSession.shared.get(endpoint: "/authenticate",
                                                      queryParams: ["username": username],
                                                      decodingType: PublicKeyCredentialRequestOptions.self)

        if let challengeData = options.challenge.data(using: .utf8) {
            initiateLogin(challenge: challengeData)
        } else {
            authErrorTitle = "Error Logging In"
            authErrorMessage = "Things are not working."
            isShowingAuthError = true
        }
    } catch {
        authErrorTitle = "Login Error"
        authErrorMessage = "\(error)"
        isShowingAuthError = true
    }
}

Pretty straightforward here, we're making a GET call to our server's /authenticate endpoint to retreive challenge data for a Passkey login. We're going to pass that challenge to our initiateLogin function which is going to interact with ASAuthorizationController again. Before we get there, here is the model class structure for PublicKeyCredentialRequestOptions:

struct PublicKeyCredentialRequestOptions: Decodable {
    /// A base64encoded string.
    let challenge: String

    let timeout: UInt32?
    let rpId: String?
    let allowCredentials: [PublicKeyCredentialDescriptor]?
    let userVerification: UserVerificationRequirement?
}

struct PublicKeyCredentialDescriptor: Decodable {
    let id: String
    let type: String
    let transports: [String]
}

public enum UserVerificationRequirement: String, Decodable {
    /// The auth server requires user verification and will fail if the user wasn't verified
    case required
    /// The auth servier prefers user verification if possible, but will not fail without it
    case preferred
    /// The auth server does not want user verification
    case discouraged
}

Now for implementation of initiateLogin:

private func initiateLogin(challenge: Data) {
    // 1. Create an assertion request
    let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: AuthService.relyingParty)
    let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)

    let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
    authController.delegate = self
    authController.presentationContextProvider = self

    // 2. Determine the flow to use with ASAuthorizationController
    if preferImmediatelyAvailableCredentials {
        authController.performRequests(options: ASAuthorizationController.RequestOptions.preferImmediatelyAvailableCredentials)
    } else {
        authController.performRequests()
    }
}

A little more detail on what's happening above:

  1. We're setting up a public key provider for our relying party, and attaching the challenge from our server to that. Since we're logging in instead of creating a new credential, we create an assertion request and will handle that with ASAuthorizationController, using the delegate we set up earlier during the registration implmentation.

  2. For now, we're just going to prefer immediately available credentials. This means that iOS will put up a modal with our saved Passkeys and use biometrics to authenticate the user. If that goes well, we'll get a good callback in the delegate and can continue the process. In the alternative flow, iOS will present a modal sheet with a QR code to use a Passkey from some other device. We are not going to cover that in this article.

Here's what that modal looks like:

Back to our ASAuthorizationControllerDelegate implementation:

extension AuthService: ASAuthorizationControllerDelegate {
    ...

    func authorizationController(controller: ASAuthorizationController,
                                 didCompleteWithAuthorization authorization: ASAuthorization) {
        switch authorization.credential {
            ...

            case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
             print("\(#function) got a credential assertion (passkey used to sign in): \(credentialAssertion)")

            if !username.isEmpty {
                Task {
                    do {
                        try await authenticate(username: username,
                                               assertion: credentialAssertion)
                    } catch {
                        authErrorTitle = "Error Logging In"
                        authErrorMessage = "Unable to log in. Reason: \(error)"
                        isShowingAuthError = true
                    }
                }
            } else {
                authErrorTitle = "Missing Data"
                authErrorMessage = "Username is required."
                isShowingAuthError = true
            }

            ...
        }
    }
}

Now we're handling the case where the authorization credential is a ASAuthorizationPlatformPublicKeyCredentialAssertion. If that's what we got back from ASAuthorizationController, we're going to pass that to our authenticate function, which will verify the credential on our server:

private func authenticate(username: String,
                          assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws {        
    do {
        let payload = credentialPayload(credentialAssertion: assertion)
        try await URLSession.shared.post(endpoint: "/authenticate",
                                         queryParams: ["username": username],
                                         postBody: JSONSerialization.data(withJSONObject: payload),
                                         decodingType: EmptyResponse.self)

        authStatus = .loginSuccess
    } catch {
        authErrorTitle = "Login Error"
        authErrorMessage = "\(error)"
        isShowingAuthError = true
    }
}

This calls our /authenticate endpoint, and returns a 200 success response if authentication of the Passkey succeeds. In a production application, you'd want to return some kind of token here that the app will save for subsequent API requests. Here's how we build the credentialPayload above:

private func credentialPayload(credentialAssertion: ASAuthorizationPlatformPublicKeyCredentialAssertion) -> [String: Any] {
    let clientDataJSON = credentialAssertion.rawClientDataJSON
    let credentialId = credentialAssertion.credentialID

    return [
        "rawId": credentialId.base64EncodedString(),
        "id": credentialId.base64URLEncode(),
        "authenticatorAttachment": "platform", // Optional
        "clientExtensionResults": [String: Any](), // Optional
        "type": "public-key",
        "response": [
            "clientDataJSON": clientDataJSON.base64EncodedString(),
            "authenticatorData": credentialAssertion.rawAuthenticatorData.base64EncodedString(),
            "signature": credentialAssertion.signature.base64EncodedString(),
            "userHandle": credentialAssertion.userID.base64URLEncode()
        ]
    ]
}

This kind of payload should be looking pretty familiar by now. We're doing lots of base64 encoding and base 64 url encoding. When I was getting all of this working myself for the first time, I spent the most time getting all of the fields encoded with the right formats. Hopefully having an example laid out like this will save you a lot of time in building your own implementation!

Conclusion

If you've made it this far, congrats! You're ready to fire up ngrok in a terminal, use the generated url as your relying party on your vapor server and iOS application, and test it out!

If you got here first, here are the other 2 articles in this series:

Here's the complete AuthService file for reference:


import SwiftUI
import AuthenticationServices
import CoreAPI

enum AuthStatus: Equatable, Hashable {
    case signUpInProgress
    case signUpSuccess
    case loginInProgress
    case loginSuccess
    case loggedOut

    var displayMessage: String {
        switch self {
        case .signUpInProgress:     "Sign up in progress..."
        case .signUpSuccess:        "Signed up successfully"
        case .loginInProgress:      "Logging in..."
        case .loginSuccess:         "Logged in!"
        case .loggedOut:            "Logged out"
        }
    }
}

@MainActor
class AuthService: NSObject, ObservableObject {

    enum PasskeyError: Error {
        case signup
        case login
    }

    static let relyingParty = "7c42-2600-1700-3e41-88b0-b052-7126-dc42-525f.ngrok-free.app"

    @Published var authErrorTitle = ""
    @Published var authErrorMessage = ""
    @Published var isShowingAuthError = false
    @Published var authStatus = AuthStatus.loggedOut

    private var username = ""
    private var userId = ""
    private var preferImmediatelyAvailableCredentials = true


    // MARK: - Sign Up / Registration

    func signUp(username: String) async {
        authStatus = .signUpInProgress

        do {
            let challengeResponse = try await URLSession.shared.get(endpoint: "/signup",
                                                                    queryParams: ["username": username],
                                                                    decodingType: ChallengeResponse.self)
            userId = challengeResponse.userId
            try startSignUpAuthorization(with: challengeResponse)
        } catch {
            authErrorTitle = "Sign Up Error"
            authErrorMessage = "\(error)"
            isShowingAuthError = true
        }
    }

    /// Sends a POST to /makeCredential
    private func makeCredential(userId: String,
                                registration: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws {
        self.userId = userId

        guard let attestationObject = registration.rawAttestationObject else {
            authErrorTitle = "Login Error"
            authErrorMessage = "Unable to find attestation object"
            isShowingAuthError = true
            return
        }

        let payload = attestationPayload(userId: userId,
                                         registration: registration,
                                         attestation: attestationObject)
        try await URLSession.shared.post(endpoint: "/makeCredential",
                                         queryParams: ["userId": userId],
                                         postBody: JSONSerialization.data(withJSONObject: payload),
                                         decodingType: EmptyResponse.self)

        authStatus = .signUpSuccess
    }

    private func attestationPayload(userId: String,
                                    registration: ASAuthorizationPlatformPublicKeyCredentialRegistration,
                                    attestation: Data) -> [String: Any] {

        let clientDataJSON = registration.rawClientDataJSON
        let credentialID = registration.credentialID

        return [
            "rawId": credentialID.base64EncodedString(),
            "id": registration.credentialID.base64URLEncode(),
            "authenticatorAttachment": "platform", // Optional
            "clientExtensionResults": [String: Any](), // Optional
            "type": "public-key",
            "response": [
                "attestationObject": attestation.base64EncodedString(),
                "clientDataJSON": clientDataJSON.base64EncodedString()
            ],
            userId: userId
        ]
    }

    /// Uses the `ChallengeResponse` obtained from the /signup endpoint to start the PassKey flow on iOS
    private func startSignUpAuthorization(with challenge: ChallengeResponse) throws {
        guard let challengeData = challenge.challenge.challenge.data(using: .utf8),
              let userIdData = challenge.challenge.user.id.data(using: .utf8) else {

            throw PasskeyError.signup
        }

        let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: AuthService.relyingParty)
        let assertionKeyRequest = platformProvider.createCredentialRegistrationRequest(challenge: challengeData,
                                                                                       name: challenge.challenge.user.name,
                                                                                       userID: userIdData)
        let authController = ASAuthorizationController(authorizationRequests: [assertionKeyRequest])
        authController.delegate = self
        authController.presentationContextProvider = self
        authController.performRequests()
    }



    // MARK: - Log in

    func login(username: String) async {
        authStatus = .loginInProgress
        self.username = username

        do {
            let options = try await URLSession.shared.get(endpoint: "/authenticate",
                                                          queryParams: ["username": username],
                                                          decodingType: PublicKeyCredentialRequestOptions.self)

            if let challengeData = options.challenge.data(using: .utf8) {
                initiateLogin(challenge: challengeData)
            } else {
                authErrorTitle = "Error Logging In"
                authErrorMessage = "Things are not working."
                isShowingAuthError = true
            }
        } catch {
            authErrorTitle = "Login Error"
            authErrorMessage = "\(error)"
            isShowingAuthError = true
        }
    }

    private func initiateLogin(challenge: Data) {
        let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: AuthService.relyingParty)
        let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)

        // Pass in any mix of supported sign-in request types.
        let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
        authController.delegate = self
        authController.presentationContextProvider = self

        if preferImmediatelyAvailableCredentials {
            authController.performRequests(options: ASAuthorizationController.RequestOptions.preferImmediatelyAvailableCredentials)
        } else {
            authController.performRequests()
        }
    }

    private func authenticate(username: String,
                              assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws {

        do {
            let payload = credentialPayload(credentialAssertion: assertion)
            try await URLSession.shared.post(endpoint: "/authenticate",
                                             queryParams: ["username": username],
                                             postBody: JSONSerialization.data(withJSONObject: payload),
                                             decodingType: EmptyResponse.self)

            authStatus = .loginSuccess
        } catch {
            authErrorTitle = "Login Error"
            authErrorMessage = "\(error)"
            isShowingAuthError = true
        }
    }

    private func credentialPayload(credentialAssertion: ASAuthorizationPlatformPublicKeyCredentialAssertion) -> [String: Any] {
        let clientDataJSON = credentialAssertion.rawClientDataJSON
        let credentialId = credentialAssertion.credentialID

        return [
            "rawId": credentialId.base64EncodedString(),
            "id": credentialId.base64URLEncode(),
            "authenticatorAttachment": "platform", // Optional
            "clientExtensionResults": [String: Any](), // Optional
            "type": "public-key",
            "response": [
                "clientDataJSON": clientDataJSON.base64EncodedString(),
                "authenticatorData": credentialAssertion.rawAuthenticatorData.base64EncodedString(),
                "signature": credentialAssertion.signature.base64EncodedString(),
                "userHandle": credentialAssertion.userID.base64URLEncode()
            ]
        ]
    }
}

extension AuthService: ASAuthorizationControllerDelegate {
    func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
        print("\(#function) error: \(error)")
    }

    func authorizationController(controller: ASAuthorizationController,
                                 didCompleteWithAuthorization authorization: ASAuthorization) {
        switch authorization.credential {
        case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration:
            print("\(#function) got a credential registration: \(credentialRegistration)")

            Task {
                do {
                    try await makeCredential(userId: userId,
                                             registration: credentialRegistration)
                } catch {
                    authErrorTitle = "Error Registering Credentials"
                    authErrorMessage = "Unable to register at this time. Reason: \(error)"
                    isShowingAuthError = true
                }
            }

        case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
             print("\(#function) got a credential assertion (passkey used to sign in): \(credentialAssertion)")

            if !username.isEmpty {
                Task {
                    do {
                        try await authenticate(username: username,
                                               assertion: credentialAssertion)
                        print("success!")
                    } catch {
                        authErrorTitle = "Error Logging In"
                        authErrorMessage = "Unable to log in. Reason: \(error)"
                        isShowingAuthError = true
                    }
                }
            } else {
                authErrorTitle = "Missing Data"
                authErrorMessage = "Username is required."
                isShowingAuthError = true
            }

        case let credential as ASPasswordCredential:
             // Handle other authentication cases, such as Sign in with Apple.
             print("\(#function) got a password credential: \(credential)")

        default:
            print("something went very wrong -- fatal error")
        }
    }
}

extension AuthService: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        ASPresentationAnchor()
    }
}