Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core/Keychain #38

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
),
Expand Down
17 changes: 10 additions & 7 deletions Sources/Apple/AppleAuthenticator+Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
2 changes: 1 addition & 1 deletion Sources/Apple/AppleAuthenticator+PovioKitAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
68 changes: 47 additions & 21 deletions Sources/Apple/AppleAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(service: "povioKit.auth")
private let keychainServiceDataKey: String = "user.data"
private var continuation: CheckedContinuation<Response, Swift.Error>?

public init(storage: UserDefaults? = nil) {
self.provider = .init()
self.storage = storage ?? .init(suiteName: "povioKit.auth.apple") ?? .standard
Expand All @@ -37,15 +39,15 @@ 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).
/// Will asynchronously return the `Response` object on success or `Error` on error.
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)
Expand All @@ -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
}

Expand All @@ -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,
Expand All @@ -93,38 +102,53 @@ 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 _:
rejectSignIn(with: .unhandledAuthorization)
}
}

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)
Expand All @@ -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 {
Expand All @@ -151,7 +175,7 @@ private extension AppleAuthenticator {
case .none:
break
}

return try await withCheckedThrowingContinuation { continuation in
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// PersonNameComponents+Extension.swift
// PersonNameComponents+PovioKitAuth.swift
// PovioKitAuth
//
// Created by Egzon Arifi on 09/11/2024.
Expand Down
74 changes: 74 additions & 0 deletions Sources/Core/KeychainService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// 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

public init(service: String = "KeychainService.main", accessGroup: String? = nil) {
if let accessGroup {
keychain = .init(service: service, accessGroup: accessGroup)
} else {
keychain = .init(service: service)
}
}
}

// 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<T>(_ 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<T>(_ 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()
}
}
2 changes: 1 addition & 1 deletion Sources/LinkedIn/Core/URL+PovioKitAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!")
Expand Down
2 changes: 1 addition & 1 deletion Sources/LinkedIn/WebView/LinkedInWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//

import SwiftUI
import WebKit
@preconcurrency import WebKit

@available(iOS 15.0, *)
public struct LinkedInWebView: UIViewRepresentable {
Expand Down