diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift index 990965c5..31694fe7 100644 --- a/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/OAuthSession.swift @@ -1,7 +1,8 @@ import AuthenticationServices public struct OAuthSession: Sendable { - private static let shared = OAuthSession() + static let shared = OAuthSession() + private var emailStorage = SessionEmailStorage() private let storage: SecureStorage private let authenticationSession: AuthenticationSession @@ -54,25 +55,47 @@ public struct OAuthSession: Sendable { try? storage.secret(with: email.rawValue) } - @discardableResult - func retrieveAccessToken(with email: Email) async throws -> KeychainToken { + func retrieveAccessToken(with email: Email) async throws { guard let secrets = await Configuration.shared.oauthSecrets else { assertionFailure("Trying to retrieve access token without configuring oauth secrets.") throw OAuthError.notConfigured } + await emailStorage.save(email) do { let url = try oauthURL(with: email, secrets: secrets) let callbackURL = try await authenticationSession.authenticate(using: url, callbackURLScheme: secrets.callbackScheme) - let tokenText = try tokenResponse(from: callbackURL).token + _ = await Self.handleCallback(callbackURL) + } catch { + throw OAuthError.from(error: error) + } + } + + public static func handleCallback(_ callbackURL: URL) async -> Bool { + guard let email = await shared.emailStorage.restore() else { return false } + + do { + let tokenText = try shared.tokenResponse(from: callbackURL).token guard try await CheckTokenAuthorizationService().isToken(tokenText, authorizedFor: email) else { throw OAuthError.loggedInWithWrongEmail(email: email.rawValue) } let newToken = KeychainToken(token: tokenText) - overrideToken(newToken, for: email) - return newToken + shared.overrideToken(newToken, for: email) + await shared.authenticationSession.cancel() + postNotification(.authorizationFinished) + return true + } catch OAuthError.couldNotParseAccessCode { + return false // The URL was not a Gravatar callback URL with a token. } catch { - throw OAuthError.from(error: error) + await shared.authenticationSession.cancel() + postNotification(.authorizationError, error: error) + return true + } + } + + private static func postNotification(_ name: Notification.Name, error: Error? = nil) { + Task { @MainActor in + NotificationCenter.default.post(name: name, object: error) } } @@ -216,6 +239,26 @@ extension [URLQueryItem] { protocol AuthenticationSession: Sendable { func authenticate(using url: URL, callbackURLScheme: String) async throws -> URL + func cancel() async } extension OldAuthenticationSession: AuthenticationSession {} + +// Stores the email used for the current OAuth flow +private actor SessionEmailStorage { + var current: Email? + + func save(_ email: Email) { + current = email + } + + func restore() -> Email? { + let currentEmail = current + return currentEmail + } +} + +extension Notification.Name { + static let authorizationFinished = Notification.Name("com.GravatarSDK.AuthorizationFinished") + static let authorizationError = Notification.Name("com.GravatarSDK.AuthorizationFinishedWithError") +} diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession+Environment.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession+Environment.swift index 92d65c3d..a9628ead 100644 --- a/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession+Environment.swift +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession+Environment.swift @@ -1,7 +1,7 @@ import SwiftUI private struct OAuthSessionKey: EnvironmentKey { - static let defaultValue: OAuthSession = .init() + static let defaultValue: OAuthSession = .shared } extension EnvironmentValues { diff --git a/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession.swift b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession.swift index a6136bcc..82712a26 100644 --- a/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession.swift +++ b/Sources/GravatarUI/SwiftUI/OAuthSession/OldAuthenticationSession.swift @@ -6,12 +6,13 @@ final class WebAuthenticationPresentationContextProvider: NSObject, ASWebAuthent } } -struct OldAuthenticationSession: Sendable { +actor OldAuthenticationSession: Sendable { let context = WebAuthenticationPresentationContextProvider() + var session: ASWebAuthenticationSession? 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 + session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme) { callbackURL, error in if let error { continuation.resume(throwing: error) } else if let callbackURL { @@ -20,9 +21,16 @@ struct OldAuthenticationSession: Sendable { } Task { @MainActor in - session.presentationContextProvider = context - session.start() + await session?.presentationContextProvider = context + await session?.start() } } } + + nonisolated + func cancel() { + Task { @MainActor in + await session?.cancel() + } + } } diff --git a/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift b/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift index 83c08913..0e278e04 100644 --- a/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift +++ b/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift @@ -55,6 +55,9 @@ struct QuickEditor: View { self.avatarUpdatedHandler = avatarUpdatedHandler } + let authorizationFinishedNotification = NotificationCenter.default.publisher(for: .authorizationFinished) + let authorizationErrorNotification = NotificationCenter.default.publisher(for: .authorizationError) + var body: some View { NavigationView { if let token { @@ -63,6 +66,12 @@ struct QuickEditor: View { noticeView() .accumulateIntrinsicHeight() } + }.onReceive(authorizationFinishedNotification) { _ in + onAuthenticationFinished() + }.onReceive(authorizationErrorNotification) { notification in + guard let error = notification.object as? OAuthError else { return } + oauthError = error + onAuthenticationFinished() } } @@ -131,8 +140,7 @@ struct QuickEditor: View { isAuthenticating = true if !oauthSession.hasValidSession(with: email) { do { - _ = try await oauthSession.retrieveAccessToken(with: email) - oauthError = nil + try await oauthSession.retrieveAccessToken(with: email) } catch OAuthError.oauthResponseError(_, let code) where code == .canceledLogin { // ignore the error if the user has cancelled the operation. } catch let error as OAuthError { @@ -141,9 +149,16 @@ struct QuickEditor: View { oauthError = nil } } - fetchedToken = oauthSession.sessionToken(with: email)?.token - isAuthenticating = false + onAuthenticationFinished() + } + } + + func onAuthenticationFinished() { + if let fetchedToken = oauthSession.sessionToken(with: email)?.token { + self.fetchedToken = fetchedToken + oauthError = nil } + isAuthenticating = false } } diff --git a/Tests/GravatarUITests/OAuthSessionTests.swift b/Tests/GravatarUITests/OAuthSessionTests.swift index 10e1975d..ce95d4cb 100644 --- a/Tests/GravatarUITests/OAuthSessionTests.swift +++ b/Tests/GravatarUITests/OAuthSessionTests.swift @@ -9,6 +9,8 @@ final class OAuthSessionTests: XCTestCase { } class AuthenticationSessionMock: AuthenticationSession, @unchecked Sendable { + func cancel() async {} + let responseURL: URL init(responseURL: URL) {