From 13e2e54b18d89a137d63b0acff2da9684cda28e9 Mon Sep 17 00:00:00 2001 From: Gil Shapira Date: Sun, 12 Nov 2023 15:55:21 +0200 Subject: [PATCH 1/3] Add support for native passkey authentication --- src/DescopeKit.swift | 9 +- src/internal/http/DescopeClient.swift | 60 +++++ src/internal/others/Internal.swift | 47 ++++ src/internal/routes/Flow.swift | 21 +- src/internal/routes/Passkey.swift | 322 ++++++++++++++++++++++++++ src/sdk/Callbacks.stencil | 3 + src/sdk/Callbacks.swift | 97 +++++++- src/sdk/Routes.swift | 67 ++++++ src/sdk/SDK.swift | 12 +- src/types/Error.swift | 10 +- src/types/Flows.swift | 45 ++-- src/types/Passkeys.swift | 78 +++++++ src/types/User.swift | 12 + 13 files changed, 728 insertions(+), 55 deletions(-) create mode 100644 src/internal/routes/Passkey.swift create mode 100644 src/types/Passkeys.swift diff --git a/src/DescopeKit.swift b/src/DescopeKit.swift index f08fa26..b6bcba0 100644 --- a/src/DescopeKit.swift +++ b/src/DescopeKit.swift @@ -58,6 +58,12 @@ public extension Descope { /// Provides functions for authentication with TOTP codes. static var totp: DescopeTOTP { sdk.totp } + /// Provides functions for authentication with passkeys. + static var passkey: DescopePasskey { sdk.passkey } + + /// Provides functions for authentication with passwords. + static var password: DescopePassword { sdk.password } + /// Provides functions for authentication with magic links. static var magicLink: DescopeMagicLink { sdk.magicLink } @@ -70,9 +76,6 @@ public extension Descope { /// Provides functions for authentication with SSO. static var sso: DescopeSSO { sdk.sso } - /// Provides functions for authentication with passwords. - static var password: DescopePassword { sdk.password } - /// Provides functions for authentication using flows. static var flow: DescopeFlow { sdk.flow } diff --git a/src/internal/http/DescopeClient.swift b/src/internal/http/DescopeClient.swift index eb99e9e..4cbe6d9 100644 --- a/src/internal/http/DescopeClient.swift +++ b/src/internal/http/DescopeClient.swift @@ -87,6 +87,66 @@ class DescopeClient: HTTPClient { ]) } + // MARK: - Passkey + + struct PasskeyStartResponse: JSONResponse { + var transactionId: String + var options: String + var create: Bool + } + + func passkeySignUpStart(loginId: String, details: SignUpDetails?, origin: String) async throws -> PasskeyStartResponse { + return try await post("auth/webauthn/signup/start", body: [ + "loginId": loginId, + "user": details?.dictValue, + "origin": origin, + ]) + } + + func passkeySignUpFinish(transactionId: String, response: String) async throws -> JWTResponse { + return try await post("auth/webauthn/signup/finish", body: [ + "transactionId": transactionId, + "response": response, + ]) + } + + func passkeySignInStart(loginId: String, origin: String, refreshJwt: String?, options: LoginOptions?) async throws -> PasskeyStartResponse { + return try await post("auth/webauthn/signin/start", headers: authorization(with: refreshJwt), body: [ + "loginId": loginId, + "origin": origin, + "loginOptions": options?.dictValue, + ]) + } + + func passkeySignInFinish(transactionId: String, response: String) async throws -> JWTResponse { + return try await post("auth/webauthn/signin/finish", body: [ + "transactionId": transactionId, + "response": response, + ]) + } + + func passkeySignUpInStart(loginId: String, origin: String, refreshJwt: String?, options: LoginOptions?) async throws -> PasskeyStartResponse { + return try await post("auth/webauthn/signup-in/start", headers: authorization(with: refreshJwt), body: [ + "loginId": loginId, + "origin": origin, + "loginOptions": options?.dictValue, + ]) + } + + func passkeyAddStart(loginId: String, origin: String, refreshJwt: String) async throws -> PasskeyStartResponse { + return try await post("auth/webauthn/update/start", headers: authorization(with: refreshJwt), body: [ + "loginId": loginId, + "origin": origin, + ]) + } + + func passkeyAddFinish(transactionId: String, response: String) async throws { + try await post("auth/webauthn/update/finish", body: [ + "transactionId": transactionId, + "response": response, + ]) + } + // MARK: - Password func passwordSignUp(loginId: String, password: String, details: SignUpDetails?) async throws -> JWTResponse { diff --git a/src/internal/others/Internal.swift b/src/internal/others/Internal.swift index e4d584c..008ff55 100644 --- a/src/internal/others/Internal.swift +++ b/src/internal/others/Internal.swift @@ -1,4 +1,6 @@ +import AuthenticationServices + extension DescopeConfig { static let initial: DescopeConfig = DescopeConfig(projectId: "") } @@ -16,3 +18,48 @@ extension DescopeError { return DescopeError(code: code, desc: desc, message: message, cause: cause) } } + +extension Data { + init?(base64URLEncoded base64URLString: String, options: Base64DecodingOptions = []) { + var str = base64URLString + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + if str.count % 4 > 0 { + str.append(String(repeating: "=", count: 4 - str.count % 4)) + } + self.init(base64Encoded: str, options: options) + } + + func base64URLEncodedString(options: Base64EncodingOptions = []) -> String { + return base64EncodedString(options: options) + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +class DefaultPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding, ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return presentationAnchor + } + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return presentationAnchor + } + + private var presentationAnchor: ASPresentationAnchor { +#if os(macOS) + return ASPresentationAnchor() +#else + let scene = UIApplication.shared.connectedScenes + .filter { $0.activationState == .foregroundActive } + .compactMap { $0 as? UIWindowScene } + .first + + let keyWindow = scene?.windows + .first { $0.isKeyWindow } + + return keyWindow ?? ASPresentationAnchor() +#endif + } +} diff --git a/src/internal/routes/Flow.swift b/src/internal/routes/Flow.swift index 9392ecc..bde04c7 100644 --- a/src/internal/routes/Flow.swift +++ b/src/internal/routes/Flow.swift @@ -26,12 +26,7 @@ class Flow: Route, DescopeFlow { // ensure that whatever the result of this method is we remove the reference // to the runner from the current property - defer { - if current === runner { - log(.debug, "Resetting current flow property") - current = nil - } - } + defer { resetRunner(runner) } // we wrap the callback based work with ASWebAuthenticationSession so it fits // an async/await code style as any other action the SDK performs. The onCancel @@ -174,6 +169,13 @@ class Flow: Route, DescopeFlow { return code } + + + private func resetRunner(_ runner: DescopeFlowRunner) { + guard current === runner else { return } + log(.debug, "Resetting current flow runner property") + current = nil + } } // Internal @@ -184,13 +186,6 @@ private extension Data { guard SecRandomCopyBytes(kSecRandomDefault, count, &bytes) == errSecSuccess else { return nil } self = Data(bytes: bytes, count: count) } - - func base64URLEncodedString(options: Data.Base64EncodingOptions = []) -> String { - return base64EncodedString(options: options) - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } } private func prepareInitialRequest(for runner: DescopeFlowRunner) throws -> (url: URL, codeVerifier: String) { diff --git a/src/internal/routes/Passkey.swift b/src/internal/routes/Passkey.swift new file mode 100644 index 0000000..235e7a3 --- /dev/null +++ b/src/internal/routes/Passkey.swift @@ -0,0 +1,322 @@ + +import AuthenticationServices +import CryptoKit + +class Passkey: Route, DescopePasskey { + let client: DescopeClient + + init(client: DescopeClient) { + self.client = client + } + + var current: DescopePasskeyRunner? + + @MainActor + @available(iOS 15.0, *) + func signUp(loginId: String, details: SignUpDetails?, runner: DescopePasskeyRunner) async throws -> AuthenticationResponse { + current = runner + defer { resetRunner(runner) } + + log(.info, "Starting passkey sign up", loginId) + let startResponse = try await client.passkeySignUpStart(loginId: loginId, details: details, origin: runner.origin) + + log(.info, "Requesting register authorization for passkey sign up", startResponse.transactionId) + let registerResponse = try await performRegister(options: startResponse.options, runner: runner) + + log(.info, "Finishing passkey sign up", startResponse.transactionId) + let jwtResponse = try await client.passkeySignUpFinish(transactionId: startResponse.transactionId, response: registerResponse) + + guard !runner.isCancelled else { throw DescopeError.passkeyCancelled } + return try jwtResponse.convert() + } + + @MainActor + @available(iOS 15.0, *) + func signIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner) async throws -> AuthenticationResponse { + current = runner + defer { resetRunner(runner) } + + log(.info, "Starting passkey sign in", loginId) + let (refreshJwt, loginOptions) = try options.convert() + let startResponse = try await client.passkeySignInStart(loginId: loginId, origin: runner.origin, refreshJwt: refreshJwt, options: loginOptions) + + log(.info, "Requesting assertion authorization for passkey sign in", startResponse.transactionId) + let assertionResponse = try await performAssertion(options: startResponse.options, runner: runner) + + log(.info, "Finishing passkey sign in", startResponse.transactionId) + let jwtResponse = try await client.passkeySignInFinish(transactionId: startResponse.transactionId, response: assertionResponse) + + guard !runner.isCancelled else { throw DescopeError.passkeyCancelled } + return try jwtResponse.convert() + } + + @MainActor + @available(iOS 15.0, *) + func signUpOrIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner) async throws -> AuthenticationResponse { + current = runner + defer { resetRunner(runner) } + + log(.info, "Starting passkey sign up or in", loginId) + let (refreshJwt, loginOptions) = try options.convert() + let startResponse = try await client.passkeySignUpInStart(loginId: loginId, origin: runner.origin, refreshJwt: refreshJwt, options: loginOptions) + + let jwtResponse: DescopeClient.JWTResponse + if startResponse.create { + log(.info, "Requesting register authorization for passkey sign up or in", startResponse.transactionId) + let registerResponse = try await performRegister(options: startResponse.options, runner: runner) + log(.info, "Finishing passkey sign up", startResponse.transactionId) + jwtResponse = try await client.passkeySignUpFinish(transactionId: startResponse.transactionId, response: registerResponse) + } else { + log(.info, "Requesting assertion authorization for passkey sign up or in", startResponse.transactionId) + let assertionResponse = try await performAssertion(options: startResponse.options, runner: runner) + log(.info, "Finishing passkey sign in", startResponse.transactionId) + jwtResponse = try await client.passkeySignInFinish(transactionId: startResponse.transactionId, response: assertionResponse) + } + + guard !runner.isCancelled else { throw DescopeError.passkeyCancelled } + return try jwtResponse.convert() + } + + @MainActor + @available(iOS 15.0, *) + func add(loginId: String, refreshJwt: String, runner: DescopePasskeyRunner) async throws { + current = runner + defer { resetRunner(runner) } + + log(.info, "Starting passkey update", loginId) + let startResponse = try await client.passkeyAddStart(loginId: loginId, origin: runner.origin, refreshJwt: refreshJwt) + + log(.info, "Requesting register authorization for passkey update", startResponse.transactionId) + let registerResponse = try await performRegister(options: startResponse.options, runner: runner) + + log(.info, "Finishing passkey update", startResponse.transactionId) + try await client.passkeyAddFinish(transactionId: startResponse.transactionId, response: registerResponse) + + guard !runner.isCancelled else { throw DescopeError.passkeyCancelled } + } + + @MainActor + @available(iOS 15.0, *) + private func performRegister(options: String, runner: DescopePasskeyRunner) async throws -> String { + let registerOptions = try RegisterOptions(from: options) + + let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: runner.domain) + + let registerRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest(challenge: registerOptions.challenge, name: registerOptions.user.name, userID: registerOptions.user.id) + registerRequest.displayName = registerOptions.user.displayName + registerRequest.userVerificationPreference = .required + + let authorization = try await performAuthorization(request: registerRequest, runner: runner) + let response = try RegisterFinish.encodedResponse(from: authorization.credential) + + return response + } + + @MainActor + @available(iOS 15.0, *) + private func performAssertion(options: String, runner: DescopePasskeyRunner) async throws -> String { + let assertionOptions = try AssertionOptions(from: options) + + let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: runner.domain) + + let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: assertionOptions.challenge) + assertionRequest.allowedCredentials = assertionOptions.allowCredentials.map { ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0) } + assertionRequest.userVerificationPreference = .required + + let authorization = try await performAuthorization(request: assertionRequest, runner: runner) + let response = try AssertionFinish.encodedResponse(from: authorization.credential) + + return response + } + + @MainActor + private func performAuthorization(request: ASAuthorizationRequest, runner: DescopePasskeyRunner) async throws -> ASAuthorization { + let passkeyDelegate = PasskeyDelegate() + + let authController = ASAuthorizationController(authorizationRequests: [ request ] ) + authController.delegate = passkeyDelegate + authController.presentationContextProvider = runner.contextProvider + authController.performRequests() + + // now that we have a reference to the ASAuthorizationController object we set a + // cancellation handler on the runner that uses it, to be invoked if the runer's + // cancel() method is called or the async operation is cancelled. + runner.cancellation = { [weak authController] in + guard #available(iOS 16.0, macOS 13, *) else { return } + authController?.cancel() + } + + // we pass a completion handler to the delegate object we can use an async/await code + // style even though we're waiting for a regular callback. The onCancel closure ensures + // that we handle task cancellation properly by calling `cancel()` on the runner, which + // is then handled internally by the rest of the code. + let result = await withTaskCancellationHandler { + return await withCheckedContinuation { continuation in + passkeyDelegate.completion = { result in + continuation.resume(returning: result) + } + } + } onCancel: { + Task { @MainActor in + runner.cancel() + } + } + + switch result { + case _ where runner.isCancelled: + log(.info, "Passkey authorization cancelled programmatically") + throw DescopeError.passkeyCancelled + case .failure(ASAuthorizationError.canceled): + log(.info, "Passkey authorization cancelled by user") + throw DescopeError.passkeyCancelled + case .failure(let error as NSError) where error.domain == "WKErrorDomain" && error.code == 31: + log(.error, "Passkey authorization timed out", error) + throw DescopeError.passkeyTimeout + case .failure(let error): + log(.error, "Passkey authorization failed", error) + throw DescopeError.passkeyFailed.with(cause: error) + case .success(let authorization): + log(.debug, "Passkey authorization succeeded", authorization) + return authorization + } + } + + private func resetRunner(_ runner: DescopePasskeyRunner) { + guard current === runner else { return } + log(.debug, "Resetting current passkey runner property") + current = nil + } +} + +private struct RegisterOptions { + var challenge: Data + var user: (id: Data, name: String, displayName: String?) + + init(from options: String) throws { + guard let root = try? JSONDecoder().decode(Root.self, from: Data(options.utf8)) else { throw DescopeError.decodeError.with(message: "Invalid passkey register options") } + guard let challengeData = Data(base64URLEncoded: root.publicKey.challenge) else { throw DescopeError.decodeError.with(message: "Invalid passkey challenge") } + challenge = challengeData + user = (id: Data(root.publicKey.user.id.utf8), name: root.publicKey.user.name, displayName: root.publicKey.user.displayName) + } + + private struct Root: Codable { + var publicKey: PublicKey + } + + private struct PublicKey: Codable { + var challenge: String + var user: User + } + + private struct User: Codable { + var id: String + var name: String + var displayName: String? + } +} + +private struct AssertionOptions { + var challenge: Data + var allowCredentials: [Data] + + init(from options: String) throws { + guard let root = try? JSONDecoder().decode(Root.self, from: Data(options.utf8)) else { throw DescopeError.decodeError.with(message: "Invalid passkey assertion options") } + guard let challengeData = Data(base64URLEncoded: root.publicKey.challenge) else { throw DescopeError.decodeError.with(message: "Invalid passkey challenge") } + challenge = challengeData + allowCredentials = try root.publicKey.allowCredentials.map { + guard let credentialId = Data(base64URLEncoded: $0.id) else { throw DescopeError.decodeError.with(message: "Invalid credential id") } + return credentialId + } + } + + private struct Root: Codable { + var publicKey: PublicKey + } + + private struct PublicKey: Codable { + var challenge: String + var allowCredentials: [Credential] = [] + } + + struct Credential: Codable { + var id: String + } +} + +private struct RegisterFinish: Codable { + var id: String + var rawId: String + var response: Response + var type: String = "public-key" + + struct Response: Codable { + var attestationObject: String + var clientDataJSON: String + } + + @available(iOS 15.0, *) + static func encodedResponse(from credential: ASAuthorizationCredential) throws -> String { + guard let registration = credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else { throw DescopeError.passkeyFailed.with(message: "Invalid register credential type") } + + let credentialId = registration.credentialID.base64URLEncodedString() + guard let attestationObject = registration.rawAttestationObject?.base64URLEncodedString() else { throw DescopeError.passkeyFailed.with(message: "Missing credential attestation object") } + let clientDataJSON = registration.rawClientDataJSON.base64URLEncodedString() + + let response = Response(attestationObject: attestationObject, clientDataJSON: clientDataJSON) + let object = RegisterFinish(id: credentialId, rawId: credentialId, response: response) + + guard let encodedObject = try? JSONEncoder().encode(object), let encoded = String(bytes: encodedObject, encoding: .utf8) else { throw DescopeError.encodeError.with(message: "Invalid register finish object") } + return encoded + } +} + +private struct AssertionFinish: Codable { + var id: String + var rawId: String + var response: Response + var type: String = "public-key" + + struct Response: Codable { + var authenticatorData: String + var clientDataJSON: String + var signature: String + var userHandle: String + } + + @available(iOS 15.0, *) + static func encodedResponse(from credential: ASAuthorizationCredential) throws -> String { + guard let assertion = credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else { throw DescopeError.passkeyFailed.with(message: "Invalid assertion credential type") } + + let credentialId = assertion.credentialID.base64URLEncodedString() + let authenticatorData = assertion.rawAuthenticatorData.base64URLEncodedString() + let clientDataJSON = assertion.rawClientDataJSON.base64URLEncodedString() + guard let userHandle = String(bytes: assertion.userID, encoding: .utf8) else { throw DescopeError.passkeyFailed.with(message: "Invalid user handle") } + let signature = assertion.signature.base64URLEncodedString() + + let response = Response(authenticatorData: authenticatorData, clientDataJSON: clientDataJSON, signature: signature, userHandle: userHandle) + let object = AssertionFinish(id: credentialId, rawId: credentialId, response: response) + + guard let encodedObject = try? JSONEncoder().encode(object), let encoded = String(bytes: encodedObject, encoding: .utf8) else { throw DescopeError.encodeError.with(message: "Invalid assertion finish object") } + return encoded + } +} + +private extension DescopePasskeyRunner { + var origin: String { "https://\(domain)" } +} + +private typealias PasskeyDelegateCompletion = (Result) -> Void + +private class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate { + var completion: PasskeyDelegateCompletion? + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + completion?(.success(authorization)) + completion = nil + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + completion?(.failure(error)) + completion = nil + } +} diff --git a/src/sdk/Callbacks.stencil b/src/sdk/Callbacks.stencil index 4b04840..bb93f71 100644 --- a/src/sdk/Callbacks.stencil +++ b/src/sdk/Callbacks.stencil @@ -12,6 +12,9 @@ public extension {{ protocol.name }} { {% for line in method.documentation %} /// {{ line }} {% endfor %} + {% for attr in method.attributes %} + {% for key, value in method.attributes %}{{ value[0] }}{% endfor %} + {% endfor %} func {{ method.callName }}({% for param in method.parameters %}{{ param.asSource }}, {% endfor %}completion: @escaping (Result<{{ method.actualReturnTypeName }}, Error>) -> Void) { Task { do { diff --git a/src/sdk/Callbacks.swift b/src/sdk/Callbacks.swift index 111f297..53001e9 100644 --- a/src/sdk/Callbacks.swift +++ b/src/sdk/Callbacks.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 2.0.1 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT // Regenerate by running: @@ -619,6 +619,101 @@ public extension DescopeOTP { } } +public extension DescopePasskey { + /// Authenticates a new user by creating a new passkey. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - details: Optional details about the user signing up. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + @available(iOS 15.0, *) + func signUp(loginId: String, details: SignUpDetails?, runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { + Task { + do { + completion(.success(try await signUp(loginId: loginId, details: details, runner: runner))) + } catch { + completion(.failure(error)) + } + } + } + + /// Authenticates an existing user by prompting for an existing passkey. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - options: Additional behaviors to perform during authentication. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + @available(iOS 15.0, *) + func signIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { + Task { + do { + completion(.success(try await signIn(loginId: loginId, options: options, runner: runner))) + } catch { + completion(.failure(error)) + } + } + } + + /// Authenticates an existing user if one exists or creates a new one. + /// + /// A new passkey will be created if the user doesn't already exist, otherwise a passkey + /// must be available on their device to authenticate with. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - options: Additional behaviors to perform during authentication. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + @available(iOS 15.0, *) + func signUpOrIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { + Task { + do { + completion(.success(try await signUpOrIn(loginId: loginId, options: options, runner: runner))) + } catch { + completion(.failure(error)) + } + } + } + + /// Updates an existing user by adding a new passkey as an authentication method. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - refreshJwt: the `refreshJwt` from an active ``DescopeSession``. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + @available(iOS 15.0, *) + func add(loginId: String, refreshJwt: String, runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { + Task { + do { + completion(.success(try await add(loginId: loginId, refreshJwt: refreshJwt, runner: runner))) + } catch { + completion(.failure(error)) + } + } + } +} + public extension DescopePassword { /// Creates a new user that can later sign in with a password. /// diff --git a/src/sdk/Routes.swift b/src/sdk/Routes.swift index 6d97dbb..0b53a89 100644 --- a/src/sdk/Routes.swift +++ b/src/sdk/Routes.swift @@ -573,6 +573,73 @@ public protocol DescopeAccessKey { func exchange(accessKey: String) async throws -> DescopeToken } +public protocol DescopePasskey { + /// Returns the ``DescopePasskeyRunner`` for the current running passkey + /// authentication or `nil` if no authentication is currently ongoing. + var current: DescopePasskeyRunner? { get } + + /// Authenticates a new user by creating a new passkey. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - details: Optional details about the user signing up. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + @available(iOS 15.0, *) + func signUp(loginId: String, details: SignUpDetails?, runner: DescopePasskeyRunner) async throws -> AuthenticationResponse + + /// Authenticates an existing user by prompting for an existing passkey. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - options: Additional behaviors to perform during authentication. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + @available(iOS 15.0, *) + func signIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner) async throws -> AuthenticationResponse + + /// Authenticates an existing user if one exists or creates a new one. + /// + /// A new passkey will be created if the user doesn't already exist, otherwise a passkey + /// must be available on their device to authenticate with. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - options: Additional behaviors to perform during authentication. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + @available(iOS 15.0, *) + func signUpOrIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner) async throws -> AuthenticationResponse + + /// Updates an existing user by adding a new passkey as an authentication method. + /// + /// - Parameters: + /// - loginId: What identifies the user when logging in, + /// typically an email, phone, or any other unique identifier. + /// - refreshJwt: the `refreshJwt` from an active ``DescopeSession``. + /// - runner: A ``DescopePasskeyRunner`` that manages this operation. + /// + /// - Throws: ``DescopeError/passkeyCancelled`` if the ``DescopePasskeyRunner/cancel()`` + /// method is called on the runner or the authentication view is cancelled by the user. + @available(iOS 15.0, *) + func add(loginId: String, refreshJwt: String, runner: DescopePasskeyRunner) async throws +} + /// Authenticate a user using a flow. /// /// Descope Flows is a visual no-code interface to build screens and authentication flows diff --git a/src/sdk/SDK.swift b/src/sdk/SDK.swift index 434cdc3..008eee6 100644 --- a/src/sdk/SDK.swift +++ b/src/sdk/SDK.swift @@ -16,6 +16,12 @@ public class DescopeSDK { /// Provides functions for authentication with TOTP codes. public let totp: DescopeTOTP + /// Provides functions for authentication with passkeys. + public let passkey: DescopePasskey + + /// Provides functions for authentication with passwords. + public let password: DescopePassword + /// Provides functions for authentication with magic links. public let magicLink: DescopeMagicLink @@ -28,9 +34,6 @@ public class DescopeSDK { /// Provides functions for authentication with SSO. public let sso: DescopeSSO - /// Provides functions for authentication with passwords. - public let password: DescopePassword - /// Provides functions for authentication using flows. public let flow: DescopeFlow @@ -85,15 +88,16 @@ public class DescopeSDK { assert(config.projectId != "", "The projectId value must not be an empty string") self.config = config self.auth = Auth(client: client) - self.accessKey = AccessKey(client: client) self.otp = OTP(client: client) self.totp = TOTP(client: client) + self.passkey = Passkey(client: client) self.password = Password(client: client) self.magicLink = MagicLink(client: client) self.enchantedLink = EnchantedLink(client: client) self.oauth = OAuth(client: client) self.sso = SSO(client: client) self.flow = Flow(client: client) + self.accessKey = AccessKey(client: client) } } diff --git a/src/types/Error.swift b/src/types/Error.swift index c3b1068..416ccb0 100644 --- a/src/types/Error.swift +++ b/src/types/Error.swift @@ -54,8 +54,10 @@ public struct DescopeError: Error { /// An optional underlying error that caused this error. /// - /// For example, when a ``DescopeError/networkError`` is caught the ``cause`` property - /// will usually have the `NSError` object thrown by the internal `URLSession` call. + /// For example, when a ``DescopeError/networkError`` error is thrown the ``cause`` + /// property will always contain the `NSError` object thrown by the internal `URLSession` + /// call. When a ``DescopeError/passkeyFailed`` error is thrown the ``cause`` property + /// will often contain an instance of AuthorizationError. public var cause: Error? } @@ -83,6 +85,10 @@ extension DescopeError { public static let flowFailed = DescopeError.sdkError("S100001", "Flow failed to run") public static let flowCancelled = DescopeError.sdkError("S100002", "Flow cancelled") + + public static let passkeyFailed = DescopeError.sdkError("S110001", "Passkey operation failed") + public static let passkeyCancelled = DescopeError.sdkError("S110002", "Passkey operation cancelled") + public static let passkeyTimeout = DescopeError.sdkError("S110003", "Passkey operation timed out") } /// Extension functions for catching ``DescopeError`` values. diff --git a/src/types/Flows.swift b/src/types/Flows.swift index 85bebb3..ceca1c3 100644 --- a/src/types/Flows.swift +++ b/src/types/Flows.swift @@ -16,6 +16,7 @@ import AuthenticationServices /// let authResponse = try await Descope.flow.start(runner: runner) /// let session = DescopeSession(from: authResponse) /// Descope.sessionManager.manageSession(session) +/// showHomeScreen() /// } catch DescopeError.flowCancelled { /// // do nothing /// } catch { @@ -95,17 +96,17 @@ public class DescopeFlowRunner { /// Cancels the flow run. /// - /// You can cancel any ongoing flow via the ``DescopeFlow/current`` property on - /// ``Descope/flow`` object, or by holding on to the ``DescopeFlowRunner`` instance directly. - /// - /// Task { - /// do { - /// let runner = DescopeFlowRunner(...) - /// let authResponse = try await Descope.flow.start(runner: runner) - /// } catch DescopeError.flowCancelled { - /// print("The flow was cancelled") - /// } catch { - /// // ... + /// You can cancel any ongoing flow via the ``DescopeFlow/current`` property on the + /// ``Descope/flow`` object, or by holding on to the ``DescopeFlowRunner`` instance + /// directly. This method can be safely called multiple times. + /// + /// do { + /// let runner = DescopeFlowRunner(...) + /// let authResponse = try await Descope.flow.start(runner: runner) + /// } catch DescopeError.flowCancelled { + /// print("The flow was cancelled") + /// } catch { + /// // ... /// } /// /// // somewhere else @@ -142,25 +143,5 @@ public class DescopeFlowRunner { } /// The default context provider that looks for the first key window in the active scene. - private let defaultContextProvider = DefaultContextProvider() -} - -// Internal - -private class DefaultContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - #if os(macOS) - return ASPresentationAnchor() - #else - let scene = UIApplication.shared.connectedScenes - .filter { $0.activationState == .foregroundActive } - .compactMap { $0 as? UIWindowScene } - .first - - let keyWindow = scene?.windows - .first { $0.isKeyWindow } - - return keyWindow ?? ASPresentationAnchor() - #endif - } + private let defaultContextProvider = DefaultPresentationContextProvider() } diff --git a/src/types/Passkeys.swift b/src/types/Passkeys.swift new file mode 100644 index 0000000..ad50310 --- /dev/null +++ b/src/types/Passkeys.swift @@ -0,0 +1,78 @@ + +import AuthenticationServices + +/// A helper object that encapsulates a single authentication with passkeys. +@MainActor +public class DescopePasskeyRunner { + public var domain: String + + /// Determines where in an application's UI the authentication view should be shown. + /// + /// Setting this delegate object is optional as the ``DescopePasskeyRunner`` will look for + /// a suitable anchor to show the authentication view. In case you need to override the + /// default behavior set your own delegate on this property. + /// + /// - Note: This property is marked as `weak` like all delegate properties, so if you + /// set a custom object make sure it's retained elsewhere. + public weak var presentationContextProvider: ASAuthorizationControllerPresentationContextProviding? + + /// Creates a new ``DescopePasskeyRunner`` object that encapsulates a single + /// passkey authentifation. + public init(domain: String) { + self.domain = domain + } + + /// Cancels the passkeys run. + /// + /// You can cancel any ongoing passkey authentication via the ``DescopePasskey/current`` + /// property on the ``Descope/passkey`` object, or by holding on to the ``DescopePasskeyRunner`` + /// instance directly. This method can be safely called multiple times. + /// + /// do { + /// let runner = DescopePasskeyRunner(domain: "acmecorp.com") + /// let authResponse = try await Descope.passkey.signIn( + /// loginId: "user@acmecorp.com", options: [], runner: runner) + /// let session = DescopeSession(from: authResponse) + /// Descope.sessionManager.manageSession(session) + /// } catch DescopeError.passkeyCancelled { + /// print("The authentication was cancelled") + /// } catch { + /// // ... + /// } + /// + /// // somewhere else + /// Descope.passkey.current?.cancel() + /// + /// Note that cancelling the `Task` that started the passkey authentication has the + /// same effect as calling this ``cancel()`` function. + /// + /// In any case, when a runner is cancelled the ``DescopePasskey`` calls always + /// throw a ``DescopeError/passkeyCancelled`` error. + /// + /// - Important: Calling ``cancel()`` will only dismiss the authentication view when running + /// on iOS 16 / macOS 13 or newer, as this functionality was not supported in earlier + /// releases. Note that even when running on older versions, once this method is called + /// the async authentication call will eventually throw a ``DescopeError/passkeyCancelled`` + /// error no matter what the user does with the authentication view. + public func cancel() { + guard !isCancelled else { return } + isCancelled = true + cancellation?() + } + + /// Returns whether this runner was cancelled. + public private(set) var isCancelled: Bool = false + + // Internal + + /// Returns the ``presentationContextProvider`` or the default provider if none was set. + var contextProvider: ASAuthorizationControllerPresentationContextProviding { + return presentationContextProvider ?? defaultContextProvider + } + + /// The default context provider that looks for the first key window in the active scene. + private let defaultContextProvider = DefaultPresentationContextProvider() + + /// Cancels the authentication encapsulated by this runner. + var cancellation: (() -> Void)? +} diff --git a/src/types/User.swift b/src/types/User.swift index 11e5a66..c5cb5b5 100644 --- a/src/types/User.swift +++ b/src/types/User.swift @@ -70,6 +70,18 @@ public struct DescopeUser: Codable, Equatable { /// Whether the phone number has been verified to be a valid authentication method /// for this user. If ``phone`` is `nil` then this is always `false`. public var isVerifiedPhone: Bool + + public init(userId: String, loginIds: [String], createdAt: Date, name: String? = nil, picture: URL? = nil, email: String? = nil, isVerifiedEmail: Bool = false, phone: String? = nil, isVerifiedPhone: Bool = false) { + self.userId = userId + self.loginIds = loginIds + self.createdAt = createdAt + self.name = name + self.picture = picture + self.email = email + self.isVerifiedEmail = isVerifiedEmail + self.phone = phone + self.isVerifiedPhone = isVerifiedPhone + } } extension DescopeUser: CustomStringConvertible { From 8325da2ea3f3b448f070d048df702bdc53a8860a Mon Sep 17 00:00:00 2001 From: Gil Shapira Date: Wed, 15 Nov 2023 21:59:26 +0200 Subject: [PATCH 2/3] Cleanup --- src/internal/others/Deprecated.swift | 12 ------------ src/session/Storage.swift | 1 - 2 files changed, 13 deletions(-) diff --git a/src/internal/others/Deprecated.swift b/src/internal/others/Deprecated.swift index d89574a..0a1737e 100644 --- a/src/internal/others/Deprecated.swift +++ b/src/internal/others/Deprecated.swift @@ -1,18 +1,6 @@ import Foundation -/// See the documentation for `DescopeUser`. -@available(*, unavailable, renamed: "DescopeUser") -public typealias MeResponse = DescopeUser - -/// See the documentation for `SignUpDetails`. -@available(*, unavailable, renamed: "SignUpDetails") -public typealias User = SignUpDetails - -/// See the documentation for `PasswordPolicyResponse`. -@available(*, unavailable, renamed: "PasswordPolicyResponse") -public typealias PasswordPolicy = PasswordPolicyResponse - public extension DescopeOTP { @available(*, deprecated, message: "Pass a value (or an empty array) for the options parameter") func signIn(with method: DeliveryMethod, loginId: String) async throws -> String { diff --git a/src/session/Storage.swift b/src/session/Storage.swift index 1fb8247..0e75bd7 100644 --- a/src/session/Storage.swift +++ b/src/session/Storage.swift @@ -31,7 +31,6 @@ public protocol DescopeSessionStorage: AnyObject { /// instance of that class to the initializer to create a ``SessionStorage`` object /// that uses a different backing store. public class SessionStorage: DescopeSessionStorage { - public let projectId: String public let store: Store From 1bac9c145d452eb24ac01a6de1d2bbf2cd858f75 Mon Sep 17 00:00:00 2001 From: Gil Shapira Date: Sun, 3 Dec 2023 14:31:42 +0200 Subject: [PATCH 3/3] Update src/internal/routes/Passkey.swift --- src/internal/routes/Passkey.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/routes/Passkey.swift b/src/internal/routes/Passkey.swift index 235e7a3..c726865 100644 --- a/src/internal/routes/Passkey.swift +++ b/src/internal/routes/Passkey.swift @@ -139,7 +139,7 @@ class Passkey: Route, DescopePasskey { authController.performRequests() // now that we have a reference to the ASAuthorizationController object we set a - // cancellation handler on the runner that uses it, to be invoked if the runer's + // cancellation handler on the runner that uses it, to be invoked if the runner's // cancel() method is called or the async operation is cancelled. runner.cancellation = { [weak authController] in guard #available(iOS 16.0, macOS 13, *) else { return }