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()
+ }
+ }
+}