Skip to content

Commit

Permalink
Added ability to sign in with Passkey (#34)
Browse files Browse the repository at this point in the history
* Added ability to sign in with Passkey

* Add a bottomSheetController instance to Rownd

* Added guards for passkey on different iOS versions

* replaced rownd.connectPasskey with rownd.connectAuthenticator

* removed password implementation from passkeyCoordinator
  • Loading branch information
mfmurray committed Dec 13, 2022
1 parent c47085b commit 7e3943c
Show file tree
Hide file tree
Showing 7 changed files with 432 additions and 7 deletions.
6 changes: 4 additions & 2 deletions Sources/Rownd/Models/Context/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,28 @@ public struct AppConfigState: Hashable {
public var isLoading: Bool = false
public var id: String?
public var icon: String?
public var name: String?
public var userVerificationFields: [String]?
public var schema: Dictionary<String, AppSchemaField>?
public var config: AppConfigConfig?
}

extension AppConfigState: Codable {
enum CodingKeys: String, CodingKey {
case id, icon, schema, config
case id, icon, schema, config, name
case userVerificationFields = "user_verification_fields"
}
}

public struct AppConfigConfig: Hashable {
public var hub: AppHubConfigState?
public var customizations: AppCustomizationsConfigState?
public var subdomain: String?
}

extension AppConfigConfig: Codable {
enum CodingKeys: String, CodingKey {
case hub, customizations
case hub, customizations, subdomain
}
}

Expand Down
355 changes: 355 additions & 0 deletions Sources/Rownd/Models/PasskeyCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,355 @@

/*
See LICENSE folder for this sample’s licensing information.
Abstract:
The authentication manager object.
*/

import AuthenticationServices
import Foundation
import os
import Get
import LocalAuthentication

extension Data {
init?(base64EncodedURLSafe string: String, options: Base64DecodingOptions = []) {
let string = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
.padding(toLength: ((string.count+3)/4)*4,
withPad: "=",
startingAt: 0)

self.init(base64Encoded: string, options: options)
}

func base64URLEncodedString() -> String {
let string = self.base64EncodedString()
return string
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

extension LAContext {
enum BiometricType: String {
case none
case touchID
case faceID
}

var biometricType: BiometricType {
var error: NSError?

guard self.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
// Capture these recoverable error thru Crashlytics
return .none
}

if #available(iOS 11.0, *) {
switch self.biometryType {
case .none:
return .none
case .touchID:
return .touchID
case .faceID:
return .faceID
@unknown default:
return .none
}
} else {
return self.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) ? .touchID : .none
}
}
}

enum PasskeyCoordinatorMethods {
case SignIn
case SignUp
}

class PasskeyCoordinator: NSObject, ASAuthorizationControllerPresentationContextProviding, ASAuthorizationControllerDelegate {

var method: PasskeyCoordinatorMethods? = nil

private func getWindowScene() -> UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes
return scenes.first as? UIWindowScene
}

private func getHubViewController() -> HubViewController? {
let bottomSheetController = Rownd.getInstance().bottomSheetController
if let hubViewController = bottomSheetController.controller as? HubViewController {
return hubViewController
}
return nil
}

func signUpWith() {
//Add passkey to the Rownd user
method = PasskeyCoordinatorMethods.SignUp
let anchor: ASPresentationAnchor = (getWindowScene()?.windows.last?.rootViewController?.view.window)!
let hubViewController = getHubViewController()

Task {
do {
let challengeResponse: PasskeyRegisterResponse = try await Rownd.apiClient.send(Get.Request(url: URL(string: "/hub/auth/passkeys/registration")!)).value

await hubViewController?.loadNewPage(targetPage: .connectPasskey, jsFnOptions: RowndConnectPasskeySignInOptions(status: Status.loading, biometricType: LAContext().biometricType.rawValue))

let appName = store.state.appConfig.name != nil ? String(describing:store.state.appConfig.name!) : ""
// Username priority in order First Name, Email, App name
var userName = appName.isEmpty ? "Add app name to Rownd" : appName
let email = store.state.user.data["email"] != nil ? String(describing: store.state.user.data["email"]!) : ""
let firstName = store.state.user.data["first_name"] != nil ? String(describing: store.state.user.data["first_name"]!) : ""

if !email.isEmpty {
userName = email
}
if !firstName.isEmpty {
userName = firstName
}

signUpWith(userName: userName, anchor: anchor, challengeResponse: challengeResponse)
}
catch {
logger.error("Failed to fetch passkey registration challenge: \(String(describing: error))")
await hubViewController?.loadNewPage(targetPage: .connectPasskey, jsFnOptions: RowndConnectPasskeySignInOptions(status: Status.failed, biometricType: LAContext().biometricType.rawValue))
}
}
}

