From 5f2e6bfbcec8fe33acb0c4b7dd64e3b232ea9239 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 17 Jul 2025 09:07:56 -0700 Subject: [PATCH 1/4] WIP --- Sources/OAuthKit/OAuth+State.swift | 6 ++ Sources/OAuthKit/OAuth.swift | 55 +++++++------------ .../OAuthKit/Views/OAWebViewCoordinator.swift | 4 +- Tests/OAuthKitTests/OAWebViewTests.swift | 2 +- Tests/OAuthKitTests/OAuth+Monitor.swift | 2 +- Tests/OAuthKitTests/OAuthTests.swift | 26 ++++----- 6 files changed, 42 insertions(+), 53 deletions(-) diff --git a/Sources/OAuthKit/OAuth+State.swift b/Sources/OAuthKit/OAuth+State.swift index cb3df55..e21f8ee 100644 --- a/Sources/OAuthKit/OAuth+State.swift +++ b/Sources/OAuthKit/OAuth+State.swift @@ -43,5 +43,11 @@ extension OAuth { /// - Provider: the oauth provider /// - Authorization: the oauth authorization case authorized(Provider, Authorization) + + /// An error has occurred during the authorization flow for the specified provider. + /// - Parameters: + /// - Provider: the oauth provider + /// - OAError: the error information + case error(Provider, OAError) } } diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index 2b6bd12..a2d58a9 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -18,7 +18,7 @@ private let defaultExtension = "json" private let defaultAuthenticationWithBiometricsOrCompanionReason = "unlock keychain" /// Provides an enum of oauth errors. -public enum OAError: Error { +public enum OAError: Error, Sendable { /// An error occurred while building a request url case malformedURL /// An error occurred while loading data from a request @@ -139,17 +139,7 @@ public extension OAuth { /// - pkce: the pkce data func token(provider: Provider, code: String, pkce: PKCE? = nil) { Task(priority: .high) { - let result = await requestToken(provider: provider, code: code, pkce: pkce) - switch result { - case .success(let token): - if provider.debug { - debugPrint("➡️ [Received token], [\(token)]") - } - case .failure(let error): - if provider.debug { - debugPrint("➡️ [Error requesting access token], [\(error)]") - } - } + await requestToken(provider: provider, code: code, pkce: pkce) } } @@ -280,7 +270,7 @@ private extension OAuth { schedule(provider: provider, auth: auth) case .receivedDeviceCode(let provider, let deviceCode): schedule(provider: provider, deviceCode: deviceCode) - case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode: + case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode: break } self.state = state @@ -338,20 +328,16 @@ extension OAuth { /// - provider: the provider the access token is being requested from /// - code: the code to exchange /// - pkce: the PKCE data to pass along with the request - /// - Returns: the exchange result - @discardableResult - func requestToken(provider: Provider, code: String, pkce: PKCE? = nil) async -> Result { + func requestToken(provider: Provider, code: String, pkce: PKCE? = nil) async { // Publish the state publish(state: .requestingAccessToken(provider)) guard let request = Request.token(provider: provider, code: code, pkce: pkce) else { - publish(state: .empty) - return .failure(.malformedURL) + return publish(state: .error(provider, .malformedURL)) } guard let (data, response) = try? await urlSession.data(for: request) else { - publish(state: .empty) - return .failure(.badResponse) + return publish(state: .error(provider, .badResponse)) } if provider.debug { @@ -362,19 +348,16 @@ extension OAuth { // Decode the token guard let token = try? decoder.decode(Token.self, from: data) else { - publish(state: .empty) - return .failure(.decoding) + return publish(state: .error(provider, .decoding)) } // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { - publish(state: .empty) - return .failure(.keychain) + return publish(state: .error(provider, .keychain)) } publish(state: .authorized(provider, authorization)) - return .success(token) } /// Refreshes the token for the specified provider. @@ -392,7 +375,7 @@ extension OAuth { } guard let (data, response) = try? await urlSession.data(for: request) else { - return publish(state: .empty) + return publish(state: .error(provider, .badResponse)) } if provider.debug { @@ -403,13 +386,13 @@ extension OAuth { // Decode the token guard response.isOK, let token = try? decoder.decode(Token.self, from: data) else { - return publish(state: .empty) + return publish(state: .error(provider, .decoding)) } // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { - return publish(state: .empty) + return publish(state: .error(provider, .keychain)) } publish(state: .authorized(provider, authorization)) } @@ -420,7 +403,7 @@ extension OAuth { func requestDeviceCode(provider: Provider) async { guard let request = Request.device(provider: provider) else { return } guard let (data, response) = try? await urlSession.data(for: request) else { - return publish(state: .empty) + return publish(state: .error(provider, .badResponse)) } if provider.debug { @@ -431,7 +414,7 @@ extension OAuth { // Decode the device code guard let deviceCode = try? decoder.decode(DeviceCode.self, from: data) else { - return publish(state: .empty) + return publish(state: .error(provider, .decoding)) } // Publish the state @@ -444,7 +427,7 @@ extension OAuth { func requestClientCredentials(provider: Provider) async { guard let request = Request.token(provider: provider) else { return } guard let (data, response) = try? await urlSession.data(for: request) else { - return publish(state: .empty) + return publish(state: .error(provider, .badResponse)) } if provider.debug { @@ -455,13 +438,13 @@ extension OAuth { // Decode the token guard let token = try? decoder.decode(Token.self, from: data) else { - return publish(state: .empty) + return publish(state: .error(provider, .decoding)) } // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { - return publish(state: .empty) + return publish(state: .error(provider, .keychain)) } publish(state: .authorized(provider, authorization)) } @@ -474,11 +457,11 @@ extension OAuth { func poll(provider: Provider, deviceCode: DeviceCode) async { guard !deviceCode.isExpired, let request = Request.token(provider: provider, deviceCode: deviceCode) else { - return publish(state: .empty) + return publish(state: .error(provider, .malformedURL)) } guard let (data, response) = try? await urlSession.data(for: request) else { - return publish(state: .empty) + return publish(state: .error(provider, .badResponse)) } if provider.debug { @@ -496,7 +479,7 @@ extension OAuth { // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { - return publish(state: .empty) + return publish(state: .error(provider, .keychain)) } publish(state: .authorized(provider, authorization)) } diff --git a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift index 8f23c82..ad7c08e 100644 --- a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift +++ b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift @@ -75,7 +75,7 @@ public class OAWebViewCoordinator: NSObject { /// - Parameter state: the published state change. func update(state: OAuth.State) { switch state { - case .empty, .authorized, .requestingAccessToken, .requestingDeviceCode: + case .empty, .error, .authorized, .requestingAccessToken, .requestingDeviceCode: break case .authorizing(let provider, let grantType): // Override the custom user agent for the provider and tell the browser to load the request @@ -99,7 +99,7 @@ extension OAWebViewCoordinator: WKNavigationDelegate { public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { guard let url = navigationAction.request.url else { return .cancel } switch oauth.state { - case .empty, .requestingAccessToken, .authorized, .requestingDeviceCode, .receivedDeviceCode: + case .empty, .error, .requestingAccessToken, .authorized, .requestingDeviceCode, .receivedDeviceCode: break case .authorizing(let provider, let grantType): handle(url: url, provider: provider, grantType: grantType) diff --git a/Tests/OAuthKitTests/OAWebViewTests.swift b/Tests/OAuthKitTests/OAWebViewTests.swift index dcfaa52..e8ff289 100644 --- a/Tests/OAuthKitTests/OAWebViewTests.swift +++ b/Tests/OAuthKitTests/OAWebViewTests.swift @@ -143,7 +143,7 @@ final class OAWebViewTests { let monitor: OAuth.Monitor = .init(oauth: oauth) for await state in monitor.stream { switch state { - case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: + case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: break case .authorized(_, _): keychain.clear() diff --git a/Tests/OAuthKitTests/OAuth+Monitor.swift b/Tests/OAuthKitTests/OAuth+Monitor.swift index 50c680e..02893e3 100644 --- a/Tests/OAuthKitTests/OAuth+Monitor.swift +++ b/Tests/OAuthKitTests/OAuth+Monitor.swift @@ -45,7 +45,7 @@ extension OAuth { let state = oauth.state continuation?.yield(state) switch state { - case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: + case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: waitForNextValue() case .authorized(_, _): continuation?.finish() diff --git a/Tests/OAuthKitTests/OAuthTests.swift b/Tests/OAuthKitTests/OAuthTests.swift index 28a5cd7..0ffe63f 100644 --- a/Tests/OAuthKitTests/OAuthTests.swift +++ b/Tests/OAuthKitTests/OAuthTests.swift @@ -260,29 +260,29 @@ final class OAuthTests { // Token let oauth: OAuth = .init(.module, options: options) - let result = await oauth.requestToken(provider: provider, code: .secureRandom()) - #expect(result == .failure(.decoding)) + await oauth.requestToken(provider: provider, code: .secureRandom()) + #expect(oauth.state == .error(provider, .decoding)) // Refresh Token let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) try! oauth.keychain.set(auth, for: provider.id) await oauth.refreshToken(provider: provider) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .decoding)) oauth.clear() // Client Credentials await oauth.requestClientCredentials(provider: provider) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .decoding)) // Device Code await oauth.requestDeviceCode(provider: provider) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .decoding)) // Device Code Polling let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: .secureRandom(), verificationUri: "https://example.com", expiresIn: 200, interval: 0) await oauth.poll(provider: provider, deviceCode: deviceCode) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .decoding)) } @Test("When Received server error") @@ -301,29 +301,29 @@ final class OAuthTests { // Token let oauth: OAuth = .init(.module, options: options) - let result = await oauth.requestToken(provider: provider, code: .secureRandom()) - #expect(result == .failure(.badResponse)) + await oauth.requestToken(provider: provider, code: .secureRandom()) + #expect(oauth.state == .error(provider, .badResponse)) // Refresh Token let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) try! oauth.keychain.set(auth, for: provider.id) await oauth.refreshToken(provider: provider) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .badResponse)) oauth.clear() // Client Credentials await oauth.requestClientCredentials(provider: provider) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .badResponse)) // Device Code await oauth.requestDeviceCode(provider: provider) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .badResponse)) // Device Code Polling let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: .secureRandom(), verificationUri: "https://example.com", expiresIn: 200, interval: 0) await oauth.poll(provider: provider, deviceCode: deviceCode) - #expect(oauth.state == .empty) + #expect(oauth.state == .error(provider, .badResponse)) } /// Tests the PKCE request parameters. @@ -427,7 +427,7 @@ final class OAuthTests { let monitor: OAuth.Monitor = .init(oauth: oauth) for await state in monitor.stream { switch state { - case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: + case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: break case .authorized(_, _): oauth.clear() From 56057d95923aac1adb2f3278a61bb9d17781363d Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 17 Jul 2025 09:20:56 -0700 Subject: [PATCH 2/4] Doc updates --- Sources/OAuthKit/OAuthKit.docc/GettingStarted.md | 8 +++++--- .../tutorial-code-files/flows-states-step-2.swift | 2 ++ .../tutorial-code-files/flows-states-step-3.swift | 2 ++ .../tutorial-code-files/flows-states-step-4.swift | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md b/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md index 6fffc4d..2ae3536 100644 --- a/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md +++ b/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md @@ -90,6 +90,8 @@ struct ContentView: View { .padding() .border(Color.primary) .font(.title) + case .error(let provider, let error): + Text("Error [\(provider.id)]: \(error.localizedDescription)") } } .onChange(of: oauth.state) { _, state in @@ -118,7 +120,7 @@ struct ContentView: View { private func handle(state: OAuth.State) { #if canImport(WebKit) switch state { - case .empty, .requestingAccessToken, .requestingDeviceCode: + case .empty, .error, .requestingAccessToken, .requestingDeviceCode: break case .authorizing, .receivedDeviceCode: openWindow(id: "oauth") @@ -140,7 +142,7 @@ OAuthKit provides an out of the box SwiftUI view ``OAWebView`` that will automat /// - Parameter state: the published state change func handle(state: OAuth.State) { switch state { - case .empty, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: + case .empty, .error, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: break case .authorizing: openWindow(id: "oauth") @@ -165,7 +167,7 @@ struct ContentView: View { var body: some View { VStack { switch oauth.state { - case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode: + case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode: EmptyView() case .authorized: Text("Authorized [\(provider.id)]") diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift index dc4b7a4..f023b9c 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift @@ -44,6 +44,8 @@ struct ContentView: View { .padding() .border(Color.primary) .font(.title) + case .error(let provider, let error): + Text("Error [\(provider.id)]: \(error.localizedDescription)") } } .onChange(of: oauth.state) { _, state in diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift index ba0178a..751570c 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift @@ -58,6 +58,8 @@ struct ContentView: View { .padding() .border(Color.primary) .font(.title) + case .error(let provider, let error): + Text("Error [\(provider.id)]: \(error.localizedDescription)") } } .onChange(of: oauth.state) { _, state in diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift index eb75dc3..c2e2239 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift @@ -24,7 +24,7 @@ struct ContentView: View { private func handle(state: OAuth.State) { #if canImport(WebKit) switch state { - case .empty, .requestingAccessToken, .requestingDeviceCode: + case .empty, .error, .requestingAccessToken, .requestingDeviceCode: break case .authorizing, .receivedDeviceCode: openWindow(id: "oauth") From b6c30df878c20908ec20d25916655e85d68becc8 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 17 Jul 2025 09:30:57 -0700 Subject: [PATCH 3/4] DocC updates --- Sources/OAuthKit/OAuth+State.swift | 2 +- Sources/OAuthKit/OAuthKit.docc/Extensions/State.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/OAuthKit/OAuth+State.swift b/Sources/OAuthKit/OAuth+State.swift index e21f8ee..7e079aa 100644 --- a/Sources/OAuthKit/OAuth+State.swift +++ b/Sources/OAuthKit/OAuth+State.swift @@ -44,7 +44,7 @@ extension OAuth { /// - Authorization: the oauth authorization case authorized(Provider, Authorization) - /// An error has occurred during the authorization flow for the specified provider. + /// An error has occurred during an authorization flow for the specified provider. /// - Parameters: /// - Provider: the oauth provider /// - OAError: the error information diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md index 37f7b74..874515b 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md @@ -37,6 +37,8 @@ struct ContentView: View { .foregroundStyle(.blue) Text("and enter the following code:") Text(deviceCode.userCode) + case .error(let provider, let error): + Text("Error [\(provider.id)]: \(error.localizedDescription)") } } .onChange(of: oauth.state) { oldState, newState in From 1515359d6d48cae84d904d6c0111c4a2b588a934 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 17 Jul 2025 10:28:26 -0700 Subject: [PATCH 4/4] Removing Sendable redeclaration. --- Sources/OAuthKit/OAuth.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index a2d58a9..4e01479 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -18,7 +18,7 @@ private let defaultExtension = "json" private let defaultAuthenticationWithBiometricsOrCompanionReason = "unlock keychain" /// Provides an enum of oauth errors. -public enum OAError: Error, Sendable { +public enum OAError: Error { /// An error occurred while building a request url case malformedURL /// An error occurred while loading data from a request