diff --git a/Package.swift b/Package.swift index be4ce65..27147fa 100644 --- a/Package.swift +++ b/Package.swift @@ -16,13 +16,16 @@ let package = Package( .library(name: "PovioKitAuthLinkedIn", targets: ["PovioKitAuthLinkedIn"]) ], dependencies: [ + .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", .upToNextMajor(from: "4.0.0")), .package(url: "https://github.com/google/GoogleSignIn-iOS", .upToNextMajor(from: "8.0.0")), .package(url: "https://github.com/facebook/facebook-ios-sdk", .upToNextMajor(from: "17.0.0")), ], targets: [ .target( name: "PovioKitAuthCore", - dependencies: [], + dependencies: [ + .product(name: "KeychainAccess", package: "KeychainAccess"), + ], path: "Sources/Core", resources: [.copy("../../Resources/PrivacyInfo.xcprivacy")] ), diff --git a/Sources/Apple/AppleAuthenticator+Models.swift b/Sources/Apple/AppleAuthenticator+Models.swift index 5abd62e..8107f0f 100644 --- a/Sources/Apple/AppleAuthenticator+Models.swift +++ b/Sources/Apple/AppleAuthenticator+Models.swift @@ -29,6 +29,12 @@ public extension AppleAuthenticator { } } + struct Email: Codable { + public let address: String + public let isPrivate: Bool + public let isVerified: Bool + } + enum Error: Swift.Error { case system(_ error: Swift.Error) case cancelled @@ -39,12 +45,9 @@ public extension AppleAuthenticator { case missingExpiration case missingEmail } -} - -public extension AppleAuthenticator.Response { - struct Email { - public let address: String - public let isPrivate: Bool - public let isVerified: Bool + + struct UserData: Codable { + let name: PersonNameComponents? + let email: Email } } diff --git a/Sources/Apple/AppleAuthenticator+PovioKitAuth.swift b/Sources/Apple/AppleAuthenticator+PovioKitAuth.swift index 2ea0f36..729540d 100644 --- a/Sources/Apple/AppleAuthenticator+PovioKitAuth.swift +++ b/Sources/Apple/AppleAuthenticator+PovioKitAuth.swift @@ -10,7 +10,7 @@ import AuthenticationServices import CryptoKit import UIKit -extension UIViewController: ASAuthorizationControllerPresentationContextProviding { +extension UIViewController: @retroactive ASAuthorizationControllerPresentationContextProviding { public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { view.window ?? UIWindow() } diff --git a/Sources/Apple/AppleAuthenticator.swift b/Sources/Apple/AppleAuthenticator.swift index 1b97ca0..12d9555 100644 --- a/Sources/Apple/AppleAuthenticator.swift +++ b/Sources/Apple/AppleAuthenticator.swift @@ -15,8 +15,10 @@ public final class AppleAuthenticator: NSObject { private let storageUserIdKey = "signIn.userId" private let storageAuthenticatedKey = "authenticated" private let provider: ASAuthorizationAppleIDProvider + private let keychainService: KeychainService = .init(name: "povioKit.auth") + private let keychainServiceDataKey: String = "user.data" private var continuation: CheckedContinuation? - + public init(storage: UserDefaults? = nil) { self.provider = .init() self.storage = storage ?? .init(suiteName: "povioKit.auth.apple") ?? .standard @@ -37,7 +39,7 @@ extension AppleAuthenticator: Authenticator { public func signIn(from presentingViewController: UIViewController) async throws -> Response { try await appleSignIn(on: presentingViewController, with: nil) } - + /// SignIn user with `nonce` value /// /// Nonce is usually needed when doing auth with an external auth provider (e.g. firebase). @@ -45,7 +47,7 @@ extension AppleAuthenticator: Authenticator { public func signIn(from presentingViewController: UIViewController, with nonce: Nonce) async throws -> Response { try await appleSignIn(on: presentingViewController, with: nonce) } - + /// Clears the signIn footprint and logs out the user immediatelly. public func signOut() { storage.removeObject(forKey: storageUserIdKey) @@ -57,11 +59,15 @@ extension AppleAuthenticator: Authenticator { public var isAuthenticated: Authenticated { storage.string(forKey: storageUserIdKey) != nil && storage.bool(forKey: storageAuthenticatedKey) } - + /// Boolean if given `url` should be handled. /// /// Call this from UIApplicationDelegateā€™s `application:openURL:options:` method. - public func canOpenUrl(_ url: URL, application: UIApplication, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool { + public func canOpenUrl( + _ url: URL, + application: UIApplication, + options: [UIApplication.OpenURLOptionsKey : Any] + ) -> Bool { false } @@ -76,7 +82,10 @@ extension AppleAuthenticator: Authenticator { // MARK: - ASAuthorizationControllerDelegate extension AppleAuthenticator: ASAuthorizationControllerDelegate { - public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { switch authorization.credential { case let credential as ASAuthorizationAppleIDCredential: guard let authCodeData = credential.authorizationCode, @@ -93,30 +102,42 @@ extension AppleAuthenticator: ASAuthorizationControllerDelegate { // parse email and related metadata let jwt = try? JWTDecoder(token: identityTokenString) - let email: Response.Email? = (credential.email ?? jwt?.string(for: "email")).map { + var email: Email? = (credential.email ?? jwt?.string(for: "email")).map { let isEmailPrivate = jwt?.bool(for: "is_private_email") ?? false let isEmailVerified = jwt?.bool(for: "email_verified") ?? false return .init(address: $0, isPrivate: isEmailPrivate, isVerified: isEmailVerified) } + // load email from keychain on subsequent logins + let existingUserData: UserData? = keychainService.read(UserData.self, for: keychainServiceDataKey) + if email == nil, let existingUserData { + email = existingUserData.email + } + // do not continue if `email` is missing - guard let email else { + guard let email, !email.address.isEmpty else { rejectSignIn(with: .missingEmail) return } + // save user data for the future logins + let updatedUserData: UserData = .init(name: credential.fullName, email: email) + try? keychainService.save(updatedUserData, for: keychainServiceDataKey) + // do not continue if `expiresAt` is missing guard let expiresAt = jwt?.expiresAt else { rejectSignIn(with: .missingExpiration) return } - let response = Response(userId: credential.user, - token: identityTokenString, - authCode: authCode, - nameComponents: credential.fullName, - email: email, - expiresAt: expiresAt) + let response = Response( + userId: credential.user, + token: identityTokenString, + authCode: authCode, + nameComponents: updatedUserData.name, + email: updatedUserData.email, + expiresAt: expiresAt + ) continuation?.resume(with: .success(response)) case _: @@ -124,7 +145,10 @@ extension AppleAuthenticator: ASAuthorizationControllerDelegate { } } - public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Swift.Error) { + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Swift.Error + ) { switch error { case let err as ASAuthorizationError where err.code == .canceled: rejectSignIn(with: .cancelled) @@ -139,7 +163,7 @@ private extension AppleAuthenticator { func appleSignIn(on presentingViewController: UIViewController, with nonce: Nonce?) async throws -> Response { let request = provider.createRequest() request.requestedScopes = [.fullName, .email] - + switch nonce { case .random(let length): guard length > 0 else { @@ -151,7 +175,7 @@ private extension AppleAuthenticator { case .none: break } - + return try await withCheckedThrowingContinuation { continuation in let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self @@ -162,10 +186,12 @@ private extension AppleAuthenticator { } func setupCredentialsRevokeListener() { - NotificationCenter.default.addObserver(self, - selector: #selector(appleCredentialRevoked), - name: ASAuthorizationAppleIDProvider.credentialRevokedNotification, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(appleCredentialRevoked), + name: ASAuthorizationAppleIDProvider.credentialRevokedNotification, + object: nil + ) } func rejectSignIn(with error: Error) { diff --git a/Sources/Core/PersonNameComponents+Extension.swift b/Sources/Core/Extensions/PersonNameComponents+PovioKitAuth.swift similarity index 95% rename from Sources/Core/PersonNameComponents+Extension.swift rename to Sources/Core/Extensions/PersonNameComponents+PovioKitAuth.swift index 05d10a5..750e8e3 100644 --- a/Sources/Core/PersonNameComponents+Extension.swift +++ b/Sources/Core/Extensions/PersonNameComponents+PovioKitAuth.swift @@ -1,5 +1,5 @@ // -// PersonNameComponents+Extension.swift +// PersonNameComponents+PovioKitAuth.swift // PovioKitAuth // // Created by Egzon Arifi on 09/11/2024. diff --git a/Sources/Core/KeychainService.swift b/Sources/Core/KeychainService.swift new file mode 100644 index 0000000..1957a3a --- /dev/null +++ b/Sources/Core/KeychainService.swift @@ -0,0 +1,79 @@ +// +// KeychainService.swift +// PovioKitAuth +// +// Created by Borut Tomazin on 21/01/2025. +// Copyright Ā© 2025 Povio Inc. All rights reserved. +// + +import Foundation +import KeychainAccess + +/// A helper class for managing keychain operations +public final class KeychainService { + public typealias Key = String + private let keychain: Keychain + + /// Initializes a new instance of `KeychainService`. + /// + /// - Parameters: + /// - name: The service name used for identifying stored keychain items. Defaults to "KeychainService.main". + /// - accessGroup: An optional access group identifier for shared keychain access. + public init(name: String = "KeychainService.main", accessGroup: String? = nil) { + if let accessGroup { + keychain = .init(service: name, accessGroup: accessGroup) + } else { + keychain = .init(service: name) + } + } +} + +// MARK: - Public Methods +public extension KeychainService { + /// Saves a string value to the keychain + /// - Parameters: + /// - value: String value to save + /// - key: Key identifier for the value + func save(_ value: String?, for key: Key) { + keychain[key] = value + } + + /// Reads a string value from the keychain + /// - Parameters: + /// - key: Key identifier for the value + /// - Returns: Optional string value stored in keychain + func read(for key: Key) -> String? { + keychain[key] + } + + /// Saves a Codable item to the keychain + /// - Parameters: + /// - item: Codable item to save + /// - key: Key identifier for the value + /// - Throws: Encoding or keychain storage errors + func save(_ item: T, for key: Key) throws where T: Codable { + let data = try JSONEncoder().encode(item) + try keychain.set(data, key: key) + } + + /// Reads a Codable item from the keychain + /// - Parameters: + /// - type: Type of the item to decode + /// - key: Key identifier for the value + /// - Returns: Optional decoded item + func read(_ item: T.Type, for key: Key) -> T? where T: Codable { + do { + guard let data = try keychain.getData(key) else { return nil } + let item = try JSONDecoder().decode(item, from: data) + return item + } catch { + return nil + } + } + + /// Removes all keychain items. + /// - Throws: Keychain removal errors + func clear() throws { + try keychain.removeAll() + } +} diff --git a/Sources/LinkedIn/Core/URL+PovioKitAuth.swift b/Sources/LinkedIn/Core/URL+PovioKitAuth.swift index 616343a..09af1b3 100644 --- a/Sources/LinkedIn/Core/URL+PovioKitAuth.swift +++ b/Sources/LinkedIn/Core/URL+PovioKitAuth.swift @@ -8,7 +8,7 @@ import Foundation -extension URL: ExpressibleByStringLiteral { +extension URL: @retroactive ExpressibleByStringLiteral { public init(stringLiteral value: String) { guard let url = URL(string: value) else { fatalError("Invalid URL string!") diff --git a/Sources/LinkedIn/WebView/LinkedInWebView.swift b/Sources/LinkedIn/WebView/LinkedInWebView.swift index 2afcb02..f604282 100644 --- a/Sources/LinkedIn/WebView/LinkedInWebView.swift +++ b/Sources/LinkedIn/WebView/LinkedInWebView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import WebKit +@preconcurrency import WebKit @available(iOS 15.0, *) public struct LinkedInWebView: UIViewRepresentable {