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,
]
}