Skip to content

Commit

Permalink
Add support for native Sign in with Apple authentication (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
shilgapira authored Dec 19, 2023
1 parent 53f402e commit 7ebcdee
Show file tree
Hide file tree
Showing 14 changed files with 302 additions and 72 deletions.
52 changes: 34 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,17 +190,35 @@ let authResponse = try await Descope.magiclink.verify(token: "<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`
Expand All @@ -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)
}
}
```

Expand All @@ -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`
Expand Down
36 changes: 30 additions & 6 deletions src/internal/http/DescopeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: [
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/internal/others/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])
}
}
16 changes: 16 additions & 0 deletions src/internal/others/Internal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,19 @@ class DefaultPresentationContextProvider: NSObject, ASWebAuthenticationPresentat
#endif
}
}

typealias AuthorizationDelegateCompletion = (Result<ASAuthorization, Error>) -> 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
}
}
107 changes: 102 additions & 5 deletions src/internal/routes/OAuth.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 4 additions & 20 deletions src/internal/routes/Passkey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -304,19 +304,3 @@ private struct AssertionFinish: Codable {
private extension DescopePasskeyRunner {
var origin: String { "https://\(domain)" }
}

private typealias PasskeyDelegateCompletion = (Result<ASAuthorization, Error>) -> 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
}
}
7 changes: 5 additions & 2 deletions src/internal/routes/SSO.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@

import Foundation

class SSO: DescopeSSO {
let client: DescopeClient

init(client: DescopeClient) {
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 {
Expand Down
4 changes: 2 additions & 2 deletions src/sdk/Callbacks.stencil
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 7ebcdee

Please sign in to comment.