diff --git a/.gitignore b/.gitignore index 1c5a234d..ac56bfb8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,4 @@ fastlane/test_output # Other openapi-generator/ -Demo/Demo/Gravatar-Demo/Secrets.swift +Demo/Demo/Secrets.swift diff --git a/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift b/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift index 05da6f2f..5767a11b 100644 --- a/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift +++ b/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift @@ -1,9 +1,11 @@ import SwiftUI +import GravatarUI struct ContentView: View { enum Page: String, CaseIterable, Identifiable { case avatarView = "Avatar view" case avatarPickerView = "Avatar picker view" + case oauth = "Profile editor with oauth" var id: Int { self.rawValue.hashValue @@ -35,6 +37,8 @@ struct ContentView: View { DemoAvatarView() case .avatarPickerView: DemoAvatarPickerView() + case .oauth: + DemoProfileEditorView() } } } diff --git a/Demo/Demo/Gravatar-SwiftUI-Demo/DemoApp.swift b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoApp.swift index a8a091cf..60087757 100644 --- a/Demo/Demo/Gravatar-SwiftUI-Demo/DemoApp.swift +++ b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoApp.swift @@ -6,12 +6,29 @@ // import SwiftUI +import Gravatar @main struct DemoApp: App { var body: some Scene { WindowGroup { ContentView() + .onAppear { + setupSecrets() + } + } + } + + func setupSecrets() { + Task { + await Configuration.shared.configure( + with: Secrets.apiKey, + oauthSecrets: .init( + clientID: Secrets.clientID, + clientSecret: Secrets.clientSecret, + redirectURI: Secrets.redirectURI + ) + ) } } } diff --git a/Demo/Demo/Gravatar-SwiftUI-Demo/DemoProfileEditorView.swift b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoProfileEditorView.swift new file mode 100644 index 00000000..6f6fc240 --- /dev/null +++ b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoProfileEditorView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import GravatarUI + +struct DemoProfileEditorView: View { + + @AppStorage("pickerEmail") private var email: String = "" + + // You can make this `true` by default to easily test the picker + @State private var isPresentingPicker: Bool = false + @State private var hasSession: Bool = false + @Environment(\.oauthSession) var oauthSession + + var body: some View { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 5) { + TextField("Email", text: $email) + .font(.callout) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .disableAutocorrection(true) + + Divider() + + } + .padding(.horizontal) + Button("Open Profile Editor with OAuth flow") { + isPresentingPicker.toggle() + } + .gravatarEditorSheet( + isPresented: $isPresentingPicker, + email: email, + entryPoint: .avatarPicker, + onDismiss: { + updateHasSession(with: email) + } + ) + if hasSession { + Button("Log out") { + oauthSession.deleteSession(with: .init(email)) + updateHasSession(with: email) + } + } + + Spacer() + }.onAppear() { + updateHasSession(with: email) + } + .onChange(of: email) { _, newValue in + updateHasSession(with: newValue) + } + } + + func updateHasSession(with email: String) { + hasSession = oauthSession.hasSession(with: .init(email)) + } +} + +#Preview { + DemoAvatarPickerView() +} diff --git a/Demo/Demo/Gravatar-UIKit-Demo/AppDelegate.swift b/Demo/Demo/Gravatar-UIKit-Demo/AppDelegate.swift index 79910f70..da389dd6 100644 --- a/Demo/Demo/Gravatar-UIKit-Demo/AppDelegate.swift +++ b/Demo/Demo/Gravatar-UIKit-Demo/AppDelegate.swift @@ -5,10 +5,15 @@ import Gravatar class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. - if let apiKey = apiKey { - Task { - await Configuration.shared.configure(with: apiKey) - } + Task { + await Configuration.shared.configure( + with: Secrets.apiKey, + oauthSecrets: .init( + clientID: Secrets.clientID, + clientSecret: Secrets.clientSecret, + redirectURI: Secrets.redirectURI + ) + ) } return true } diff --git a/Demo/Demo/Gravatar-UIKit-Demo/Secrets.swift b/Demo/Demo/Gravatar-UIKit-Demo/Secrets.swift deleted file mode 100644 index 1ef70240..00000000 --- a/Demo/Demo/Gravatar-UIKit-Demo/Secrets.swift +++ /dev/null @@ -1 +0,0 @@ -let apiKey: String? = nil diff --git a/Demo/Demo/Secrets.tpl b/Demo/Demo/Secrets.tpl new file mode 100644 index 00000000..7c568db7 --- /dev/null +++ b/Demo/Demo/Secrets.tpl @@ -0,0 +1,11 @@ +// Secrets used in the demo app. +// Do not modify the .tpl file. +// After a first build of any of the demo apps, a `Secrets.swift` file will be generated. +// Use the generated file to paste the secrets needed from https://gravatar.com/developers/applications + +struct Secrets { + static let apiKey: String? = nil + static let clientID: String = "" + static let clientSecret: String = "" + static let redirectURI: String = "" +} diff --git a/Demo/Gravatar-Demo.xcodeproj/project.pbxproj b/Demo/Gravatar-Demo.xcodeproj/project.pbxproj index 31dce079..5c13d147 100644 --- a/Demo/Gravatar-Demo.xcodeproj/project.pbxproj +++ b/Demo/Gravatar-Demo.xcodeproj/project.pbxproj @@ -9,8 +9,10 @@ /* Begin PBXBuildFile section */ 1E0087932B63CFFE0012ECEA /* DemoFetchProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0087922B63CFFE0012ECEA /* DemoFetchProfileViewController.swift */; }; 1E0087952B63DBCB0012ECEA /* DemoUploadImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0087942B63DBCB0012ECEA /* DemoUploadImageViewController.swift */; }; + 1E3FA2402C74B8CC002901F2 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3FA23F2C74B8CC002901F2 /* Secrets.swift */; }; + 1E3FA2412C74E539002901F2 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3FA23F2C74B8CC002901F2 /* Secrets.swift */; }; + 1E3FA2452C75E403002901F2 /* DemoProfileEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3FA2442C75E403002901F2 /* DemoProfileEditorView.swift */; }; 1ECAB5072BC984440043A331 /* DemoProfileConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ECAB5062BC984440043A331 /* DemoProfileConfigurationViewController.swift */; }; - 1ED769E72C048D9C00680D78 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED769E62C048D9C00680D78 /* Secrets.swift */; }; 4948C4EC2B61C41100AC4875 /* Gravatar in Frameworks */ = {isa = PBXBuildFile; productRef = 4948C4EB2B61C41100AC4875 /* Gravatar */; }; 4948C4EE2B61C41800AC4875 /* Gravatar in Frameworks */ = {isa = PBXBuildFile; productRef = 4948C4ED2B61C41800AC4875 /* Gravatar */; }; 495775E22B5B34970082812A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 495775E12B5B34970082812A /* AppDelegate.swift */; }; @@ -39,8 +41,10 @@ /* Begin PBXFileReference section */ 1E0087922B63CFFE0012ECEA /* DemoFetchProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoFetchProfileViewController.swift; sourceTree = ""; }; 1E0087942B63DBCB0012ECEA /* DemoUploadImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoUploadImageViewController.swift; sourceTree = ""; }; + 1E3FA23D2C74B7B5002901F2 /* Secrets.tpl */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.tpl; sourceTree = ""; }; + 1E3FA23F2C74B8CC002901F2 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; + 1E3FA2442C75E403002901F2 /* DemoProfileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoProfileEditorView.swift; sourceTree = ""; }; 1ECAB5062BC984440043A331 /* DemoProfileConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoProfileConfigurationViewController.swift; sourceTree = ""; }; - 1ED769E62C048D9C00680D78 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; 3FD4781E2C50D6FD0071B8B9 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; 3FD478212C51D5CE0071B8B9 /* Gravatar-UIKit-Demo.Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Gravatar-UIKit-Demo.Release.xcconfig"; sourceTree = ""; }; 3FD478222C51D7E20071B8B9 /* Gravatar-SwiftUI-Demo.Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Gravatar-SwiftUI-Demo.Release.xcconfig"; sourceTree = ""; }; @@ -108,6 +112,7 @@ 49C5D60F2B5B33E20067C2A8 /* ContentView.swift */, 9146A7AD2C3BD8F000E07C63 /* DemoAvatarView.swift */, 91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */, + 1E3FA2442C75E403002901F2 /* DemoProfileEditorView.swift */, 49C5D6112B5B33E20067C2A8 /* Assets.xcassets */, 49C5D6132B5B33E20067C2A8 /* Preview Content */, 49EFFB572C51AA3E0086589A /* Gravatar-SwiftUI-Demo.Base.xcconfig */, @@ -121,7 +126,6 @@ children = ( 91956A502B67939F00BF3CF0 /* Common */, 495775E12B5B34970082812A /* AppDelegate.swift */, - 1ED769E62C048D9C00680D78 /* Secrets.swift */, 91F0B3E12B6281A60025C4F8 /* Main.storyboard */, 495775E32B5B34970082812A /* SceneDelegate.swift */, 914AC0172BD7FF08005DA4A5 /* DemoBaseProfileViewController.swift */, @@ -167,8 +171,10 @@ children = ( 3FD4781E2C50D6FD0071B8B9 /* Base.xcconfig */, 3FD478232C51D9280071B8B9 /* Enterprise.xcconfig */, + 1E3FA23F2C74B8CC002901F2 /* Secrets.swift */, 495775E02B5B34970082812A /* Gravatar-UIKit-Demo */, 495775DA2B5B34220082812A /* Gravatar-SwiftUI-Demo */, + 1E3FA23D2C74B7B5002901F2 /* Secrets.tpl */, ); path = Demo; sourceTree = ""; @@ -218,6 +224,7 @@ isa = PBXNativeTarget; buildConfigurationList = 49C5D6182B5B33E20067C2A8 /* Build configuration list for PBXNativeTarget "Gravatar SwiftUI" */; buildPhases = ( + 1E3FA23E2C74B808002901F2 /* ShellScript */, 49C5D6062B5B33E20067C2A8 /* Sources */, 49C5D6072B5B33E20067C2A8 /* Frameworks */, 49C5D6082B5B33E20067C2A8 /* Resources */, @@ -297,6 +304,25 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1E3FA23E2C74B808002901F2 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Demo/Secrets.tpl", + ); + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Demo/Secrets.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "TEMPLATE_PATH=\"${SRCROOT}/Demo/Secrets.tpl\"\nSECRETS_PATH=\"${SRCROOT}/Demo/Secrets.swift\"\n\nif [ ! -f \"${SECRETS_PATH}\" ]; then\n cp \"${TEMPLATE_PATH}\" \"${SECRETS_PATH}\"\nfi\n"; + }; 49920BD82C3D93E6009E8DCF /* Generate Secrets.swift */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -305,16 +331,17 @@ inputFileListPaths = ( ); inputPaths = ( + "$(SRCROOT)/Demo/Secrets.tpl", ); name = "Generate Secrets.swift"; outputFileListPaths = ( ); outputPaths = ( - "$(SRCROOT)/Demo/Gravatar-Demo/Secrets.swift", + "$(SRCROOT)/Demo/Secrets.swift", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "SECRETS_PATH=\"${SRCROOT}/Demo/Gravatar-Demo/Secrets.swift\"\n\nif [ ! -f \"${SECRETS_PATH}\" ]; then\n echo \"let apiKey: String? = nil\" > \"${SECRETS_PATH}\"\nfi\n"; + shellScript = "TEMPLATE_PATH=\"${SRCROOT}/Demo/Secrets.tpl\"\nSECRETS_PATH=\"${SRCROOT}/Demo/Secrets.swift\"\n\nif [ ! -f \"${SECRETS_PATH}\" ]; then\n cp \"${TEMPLATE_PATH}\" \"${SECRETS_PATH}\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -324,6 +351,7 @@ buildActionMask = 2147483647; files = ( 91F0B3DD2B62815F0025C4F8 /* MainTableViewController.swift in Sources */, + 1E3FA2412C74E539002901F2 /* Secrets.swift in Sources */, 914AC0192BD7FF08005DA4A5 /* DemoBaseProfileViewController.swift in Sources */, 1E0087932B63CFFE0012ECEA /* DemoFetchProfileViewController.swift in Sources */, 1E0087952B63DBCB0012ECEA /* DemoUploadImageViewController.swift in Sources */, @@ -332,7 +360,6 @@ 914AC01A2BD7FF08005DA4A5 /* DemoProfilePresentationStylesViewController.swift in Sources */, 91F0B3DE2B62815F0025C4F8 /* DemoAvatarDownloadViewController.swift in Sources */, 91956A542B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift in Sources */, - 1ED769E72C048D9C00680D78 /* Secrets.swift in Sources */, 914AC0202BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift in Sources */, 495775E42B5B34970082812A /* SceneDelegate.swift in Sources */, 1ECAB5072BC984440043A331 /* DemoProfileConfigurationViewController.swift in Sources */, @@ -345,9 +372,11 @@ buildActionMask = 2147483647; files = ( 49C5D6102B5B33E20067C2A8 /* ContentView.swift in Sources */, + 1E3FA2402C74B8CC002901F2 /* Secrets.swift in Sources */, 9146A7AE2C3BD8F000E07C63 /* DemoAvatarView.swift in Sources */, 91B73B372C404F6E00E7D325 /* DemoAvatarPickerView.swift in Sources */, 49C5D60E2B5B33E20067C2A8 /* DemoApp.swift in Sources */, + 1E3FA2452C75E403002901F2 /* DemoProfileEditorView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Gravatar/Configuration.swift b/Sources/Gravatar/Configuration.swift index 3be18e26..97ca3d0c 100644 --- a/Sources/Gravatar/Configuration.swift +++ b/Sources/Gravatar/Configuration.swift @@ -2,8 +2,24 @@ import Foundation /// Gravatar API Configuration public actor Configuration { + public struct OAuthSecrets: Sendable { + package let clientID: String + package let clientSecret: String + package let redirectURI: String + package var callbackScheme: String { + URLComponents(string: redirectURI)?.scheme ?? "" + } + + public init(clientID: String, clientSecret: String, redirectURI: String) { + self.clientID = clientID + self.clientSecret = clientSecret + self.redirectURI = redirectURI + } + } + /// Authorisation key to gain access to extra features on the Gravatar API. - private(set) var apiKey: String? + public private(set) var apiKey: String? + package private(set) var oauthSecrets: OAuthSecrets? /// Global configuration instance. Use this instance to configure the usage of the Gravatar API public static let shared = Configuration() @@ -12,7 +28,8 @@ public actor Configuration { /// Updates the current configuration instance. /// - Parameter apiKey: The new authorisation API key. - public func configure(with apiKey: String?) { + public func configure(with apiKey: String?, oauthSecrets: OAuthSecrets? = nil) { self.apiKey = apiKey + self.oauthSecrets = oauthSecrets } } diff --git a/Sources/GravatarUI/SwiftUI/ModalPresentationModifier.swift b/Sources/GravatarUI/SwiftUI/ModalPresentationModifier.swift index 4635cc42..c5a67cb1 100644 --- a/Sources/GravatarUI/SwiftUI/ModalPresentationModifier.swift +++ b/Sources/GravatarUI/SwiftUI/ModalPresentationModifier.swift @@ -2,11 +2,18 @@ import SwiftUI struct ModalPresentationModifier: ViewModifier { @Binding var isPresented: Bool + let onDismiss: (() -> Void)? let modalView: ModalView + init(isPresented: Binding, onDismiss: (() -> Void)? = nil, modalView: ModalView) { + self._isPresented = isPresented + self.onDismiss = onDismiss + self.modalView = modalView + } + func body(content: Content) -> some View { content - .sheet(isPresented: $isPresented) { + .sheet(isPresented: $isPresented, onDismiss: onDismiss) { modalView } } diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/Keychain.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/Keychain.swift new file mode 100644 index 00000000..2fe1ae30 --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/Keychain.swift @@ -0,0 +1,73 @@ +import Foundation + +protocol SecureStorage: Sendable { + func setSecret(_ secret: String, for key: String) throws + func deleteSecret(with key: String) throws + func secret(with key: String) throws -> String? +} + +struct Keychain: SecureStorage { + enum KeychainError: Error { + case unexpectedSecretData + case cannotConvertSecretIntoData + case unhandledError(status: OSStatus, message: String?) + } + + func setSecret(_ secret: String, for key: String) throws { + guard let tokenData = secret.data(using: .utf8) else { + throw KeychainError.cannotConvertSecretIntoData + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: tokenData, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + if status != errSecSuccess { + let message = SecCopyErrorMessageString(status, nil) as? String + throw KeychainError.unhandledError(status: status, message: message) + } + } + + func secret(with key: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccount as String: key, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess else { + let message = SecCopyErrorMessageString(status, nil) as? String + throw KeychainError.unhandledError(status: status, message: message) + } + + guard + let existingItem = item as? [String: Any], + let secretData = existingItem[kSecValueData as String] as? Data, + let secret = String(data: secretData, encoding: .utf8) + else { + throw KeychainError.unexpectedSecretData + } + + return secret + } + + func deleteSecret(with key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + let message = SecCopyErrorMessageString(status, nil) as? String + throw KeychainError.unhandledError(status: status, message: message) + } + } +} diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift new file mode 100644 index 00000000..534c121a --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift @@ -0,0 +1,203 @@ +import AuthenticationServices + +public struct OAuthSession: Sendable { + private let storage: SecureStorage + private let authenticationSession: AuthenticationSession + private let snakeCaseDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + init(authenticationSession: AuthenticationSession = OldAuthenticationSession(), storage: SecureStorage = Keychain()) { + self.authenticationSession = authenticationSession + self.storage = storage + } + + public func hasSession(with email: Email) -> Bool { + (try? storage.secret(with: email.rawValue) ?? nil) != nil + } + + public func deleteSession(with email: Email) { + try? storage.deleteSecret(with: email.rawValue) + } + + func sessionToken(with email: Email) -> String? { + try? storage.secret(with: email.rawValue) + } + + @discardableResult + func retrieveAccessToken(with email: Email) async throws -> String { + guard let secrets = await Configuration.shared.oauthSecrets else { + assertionFailure("Trying to retrieve access token without configuring oauth secrets.") + throw OAuthError.notConfigured + } + + do { + let url = try oauthURL(with: email, secrets: secrets) + let callbackURL = try await authenticationSession.authenticate(using: url, callbackURLScheme: secrets.callbackScheme) + let token = try await getToken(from: callbackURL, secrets: secrets) + try storage.setSecret(token, for: email.rawValue) + return token + } catch { + throw OAuthError.from(error: error) + } + } + + private func getToken(from callbackURL: URL, secrets: Configuration.OAuthSecrets) async throws -> String { + let queryItems = URLComponents(string: callbackURL.absoluteString)?.queryItems + guard let code = queryItems?.filter({ $0.name == "code" }).first?.value else { + throw OAuthError.couldNotParseAccessCode(callbackURL.absoluteString) + } + + return try await requestAccessToken(code: code, secrets: secrets) + } + + private func requestAccessToken(code: String, secrets: Configuration.OAuthSecrets) async throws -> String { + do { + let request = try accessTokenRequest(with: code, secrets: secrets) + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = (response as? HTTPURLResponse), httpResponse.statusCode >= 400 { + let error = try snakeCaseDecoder.decode(RemoteOAuthError.self, from: data) + throw OAuthError.oauthResponseError(error.errorDescription) + } else { + let auth = try snakeCaseDecoder.decode(OAuthResponse.self, from: data) + return auth.accessToken + } + } catch { + throw error + } + } + + private func oauthURL(with email: Email, secrets: Configuration.OAuthSecrets) throws -> URL { + let params = OAuthURLParams(email: email, secrets: secrets) + var urlComponents = URLComponents(string: "https://public-api.wordpress.com/oauth2/authorize")! + do { + urlComponents.queryItems = try params.queryItems + guard let finalURL = urlComponents.url else { + assertionFailure( + "Error encoding oauth secrets. Check the config in `Configuration.shared.configure(with:oauthSecrets:)` and try again" + ) + throw OAuthError.couldNotCreateOAuthURLWithGivenSecrets + } + return finalURL + } catch { + assertionFailure( + "Error encoding oauth secrets. Check the config in `Configuration.shared.configure(with:oauthSecrets:)` and try again" + ) + throw OAuthError.couldNotCreateOAuthURLWithGivenSecrets + } + } + + private func accessTokenRequest(with code: String, secrets: Configuration.OAuthSecrets) throws -> URLRequest { + let tokenURL = URL(string: "https://public-api.wordpress.com/oauth2/token")! + let params = AccessTokenRequestParams(secrets: secrets, code: code) + + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = try params.queryItems.string?.data(using: .utf8) + + return request + } +} + +enum OAuthError: Error { + case notConfigured + case couldNotCreateOAuthURLWithGivenSecrets + case couldNotParseAccessCode(String) + case oauthResponseError(String) + case unknown(Error) + case couldNotStoreToken(Error) + case decodingError(Error) +} + +extension OAuthError { + static func from(error: Error) -> OAuthError { + switch error { + case let error as OAuthError: + return error + case let error as Keychain.KeychainError: + return .couldNotStoreToken(error) + case let error as DecodingError: + assertionFailure("Unable to decode the response. Error: \(error.localizedDescription)") + return OAuthError.decodingError(error) + case let error as NSError: + if error.domain == ASWebAuthenticationSessionErrorDomain { + return .oauthResponseError(error.localizedDescription) + } + return .unknown(error) + default: + return .unknown(error) + } + } +} + +private struct AccessTokenRequestParams: Encodable { + let clientID: String + let redirectURI: String + let clientSecret: String + let grantType: String = "authorization_code" + let code: String + + init(secrets: Configuration.OAuthSecrets, code: String) { + clientID = secrets.clientID + redirectURI = secrets.redirectURI + clientSecret = secrets.clientSecret + self.code = code + } +} + +private struct OAuthURLParams: Encodable { + let clientID: String + let responseType: String + let blogID: String + let redirectURI: String + let userEmail: String + + init(email: Email, secrets: Configuration.OAuthSecrets) { + self.clientID = secrets.clientID + self.responseType = "code" + self.blogID = "0" + self.redirectURI = secrets.redirectURI + self.userEmail = email.rawValue + } +} + +private struct OAuthResponse: Decodable { + let accessToken: String +} + +private struct RemoteOAuthError: Decodable { + let error: String + let errorDescription: String +} + +extension Encodable { + fileprivate var queryItems: [URLQueryItem] { + get throws { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let data = try encoder.encode(self) + let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: String] + return dictionary?.map { + URLQueryItem(name: $0.key, value: $0.value) + } ?? [] + } + } +} + +extension [URLQueryItem] { + fileprivate var string: String? { + var components = URLComponents() + components.queryItems = self + return components.query + } +} + +protocol AuthenticationSession: Sendable { + func authenticate(using url: URL, callbackURLScheme: String) async throws -> URL +} + +extension OldAuthenticationSession: AuthenticationSession {} diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession+Environment.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession+Environment.swift new file mode 100644 index 00000000..92d65c3d --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession+Environment.swift @@ -0,0 +1,12 @@ +import SwiftUI + +private struct OAuthSessionKey: EnvironmentKey { + static let defaultValue: OAuthSession = .init() +} + +extension EnvironmentValues { + public var oauthSession: OAuthSession { + get { self[OAuthSessionKey.self] } + set { self[OAuthSessionKey.self] = newValue } + } +} diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession.swift new file mode 100644 index 00000000..a6136bcc --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession.swift @@ -0,0 +1,28 @@ +@preconcurrency import AuthenticationServices + +final class WebAuthenticationPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding, Sendable { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + ASPresentationAnchor() + } +} + +struct OldAuthenticationSession: Sendable { + let context = WebAuthenticationPresentationContextProvider() + + func authenticate(using url: URL, callbackURLScheme: String) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme) { callbackURL, error in + if let error { + continuation.resume(throwing: error) + } else if let callbackURL { + continuation.resume(returning: callbackURL) + } + } + + Task { @MainActor in + session.presentationContextProvider = context + session.start() + } + } + } +} diff --git a/Sources/GravatarUI/SwiftUI/ProfileEditor/ProfileEditor.swift b/Sources/GravatarUI/SwiftUI/ProfileEditor/ProfileEditor.swift new file mode 100644 index 00000000..d6042a36 --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/ProfileEditor/ProfileEditor.swift @@ -0,0 +1,60 @@ +import SwiftUI + +public enum ProfileEditorEntryPoint { + case avatarPicker +} + +struct ProfileEditor: View { + @Environment(\.oauthSession) private var oauthSession + @State var hasSession: Bool = false + @State var entryPoint: ProfileEditorEntryPoint + @State var isAuthenticating: Bool = true + @Binding var isPresented: Bool + + let email: Email + + init(email: Email, entryPoint: ProfileEditorEntryPoint, isPresented: Binding) { + self.email = email + self.entryPoint = entryPoint + self._isPresented = isPresented + } + + var body: some View { + VStack { + if hasSession, let token = oauthSession.sessionToken(with: email) { + switch entryPoint { + case .avatarPicker: + AvatarPickerView(model: .init(email: email, authToken: token), isPresented: $isPresented) + } + } else { + if !isAuthenticating { + Button("Authenticate (Future error view)") { + Task { + performAuthentication() + } + } + } else { + ProgressView() + } + } + }.task { + performAuthentication() + } + } + + @MainActor + func performAuthentication() { + Task { + isAuthenticating = true + if !oauthSession.hasSession(with: email) { + _ = try? await oauthSession.retrieveAccessToken(with: email) + } + hasSession = oauthSession.hasSession(with: email) + isAuthenticating = false + } + } +} + +#Preview { + ProfileEditor(email: .init(""), entryPoint: .avatarPicker, isPresented: .constant(true)) +} diff --git a/Sources/GravatarUI/SwiftUI/View+Additions.swift b/Sources/GravatarUI/SwiftUI/View+Additions.swift index 81c0eeb5..eba41292 100644 --- a/Sources/GravatarUI/SwiftUI/View+Additions.swift +++ b/Sources/GravatarUI/SwiftUI/View+Additions.swift @@ -29,4 +29,14 @@ extension View { ) .padding(.vertical, borderWidth) // to prevent borders from getting clipped } + + public func gravatarEditorSheet( + isPresented: Binding, + email: String, + entryPoint: ProfileEditorEntryPoint, + onDismiss: (() -> Void)? = nil + ) -> some View { + let editor = ProfileEditor(email: .init(email), entryPoint: entryPoint, isPresented: isPresented) + return modifier(ModalPresentationModifier(isPresented: isPresented, onDismiss: onDismiss, modalView: editor)) + } } diff --git a/Tests/GravatarUITests/OAuthSessionTests.swift b/Tests/GravatarUITests/OAuthSessionTests.swift new file mode 100644 index 00000000..96bdd2b0 --- /dev/null +++ b/Tests/GravatarUITests/OAuthSessionTests.swift @@ -0,0 +1,21 @@ +@testable import GravatarUI +import XCTest + +final class OAuthSessionTests: XCTestCase { + func testOAuth() { + let mockSession = AuthenticationSessionMock(responseURL: URL(string: "some://url.com?code=someCode")!) + let oauth = OAuthSession(authenticationSession: mockSession) + } +} + +class AuthenticationSessionMock: AuthenticationSession, @unchecked Sendable { + let responseURL: URL + + init(responseURL: URL) { + self.responseURL = responseURL + } + + func authenticate(using url: URL, callbackURLScheme: String) async throws -> URL { + responseURL + } +}