diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/PovioKitAuth-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/PovioKitAuth-Package.xcscheme index 10139c2..c920580 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/PovioKitAuth-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/PovioKitAuth-Package.xcscheme @@ -62,6 +62,20 @@ ReferencedContainer = "container:"> + + + + URL { + .init(stringLiteral: url) + } +} diff --git a/Sources/LinkedIn/API/LinkedInAPI+Endpoints.swift b/Sources/LinkedIn/API/LinkedInAPI+Endpoints.swift new file mode 100644 index 0000000..5d111f7 --- /dev/null +++ b/Sources/LinkedIn/API/LinkedInAPI+Endpoints.swift @@ -0,0 +1,38 @@ +// +// LinkedInAPI+Endpoints.swift +// PovioKitAuth +// +// Created by Borut Tomazin on 04/09/2023. +// Copyright © 2023 Povio Inc. All rights reserved. +// + +import Foundation +import PovioKitNetworking + +extension LinkedInAPI { + enum Endpoints: EndpointEncodable { + case accessToken + case profile + case email + + var path: Path { + switch self { + case .accessToken: + return "accessToken" + case .profile: + return "me" + case .email: + return "emailAddress?q=members&projection=(elements*(handle~))" + } + } + + var url: String { + switch self { + case .accessToken: + return "https://www.linkedin.com/oauth/v2/\(path)" + case .profile, .email: + return "https://api.linkedin.com/v2/\(path)" + } + } + } +} diff --git a/Sources/LinkedIn/API/LinkedInAPI+Models.swift b/Sources/LinkedIn/API/LinkedInAPI+Models.swift new file mode 100644 index 0000000..6d7da0a --- /dev/null +++ b/Sources/LinkedIn/API/LinkedInAPI+Models.swift @@ -0,0 +1,61 @@ +// +// LinkedInAPI+Models.swift +// PovioKitAuth +// +// Created by Borut Tomazin on 04/09/2023. +// Copyright © 2023 Povio Inc. All rights reserved. +// + +import Foundation + +public extension LinkedInAPI { + struct LinkedInAuthRequest: Encodable { + let grantType: String = "authorization_code" + let code: String + let redirectUri: String + let clientId: String + let clientSecret: String + + public init(code: String, redirectUri: String, clientId: String, clientSecret: String) { + self.code = code + self.redirectUri = redirectUri + self.clientId = clientId + self.clientSecret = clientSecret + } + } + + struct LinkedInAuthResponse: Decodable { + public let accessToken: String + public let expiresIn: Date + } + + struct LinkedInProfileRequest: Encodable { + let token: String + + public init(token: String) { + self.token = token + } + } + + struct LinkedInProfileResponse: Decodable { + public let id: String + public let localizedFirstName: String + public let localizedLastName: String + } + + struct LinkedInEmailResponse: Decodable { + public let elements: [LinkedInEmailHandleResponse] + } + + struct LinkedInEmailHandleResponse: Decodable { + public let handle: LinkedInEmailValueResponse + + enum CodingKeys: String, CodingKey { + case handle = "handle~" + } + } + + struct LinkedInEmailValueResponse: Decodable { + public let emailAddress: String + } +} diff --git a/Sources/LinkedIn/API/LinkedInAPI.swift b/Sources/LinkedIn/API/LinkedInAPI.swift new file mode 100644 index 0000000..0c21651 --- /dev/null +++ b/Sources/LinkedIn/API/LinkedInAPI.swift @@ -0,0 +1,79 @@ +// +// LinkedInAPI.swift +// PovioKitAuth +// +// Created by Borut Tomazin on 04/09/2023. +// Copyright © 2023 Povio Inc. All rights reserved. +// + +import Foundation +import PovioKitNetworking + +public final class LinkedInAPI { + private let client: AlamofireNetworkClient + + public init(client: AlamofireNetworkClient = .init()) { + self.client = client + } +} + +public extension LinkedInAPI { + func login(with request: LinkedInAuthRequest) async throws -> LinkedInAuthResponse { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let secondsRemaining = try container.decode(Int.self) + return Date().addingTimeInterval(TimeInterval(secondsRemaining)) + } + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + return try await client + .request( + method: .post, + endpoint: Endpoints.accessToken, + encode: request, + parameterEncoder: .urlEncoder(encoder: encoder) + ) + .validate() + .decode(LinkedInAuthResponse.self, decoder: decoder) + .asAsync + } + + func loadProfile(with request: LinkedInProfileRequest) async throws -> LinkedInProfileResponse { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + + return try await client + .request( + method: .get, + endpoint: Endpoints.profile, + headers: ["Authorization": "Bearer \(request.token)"] + ) + .validate() + .decode(LinkedInProfileResponse.self, decoder: decoder) + .asAsync + } + + func loadEmail(with request: LinkedInProfileRequest) async throws -> LinkedInEmailValueResponse { + return try await client + .request( + method: .get, + endpoint: Endpoints.email, + headers: ["Authorization": "Bearer \(request.token)"]) + .validate() + .decode(LinkedInEmailResponse.self) + .compactMap { $0.elements.first?.handle } + .asAsync + } +} + +// MARK: - Error +public extension LinkedInAPI { + enum Error: Swift.Error { + case missingParameters + } +} diff --git a/Sources/LinkedIn/LinkedInAuthenticator+Models.swift b/Sources/LinkedIn/LinkedInAuthenticator+Models.swift new file mode 100644 index 0000000..956d1ab --- /dev/null +++ b/Sources/LinkedIn/LinkedInAuthenticator+Models.swift @@ -0,0 +1,93 @@ +// +// GoogleAuthenticator+Models.swift +// PovioKitAuth +// +// Created by Borut Tomazin on 30/01/2023. +// Copyright © 2023 Povio Inc. All rights reserved. +// + +import Foundation + +public extension LinkedInAuthenticator { + struct Configuration { + let clientId: String + let clientSecret: String + let permissions: String + let redirectUrl: URL + var authEndpoint: URL = "https://www.linkedin.com/oauth/v2/authorization" + var authCancel: URL = "https://www.linkedin.com/oauth/v2/login-cancel" + var audience: String? + public var codeVerifier: String? + var codeChallenge: String? + var codeChallengeMethod: String? + + public init( + clientId: String, + clientSecret: String, + permissions: String, + redirectUrl: URL + ) { + self.clientId = clientId + self.clientSecret = clientSecret + self.permissions = permissions + self.redirectUrl = redirectUrl + } + + public init( + clientId: String, + clientSecret: String, + permissions: String, + redirectUrl: URL, + authEndpoint: URL, + authCancel: URL, + audience: String?, + codeVerifier: String?, + codeChallenge: String?, + codeChallengeMethod: String? + ) { + self.clientId = clientId + self.clientSecret = clientSecret + self.permissions = permissions + self.redirectUrl = redirectUrl + self.authEndpoint = authEndpoint + self.authCancel = authCancel + self.audience = audience + self.codeVerifier = codeVerifier + self.codeChallenge = codeChallenge + self.codeChallengeMethod = codeChallengeMethod + } + + public func authorizationUrl(state: String) -> URL? { + guard var urlComponents = URLComponents(url: authEndpoint, resolvingAgainstBaseURL: false) else { return nil } + var queryItems: [URLQueryItem] = [ + .init(name: "response_type", value: "code"), + .init(name: "connection", value: "linkedin"), + .init(name: "client_id", value: clientId), + .init(name: "redirect_uri", value: redirectUrl.absoluteString), + .init(name: "state", value: state), + .init(name: "scope", value: permissions) + ] + + if let audience { + queryItems.append(.init(name: "audience", value: audience)) + } + if let codeChallenge { + queryItems.append(.init(name: "code_challenge", value: codeChallenge)) + } + if let codeChallengeMethod { + queryItems.append(.init(name: "code_challenge_method", value: codeChallengeMethod)) + } + + urlComponents.queryItems = queryItems + return urlComponents.url + } + } + + struct Response { + public let userId: String + public let token: String + public let name: String + public let email: String + public let expiresAt: Date + } +} diff --git a/Sources/LinkedIn/LinkedInAuthenticator.swift b/Sources/LinkedIn/LinkedInAuthenticator.swift new file mode 100644 index 0000000..cfb8ca7 --- /dev/null +++ b/Sources/LinkedIn/LinkedInAuthenticator.swift @@ -0,0 +1,68 @@ +// +// LinkedInAuthenticator.swift +// PovioKitAuth +// +// Created by Borut Tomazin on 04/09/2023. +// Copyright © 2023 Povio Inc. All rights reserved. +// + +import UIKit +import PovioKitAuthCore + +public final class LinkedInAuthenticator { + private let storage: UserDefaults + private let storageIsAuthenticatedKey = "signIn.isAuthenticated" + private let linkedInAPI: LinkedInAPI + + public init(storage: UserDefaults? = nil, + linkedInAPI: LinkedInAPI = .init()) { + self.storage = storage ?? .init(suiteName: "povioKit.auth.linkedIn") ?? .standard + self.linkedInAPI = linkedInAPI + } +} + +// MARK: - Public Methods +extension LinkedInAuthenticator: Authenticator { + /// SignIn user. + /// + /// Will return promise with the `Response` object on success or with `Error` on error. + public func signIn(authCode: String, configuration: Configuration) async throws -> Response { + let authRequest: LinkedInAPI.LinkedInAuthRequest = .init( + code: authCode, + redirectUri: configuration.redirectUrl.absoluteString, + clientId: configuration.clientId, + clientSecret: configuration.clientSecret + ) + let authResponse = try await linkedInAPI.login(with: authRequest) + let profileResponse = try await linkedInAPI.loadProfile(with: .init(token: authResponse.accessToken)) + let emailResponse = try await linkedInAPI.loadEmail(with: .init(token: authResponse.accessToken)) + + storage.set(true, forKey: storageIsAuthenticatedKey) + + let name = "\(profileResponse.localizedFirstName) \(profileResponse.localizedLastName)" + return Response( + userId: profileResponse.id, + token: authResponse.accessToken, + name: name, + email: emailResponse.emailAddress, + expiresAt: authResponse.expiresIn + ) + } + + /// Clears the signIn footprint and logs out the user immediatelly. + public func signOut() { + storage.removeObject(forKey: storageIsAuthenticatedKey) + } + + /// Returns the current authentication state. + public var isAuthenticated: Authenticated { + storage.bool(forKey: storageIsAuthenticatedKey) + } + + /// 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 { + true + } +} diff --git a/Sources/LinkedIn/WebView/LinkedInSheet.swift b/Sources/LinkedIn/WebView/LinkedInSheet.swift new file mode 100644 index 0000000..92d5a0d --- /dev/null +++ b/Sources/LinkedIn/WebView/LinkedInSheet.swift @@ -0,0 +1,40 @@ +// +// File.swift +// +// +// Created by Borut Tomazin on 04/09/2023. +// + +import SwiftUI + +@available(iOS 15.0, *) +public struct LinkedInSheet: ViewModifier { + public typealias SuccessHandler = LinkedInWebView.SuccessHandler // (Bool) -> Void + public typealias ErrorHandler = LinkedInWebView.ErrorHandler // (Error) -> Void + public let config: LinkedInAuthenticator.Configuration + public let isPresented: Binding + public let onSuccess: SuccessHandler? + public let onError: ErrorHandler? + + public func body(content: Content) -> some View { + content + .sheet(isPresented: isPresented) { + LinkedInWebView(with: config) { data in + onSuccess?(data) + } onFailure: { + onError?() + } + } + } +} + +@available(iOS 15.0, *) +public extension View { + /// ViewModifier to present `LinkedInWebView` in sheet + func linkedInSheet(with config: LinkedInAuthenticator.Configuration, + isPresented: Binding, + onSuccess: LinkedInSheet.SuccessHandler? = nil, + onError: LinkedInSheet.ErrorHandler? = nil) -> some View { + modifier(LinkedInSheet(config: config, isPresented: isPresented, onSuccess: onSuccess, onError: onError)) + } +} diff --git a/Sources/LinkedIn/WebView/LinkedInWebView.swift b/Sources/LinkedIn/WebView/LinkedInWebView.swift new file mode 100644 index 0000000..fe4396f --- /dev/null +++ b/Sources/LinkedIn/WebView/LinkedInWebView.swift @@ -0,0 +1,93 @@ +// +// LinkedInWebView.swift +// PovioKitAuth +// +// Created by Borut Tomazin on 04/09/2023. +// Copyright © 2023 Povio Inc. All rights reserved. +// + +import PovioKitCore +import SwiftUI +import WebKit + +@available(iOS 15.0, *) +public struct LinkedInWebView: UIViewRepresentable { + @Environment(\.dismiss) var dismiss + // create a random string based on the time interval (it will be in the number form) - Needed for state. + public typealias SuccessHandler = ((code: String, state: String)) -> Void + public typealias ErrorHandler = () -> Void + private let requestState: String = "\(Int(Date().timeIntervalSince1970))" + private let webView: WKWebView + private let configuration: LinkedInAuthenticator.Configuration + public let onSuccess: SuccessHandler? + public let onFailure: ErrorHandler? + + public init(with configuration: LinkedInAuthenticator.Configuration, + onSuccess: @escaping SuccessHandler, + onFailure: @escaping ErrorHandler) { + self.configuration = configuration + let config = WKWebViewConfiguration() + config.websiteDataStore = WKWebsiteDataStore.default() + webView = WKWebView(frame: .zero, configuration: config) + self.onSuccess = onSuccess + self.onFailure = onFailure + } + + public func makeUIView(context: Context) -> some UIView { + webView + } + + public func updateUIView(_ uiView: UIViewType, context: Context) { + guard let webView = uiView as? WKWebView else { return } + guard let authURL = configuration.authorizationUrl(state: requestState) else { + Logger.error("Failed to geet auth url!") + dismiss() + return + } + webView.navigationDelegate = context.coordinator + webView.load(.init(url: authURL)) + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self, requestState: requestState) + } +} + +@available(iOS 15.0, *) +public extension LinkedInWebView { + class Coordinator: NSObject, WKNavigationDelegate { + private let parent: LinkedInWebView + private let requestState: String + + public init(_ parent: LinkedInWebView, requestState: String) { + self.parent = parent + self.requestState = requestState + } + + public func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + // if authCancel endpoint was called, dismiss the view + if let url = navigationAction.request.url, + url.absoluteString.hasPrefix(parent.configuration.authCancel.absoluteString) { + decisionHandler(.cancel) + parent.dismiss() + return + } + + // extract the authorization code from the redirect url + guard let url = webView.url, + url.host == parent.configuration.redirectUrl.host, + let components = URLComponents(string: url.absoluteString), + let state = components.queryItems?.first(where: { $0.name == "state" })?.value, + requestState == state, + let code = components.queryItems?.first(where: { $0.name == "code" }) else { + decisionHandler(.allow) + return + } + parent.onSuccess?((code.value ?? "", parent.requestState)) + decisionHandler(.cancel) + parent.dismiss() + } + } +}