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

Gravatar OAuth in SwiftUI #359

Merged
merged 13 commits into from
Aug 23, 2024
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ fastlane/test_output

# Other
openapi-generator/
Demo/Demo/Gravatar-Demo/Secrets.swift
Demo/Demo/Secrets.swift
4 changes: 4 additions & 0 deletions Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -35,6 +37,8 @@ struct ContentView: View {
DemoAvatarView()
case .avatarPickerView:
DemoAvatarPickerView()
case .oauth:
DemoProfileEditorView()
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions Demo/Demo/Gravatar-SwiftUI-Demo/DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}
}
}
35 changes: 35 additions & 0 deletions Demo/Demo/Gravatar-SwiftUI-Demo/DemoProfileEditorViewView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import SwiftUI
import GravatarUI
etoledom marked this conversation as resolved.
Show resolved Hide resolved

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

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

#Preview {
DemoAvatarPickerView()
}
13 changes: 9 additions & 4 deletions Demo/Demo/Gravatar-UIKit-Demo/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 0 additions & 1 deletion Demo/Demo/Gravatar-UIKit-Demo/Secrets.swift

This file was deleted.

11 changes: 11 additions & 0 deletions Demo/Demo/Secrets.tpl
Original file line number Diff line number Diff line change
@@ -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 = ""
}
41 changes: 35 additions & 6 deletions Demo/Gravatar-Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 /* DemoProfileEditorViewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3FA2442C75E403002901F2 /* DemoProfileEditorViewView.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 */; };
Expand Down Expand Up @@ -39,8 +41,10 @@
/* Begin PBXFileReference section */
1E0087922B63CFFE0012ECEA /* DemoFetchProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoFetchProfileViewController.swift; sourceTree = "<group>"; };
1E0087942B63DBCB0012ECEA /* DemoUploadImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoUploadImageViewController.swift; sourceTree = "<group>"; };
1E3FA23D2C74B7B5002901F2 /* Secrets.tpl */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.tpl; sourceTree = "<group>"; };
1E3FA23F2C74B8CC002901F2 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; };
1E3FA2442C75E403002901F2 /* DemoProfileEditorViewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoProfileEditorViewView.swift; sourceTree = "<group>"; };
1ECAB5062BC984440043A331 /* DemoProfileConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoProfileConfigurationViewController.swift; sourceTree = "<group>"; };
1ED769E62C048D9C00680D78 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; };
3FD4781E2C50D6FD0071B8B9 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
3FD478212C51D5CE0071B8B9 /* Gravatar-UIKit-Demo.Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Gravatar-UIKit-Demo.Release.xcconfig"; sourceTree = "<group>"; };
3FD478222C51D7E20071B8B9 /* Gravatar-SwiftUI-Demo.Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Gravatar-SwiftUI-Demo.Release.xcconfig"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -108,6 +112,7 @@
49C5D60F2B5B33E20067C2A8 /* ContentView.swift */,
9146A7AD2C3BD8F000E07C63 /* DemoAvatarView.swift */,
91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */,
1E3FA2442C75E403002901F2 /* DemoProfileEditorViewView.swift */,
49C5D6112B5B33E20067C2A8 /* Assets.xcassets */,
49C5D6132B5B33E20067C2A8 /* Preview Content */,
49EFFB572C51AA3E0086589A /* Gravatar-SwiftUI-Demo.Base.xcconfig */,
Expand All @@ -121,7 +126,6 @@
children = (
91956A502B67939F00BF3CF0 /* Common */,
495775E12B5B34970082812A /* AppDelegate.swift */,
1ED769E62C048D9C00680D78 /* Secrets.swift */,
91F0B3E12B6281A60025C4F8 /* Main.storyboard */,
495775E32B5B34970082812A /* SceneDelegate.swift */,
914AC0172BD7FF08005DA4A5 /* DemoBaseProfileViewController.swift */,
Expand Down Expand Up @@ -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 = "<group>";
Expand Down Expand Up @@ -218,6 +224,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 49C5D6182B5B33E20067C2A8 /* Build configuration list for PBXNativeTarget "Gravatar SwiftUI" */;
buildPhases = (
1E3FA23E2C74B808002901F2 /* ShellScript */,
49C5D6062B5B33E20067C2A8 /* Sources */,
49C5D6072B5B33E20067C2A8 /* Frameworks */,
49C5D6082B5B33E20067C2A8 /* Resources */,
Expand Down Expand Up @@ -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;
Expand All @@ -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 */

Expand All @@ -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 */,
Expand All @@ -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 */,
Expand All @@ -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 /* DemoProfileEditorViewView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
21 changes: 19 additions & 2 deletions Sources/Gravatar/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
private(set) public 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()
Expand All @@ -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?) {
self.apiKey = apiKey
self.oauthSecrets = oauthSecrets
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct AvatarPickerView: View {
avatarsLoadingView()
}
}
.task {
.onAppear() {
etoledom marked this conversation as resolved.
Show resolved Hide resolved
model.refresh()
}
if model.grid.isEmpty == false {
Expand Down
73 changes: 73 additions & 0 deletions Sources/GravatarUI/SwiftUI/OAuthSession/Keychain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation

protocol SecureStorage: Sendable {
func setPassword(_ password: String, for key: String) throws
func deletePassword(with key: String) throws
func password(with key: String) throws -> String?
etoledom marked this conversation as resolved.
Show resolved Hide resolved
}

struct Keychain: SecureStorage {
enum KeychainError: Error {
case unexpectedPasswordData
case cannotConvertPasswordIntoData
etoledom marked this conversation as resolved.
Show resolved Hide resolved
case unhandledError(status: OSStatus, message: String?)
}

func setPassword(_ password: String, for key: String) throws {
guard let tokenData = password.data(using: .utf8) else {
throw KeychainError.cannotConvertPasswordIntoData
}

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 password(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 passwordData = existingItem[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw KeychainError.unexpectedPasswordData
}

return password
}

func deletePassword(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)
}
}
}
Loading