From 7ebcdee5c6edab7625dfb51e14561231e7ec00b5 Mon Sep 17 00:00:00 2001 From: Gil Shapira Date: Tue, 19 Dec 2023 12:04:49 +0200 Subject: [PATCH] Add support for native Sign in with Apple authentication (#50) --- README.md | 52 ++++++++----- src/internal/http/DescopeClient.swift | 36 +++++++-- src/internal/others/Deprecated.swift | 4 +- src/internal/others/Internal.swift | 16 ++++ src/internal/routes/OAuth.swift | 107 ++++++++++++++++++++++++-- src/internal/routes/Passkey.swift | 24 +----- src/internal/routes/SSO.swift | 7 +- src/sdk/Callbacks.stencil | 4 +- src/sdk/Callbacks.swift | 56 ++++++++++++-- src/sdk/Routes.swift | 49 +++++++++++- src/sdk/SDK.swift | 2 +- src/session/Session.swift | 2 +- src/types/Error.swift | 8 +- src/types/Passkeys.swift | 7 +- 14 files changed, 302 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 01a9844..f11fc92 100644 --- a/README.md +++ b/README.md @@ -190,17 +190,35 @@ let authResponse = try await Descope.magiclink.verify(token: "") ### OAuth -Users can authenticate using their social logins, using the OAuth protocol. -Configure your OAuth settings on the [Descope console](https://app.descope.com/settings/authentication/social). -To start a flow call: +When a user wants to use social login with Apple you can leverage the [Sign in with Apple](https://developer.apple.com/sign-in-with-apple/) +feature to show a native authentication view that allows the user to login using the Apple ID +they are already logged into on their device. Note that the OAuth provider you choose to use +must be configured with the application's Bundle Identifier as the Client ID in the +[Descope console](https://app.descope.com/settings/authentication/social). + +```swift +do { + let authResponse = try await Descope.oauth.native(provider: .apple, options: []) + let session = DescopeSession(from: authResponse) + Descope.sessionManager.manageSession(session) + showHomeScreen() +} catch DescopeError.oauthNativeCancelled { + print("Authentication cancelled") +} catch { + showErrorAlert(error) +} +``` + +Users can authenticate using any other social login providers, using the OAuth protocol via +a browser based authentication flow. Configure your OAuth settings on the [Descope console](https://app.descope.com/settings/authentication/social). +To start an OAuth authentication call: ```swift // Choose an oauth provider out of the supported providers // If configured globally, the redirect URL is optional. If provided however, it will be used // instead of any global configuration. // Redirect the user to the returned URL to start the OAuth redirect chain -let authURL = try await Descope.oauth.start(provider: .github, redirectURL: "exampleauthschema://my-app.com/handle-oauth") -guard let authURL = URL(string: url) else { return } +let authURL = try await Descope.oauth.start(provider: .github, redirectURL: "exampleauthschema://my-app.com/handle-oauth", options: []) ``` Take the generated URL and authenticate the user using `ASWebAuthenticationSession` @@ -212,18 +230,17 @@ Exchange it to validate the user: ```swift // Start the authentication session let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "exampleauthschema") { callbackURL, error in - // Extract the returned code - guard let url = callbackURL else {return} - let component = URLComponents(url: url, resolvingAgainstBaseURL: false) - guard let code = component?.queryItems?.first(where: {$0.name == "code"})?.value else { return } - - // ... Trigger asynchronously - - // Exchange code for session - let authResponse = try await Descope.oauth.exchange(code: code) - let session = DescopeSession(from: authResponse) - Descope.sessionManager.manageSession(session) + guard let url = callbackURL else { return } + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value else { return } + + Task { + // Exchange code for session + let authResponse = try await Descope.oauth.exchange(code: code) + let session = DescopeSession(from: authResponse) + Descope.sessionManager.manageSession(session) + } } ``` @@ -238,8 +255,7 @@ To start a flow call: // If configured globally, the return URL is optional. If provided however, it will be used // instead of any global configuration. // Redirect the user to the returned URL to start the SSO/SAML redirect chain -let authURL = try await Descope.sso.start(emailOrTenantName: "my-tenant-ID", redirectURL: "exampleauthschema://my-app.com/handle-saml") -guard let authURL = URL(string: url) else { return } +let authURL = try await Descope.sso.start(emailOrTenantName: "my-tenant-ID", redirectURL: "exampleauthschema://my-app.com/handle-saml", options: []) ``` Take the generated URL and authenticate the user using `ASWebAuthenticationSession` diff --git a/src/internal/http/DescopeClient.swift b/src/internal/http/DescopeClient.swift index 4cbe6d9..d583b9b 100644 --- a/src/internal/http/DescopeClient.swift +++ b/src/internal/http/DescopeClient.swift @@ -306,19 +306,43 @@ class DescopeClient: HTTPClient { var url: String } - func oauthStart(provider: OAuthProvider, redirectURL: String?, refreshJwt: String?, options: LoginOptions?) async throws -> OAuthResponse { + struct OAuthNativeStartResponse: JSONResponse { + var clientId: String + var stateId: String + var nonce: String + var implicit: Bool + } + + func oauthWebStart(provider: OAuthProvider, redirectURL: String?, refreshJwt: String?, options: LoginOptions?) async throws -> OAuthResponse { return try await post("auth/oauth/authorize", headers: authorization(with: refreshJwt), params: [ "provider": provider.name, "redirectUrl": redirectURL ], body: options?.dictValue ?? [:]) } - func oauthExchange(code: String) async throws -> JWTResponse { + func oauthWebExchange(code: String) async throws -> JWTResponse { return try await post("auth/oauth/exchange", body: [ "code": code ]) } + func oauthNativeStart(provider: OAuthProvider, refreshJwt: String?, options: LoginOptions?) async throws -> OAuthNativeStartResponse { + return try await post("auth/oauth/native/start", headers: authorization(with: refreshJwt), body: [ + "provider": provider.name, + "loginOptions": options?.dictValue + ]) + } + + func oauthNativeFinish(provider: OAuthProvider, stateId: String, user: String?, authorizationCode: String?, identityToken: String?) async throws -> JWTResponse { + return try await post("auth/oauth/native/finish", body: [ + "provider": provider.name, + "stateId": stateId, + "user": user, + "code": authorizationCode, + "idToken": identityToken, + ]) + } + // MARK: - SSO struct SSOResponse: JSONResponse { @@ -348,7 +372,7 @@ class DescopeClient: HTTPClient { return try await post("auth/accesskey/exchange", headers: authorization(with: accessKey)) } - // Mark: - Flow + // MARK: - Flow func flowExchange(authorizationCode: String, codeVerifier: String) async throws -> JWTResponse { return try await post("flow/exchange", body: [ @@ -360,15 +384,15 @@ class DescopeClient: HTTPClient { // MARK: - Others func me(refreshJwt: String) async throws -> UserResponse { - return try await get("me", headers: authorization(with: refreshJwt)) + return try await get("auth/me", headers: authorization(with: refreshJwt)) } func refresh(refreshJwt: String) async throws -> JWTResponse { - return try await post("refresh", headers: authorization(with: refreshJwt)) + return try await post("auth/refresh", headers: authorization(with: refreshJwt)) } func logout(refreshJwt: String) async throws { - try await post("logout", headers: authorization(with: refreshJwt)) + try await post("auth/logout", headers: authorization(with: refreshJwt)) } // MARK: - Shared diff --git a/src/internal/others/Deprecated.swift b/src/internal/others/Deprecated.swift index 0a1737e..fab74f6 100644 --- a/src/internal/others/Deprecated.swift +++ b/src/internal/others/Deprecated.swift @@ -46,14 +46,14 @@ public extension DescopeEnchantedLink { public extension DescopeOAuth { @available(*, deprecated, message: "Pass a value (or an empty array) for the options parameter") - func start(provider: OAuthProvider, redirectURL: String?) async throws -> String { + func start(provider: OAuthProvider, redirectURL: String?) async throws -> URL { return try await start(provider: provider, redirectURL: redirectURL, options: []) } } public extension DescopeSSO { @available(*, deprecated, message: "Pass a value (or an empty array) for the options parameter") - func start(emailOrTenantName: String, redirectURL: String?) async throws -> String { + func start(emailOrTenantName: String, redirectURL: String?) async throws -> URL { return try await start(emailOrTenantName: emailOrTenantName, redirectURL: redirectURL, options: []) } } diff --git a/src/internal/others/Internal.swift b/src/internal/others/Internal.swift index 008ff55..42470dc 100644 --- a/src/internal/others/Internal.swift +++ b/src/internal/others/Internal.swift @@ -63,3 +63,19 @@ class DefaultPresentationContextProvider: NSObject, ASWebAuthenticationPresentat #endif } } + +typealias AuthorizationDelegateCompletion = (Result) -> Void + +class AuthorizationDelegate: NSObject, ASAuthorizationControllerDelegate { + var completion: AuthorizationDelegateCompletion? + + 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/internal/routes/OAuth.swift b/src/internal/routes/OAuth.swift index 3d4f1ee..e232c9e 100644 --- a/src/internal/routes/OAuth.swift +++ b/src/internal/routes/OAuth.swift @@ -1,18 +1,115 @@ -class OAuth: DescopeOAuth { +import AuthenticationServices + +class OAuth: Route, DescopeOAuth { let client: DescopeClient init(client: DescopeClient) { self.client = client } - func start(provider: OAuthProvider, redirectURL: String?, options: [SignInOptions]) async throws -> String { + func start(provider: OAuthProvider, redirectURL: String?, options: [SignInOptions]) async throws -> URL { let (refreshJwt, loginOptions) = try options.convert() - let response = try await client.oauthStart(provider: provider, redirectURL: redirectURL, refreshJwt: refreshJwt, options: loginOptions) - return response.url + let response = try await client.oauthWebStart(provider: provider, redirectURL: redirectURL, refreshJwt: refreshJwt, options: loginOptions) + guard let url = URL(string: response.url) else { throw DescopeError.decodeError.with(message: "Invalid redirect URL") } + return url } func exchange(code: String) async throws -> AuthenticationResponse { - return try await client.oauthExchange(code: code).convert() + return try await client.oauthWebExchange(code: code).convert() + } + + @MainActor + func native(provider: OAuthProvider, options: [SignInOptions]) async throws -> AuthenticationResponse { + log(.info, "Starting authentication using Sign in with Apple") + let (refreshJwt, loginOptions) = try options.convert() + let startResponse = try await client.oauthNativeStart(provider: provider, refreshJwt: refreshJwt, options: loginOptions) + + if startResponse.clientId != Bundle.main.bundleIdentifier { + log(.error, "Sign in with Apple requires an OAuth provider that's configured with a clientId matching the application's bundle identifier", startResponse.clientId, Bundle.main.bundleIdentifier) + throw DescopeError.oauthNativeFailed.with(message: "OAuth provider clientId doesn't match bundle identifier") + } + + log(.info, "Requesting authorization for Sign in with Apple", startResponse.clientId) + let authorization = try await performAuthorization(nonce: startResponse.nonce) + let (authorizationCode, identityToken, user) = try parseCredential(authorization.credential, implicit: startResponse.implicit) + + log(.info, "Finishing authentication using Sign in with Apple") + return try await client.oauthNativeFinish(provider: provider, stateId: startResponse.stateId, user: user, authorizationCode: authorizationCode, identityToken: identityToken).convert() + } + + @MainActor + private func performAuthorization(nonce: String) async throws -> ASAuthorization { + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + request.requestedScopes = [.fullName, .email] + request.nonce = nonce + + let contextProvider = DefaultPresentationContextProvider() + + let authDelegate = AuthorizationDelegate() + let authController = ASAuthorizationController(authorizationRequests: [ request ] ) + authController.delegate = authDelegate + authController.presentationContextProvider = contextProvider + authController.performRequests() + + let result = await withCheckedContinuation { continuation in + authDelegate.completion = { result in + continuation.resume(returning: result) + } + } + + switch result { + case .failure(ASAuthorizationError.canceled): + log(.info, "OAuth authorization cancelled by user") + throw DescopeError.oauthNativeCancelled + case .failure(ASAuthorizationError.unknown): + log(.info, "OAuth authorization aborted") + throw DescopeError.oauthNativeCancelled.with(message: "The operation was aborted") + case .failure(let error): + log(.error, "OAuth authorization failed", error) + throw DescopeError.oauthNativeFailed.with(cause: error) + case .success(let authorization): + log(.debug, "OAuth authorization succeeded", authorization) + return authorization + } + } + + private func parseCredential(_ credential: ASAuthorizationCredential, implicit: Bool) throws -> (authorizationCode: String?, identityToken: String?, user: String?) { + guard let credential = credential as? ASAuthorizationAppleIDCredential else { throw DescopeError.oauthNativeFailed.with(message: "Invalid Apple credential type") } + log(.debug, "Received Apple credential", credential.realUserStatus) + + var authorizationCode: String? + if !implicit, let data = credential.authorizationCode, let value = String(bytes: data, encoding: .utf8) { + log(.debug, "Adding authorization code from Apple credential", value) + authorizationCode = value + } + + var identityToken: String? + if implicit, let data = credential.identityToken, let value = String(bytes: data, encoding: .utf8) { + log(.debug, "Adding identity token from Apple credential", value) + identityToken = value + } + + var user: String? + if let names = credential.fullName, names.givenName != nil || names.middleName != nil || names.familyName != nil { + var name: [String: Any] = [:] + if let givenName = names.givenName { + name["firstName"] = givenName + } + if let middleName = names.middleName { + name["middleName"] = middleName + } + if let familyName = names.familyName { + name["lastName"] = familyName + } + let object = ["name": name] + if let data = try? JSONSerialization.data(withJSONObject: object), let value = String(bytes: data, encoding: .utf8) { + log(.debug, "Adding user name from Apple credential", name) + user = value + } + } + + return (authorizationCode, identityToken, user) } } diff --git a/src/internal/routes/Passkey.swift b/src/internal/routes/Passkey.swift index c726865..c44f62c 100644 --- a/src/internal/routes/Passkey.swift +++ b/src/internal/routes/Passkey.swift @@ -131,10 +131,10 @@ class Passkey: Route, DescopePasskey { @MainActor private func performAuthorization(request: ASAuthorizationRequest, runner: DescopePasskeyRunner) async throws -> ASAuthorization { - let passkeyDelegate = PasskeyDelegate() + let authDelegate = AuthorizationDelegate() let authController = ASAuthorizationController(authorizationRequests: [ request ] ) - authController.delegate = passkeyDelegate + authController.delegate = authDelegate authController.presentationContextProvider = runner.contextProvider authController.performRequests() @@ -152,7 +152,7 @@ class Passkey: Route, DescopePasskey { // is then handled internally by the rest of the code. let result = await withTaskCancellationHandler { return await withCheckedContinuation { continuation in - passkeyDelegate.completion = { result in + authDelegate.completion = { result in continuation.resume(returning: result) } } @@ -171,7 +171,7 @@ class Passkey: Route, DescopePasskey { 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 + throw DescopeError.passkeyCancelled.with(message: "The operation timed out") case .failure(let error): log(.error, "Passkey authorization failed", error) throw DescopeError.passkeyFailed.with(cause: error) @@ -304,19 +304,3 @@ private struct AssertionFinish: Codable { 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/internal/routes/SSO.swift b/src/internal/routes/SSO.swift index 7bf47b2..22164f5 100644 --- a/src/internal/routes/SSO.swift +++ b/src/internal/routes/SSO.swift @@ -1,4 +1,6 @@ +import Foundation + class SSO: DescopeSSO { let client: DescopeClient @@ -6,10 +8,11 @@ class SSO: DescopeSSO { self.client = client } - func start(emailOrTenantName: String, redirectURL: String?, options: [SignInOptions]) async throws -> String { + func start(emailOrTenantName: String, redirectURL: String?, options: [SignInOptions]) async throws -> URL { let (refreshJwt, loginOptions) = try options.convert() let response = try await client.ssoStart(emailOrTenantName: emailOrTenantName, redirectURL: redirectURL, refreshJwt: refreshJwt, options: loginOptions) - return response.url + guard let url = URL(string: response.url) else { throw DescopeError.decodeError.with(message: "Invalid redirect URL") } + return url } func exchange(code: String) async throws -> AuthenticationResponse { diff --git a/src/sdk/Callbacks.stencil b/src/sdk/Callbacks.stencil index bb93f71..8e0df5a 100644 --- a/src/sdk/Callbacks.stencil +++ b/src/sdk/Callbacks.stencil @@ -12,8 +12,8 @@ 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 %} + {% for key, value in method.attributes %} + {{ value[0] }} {% endfor %} func {{ method.callName }}({% for param in method.parameters %}{{ param.asSource }}, {% endfor %}completion: @escaping (Result<{{ method.actualReturnTypeName }}, Error>) -> Void) { Task { diff --git a/src/sdk/Callbacks.swift b/src/sdk/Callbacks.swift index 53001e9..834ee12 100644 --- a/src/sdk/Callbacks.swift +++ b/src/sdk/Callbacks.swift @@ -460,7 +460,7 @@ public extension DescopeOAuth { /// /// - Returns: A URL to redirect to in order to authenticate the user against /// the chosen provider. - func start(provider: OAuthProvider, redirectURL: String?, options: [SignInOptions], completion: @escaping (Result) -> Void) { + func start(provider: OAuthProvider, redirectURL: String?, options: [SignInOptions], completion: @escaping (Result) -> Void) { Task { do { completion(.success(try await start(provider: provider, redirectURL: redirectURL, options: options))) @@ -486,6 +486,47 @@ public extension DescopeOAuth { } } } + + /// Authenticates the user using the native `Sign in with Apple` dialog. + /// + /// This API enables a more streamlined user experience than the equivalent browser + /// based OAuth authentication, when using the `.apple` provider or a custom provider + /// that's configured for Apple. The authentication presents a native dialog that lets + /// the user sign in with the Apple ID they're already using on their device. + /// + /// The Sign in with Apple APIs require some setup in your Xcode project, including + /// at the very least adding the `Sign in with Apple` capability. You will also need + /// to configure the Apple provider in the [Descope console](https://app.descope.com/settings/authentication/social). + /// In particular, when using your own account make sure that the `Client ID` value + /// matches the Bundle Identifier of your app. + /// + /// - Parameters: + /// - provider: The provider the user wishes to authenticate with, this will usually + /// either be `.apple` or the name of a custom provider that's configured for Apple. + /// - options: Require additional behaviors when authenticating a user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + /// + /// - Throws: ``DescopeError/oauthNativeCancelled`` if the authentication view is aborted + /// or cancelled by the user. + /// + /// - Note: This is an asynchronous operation that performs network requests before and + /// after displaying the modal authentication view. It is thus recommended to show an + /// activity indicator or switch the user interface to a loading state before calling + /// this, otherwise the user might accidentally interact with the app when the + /// authentication view is not being displayed. + /// + /// - SeeAlso: For more details about configuring your app and generating client secrets + /// see the [Sign in with Apple documentation](https://developer.apple.com/sign-in-with-apple/get-started/). + func native(provider: OAuthProvider, options: [SignInOptions], completion: @escaping (Result) -> Void) { + Task { + do { + completion(.success(try await native(provider: provider, options: options))) + } catch { + completion(.failure(error)) + } + } + } } public extension DescopeOTP { @@ -632,7 +673,7 @@ public extension DescopePasskey { /// 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, *) + @available(iOS 15.0, *) func signUp(loginId: String, details: SignUpDetails?, runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { Task { do { @@ -655,7 +696,7 @@ public extension DescopePasskey { /// 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, *) + @available(iOS 15.0, *) func signIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { Task { do { @@ -681,7 +722,7 @@ public extension DescopePasskey { /// 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, *) + @available(iOS 15.0, *) func signUpOrIn(loginId: String, options: [SignInOptions], runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { Task { do { @@ -702,7 +743,7 @@ public extension DescopePasskey { /// /// - 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, *) + @available(iOS 15.0, *) func add(loginId: String, refreshJwt: String, runner: DescopePasskeyRunner, completion: @escaping (Result) -> Void) { Task { do { @@ -783,7 +824,8 @@ public extension DescopePassword { /// - loginId: The existing user's loginId. /// - oldPassword: The user's current password. /// - newPassword: The new password to set for the user. - /// - Returns: An ``AuthenticationResponse`` value upon successful replacement and authentication. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful replacement and authentication. func replace(loginId: String, oldPassword: String, newPassword: String, completion: @escaping (Result) -> Void) { Task { do { @@ -851,7 +893,7 @@ public extension DescopeSSO { /// /// - Returns: A URL to redirect to in order to authenticate the user against /// the chosen provider. - func start(emailOrTenantName: String, redirectURL: String?, options: [SignInOptions], completion: @escaping (Result) -> Void) { + func start(emailOrTenantName: String, redirectURL: String?, options: [SignInOptions], completion: @escaping (Result) -> Void) { Task { do { completion(.success(try await start(emailOrTenantName: emailOrTenantName, redirectURL: redirectURL, options: options))) diff --git a/src/sdk/Routes.swift b/src/sdk/Routes.swift index 0b53a89..4460082 100644 --- a/src/sdk/Routes.swift +++ b/src/sdk/Routes.swift @@ -417,7 +417,7 @@ public protocol DescopeEnchantedLink { /// Use the Descope console to configure which authentication provider you'd like to support. /// It's recommended to use `ASWebAuthenticationSession` to perform the authentication /// -/// For further reference see: [Authenticating a User Through a Web Service](https://developer.apple.com/documentation/authenticationservices/authenticating_a_user_through_a_web_service) +/// - SeeAlso: For further reference see: [Authenticating a User Through a Web Service](https://developer.apple.com/documentation/authenticationservices/authenticating_a_user_through_a_web_service) public protocol DescopeOAuth { /// Starts an OAuth redirect chain to authenticate a user. /// @@ -440,7 +440,7 @@ public protocol DescopeOAuth { /// /// - Returns: A URL to redirect to in order to authenticate the user against /// the chosen provider. - func start(provider: OAuthProvider, redirectURL: String?, options: [SignInOptions]) async throws -> String + func start(provider: OAuthProvider, redirectURL: String?, options: [SignInOptions]) async throws -> URL /// Completes an OAuth redirect chain by exchanging the code received in /// the `code` URL parameter for an ``AuthenticationResponse``. @@ -450,6 +450,39 @@ public protocol DescopeOAuth { /// /// - Returns: An ``AuthenticationResponse`` value upon successful exchange. func exchange(code: String) async throws -> AuthenticationResponse + + /// Authenticates the user using the native `Sign in with Apple` dialog. + /// + /// This API enables a more streamlined user experience than the equivalent browser + /// based OAuth authentication, when using the `.apple` provider or a custom provider + /// that's configured for Apple. The authentication presents a native dialog that lets + /// the user sign in with the Apple ID they're already using on their device. + /// + /// The Sign in with Apple APIs require some setup in your Xcode project, including + /// at the very least adding the `Sign in with Apple` capability. You will also need + /// to configure the Apple provider in the [Descope console](https://app.descope.com/settings/authentication/social). + /// In particular, when using your own account make sure that the `Client ID` value + /// matches the Bundle Identifier of your app. + /// + /// - Parameters: + /// - provider: The provider the user wishes to authenticate with, this will usually + /// either be `.apple` or the name of a custom provider that's configured for Apple. + /// - options: Require additional behaviors when authenticating a user. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful authentication. + /// + /// - Throws: ``DescopeError/oauthNativeCancelled`` if the authentication view is aborted + /// or cancelled by the user. + /// + /// - Note: This is an asynchronous operation that performs network requests before and + /// after displaying the modal authentication view. It is thus recommended to show an + /// activity indicator or switch the user interface to a loading state before calling + /// this, otherwise the user might accidentally interact with the app when the + /// authentication view is not being displayed. + /// + /// - SeeAlso: For more details about configuring your app and generating client secrets + /// see the [Sign in with Apple documentation](https://developer.apple.com/sign-in-with-apple/get-started/). + func native(provider: OAuthProvider, options: [SignInOptions]) async throws -> AuthenticationResponse } @@ -475,7 +508,7 @@ public protocol DescopeSSO { /// /// - Returns: A URL to redirect to in order to authenticate the user against /// the chosen provider. - func start(emailOrTenantName: String, redirectURL: String?, options: [SignInOptions]) async throws -> String + func start(emailOrTenantName: String, redirectURL: String?, options: [SignInOptions]) async throws -> URL /// Completes an SSO redirect chain by exchanging the code received in /// the `code` URL parameter for an ``AuthenticationResponse``. @@ -533,7 +566,8 @@ public protocol DescopePassword { /// - loginId: The existing user's loginId. /// - oldPassword: The user's current password. /// - newPassword: The new password to set for the user. - /// - Returns: An ``AuthenticationResponse`` value upon successful replacement and authentication. + /// + /// - Returns: An ``AuthenticationResponse`` value upon successful replacement and authentication. func replace(loginId: String, oldPassword: String, newPassword: String) async throws -> AuthenticationResponse /// Sends a password reset email to the user. @@ -573,6 +607,13 @@ public protocol DescopeAccessKey { func exchange(accessKey: String) async throws -> DescopeToken } +/// Authenticate a user using passkeys. +/// +/// The authentication functions in this protocol are all asynchronous operations +/// that perform network requests before and after displaying the modal authentication +/// view. It is thus recommended to show an activity indicator or switch the user interface +/// to a loading state before calling these functions, otherwise the user might accidentally +/// interact with the app when the authentication view is not being displayed. public protocol DescopePasskey { /// Returns the ``DescopePasskeyRunner`` for the current running passkey /// authentication or `nil` if no authentication is currently ongoing. diff --git a/src/sdk/SDK.swift b/src/sdk/SDK.swift index 008eee6..5c81267 100644 --- a/src/sdk/SDK.swift +++ b/src/sdk/SDK.swift @@ -107,7 +107,7 @@ public extension DescopeSDK { static let name = "DescopeKit" /// The Descope SDK version - static let version = "0.9.1" + static let version = "0.9.2" } // Internal diff --git a/src/session/Session.swift b/src/session/Session.swift index 41d2c4a..9ac5a2c 100644 --- a/src/session/Session.swift +++ b/src/session/Session.swift @@ -129,6 +129,6 @@ extension DescopeSession: CustomStringConvertible { let label = refreshToken.isExpired ? "expired" : "expires" expires = "\(label): \(refreshExpiresAt)" } - return "DescopeSession(id: \"\(user.userId)\", \(expires))" + return "DescopeSession(userId: \"\(user.userId)\", \(expires))" } } diff --git a/src/types/Error.swift b/src/types/Error.swift index 416ccb0..e48377b 100644 --- a/src/types/Error.swift +++ b/src/types/Error.swift @@ -86,9 +86,11 @@ 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") + public static let passkeyFailed = DescopeError.sdkError("S110001", "Passkey authentication failed") + public static let passkeyCancelled = DescopeError.sdkError("S110002", "Passkey authentication cancelled") + + public static let oauthNativeFailed = DescopeError.sdkError("S110001", "Sign in with Apple failed") + public static let oauthNativeCancelled = DescopeError.sdkError("S110002", "Sign in with Apple cancelled") } /// Extension functions for catching ``DescopeError`` values. diff --git a/src/types/Passkeys.swift b/src/types/Passkeys.swift index ad50310..950390e 100644 --- a/src/types/Passkeys.swift +++ b/src/types/Passkeys.swift @@ -4,6 +4,8 @@ import AuthenticationServices /// A helper object that encapsulates a single authentication with passkeys. @MainActor public class DescopePasskeyRunner { + /// The domain of the web credential as configured in the Xcode project's + /// associated domains. public var domain: String /// Determines where in an application's UI the authentication view should be shown. @@ -17,7 +19,10 @@ public class DescopePasskeyRunner { public weak var presentationContextProvider: ASAuthorizationControllerPresentationContextProviding? /// Creates a new ``DescopePasskeyRunner`` object that encapsulates a single - /// passkey authentifation. + /// passkey authentication run. + /// + /// - Parameter domain: The domain of the web credential as configured in the Xcode + /// project's associated domains. public init(domain: String) { self.domain = domain }