func signInWith() {
//Use passkey to sign in as a Rownd user
method = PasskeyCoordinatorMethods.SignIn
let anchor: ASPresentationAnchor = (getWindowScene()?.windows.last?.rootViewController?.view.window)!
Task {
do {
let challengeResponse: PasskeyAuthenticationResponse = try await Rownd.apiClient.send(Get.Request(url: URL(string: "/hub/auth/passkeys/authentication")!)).value
signInWith(anchor: anchor, preferImmediatelyAvailableCredentials: false, challengeResponse: challengeResponse)
}
catch {
logger.error("Failed to fetch passkey challenge: \(String(describing: error))")
}
}
}

func signInWith(anchor: ASPresentationAnchor, preferImmediatelyAvailableCredentials: Bool, challengeResponse: PasskeyAuthenticationResponse) {
guard let subdomain = store.state.appConfig.config?.subdomain else {
logger.trace("Please go to the Rownd dashboard https://app.rownd.io/applications and add a subdomain in mobile sign-in")
return
}
guard #available(iOS 15.0, *) else {
logger.trace("iOS 15.0 is required to sign in with Passkey")
return
}
let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: subdomain + Rownd.config.subdomainExtension)

let challenge = Data(base64EncodedURLSafe: challengeResponse.challenge)!

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
authController.performRequests()

}

func signUpWith(userName: String, anchor: ASPresentationAnchor, challengeResponse: PasskeyRegisterResponse) {
guard let subdomain = store.state.appConfig.config?.subdomain else {
logger.trace("Please go to the Rownd dashboard https://app.rownd.io/applications and add a subdomain in mobile sign-in")
return
}
guard #available(iOS 15.0, *) else {
logger.trace("iOS 15.0 is required to sign in with Passkey")
return
}
let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: subdomain + Rownd.config.subdomainExtension)

let challenge = Data(base64EncodedURLSafe: challengeResponse.challenge)!
let userID = Data(challengeResponse.user.id.utf8)

let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest(challenge: challenge, name: userName, userID: userID)

let authController = ASAuthorizationController(authorizationRequests: [ registrationRequest ] )
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
}

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
guard #available(iOS 15.0, *) else {
logger.trace("iOS 15.0 is required to sign in with Passkey")
return
}
switch authorization.credential {
case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration:

let attestationObject = credentialRegistration.rawAttestationObject
let clientDataJSON = credentialRegistration.rawClientDataJSON
let credentialID = credentialRegistration.credentialID.base64URLEncodedString()

let hubViewController = getHubViewController()

Task {
let body: PasskeyRegisterPayload = PasskeyRegisterPayload(response: PasskeyRegisterPayloadResponse(attestationObject: attestationObject?.base64URLEncodedString() ?? "", clientDataJSON: clientDataJSON.base64URLEncodedString()), id: credentialID, rawId: credentialID)

do {
let _ = try await Rownd.apiClient.send(Get.Request(url: URL(string: "/hub/auth/passkeys/registration")!, method: "post", body: body, headers: ["content-type":"application/json"] )).value
await hubViewController?.loadNewPage(targetPage: .connectPasskey, jsFnOptions: RowndConnectPasskeySignInOptions(status: Status.success, biometricType: LAContext().biometricType.rawValue))
} catch {
logger.error("Failed passkey POST registration: \(String(describing: error))")
await hubViewController?.loadNewPage(targetPage: .connectPasskey, jsFnOptions: RowndConnectPasskeySignInOptions(status: Status.failed, biometricType: LAContext().biometricType.rawValue))
}
}
case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
let signature = credentialAssertion.signature
let clientDataJSON = credentialAssertion.rawClientDataJSON
let userId = credentialAssertion.userID
let credentialID = credentialAssertion.credentialID.base64URLEncodedString()
let authenticatorData = credentialAssertion.rawAuthenticatorData

Task {
let body: PasskeyAuthenticationPayload = PasskeyAuthenticationPayload(response: PasskeyAuthenticationPayloadResponse(clientDataJSON: clientDataJSON.base64URLEncodedString(), signature: signature?.base64URLEncodedString() ?? "", userHandle: userId?.base64URLEncodedString() ?? "", authenticatorData: authenticatorData?.base64URLEncodedString() ?? ""), rawId: credentialID, id: credentialID)

do {
let challengeAuthenticationCompleteResponse: PasskeyAuthenticationCompleteResponse = try await Rownd.apiClient.send(Get.Request(url: URL(string: "/hub/auth/passkeys/authentication")!, method: "post", body: body, headers: ["content-type":"application/json"] )).value

DispatchQueue.main.async {
store.dispatch(SetAuthState(payload: AuthState(accessToken: challengeAuthenticationCompleteResponse.access_token, refreshToken: challengeAuthenticationCompleteResponse.refresh_token)))
store.dispatch(UserData.fetch())
}
} catch {
logger.error("Failed passkey POST registration: \(String(describing: error))")
}
}
default:
logger.error("Failed: Unsupported authorization type")
}

}

