diff --git a/Application/App/Sources/App/Delegate/AppDelegate.swift b/Application/App/Sources/App/Delegate/AppDelegate.swift index 8dbd2565..2c34c8fb 100644 --- a/Application/App/Sources/App/Delegate/AppDelegate.swift +++ b/Application/App/Sources/App/Delegate/AppDelegate.swift @@ -15,14 +15,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private let logger = Logger(category: "AppDelegate") private let container = AppDIContainer.shared - func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - return GoogleSignInURLHandler.handle(url) - } - func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil diff --git a/Application/App/Sources/Resource/Info.plist b/Application/App/Sources/Resource/Info.plist index 84f1d58e..4ae74740 100644 --- a/Application/App/Sources/Resource/Info.plist +++ b/Application/App/Sources/Resource/Info.plist @@ -27,7 +27,6 @@ Editor CFBundleURLSchemes - $(REVERSED_CLIENT_ID) DevLog @@ -48,8 +47,6 @@ $(FIRESTORE_DATABASE_ID) FUNCTION_API_BASE_URL $(FUNCTION_API_BASE_URL) - GIDClientID - $(CLIENT_ID) ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift index 6c156b93..b5402c77 100644 --- a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift +++ b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift @@ -306,7 +306,6 @@ private actor UserServiceSpy: UserService { private final class AuthServiceSpy: AuthService { var uid: String? let providerIDs = [String]() - let currentUserEmail: String? = nil let providerCount = 0 private let subject = PassthroughSubject() diff --git a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift index 33105332..9474cd37 100644 --- a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift +++ b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift @@ -218,7 +218,6 @@ private actor UserServiceSpy: UserService { private final class AuthServiceSpy: AuthService { var uid: String? let providerIDs = [String]() - let currentUserEmail: String? = nil let providerCount = 0 private let subject = PassthroughSubject() diff --git a/Application/Data/Sources/Protocol/AuthService.swift b/Application/Data/Sources/Protocol/AuthService.swift index 82f7d84b..789befa4 100644 --- a/Application/Data/Sources/Protocol/AuthService.swift +++ b/Application/Data/Sources/Protocol/AuthService.swift @@ -11,7 +11,6 @@ import Foundation public protocol AuthService { var uid: String? { get } var providerIDs: [String] { get } - var currentUserEmail: String? { get } var providerCount: Int { get } func observeSignedIn() -> AnyPublisher diff --git a/Application/Data/Sources/Protocol/AuthenticationService.swift b/Application/Data/Sources/Protocol/AuthenticationService.swift index 31a9845c..1d2dc1ff 100644 --- a/Application/Data/Sources/Protocol/AuthenticationService.swift +++ b/Application/Data/Sources/Protocol/AuthenticationService.swift @@ -11,6 +11,6 @@ public protocol AuthenticationService { func signIn() async throws -> AuthDataResponse? func signOut(_ uid: String) async throws func deleteAuth(_ uid: String) async throws - func link(uid: String, email: String) async throws -> Bool + func link(uid: String) async throws -> Bool func unlink(_ uid: String) async throws } diff --git a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift index c5dfc743..542d9f18 100644 --- a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift @@ -38,8 +38,7 @@ final class AuthDataRepositoryImpl: AuthDataRepository { } func linkProvider(_ provider: AuthProvider) async throws -> Bool { - guard let uid = authService.uid, - let email = authService.currentUserEmail else { + guard let uid = authService.uid else { throw AuthError.notAuthenticated } @@ -54,7 +53,7 @@ final class AuthDataRepositoryImpl: AuthDataRepository { } do { - return try await service.link(uid: uid, email: email) + return try await service.link(uid: uid) } catch { throw mapLinkError(error) } diff --git a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift index 7dc3208f..7d405e60 100644 --- a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift @@ -93,11 +93,6 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { widgetSnapshotUpdater.clear() } - func restore() -> Bool { - // MARK: 후에 Google API를 사용 시 Google만의 restorePreviousSignIn 로직 추가 - return authService.uid != nil - } - func delete() async throws { guard let uid = authService.uid else { throw AuthError.notAuthenticated diff --git a/Application/Data/Tests/Repository/AuthDataRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthDataRepositoryImplTests.swift index d92db11e..70c6d08a 100644 --- a/Application/Data/Tests/Repository/AuthDataRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/AuthDataRepositoryImplTests.swift @@ -89,7 +89,6 @@ struct AuthDataRepositoryImplTests { uid: "user-id", providerID: "apple.com", providerIDs: ["apple.com", "google.com"], - currentUserEmail: "apple@example.com", providerCount: providerCount, events: events ) diff --git a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift index 4739ca26..6a5a0b10 100644 --- a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift @@ -175,14 +175,12 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { var uid: String? let providerIDs: [String] - let currentUserEmail: String? let providerCount: Int init( uid: String?, providerID: String?, providerIDs: [String], - currentUserEmail: String? = nil, providerCount: Int? = nil, deleteCurrentUserError: Error? = nil, events: AuthenticationRepositoryEventRecorder @@ -190,7 +188,6 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { self.uid = uid self.providerID = providerID self.providerIDs = providerIDs - self.currentUserEmail = currentUserEmail self.providerCount = providerCount ?? providerIDs.count self.deleteCurrentUserError = deleteCurrentUserError self.events = events @@ -263,7 +260,7 @@ actor AuthenticationServiceSpy: AuthenticationService { events.record("\(provider).deleteAuth") } - func link(uid: String, email: String) async throws -> Bool { + func link(uid: String) async throws -> Bool { events.record("\(provider).link") return try linkResult.get() } diff --git a/Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift b/Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift index 01c0396f..efb8869f 100644 --- a/Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift @@ -131,7 +131,6 @@ private final class WebPageRepositoryAuthServiceSpy: AuthService { var uid: String? var providerIDs: [String] { [] } - var currentUserEmail: String? { nil } var providerCount: Int { 0 } init(uid: String?) { diff --git a/Application/Domain/Sources/Protocol/AuthenticationRepository.swift b/Application/Domain/Sources/Protocol/AuthenticationRepository.swift index a353a44e..7e8c0847 100644 --- a/Application/Domain/Sources/Protocol/AuthenticationRepository.swift +++ b/Application/Domain/Sources/Protocol/AuthenticationRepository.swift @@ -10,6 +10,5 @@ import Foundation public protocol AuthenticationRepository { func signIn(_ provider: AuthProvider) async throws -> Bool func signOut() async throws - func restore() -> Bool func delete() async throws } diff --git a/Application/Infra/Sources/Common/InfraLayerError.swift b/Application/Infra/Sources/Common/InfraLayerError.swift index ed9d4ca8..bcd092d9 100644 --- a/Application/Infra/Sources/Common/InfraLayerError.swift +++ b/Application/Infra/Sources/Common/InfraLayerError.swift @@ -7,7 +7,6 @@ import AuthenticationServices import Foundation -import Data enum FirestoreError: Error, LocalizedError { case dataNotFound(_ key: String) @@ -20,16 +19,7 @@ enum FirestoreError: Error, LocalizedError { } } -enum UIError: Error { - case notFoundTopViewController -} - -enum TokenError: Error { - case invalidResponse -} - enum SocialLoginError: Error { - case invalidOAuthState case invalidOAuthCallback case failedToStartWebAuthenticationSession case authenticationAlreadyInProgress @@ -43,8 +33,7 @@ extension Error { case let webAuthError as ASWebAuthenticationSessionError: return webAuthError.code == .canceledLogin default: - let nsError = self as NSError - return nsError.domain == "com.google.GIDSignIn" && nsError.code == -5 + return false } } } diff --git a/Application/Infra/Sources/Common/TopViewControllerProvider.swift b/Application/Infra/Sources/Common/TopViewControllerProvider.swift deleted file mode 100644 index 14d47301..00000000 --- a/Application/Infra/Sources/Common/TopViewControllerProvider.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// TopViewControllerProvider.swift -// Infra -// -// Created by 최윤진 on 2/12/26. -// - -import UIKit -import Data - -final class TopViewControllerProvider { - @MainActor - func topViewController() -> UIViewController? { - guard let keyWindow = keyWindow() else { - return nil - } - - let rootController = keyWindow.rootViewController - return topViewController(controller: rootController) - } - - @MainActor - func keyWindow() -> UIWindow? { - return UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first { $0.isKeyWindow } - } - - @MainActor - private func topViewController(controller: UIViewController?) -> UIViewController? { - if let navigationController = controller as? UINavigationController { - return topViewController(controller: navigationController.visibleViewController) - } - - if let tabController = controller as? UITabBarController, - let selectedController = tabController.selectedViewController { - return topViewController(controller: selectedController) - } - - if let presentedController = controller?.presentedViewController { - return topViewController(controller: presentedController) - } - - return controller - } -} diff --git a/Application/Infra/Sources/Service/AuthServiceImpl.swift b/Application/Infra/Sources/Service/AuthServiceImpl.swift index b635a46b..b04a6e32 100644 --- a/Application/Infra/Sources/Service/AuthServiceImpl.swift +++ b/Application/Infra/Sources/Service/AuthServiceImpl.swift @@ -39,10 +39,6 @@ final class AuthServiceImpl: AuthService { Auth.auth().currentUser?.providerData.map { $0.providerID } ?? [] } - var currentUserEmail: String? { - Auth.auth().currentUser?.email - } - var providerCount: Int { Auth.auth().currentUser?.providerData.count ?? 0 } diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index b27b5b1f..4ed24fb4 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -145,14 +145,12 @@ private enum FunctionAPIErrorCode: String { case emailMismatch = "email-mismatch" case githubEmailConflict = "github-email-changed-account-conflict" case appleProviderLinkConflict = "apple-provider-link-conflict" + case googleProviderLinkConflict = "google-provider-link-conflict" case lastProvider = "last-provider" } -enum AppleAuthenticationAPIError: Error, Equatable { - case providerLinkConflict -} - enum AuthenticationAPIError: Error, Equatable { + case providerLinkConflict case lastProvider } @@ -174,8 +172,8 @@ struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { return EmailError.mismatch case .githubEmailConflict: return EmailError.githubEmailConflict - case .appleProviderLinkConflict: - return AppleAuthenticationAPIError.providerLinkConflict + case .appleProviderLinkConflict, .googleProviderLinkConflict: + return AuthenticationAPIError.providerLinkConflict case .lastProvider: return AuthenticationAPIError.lastProvider } @@ -197,10 +195,6 @@ extension Error { functionAPIUnderlyingError as? EmailError } - var apiAppleAuthenticationError: AppleAuthenticationAPIError? { - functionAPIUnderlyingError as? AppleAuthenticationAPIError - } - var apiAuthenticationError: AuthenticationAPIError? { functionAPIUnderlyingError as? AuthenticationAPIError } diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index 008153f6..070eb6d0 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -37,6 +37,9 @@ extension FunctionAPIEndpoint where Response == EmptyAPIResponse { static let revokeGithubAccessToken = Self(method: .delete, path: "/auth/github/access-token") static let linkGithubAccount = Self(method: .put, path: "/auth/github/account-link") static let unlinkGithubAccount = Self(method: .delete, path: "/auth/github/account-link") + static let revokeGoogleAccessToken = Self(method: .delete, path: "/auth/google/access-token") + static let linkGoogleAccount = Self(method: .put, path: "/auth/google/account-link") + static let unlinkGoogleAccount = Self(method: .delete, path: "/auth/google/account-link") static let linkAppleAccount = Self(method: .put, path: "/auth/apple/account-link") static let unlinkAppleAccount = Self(method: .delete, path: "/auth/apple/account-link") static let revokeAppleAccessToken = Self(method: .delete, path: "/auth/apple/access-token") @@ -49,6 +52,7 @@ extension FunctionAPIEndpoint where Response == AppleChallengeResponse { extension FunctionAPIEndpoint where Response == FirebaseCustomTokenResponse { static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token") static let requestGithubCustomToken = Self(method: .post, path: "/auth/github/custom-token") + static let requestGoogleCustomToken = Self(method: .post, path: "/auth/google/custom-token") } extension FunctionAPIEndpoint where Response == OAuthAuthenticationSessionResponse { @@ -60,6 +64,14 @@ extension FunctionAPIEndpoint where Response == OAuthAuthenticationSessionRespon method: .post, path: "/auth/github/account-link-sessions" ) + static let requestGoogleSignInSession = Self( + method: .post, + path: "/auth/google/sign-in-sessions" + ) + static let requestGoogleAccountLinkSession = Self( + method: .post, + path: "/auth/google/account-link-sessions" + ) } private func functionAPIPathSegment(_ value: String) -> String { diff --git a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 2568933f..43bcf746 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -31,7 +31,6 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { private let store = FirebaseConfiguration.firestore private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } - private let providerID = AuthProviderID.apple private let logger = Logger(category: "AppleAuthService") func signIn() async throws -> AuthDataResponse? { @@ -98,7 +97,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - func link(uid: String, email _: String) async throws -> Bool { + func link(uid: String) async throws -> Bool { do { logger.info("Linking Apple account for user: \(uid)") let challenge = try await requestAppleChallenge() @@ -236,13 +235,11 @@ private extension AppleAuthenticationServiceImpl { return emailError } - if error.apiAuthenticationError == .lastProvider { - return DataLayerError.failedToUnlinkLastProvider - } - - switch error.apiAppleAuthenticationError { + switch error.apiAuthenticationError { case .providerLinkConflict: return DataLayerError.linkCredentialAlreadyInUse + case .lastProvider: + return DataLayerError.failedToUnlinkLastProvider case .none: return error } diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index 554580e9..a616dee6 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -33,7 +33,7 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { logger.info("Starting GitHub sign in") do { - let request = try await requestAuthenticationTicket( + let request = try await OAuthAuthenticationTicketRequester.request( endpoint: .requestGithubSignInSession, requiresAuthentication: false ) @@ -89,11 +89,11 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { } } - func link(uid: String, email _: String) async throws -> Bool { + func link(uid: String) async throws -> Bool { logger.info("Linking GitHub account for user: \(uid)") do { - let request = try await requestAuthenticationTicket( + let request = try await OAuthAuthenticationTicketRequester.request( endpoint: .requestGithubAccountLinkSession, requiresAuthentication: true ) @@ -130,29 +130,6 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { } private extension GithubAuthenticationServiceImpl { - func requestAuthenticationTicket( - endpoint: FunctionAPIEndpoint, - requiresAuthentication: Bool - ) async throws -> OAuthAuthenticationTicketRequest { - let proof = OAuthWebAuthenticationProof() - let response = try await FunctionAPIClient.shared.send( - endpoint, - payload: OAuthAuthenticationSessionRequest( - appChallenge: proof.appChallenge - ), - requiresAuthentication: requiresAuthentication - ) - let callbackURL = try await OAuthWebAuthenticationSession.authenticate( - url: response.authorizationURL, - callbackURLScheme: OAuthWebAuthenticationProof.callbackURLScheme - ) - - return OAuthAuthenticationTicketRequest( - ticket: try proof.ticket(from: callbackURL), - appVerifier: proof.appVerifier - ) - } - func mapGithubAPIError(_ error: Error) -> Error { if let emailError = error.apiEmailError { return emailError diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 3c34ff1b..44aae388 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -8,8 +8,6 @@ import FirebaseAuth import FirebaseFirestore import FirebaseMessaging -import Foundation -import GoogleSignIn import Core import Data @@ -29,48 +27,34 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { private let store = FirebaseConfiguration.firestore private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } - private let provider = TopViewControllerProvider() private let logger = Logger(category: "GoogleAuthService") - @MainActor func signIn() async throws -> AuthDataResponse? { logger.info("Starting Google sign in") - - guard let topViewController = provider.topViewController() else { - logger.error("Top view controller not found") - throw UIError.notFoundTopViewController - } do { - let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: topViewController) - - guard let idToken = signIn.user.idToken?.tokenString else { - logger.error("ID token not found") - throw URLError(.badServerResponse) - } - - let accessToken = signIn.user.accessToken.tokenString - let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken) - - logger.debug("Signing in with Google credential") - let result = try await Auth.auth().signIn(with: credential) - - if let photoURL = signIn.user.profile?.imageURL(withDimension: 200) { - let changeRequest = result.user.createProfileChangeRequest() - changeRequest.photoURL = photoURL - changeRequest.displayName = signIn.user.profile?.name - - try await changeRequest.commitChanges() - } + let request = try await OAuthAuthenticationTicketRequester.request( + endpoint: .requestGoogleSignInSession, + requiresAuthentication: false + ) + let response = try await FunctionAPIClient.shared.send( + .requestGoogleCustomToken, + payload: request, + requiresAuthentication: false + ) + + logger.debug("Signing in with custom token") + let result = try await Auth.auth().signIn(withCustomToken: response.customToken) logger.info("Successfully signed in with Google") return result.user.makeResponse(providerID: .google) } catch { if error.isSocialLoginCancelled { return nil } - logger.error("Failed to sign in with Google", error: error) - record(error, code: .signIn) - throw error + let mappedError = mapGoogleAPIError(error) + logger.error("Failed to sign in with Google", error: mappedError) + record(mappedError, code: .signIn) + throw mappedError } } @@ -79,9 +63,6 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - GIDSignIn.sharedInstance.signOut() - try await GIDSignIn.sharedInstance.disconnect() - if messaging.fcmToken != nil { do { try await messaging.deleteToken() @@ -100,8 +81,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { func deleteAuth(_ uid: String) async throws { do { - GIDSignIn.sharedInstance.signOut() - try await GIDSignIn.sharedInstance.disconnect() + try await FunctionAPIClient.shared.send(.revokeGoogleAccessToken) } catch { logger.error("Failed to delete Google auth", error: error) record(error, code: .deleteAuth) @@ -109,65 +89,62 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } } - @MainActor - func link(uid: String, email: String) async throws -> Bool { - do { - guard let topViewController = provider.topViewController() else { - throw UIError.notFoundTopViewController - } - - if GIDSignIn.sharedInstance.hasPreviousSignIn() { - GIDSignIn.sharedInstance.signOut() - } - - let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: topViewController) - - guard let googleEmail = signIn.user.profile?.email else { - throw EmailError.notFound - } - - if googleEmail != email { - throw EmailError.mismatch - } - - guard let idToken = signIn.user.idToken?.tokenString else { - throw URLError(.badServerResponse) - } - - let accessToken = signIn.user.accessToken.tokenString - let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken) + func link(uid: String) async throws -> Bool { + logger.info("Linking Google account for user: \(uid)") - try await user?.link(with: credential) + do { + let request = try await OAuthAuthenticationTicketRequester.request( + endpoint: .requestGoogleAccountLinkSession, + requiresAuthentication: true + ) + try await FunctionAPIClient.shared.send( + .linkGoogleAccount, + payload: request + ) + try await user?.reload() + + logger.info("Successfully linked Google account") return true } catch { if error.isSocialLoginCancelled { return false } - logger.error("Failed to link Google account", error: error) - record(error, code: .link) - if error.isFirebaseCredentialAlreadyInUse { - throw DataLayerError.linkCredentialAlreadyInUse - } - throw error + let mappedError = mapGoogleAPIError(error) + logger.error("Failed to link Google account", error: mappedError) + record(mappedError, code: .link) + throw mappedError } } func unlink(_ uid: String) async throws { do { - logger.info("Starting Google disconnect for unlink. uid: \(uid)") - GIDSignIn.sharedInstance.signOut() - try await GIDSignIn.sharedInstance.disconnect() - - logger.info("Starting Firebase Google provider unlink. uid: \(uid)") - _ = try await user?.unlink(fromProvider: AuthProviderID.google.rawValue) + logger.info("Unlinking Google account for user: \(uid)") + try await FunctionAPIClient.shared.send(.unlinkGoogleAccount) + try await user?.reload() } catch { - logger.error("Failed to unlink Google account", error: error) - record(error, code: .unlink) - throw error + let mappedError = mapGoogleAPIError(error) + logger.error("Failed to unlink Google account", error: mappedError) + record(mappedError, code: .unlink) + throw mappedError } } } private extension GoogleAuthenticationServiceImpl { + func mapGoogleAPIError(_ error: Error) -> Error { + if let emailError = error.apiEmailError { + return emailError + } + + switch error.apiAuthenticationError { + case .providerLinkConflict: + return DataLayerError.linkCredentialAlreadyInUse + case .lastProvider: + return DataLayerError.failedToUnlinkLastProvider + case .none: + return error + } + } + private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( error, diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift deleted file mode 100644 index 39ffa680..00000000 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// GoogleSignInURLHandler.swift -// Infra -// -// Created by opfic on 5/23/26. -// - -import Foundation -import GoogleSignIn - -public enum GoogleSignInURLHandler { - public static func handle(_ url: URL) -> Bool { - GIDSignIn.sharedInstance.handle(url) - } -} diff --git a/Application/Infra/Sources/Service/SocialLogin/OAuthAuthenticationTicketRequester.swift b/Application/Infra/Sources/Service/SocialLogin/OAuthAuthenticationTicketRequester.swift new file mode 100644 index 00000000..4281b588 --- /dev/null +++ b/Application/Infra/Sources/Service/SocialLogin/OAuthAuthenticationTicketRequester.swift @@ -0,0 +1,31 @@ +// +// OAuthAuthenticationTicketRequester.swift +// Infra +// +// Created by opfic on 7/13/26. +// + +enum OAuthAuthenticationTicketRequester { + static func request( + endpoint: FunctionAPIEndpoint, + requiresAuthentication: Bool + ) async throws -> OAuthAuthenticationTicketRequest { + let handler = OAuthTicketExchangeHandler() + let response = try await FunctionAPIClient.shared.send( + endpoint, + payload: OAuthAuthenticationSessionRequest( + appChallenge: handler.appChallenge + ), + requiresAuthentication: requiresAuthentication + ) + let callbackURL = try await OAuthWebAuthenticationSession.authenticate( + url: response.authorizationURL, + callbackURLScheme: OAuthTicketExchangeHandler.callbackURLScheme + ) + + return OAuthAuthenticationTicketRequest( + ticket: try handler.ticket(from: callbackURL), + appVerifier: handler.appVerifier + ) + } +} diff --git a/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationProof.swift b/Application/Infra/Sources/Service/SocialLogin/OAuthTicketExchangeHandler.swift similarity index 93% rename from Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationProof.swift rename to Application/Infra/Sources/Service/SocialLogin/OAuthTicketExchangeHandler.swift index a73b6880..3ad4c97d 100644 --- a/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationProof.swift +++ b/Application/Infra/Sources/Service/SocialLogin/OAuthTicketExchangeHandler.swift @@ -1,5 +1,5 @@ // -// OAuthWebAuthenticationProof.swift +// OAuthTicketExchangeHandler.swift // Infra // // Created by opfic on 7/12/26. @@ -8,7 +8,7 @@ import CryptoKit import Foundation -struct OAuthWebAuthenticationProof { +struct OAuthTicketExchangeHandler { static let callbackURLScheme = "DevLog" let appVerifier: String @@ -44,7 +44,7 @@ struct OAuthWebAuthenticationProof { } } -private extension OAuthWebAuthenticationProof { +private extension OAuthTicketExchangeHandler { static func makeVerifier() -> String { [UUID().uuidString, UUID().uuidString] .joined() diff --git a/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationSession.swift b/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationSession.swift index 594ebde7..a98ce24b 100644 --- a/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationSession.swift +++ b/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationSession.swift @@ -7,10 +7,9 @@ import AuthenticationServices import Foundation +import UIKit -@MainActor final class OAuthWebAuthenticationSession: NSObject { - private let provider = TopViewControllerProvider() private var session: ASWebAuthenticationSession? static func authenticate( @@ -24,6 +23,7 @@ final class OAuthWebAuthenticationSession: NSObject { ) } + @MainActor private func authenticate( url: URL, callbackURLScheme: String @@ -63,7 +63,11 @@ final class OAuthWebAuthenticationSession: NSObject { } extension OAuthWebAuthenticationSession: ASWebAuthenticationPresentationContextProviding { + @MainActor func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - provider.keyWindow() ?? ASPresentationAnchor() + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } ?? ASPresentationAnchor() } } diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift index 15e65d69..fffd87d3 100644 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -132,16 +132,9 @@ struct FunctionAPIEndpointTests { #expect(request.requestedScopes == [.fullName, .email]) } - @Test( - "Apple 서버 오류를 앱 인증 오류 원인으로 분류한다", - arguments: [ - ("apple-provider-link-conflict", AppleAuthenticationAPIError.providerLinkConflict) - ] - ) - func Apple_서버_오류를_앱_인증_오류_원인으로_분류한다( - code: String, - expected: AppleAuthenticationAPIError - ) throws { + @Test("Apple provider 연결 충돌을 공통 인증 오류로 분류한다") + func Apple_provider_연결_충돌을_공통_인증_오류로_분류한다() throws { + let code = "apple-provider-link-conflict" let data = try JSONEncoder().encode(FunctionAPIErrorFixture(code: code)) let response = try #require( HTTPURLResponse( @@ -158,7 +151,30 @@ struct FunctionAPIEndpointTests { decoder: JSONDecoder() ) - #expect(error as? AppleAuthenticationAPIError == expected) + #expect(error as? AuthenticationAPIError == .providerLinkConflict) + } + + @Test("Google provider 연결 충돌을 공통 인증 오류로 분류한다") + func Google_provider_연결_충돌을_공통_인증_오류로_분류한다() throws { + let data = try JSONEncoder().encode( + FunctionAPIErrorFixture(code: "google-provider-link-conflict") + ) + let response = try #require( + HTTPURLResponse( + url: URL(string: "https://example.com")!, + statusCode: 409, + httpVersion: nil, + headerFields: nil + ) + ) + + let error = FunctionAPIServerErrorDecoder().decodeServerError( + data: data, + response: response, + decoder: JSONDecoder() + ) + + #expect(error as? AuthenticationAPIError == .providerLinkConflict) } @Test("마지막 provider 서버 오류를 공통 인증 오류로 분류한다") @@ -243,6 +259,57 @@ struct FunctionAPIEndpointTests { #expect(endpoint.method.rawValue == "DELETE") #expect(endpoint.path == "/auth/github/account-link") } + + @Test("Google 로그인 session endpoint는 sign-in-sessions 경로를 사용한다") + func Google_로그인_session_endpoint는_sign_in_sessions_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestGoogleSignInSession + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/google/sign-in-sessions") + } + + @Test("Google custom token endpoint는 custom-token 경로를 사용한다") + func Google_custom_token_endpoint는_custom_token_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestGoogleCustomToken + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/google/custom-token") + } + + @Test("Google 계정 연결 session endpoint는 account-link-sessions 경로를 사용한다") + func Google_계정_연결_session_endpoint는_account_link_sessions_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestGoogleAccountLinkSession + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/google/account-link-sessions") + } + + @Test("Google 계정 연결 endpoint는 PUT account-link 경로를 사용한다") + func Google_계정_연결_endpoint는_PUT_account_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.linkGoogleAccount + + #expect(endpoint.method.rawValue == "PUT") + #expect(endpoint.path == "/auth/google/account-link") + } + + @Test("Google 계정 연결 해제 endpoint는 DELETE account-link 경로를 사용한다") + func Google_계정_연결_해제_endpoint는_DELETE_account_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.unlinkGoogleAccount + + #expect(endpoint.method.rawValue == "DELETE") + #expect(endpoint.path == "/auth/google/account-link") + } + + @Test("Google access token endpoint는 DELETE access-token 경로를 사용한다") + func Google_access_token_endpoint는_DELETE_access_token_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.revokeGoogleAccessToken + + #expect(endpoint.method.rawValue == "DELETE") + #expect(endpoint.path == "/auth/google/access-token") + } } private struct FunctionAPIErrorFixture: Encodable { diff --git a/Application/Infra/Tests/Service/OAuthWebAuthenticationProofTests.swift b/Application/Infra/Tests/Service/OAuthTicketExchangeHandlerTests.swift similarity index 63% rename from Application/Infra/Tests/Service/OAuthWebAuthenticationProofTests.swift rename to Application/Infra/Tests/Service/OAuthTicketExchangeHandlerTests.swift index 961f671e..f70eaf94 100644 --- a/Application/Infra/Tests/Service/OAuthWebAuthenticationProofTests.swift +++ b/Application/Infra/Tests/Service/OAuthTicketExchangeHandlerTests.swift @@ -1,5 +1,5 @@ // -// OAuthWebAuthenticationProofTests.swift +// OAuthTicketExchangeHandlerTests.swift // InfraTests // // Created by opfic on 7/12/26. @@ -9,54 +9,54 @@ import Foundation import Testing @testable import Infra -struct OAuthWebAuthenticationProofTests { +struct OAuthTicketExchangeHandlerTests { @Test("app challenge는 app verifier의 SHA-256 base64url 값이다") func app_challenge는_app_verifier의_SHA_256_base64url_값이다() { - let proof = OAuthWebAuthenticationProof( + let handler = OAuthTicketExchangeHandler( appVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" ) - #expect(proof.appChallenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + #expect(handler.appChallenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") } @Test("생성된 app verifier는 64자리 소문자 16진수다") func 생성된_app_verifier는_64자리_소문자_16진수다() { - let proof = OAuthWebAuthenticationProof() + let handler = OAuthTicketExchangeHandler() let allowedCharacters = CharacterSet(charactersIn: "0123456789abcdef") - #expect(proof.appVerifier.count == 64) - #expect(proof.appVerifier.unicodeScalars.allSatisfy(allowedCharacters.contains)) + #expect(handler.appVerifier.count == 64) + #expect(handler.appVerifier.unicodeScalars.allSatisfy(allowedCharacters.contains)) } @Test("DevLog OAuth callback에서 ticket을 추출한다") func DevLog_OAuth_callback에서_ticket을_추출한다() throws { - let proof = OAuthWebAuthenticationProof(appVerifier: "verifier") + let handler = OAuthTicketExchangeHandler(appVerifier: "verifier") let callbackURL = try #require( URL(string: "DevLog://oauth-callback?ticket=one-time-ticket") ) - #expect(try proof.ticket(from: callbackURL) == "one-time-ticket") + #expect(try handler.ticket(from: callbackURL) == "one-time-ticket") } @Test("다른 callback host는 거부한다") func 다른_callback_host는_거부한다() throws { - let proof = OAuthWebAuthenticationProof(appVerifier: "verifier") + let handler = OAuthTicketExchangeHandler(appVerifier: "verifier") let callbackURL = try #require( URL(string: "DevLog://unexpected?ticket=one-time-ticket") ) #expect(throws: SocialLoginError.self) { - try proof.ticket(from: callbackURL) + try handler.ticket(from: callbackURL) } } @Test("ticket이 없는 callback은 거부한다") func ticket이_없는_callback은_거부한다() throws { - let proof = OAuthWebAuthenticationProof(appVerifier: "verifier") + let handler = OAuthTicketExchangeHandler(appVerifier: "verifier") let callbackURL = try #require(URL(string: "DevLog://oauth-callback")) #expect(throws: SocialLoginError.self) { - try proof.ticket(from: callbackURL) + try handler.ticket(from: callbackURL) } } } diff --git a/README.md b/README.md index 9a2cf199..df38de89 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Todo, 저장 링크, 오늘 할 일, 받은 알림, 누적 활동을 하나의 | Backend | Firebase Authentication, Firestore, Cloud Functions, Cloud Messaging | | Monitoring | Firebase Analytics, Crashlytics | | Apple Frameworks | AuthenticationServices, UserNotifications, LinkPresentation, Network, CryptoKit, os.log | -| External Packages | ComposableArchitecture, MarkdownUI, OrderedCollections, GoogleSignIn, Nexa | +| External Packages | ComposableArchitecture, MarkdownUI, OrderedCollections, Nexa | | Testing | swift-testing, TCA TestStore | | Tooling | Xcode, Tuist, mise, Swift Package Manager, SwiftLint, Fastlane | diff --git a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift index dd115836..bde4bbf6 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -17,10 +17,6 @@ public enum DevLogPackages { url: "https://github.com/firebase/firebase-ios-sdk", .exact("11.15.0") ) - public static let googleSignInPackage: Package = .package( - url: "https://github.com/google/GoogleSignIn-iOS", - .revision("02616ac6b469e8f00212436d2cac16e6efad7954") - ) public static let nexaPackage: Package = .package( url: "https://github.com/opficdev/Nexa", .upToNextMinor(from: "1.1.1") @@ -40,7 +36,6 @@ public enum DevLogPackages { .package(product: "FirebaseCrashlytics"), .package(product: "FirebaseMessaging"), .package(product: "FirebaseFirestore"), - .package(product: "GoogleSignIn"), .package(product: "Nexa"), ] @@ -54,7 +49,6 @@ public enum DevLogPackages { public static let infraPackages: [Package] = [ firebasePackage, - googleSignInPackage, nexaPackage, ] }