func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
let hubViewController = getHubViewController()

guard let authorizationError = error as? ASAuthorizationError else {
logger.error("Unexpected authorization error: \(error.localizedDescription)")
if (method == PasskeyCoordinatorMethods.SignUp) {
hubViewController?.loadNewPage(targetPage: .connectPasskey, jsFnOptions: RowndConnectPasskeySignInOptions(status: Status.failed, biometricType: LAContext().biometricType.rawValue))
}
return
}

if authorizationError.code == .canceled {
// Either the system doesn't find any credentials and the request ends silently, or the user cancels the request.
// This is a good time to show a traditional login form, or ask the user to create an account.
logger.log("Request canceled.")
hubViewController?.dismiss(animated: true)

} else {
logger.error("Error: \((error as NSError).userInfo)")
if (method == PasskeyCoordinatorMethods.SignUp) {
hubViewController?.loadNewPage(targetPage: .connectPasskey, jsFnOptions: RowndConnectPasskeySignInOptions(status: Status.failed, biometricType: LAContext().biometricType.rawValue))
}
}

}

func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
let vc = windowScene?.windows.last?.rootViewController
return (vc?.view.window!)!
}


}

struct PasskeyRegisterResponse: Hashable, Codable {
public var challenge: String
public var user: PasskeyRegisterResponseUser

enum CodingKeys: String, CodingKey {
case challenge, user
}
}

public struct PasskeyRegisterResponseUser: Hashable {
public var id: String
}

extension PasskeyRegisterResponseUser: Codable {
enum CodingKeys: String, CodingKey {
case id
}
}

struct PasskeyRegisterPayload: Hashable, Codable {
public var response: PasskeyRegisterPayloadResponse
public var id: String
public var rawId: String
public var type: String = "public-key"

enum CodingKeys: String, CodingKey {
case response, id, rawId, type
}
}

public struct PasskeyRegisterPayloadResponse: Hashable {
public var attestationObject: String
public var clientDataJSON: String
}

extension PasskeyRegisterPayloadResponse: Codable {
enum CodingKeys: String, CodingKey {
case attestationObject, clientDataJSON
}
}


struct PasskeyAuthenticationResponse: Hashable, Codable {
public var challenge: String

enum CodingKeys: String, CodingKey {
case challenge
}
}

struct PasskeyAuthenticationPayload: Hashable, Codable {
public var response: PasskeyAuthenticationPayloadResponse
public var rawId: String
public var id: String
public var type: String = "public-key"

enum CodingKeys: String, CodingKey {
case response, rawId, id, type
}
}

public struct PasskeyAuthenticationPayloadResponse: Hashable {
public var clientDataJSON: String
public var signature: String
public var userHandle: String
public var authenticatorData: String
}

extension PasskeyAuthenticationPayloadResponse: Codable {
enum CodingKeys: String, CodingKey {
case clientDataJSON, signature, userHandle, authenticatorData
}
}


struct PasskeyAuthenticationCompleteResponse: Hashable, Codable {
public var verified: Bool
public var access_token: String
public var refresh_token: String

enum CodingKeys: String, CodingKey {
case verified, access_token, refresh_token
}
}
1 change: 1 addition & 0 deletions Sources/Rownd/Models/RowndConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public struct RowndConfig: Encodable {

public var apiUrl = "https://api.rownd.io"
public var baseUrl = "https://hub.rownd.io"
public var subdomainExtension = ".rownd.link"
public var appKey = ""
public var forceDarkMode = false
public var postSignInRedirect: String? = "NATIVE_APP"
Expand Down
Loading

0 comments on commit 7e3943c

Please sign in to comment.