diff --git a/Application/App/Sources/App/Delegate/AppDelegate.swift b/Application/App/Sources/App/Delegate/AppDelegate.swift index 2c34c8fb..ca1daa5f 100644 --- a/Application/App/Sources/App/Delegate/AppDelegate.swift +++ b/Application/App/Sources/App/Delegate/AppDelegate.swift @@ -15,6 +15,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private let logger = Logger(category: "AppDelegate") private let container = AppDIContainer.shared + // Google 로그인 URL 콜백 처리 + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + 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 78831b61..99ef65df 100644 --- a/Application/App/Sources/Resource/Info.plist +++ b/Application/App/Sources/Resource/Info.plist @@ -31,6 +31,7 @@ Editor CFBundleURLSchemes + $(REVERSED_CLIENT_ID) DevLog @@ -53,6 +54,10 @@ $(FIRESTORE_DATABASE_ID) FUNCTION_API_BASE_URL $(FUNCTION_API_BASE_URL) + GIDClientID + $(CLIENT_ID) + GIDServerClientID + $(GID_SERVER_CLIENT_ID) ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/Application/Core/Sources/Authentication/AuthPresentationContext.swift b/Application/Core/Sources/Authentication/AuthPresentationContext.swift new file mode 100644 index 00000000..37c6b4cc --- /dev/null +++ b/Application/Core/Sources/Authentication/AuthPresentationContext.swift @@ -0,0 +1,17 @@ +// +// AuthPresentationContext.swift +// Core +// +// Created by opfic on 7/27/26. +// + +public struct AuthPresentationContext: Equatable, Sendable { + public let identifier: String + + // 인증 호출 경로에서 같은 key로 접근하되 실제 값은 각 Task에 격리하기 위한 정적 변수 + @TaskLocal public static var current: Self? + + public init(identifier: String) { + self.identifier = identifier + } +} diff --git a/Application/Data/Sources/Protocol/AuthenticationService.swift b/Application/Data/Sources/Protocol/AuthenticationService.swift index 1d2dc1ff..71818cc3 100644 --- a/Application/Data/Sources/Protocol/AuthenticationService.swift +++ b/Application/Data/Sources/Protocol/AuthenticationService.swift @@ -9,7 +9,7 @@ import Foundation public protocol AuthenticationService { func signIn() async throws -> AuthDataResponse? - func signOut(_ uid: String) async throws + func clearLocalSession() func deleteAuth(_ uid: String) async throws func link(uid: String) async throws -> Bool func unlink(_ uid: String) async throws diff --git a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift index 7d405e60..f80bd7ce 100644 --- a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift @@ -64,32 +64,15 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { } func signOut() async throws { - guard let uid = authService.uid, - let providerID = try await authService.getProviderID(), - let provider = AuthProvider(rawValue: providerID) - else { - try await authService.clearCurrentSession() - widgetSnapshotUpdater.clear() - return - } + let providers = authService.providerIDs.compactMap { AuthProvider(rawValue: $0) } do { - switch provider { - case .apple: - try await appleAuthService.signOut(uid) - case .github: - try await githubAuthService.signOut(uid) - case .google: - try await googleAuthService.signOut(uid) - } + try await authService.clearCurrentSession() } catch { - if case AuthError.notAuthenticated = error.toDomain() { - try await authService.clearCurrentSession() - } else { - throw error.toDomain() - } + throw error.toDomain() } + clearProviderSessions(providers) widgetSnapshotUpdater.clear() } @@ -114,6 +97,7 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { do { try await authService.deleteCurrentUser() try await authService.clearCurrentSession() + clearProviderSessions(providers) widgetSnapshotUpdater.clear() } catch { throw error.toDomain() @@ -122,6 +106,19 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { } private extension AuthenticationRepositoryImpl { + func clearProviderSessions(_ providers: [AuthProvider]) { + for provider in providers { + switch provider { + case .apple: + appleAuthService.clearLocalSession() + case .github: + githubAuthService.clearLocalSession() + case .google: + googleAuthService.clearLocalSession() + } + } + } + func mapSignInError(_ error: Error) -> Error { if let emailError = error as? EmailError, emailError == .notFound { diff --git a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift index 6a5a0b10..02eaecf8 100644 --- a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift @@ -44,34 +44,68 @@ struct AuthenticationRepositoryImplTests { ]) } - @Test("Apple 로그아웃은 provider 로그아웃 뒤 위젯 데이터를 정리한다") - func Apple_로그아웃은_provider_로그아웃_뒤_위젯_데이터를_정리한다() async throws { + @Test("로그아웃은 공통 세션 정리 후 연결된 모든 provider 세션과 위젯 데이터를 정리한다") + func 로그아웃은_공통_세션_정리_후_연결된_모든_provider_세션과_위젯_데이터를_정리한다() async throws { let fixture = makeAuthenticationRepositoryFixture( uid: "user-id", - providerID: "apple.com" + providerIDs: ["apple.com", "github.com", "google.com"] + ) + + try await fixture.repository.signOut() + + #expect(fixture.events.values() == [ + "auth.clearCurrentSession", + "apple.clearLocalSession", + "github.clearLocalSession", + "google.clearLocalSession", + "widget.clear" + ]) + } + + @Test("공통 세션 정리 실패는 provider 세션과 위젯 데이터를 정리하지 않고 오류를 전달한다") + func 공통_세션_정리_실패는_provider_세션과_위젯_데이터를_정리하지_않고_오류를_전달한다() async { + let fixture = makeAuthenticationRepositoryFixture( + uid: "user-id", + providerIDs: ["apple.com", "github.com", "google.com"], + clearCurrentSessionError: AuthenticationRepositoryTestError.clearCurrentSession ) + await #expect(throws: AuthenticationRepositoryTestError.clearCurrentSession) { + try await fixture.repository.signOut() + } + #expect(fixture.events.values() == ["auth.clearCurrentSession"]) + } + + @Test("provider 목록이 없어도 공통 세션과 위젯 데이터를 정리한다") + func provider_목록이_없어도_공통_세션과_위젯_데이터를_정리한다() async throws { + let fixture = makeAuthenticationRepositoryFixture(uid: "user-id") + try await fixture.repository.signOut() #expect(fixture.events.values() == [ - "apple.signOut", + "auth.clearCurrentSession", "widget.clear" ]) } - @Test("Apple 회원탈퇴는 grant 정리 후 사용자와 세션과 위젯 데이터를 정리한다") - func Apple_회원탈퇴는_grant_정리_후_사용자와_세션과_위젯_데이터를_정리한다() async throws { + @Test("회원탈퇴는 provider grant 정리 후 사용자와 로컬 세션과 위젯 데이터를 정리한다") + func 회원탈퇴는_provider_grant_정리_후_사용자와_로컬_세션과_위젯_데이터를_정리한다() async throws { let fixture = makeAuthenticationRepositoryFixture( uid: "user-id", - providerIDs: ["apple.com"] + providerIDs: ["apple.com", "github.com", "google.com"] ) try await fixture.repository.delete() #expect(fixture.events.values() == [ "apple.deleteAuth", + "github.deleteAuth", + "google.deleteAuth", "auth.deleteCurrentUser", "auth.clearCurrentSession", + "apple.clearLocalSession", + "github.clearLocalSession", + "google.clearLocalSession", "widget.clear" ]) } @@ -98,7 +132,8 @@ struct AuthenticationRepositoryImplTests { providerID: String? = nil, providerIDs: [String] = [], signInResult: Result = .success(nil), - deleteCurrentUserError: Error? = nil + deleteCurrentUserError: Error? = nil, + clearCurrentSessionError: Error? = nil ) -> AuthenticationRepositoryFixture { let events = AuthenticationRepositoryEventRecorder() let authService = AuthenticationRepositoryAuthServiceSpy( @@ -106,6 +141,7 @@ struct AuthenticationRepositoryImplTests { providerID: providerID, providerIDs: providerIDs, deleteCurrentUserError: deleteCurrentUserError, + clearCurrentSessionError: clearCurrentSessionError, events: events ) let appleAuthService = AuthenticationServiceSpy( @@ -149,6 +185,7 @@ private struct AuthenticationRepositoryFixture { } private enum AuthenticationRepositoryTestError: Error, Equatable { + case clearCurrentSession case requiresRecentLogin } @@ -171,6 +208,7 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { private let subject: CurrentValueSubject private let providerID: String? private let deleteCurrentUserError: Error? + private let clearCurrentSessionError: Error? private let events: AuthenticationRepositoryEventRecorder var uid: String? @@ -183,6 +221,7 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { providerIDs: [String], providerCount: Int? = nil, deleteCurrentUserError: Error? = nil, + clearCurrentSessionError: Error? = nil, events: AuthenticationRepositoryEventRecorder ) { self.uid = uid @@ -190,6 +229,7 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { self.providerIDs = providerIDs self.providerCount = providerCount ?? providerIDs.count self.deleteCurrentUserError = deleteCurrentUserError + self.clearCurrentSessionError = clearCurrentSessionError self.events = events self.subject = CurrentValueSubject(uid != nil) } @@ -223,10 +263,13 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { func clearCurrentSession() async throws { events.record("auth.clearCurrentSession") + if let clearCurrentSessionError { + throw clearCurrentSessionError + } } } -actor AuthenticationServiceSpy: AuthenticationService { +final class AuthenticationServiceSpy: AuthenticationService { private let provider: String private let signInResult: Result private let linkResult: Result @@ -252,8 +295,8 @@ actor AuthenticationServiceSpy: AuthenticationService { return try signInResult.get() } - func signOut(_ uid: String) async throws { - events.record("\(provider).signOut") + func clearLocalSession() { + events.record("\(provider).clearLocalSession") } func deleteAuth(_ uid: String) async throws { diff --git a/Application/Infra/Sources/Common/InfraLayerError.swift b/Application/Infra/Sources/Common/InfraLayerError.swift index c9892578..437efe78 100644 --- a/Application/Infra/Sources/Common/InfraLayerError.swift +++ b/Application/Infra/Sources/Common/InfraLayerError.swift @@ -19,6 +19,10 @@ enum FirestoreError: Error, LocalizedError { } } +enum UIError: Error { + case notFoundTopViewController +} + enum SocialLoginError: Error { case invalidOAuthCallback case failedToStartWebAuthenticationSession @@ -40,7 +44,8 @@ extension Error { case let webAuthError as ASWebAuthenticationSessionError: return webAuthError.code == .canceledLogin default: - return false + let error = self as NSError + return error.domain == "com.google.GIDSignIn" && error.code == -5 } } } diff --git a/Application/Infra/Sources/Common/TopViewControllerProvider.swift b/Application/Infra/Sources/Common/TopViewControllerProvider.swift new file mode 100644 index 00000000..21f0d820 --- /dev/null +++ b/Application/Infra/Sources/Common/TopViewControllerProvider.swift @@ -0,0 +1,65 @@ +// +// TopViewControllerProvider.swift +// Infra +// +// Created by opfic on 7/26/26. +// + +import UIKit + +enum TopViewControllerProvider { + struct Candidate { + let identifier: String + let rootViewController: UIViewController? + } + + @MainActor + static func topViewController(identifier: String) -> UIViewController? { + let candidates = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .map { + Candidate( + identifier: $0.session.persistentIdentifier, + rootViewController: $0.windows + .first(where: \.isKeyWindow)? + .rootViewController + ) + } + + return topViewController( + identifier: identifier, + candidates: candidates + ) + } + + @MainActor + static func topViewController( + identifier: String, + candidates: [Candidate] + ) -> UIViewController? { + let rootViewController = candidates + .first { + $0.identifier == identifier + }? + .rootViewController + return topViewController(from: rootViewController) + } + + @MainActor + static func topViewController(from viewController: UIViewController?) -> UIViewController? { + if let navigationController = viewController as? UINavigationController { + return topViewController(from: navigationController.visibleViewController) + } + + if let tabBarController = viewController as? UITabBarController, + let selectedViewController = tabBarController.selectedViewController { + return topViewController(from: selectedViewController) + } + + if let presentedViewController = viewController?.presentedViewController { + return topViewController(from: presentedViewController) + } + + return viewController + } +} diff --git a/Application/Infra/Sources/Service/AuthServiceImpl.swift b/Application/Infra/Sources/Service/AuthServiceImpl.swift index b04a6e32..89d386f4 100644 --- a/Application/Infra/Sources/Service/AuthServiceImpl.swift +++ b/Application/Infra/Sources/Service/AuthServiceImpl.swift @@ -119,6 +119,11 @@ final class AuthServiceImpl: AuthService { func clearCurrentSession() async throws { logger.info("Clearing current auth session") + if let uid { + let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) + try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) + } + do { if messaging.fcmToken != nil { try await messaging.deleteToken() diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index 4ed24fb4..1658d0ec 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -112,6 +112,10 @@ struct OAuthAuthenticationTicketRequest: Encodable { let appVerifier: String } +struct GoogleAuthorizationCodeRequest: Encodable { + let serverAuthCode: String +} + struct AppleChallengeResponse: Decodable { let challengeId: String let hashedNonce: String diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index 070eb6d0..ec957c77 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -38,7 +38,10 @@ extension FunctionAPIEndpoint where Response == EmptyAPIResponse { 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 linkGoogleAccount = Self( + method: .put, + path: "/auth/google/authorization-code/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") @@ -52,7 +55,10 @@ 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") + static let requestGoogleAuthorizationCustomToken = Self( + method: .post, + path: "/auth/google/authorization-code/custom-token" + ) } extension FunctionAPIEndpoint where Response == OAuthAuthenticationSessionResponse { @@ -64,14 +70,6 @@ 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 43bcf746..eaad706a 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -7,8 +7,6 @@ import AuthenticationServices import FirebaseAuth -import FirebaseFirestore -import FirebaseMessaging import Foundation import Core import Data @@ -19,7 +17,6 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { enum Code: Int { case signIn = 1 - case signOut case deleteAuth case link case unlink @@ -28,8 +25,6 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { private var appleSignInDelegate: AppleSignInDelegate? private var appleSignInContinuation: CheckedContinuation? - private let store = FirebaseConfiguration.firestore - private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let logger = Logger(category: "AppleAuthService") @@ -65,26 +60,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - func signOut(_ uid: String) async throws { - do { - let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - - if messaging.fcmToken != nil { - do { - try await messaging.deleteToken() - } catch { - logger.error("Failed to delete FCM token while signing out with Apple", error: error) - } - } - - try Auth.auth().signOut() - } catch { - logger.error("Failed to sign out with Apple", error: error) - record(error, code: .signOut) - throw error - } - } + func clearLocalSession() { } func deleteAuth(_ uid: String) async throws { do { diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index a616dee6..84ced79c 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -6,8 +6,6 @@ // import FirebaseAuth -import FirebaseFirestore -import FirebaseMessaging import Core import Data @@ -17,15 +15,12 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { enum Code: Int { case signIn = 1 - case signOut case deleteAuth case link case unlink } } - private let store = FirebaseConfiguration.firestore - private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let logger = Logger(category: "GithubAuthService") @@ -58,26 +53,7 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { } } - func signOut(_ uid: String) async throws { - do { - let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - - if messaging.fcmToken != nil { - do { - try await messaging.deleteToken() - } catch { - logger.error("Failed to delete FCM token while signing out with GitHub", error: error) - } - } - - try Auth.auth().signOut() - } catch { - logger.error("Failed to sign out with GitHub", error: error) - record(error, code: .signOut) - throw error - } - } + func clearLocalSession() { } func deleteAuth(_ uid: String) async throws { do { diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 44aae388..bf848497 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -6,8 +6,8 @@ // import FirebaseAuth -import FirebaseFirestore -import FirebaseMessaging +import Foundation +import GoogleSignIn import Core import Data @@ -17,15 +17,12 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { enum Code: Int { case signIn = 1 - case signOut case deleteAuth case link case unlink } } - private let store = FirebaseConfiguration.firestore - private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let logger = Logger(category: "GoogleAuthService") @@ -33,13 +30,12 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { logger.info("Starting Google sign in") do { - let request = try await OAuthAuthenticationTicketRequester.request( - endpoint: .requestGoogleSignInSession, - requiresAuthentication: false - ) + let serverAuthCode = try await Self.requestGoogleServerAuthCode() let response = try await FunctionAPIClient.shared.send( - .requestGoogleCustomToken, - payload: request, + .requestGoogleAuthorizationCustomToken, + payload: GoogleAuthorizationCodeRequest( + serverAuthCode: serverAuthCode + ), requiresAuthentication: false ) @@ -58,25 +54,8 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } } - func signOut(_ uid: String) async throws { - do { - let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - - if messaging.fcmToken != nil { - do { - try await messaging.deleteToken() - } catch { - logger.error("Failed to delete FCM token while signing out with Google", error: error) - } - } - - try Auth.auth().signOut() - } catch { - logger.error("Failed to sign out with Google", error: error) - record(error, code: .signOut) - throw error - } + func clearLocalSession() { + GIDSignIn.sharedInstance.signOut() } func deleteAuth(_ uid: String) async throws { @@ -93,13 +72,10 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { logger.info("Linking Google account for user: \(uid)") do { - let request = try await OAuthAuthenticationTicketRequester.request( - endpoint: .requestGoogleAccountLinkSession, - requiresAuthentication: true - ) + let serverAuthCode = try await Self.requestGoogleServerAuthCode(signOutPreviousSession: true) try await FunctionAPIClient.shared.send( .linkGoogleAccount, - payload: request + payload: GoogleAuthorizationCodeRequest(serverAuthCode: serverAuthCode) ) try await user?.reload() @@ -130,6 +106,31 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } private extension GoogleAuthenticationServiceImpl { + @MainActor + static func requestGoogleServerAuthCode( + signOutPreviousSession: Bool = false + ) async throws -> String { + guard let identifier = AuthPresentationContext.current?.identifier, + let controller = TopViewControllerProvider.topViewController( + identifier: identifier + ) else { + throw UIError.notFoundTopViewController + } + + if signOutPreviousSession, + GIDSignIn.sharedInstance.hasPreviousSignIn() { + GIDSignIn.sharedInstance.signOut() + } + + let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: controller) + + guard let serverAuthCode = signIn.serverAuthCode else { + throw URLError(.badServerResponse) + } + + return serverAuthCode + } + func mapGoogleAPIError(_ error: Error) -> Error { if let emailError = error.apiEmailError { return emailError diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift new file mode 100644 index 00000000..e7da0214 --- /dev/null +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift @@ -0,0 +1,15 @@ +// +// GoogleSignInURLHandler.swift +// Infra +// +// Created by opfic on 7/26/26. +// + +import Foundation +import GoogleSignIn + +public enum GoogleSignInURLHandler { + public static func handle(_ url: URL) -> Bool { + GIDSignIn.sharedInstance.handle(url) + } +} diff --git a/Application/Infra/Tests/Common/SocialLoginErrorTests.swift b/Application/Infra/Tests/Common/SocialLoginErrorTests.swift new file mode 100644 index 00000000..2b098e5f --- /dev/null +++ b/Application/Infra/Tests/Common/SocialLoginErrorTests.swift @@ -0,0 +1,32 @@ +// +// SocialLoginErrorTests.swift +// InfraTests +// +// Created by opfic on 7/26/26. +// + +import Foundation +import Testing +@testable import Infra + +struct SocialLoginErrorTests { + @Test("Google 로그인 취소 오류를 소셜 로그인 취소로 분류한다") + func Google_로그인_취소_오류를_소셜_로그인_취소로_분류한다() { + let error = NSError( + domain: "com.google.GIDSignIn", + code: -5 + ) + + #expect(error.isSocialLoginCancelled) + } + + @Test("Google 로그인의 다른 오류를 취소로 분류하지 않는다") + func Google_로그인의_다른_오류를_취소로_분류하지_않는다() { + let error = NSError( + domain: "com.google.GIDSignIn", + code: -1 + ) + + #expect(!error.isSocialLoginCancelled) + } +} diff --git a/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift b/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift new file mode 100644 index 00000000..78aed71c --- /dev/null +++ b/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift @@ -0,0 +1,90 @@ +// +// TopViewControllerProviderTests.swift +// InfraTests +// +// Created by opfic on 7/26/26. +// + +import Testing +import UIKit +@testable import Infra + +@MainActor +struct TopViewControllerProviderTests { + @Test("Navigation의 표시 화면을 반환한다") + func Navigation의_표시_화면을_반환한다() { + let rootViewController = UIViewController() + let visibleViewController = UIViewController() + let navigationController = UINavigationController( + rootViewController: rootViewController + ) + navigationController.pushViewController( + visibleViewController, + animated: false + ) + + let result = TopViewControllerProvider.topViewController( + from: navigationController + ) + + #expect(result === visibleViewController) + } + + @Test("Tab의 선택 화면을 반환한다") + func Tab의_선택_화면을_반환한다() { + let firstViewController = UIViewController() + let selectedViewController = UIViewController() + let tabBarController = UITabBarController() + tabBarController.viewControllers = [ + firstViewController, + selectedViewController + ] + tabBarController.selectedViewController = selectedViewController + + let result = TopViewControllerProvider.topViewController( + from: tabBarController + ) + + #expect(result === selectedViewController) + } + + @Test("요청 scene 식별자와 일치하는 화면을 반환한다") + func 요청_scene_식별자와_일치하는_화면을_반환한다() { + let otherViewController = UIViewController() + let requestedViewController = UIViewController() + let candidates = [ + TopViewControllerProvider.Candidate( + identifier: "other-scene", + rootViewController: otherViewController + ), + TopViewControllerProvider.Candidate( + identifier: "requested-scene", + rootViewController: requestedViewController + ) + ] + + let result = TopViewControllerProvider.topViewController( + identifier: "requested-scene", + candidates: candidates + ) + + #expect(result === requestedViewController) + } + + @Test("요청 scene 식별자와 일치하는 화면이 없으면 nil을 반환한다") + func 요청_scene_식별자와_일치하는_화면이_없으면_nil을_반환한다() { + let candidates = [ + TopViewControllerProvider.Candidate( + identifier: "other-scene", + rootViewController: UIViewController() + ) + ] + + let result = TopViewControllerProvider.topViewController( + identifier: "requested-scene", + candidates: candidates + ) + + #expect(result == nil) + } +} diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift index fffd87d3..0de446d9 100644 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -217,6 +217,15 @@ struct FunctionAPIEndpointTests { #expect(try encodedKeys(request) == ["ticket", "appVerifier"]) } + @Test("Google authorization code 요청은 server auth code만 인코딩한다") + func Google_authorization_code_요청은_server_auth_code만_인코딩한다() throws { + let request = GoogleAuthorizationCodeRequest( + serverAuthCode: "server-auth-code" + ) + + #expect(try encodedKeys(request) == ["serverAuthCode"]) + } + @Test("GitHub 로그인 session endpoint는 sign-in-sessions 경로를 사용한다") func GitHub_로그인_session_endpoint는_sign_in_sessions_경로를_사용한다() { let endpoint = FunctionAPIEndpoint @@ -260,39 +269,21 @@ struct FunctionAPIEndpointTests { #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_경로를_사용한다() { + @Test("Google authorization code custom token endpoint는 인증 코드 경로를 사용한다") + func Google_authorization_code_custom_token_endpoint는_인증_코드_경로를_사용한다() { let endpoint = FunctionAPIEndpoint - .requestGoogleCustomToken + .requestGoogleAuthorizationCustomToken #expect(endpoint.method.rawValue == "POST") - #expect(endpoint.path == "/auth/google/custom-token") + #expect(endpoint.path == "/auth/google/authorization-code/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_경로를_사용한다() { + @Test("Google 계정 연결 endpoint는 PUT 인증 코드 경로를 사용한다") + func Google_계정_연결_endpoint는_PUT_인증_코드_경로를_사용한다() { let endpoint = FunctionAPIEndpoint.linkGoogleAccount #expect(endpoint.method.rawValue == "PUT") - #expect(endpoint.path == "/auth/google/account-link") + #expect(endpoint.path == "/auth/google/authorization-code/account-link") } @Test("Google 계정 연결 해제 endpoint는 DELETE account-link 경로를 사용한다") diff --git a/Application/Presentation/Entry/Sources/Login/LoginFeature.swift b/Application/Presentation/Entry/Sources/Login/LoginFeature.swift index f06e764d..373876e9 100644 --- a/Application/Presentation/Entry/Sources/Login/LoginFeature.swift +++ b/Application/Presentation/Entry/Sources/Login/LoginFeature.swift @@ -6,6 +6,7 @@ // import Foundation +import Core import Domain import PresentationShared @@ -16,6 +17,7 @@ struct LoginFeature { @Presents var alert: AlertState? var activeSignInProvider: AuthProvider? var loading = LoadingFeature.State() + var presentationContext: AuthPresentationContext? var isLoading: Bool { loading.isLoading @@ -25,6 +27,7 @@ struct LoginFeature { enum Action { case alert(PresentationAction) case tapSignInButton(AuthProvider) + case setPresentationContext(AuthPresentationContext?) case signInFailed(AlertType) case loading(LoadingFeature.Action) } @@ -45,9 +48,18 @@ struct LoginFeature { case .alert: break case .tapSignInButton(let provider): - guard !state.isLoading else { return .none } + guard !state.isLoading, + let context = state.presentationContext + else { + return .none + } state.activeSignInProvider = provider - return signInEffect(provider) + return signInEffect( + provider, + context: context + ) + case .setPresentationContext(let context): + state.presentationContext = context case .signInFailed(let alertType): state.alert = Self.alertState(for: alertType) case .loading: @@ -79,11 +91,18 @@ private enum SignInUseCaseKey: DependencyKey { } private extension LoginFeature { - func signInEffect(_ provider: AuthProvider) -> Effect { + func signInEffect( + _ provider: AuthProvider, + context: AuthPresentationContext + ) -> Effect { .run { [signInUseCase] send in await send(.loading(.begin(target: .default, mode: .immediate))) do { - let signedIn = try await signInUseCase.execute(provider) + let signedIn = try await AuthPresentationContext + .$current + .withValue(context) { + try await signInUseCase.execute(provider) + } // 유스케이스 완료가 화면 전환 완료를 의미하지 않으므로 LoginView가 교체될 때까지 로딩을 유지한다. guard !signedIn else { return } await send(.loading(.end(target: .default, mode: .immediate))) diff --git a/Application/Presentation/Entry/Sources/Login/LoginView.swift b/Application/Presentation/Entry/Sources/Login/LoginView.swift index 781a2f0c..41db45f9 100644 --- a/Application/Presentation/Entry/Sources/Login/LoginView.swift +++ b/Application/Presentation/Entry/Sources/Login/LoginView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Core import PresentationShared import Domain @@ -59,6 +60,13 @@ struct LoginView: View { .padding(.vertical) } .alert($store.scope(state: \.alert, action: \.alert)) + .background { + WindowSceneIdentifierReader { + store.send(.setPresentationContext( + $0.map(AuthPresentationContext.init(identifier:)) + )) + } + } } private func signInButton( @@ -73,7 +81,7 @@ struct LoginView: View { ) { store.send(.tapSignInButton(provider)) } - .disabled(store.isLoading) + .disabled(store.isLoading || store.presentationContext == nil) .opacity(store.isLoading ? 0.5 : 1) } } diff --git a/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift b/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift index 0159b467..224937a2 100644 --- a/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import Core import PresentationShared import Foundation import Domain @@ -25,6 +26,7 @@ struct LoginFeatureTests { } #expect(spy.calledProviders == [.github]) + #expect(spy.calledPresentationContextIdentifiers == ["scene-id"]) } @Test("로그인 성공 후에도 메인 화면 전환 전까지 로딩 상태를 유지한다") @@ -173,8 +175,10 @@ private struct LoginTestDriver { } init(useCase: SignInUseCase) { + var state = LoginFeature.State() + state.presentationContext = AuthPresentationContext(identifier: "scene-id") feature = ComposableArchitecture.Store( - initialState: LoginFeature.State() + initialState: state ) { LoginFeature() } withDependencies: { diff --git a/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift b/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift index 950f9757..4b1ac4a3 100644 --- a/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift +++ b/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift @@ -5,6 +5,7 @@ // Created by opfic on 7/6/26. // +import Core import Domain @MainActor @@ -26,12 +27,16 @@ final class SignInUseCaseSpy: SignInUseCase { var signedIn = true var shouldSuspend = false private(set) var calledProviders = [AuthProvider]() + private(set) var calledPresentationContextIdentifiers = [String]() private(set) var successfulProviders = [AuthProvider]() private var continuation: CheckedContinuation? private var shouldResume = false func execute(_ provider: AuthProvider) async throws -> Bool { calledProviders.append(provider) + calledPresentationContextIdentifiers.append( + AuthPresentationContext.current?.identifier ?? "" + ) if shouldSuspend { await withCheckedContinuation { continuation in diff --git a/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift b/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift new file mode 100644 index 00000000..74f0b0aa --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift @@ -0,0 +1,48 @@ +// +// WindowSceneIdentifierReader.swift +// PresentationShared +// +// Created by opfic on 7/27/26. +// + +import SwiftUI + +public struct WindowSceneIdentifierReader: UIViewRepresentable { + private let onResolve: (String?) -> Void + + public final class Coordinator { + private var identifier: String? + + func update(identifier: String?) -> Bool { + guard self.identifier != identifier else { return false } + self.identifier = identifier + return true + } + } + + public init(onResolve: @escaping (String?) -> Void) { + self.onResolve = onResolve + } + + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + public func makeUIView(context: Context) -> UIView { + let view = UIView() + resolve(from: view, coordinator: context.coordinator) + return view + } + + public func updateUIView(_ uiView: UIView, context: Context) { + resolve(from: uiView, coordinator: context.coordinator) + } + + private func resolve(from view: UIView, coordinator: Coordinator) { + DispatchQueue.main.async { + let identifier = view.window?.windowScene?.session.persistentIdentifier + guard coordinator.update(identifier: identifier) else { return } + onResolve(identifier) + } + } +} diff --git a/Application/Presentation/PresentationShared/Tests/UIKit/WindowSceneIdentifierReaderTests.swift b/Application/Presentation/PresentationShared/Tests/UIKit/WindowSceneIdentifierReaderTests.swift new file mode 100644 index 00000000..80fb972a --- /dev/null +++ b/Application/Presentation/PresentationShared/Tests/UIKit/WindowSceneIdentifierReaderTests.swift @@ -0,0 +1,22 @@ +// +// WindowSceneIdentifierReaderTests.swift +// PresentationSharedTests +// +// Created by opfic on 7/27/26. +// + +import Testing +@testable import PresentationShared + +struct WindowSceneIdentifierReaderTests { + @Test("Coordinator는 scene identifier가 변경될 때만 갱신한다") + func Coordinator는_scene_identifier가_변경될_때만_갱신한다() { + let coordinator = WindowSceneIdentifierReader.Coordinator() + + #expect(coordinator.update(identifier: nil) == false) + #expect(coordinator.update(identifier: "scene-a")) + #expect(coordinator.update(identifier: "scene-a") == false) + #expect(coordinator.update(identifier: "scene-b")) + #expect(coordinator.update(identifier: nil)) + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift index b21eb811..0eae963e 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift @@ -5,6 +5,7 @@ // Created by opfic on 6/11/26. // +import Core import Domain import Foundation import PresentationShared @@ -19,6 +20,7 @@ struct AccountFeature { var disconnectedProviders: [AuthProvider] = [] var activeLoadingProvider: AuthProvider? var loading = LoadingFeature.State() + var presentationContext: AuthPresentationContext? var isLoading: Bool { loading.isLoading @@ -29,6 +31,7 @@ struct AccountFeature { case alert(PresentationAction) case onAppear case linkWithProvider(AuthProvider) + case setPresentationContext(AuthPresentationContext?) case unlinkFromProvider(AuthProvider) case setAlert(AlertType) case setProviders(currentProvider: AuthProvider?, allProviders: [AuthProvider]) @@ -58,9 +61,18 @@ struct AccountFeature { case .onAppear: return fetchProvidersEffect() case .linkWithProvider(let provider): - guard !state.isLoading else { return .none } + guard !state.isLoading, + let context = state.presentationContext + else { + return .none + } state.activeLoadingProvider = provider - return linkProviderEffect(provider) + return linkProviderEffect( + provider, + context: context + ) + case .setPresentationContext(let context): + state.presentationContext = context case .unlinkFromProvider(let provider): guard !state.isLoading else { return .none } state.activeLoadingProvider = provider @@ -147,11 +159,18 @@ private extension AccountFeature { } } - func linkProviderEffect(_ provider: AuthProvider) -> Effect { + func linkProviderEffect( + _ provider: AuthProvider, + context: AuthPresentationContext + ) -> Effect { .run { [fetchProvidersUseCase, linkProviderUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { - let linked = try await linkProviderUseCase.execute(provider) + let linked = try await AuthPresentationContext + .$current + .withValue(context) { + try await linkProviderUseCase.execute(provider) + } guard linked else { await send(.loading(.end(target: .default, mode: .delayed))) return diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift index 96ae101a..961e6ef4 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Core import PresentationShared import Domain @@ -47,7 +48,10 @@ struct AccountView: View { .clipShape(.capsule) } .buttonStyle(.plain) - .disabled(store.isLoading) + .disabled( + store.isLoading || + (!isConnected && store.presentationContext == nil) + ) .opacity(showProgressView ? 0 : 1) .overlay { if showProgressView { @@ -63,6 +67,13 @@ struct AccountView: View { .navigationTitle(String(localized: "nav_account")) .onAppear { store.send(.onAppear) } .alert($store.scope(state: \.alert, action: \.alert)) + .background { + WindowSceneIdentifierReader { + store.send(.setPresentationContext( + $0.map(AuthPresentationContext.init(identifier:)) + )) + } + } } private func formattedProviderName(_ provider: AuthProvider) -> String { diff --git a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift index bdc2c49b..a258fe4a 100644 --- a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift @@ -8,6 +8,7 @@ // swiftlint:disable file_length import Testing +import Core import Foundation import Domain import PresentationShared @@ -55,6 +56,7 @@ struct AccountFeatureTests { #expect(driver.currentProvider == .google) #expect(driver.connectedProviders == [.github]) #expect(driver.disconnectedProviders == [.apple]) + #expect(linkSpy.presentationContextIdentifiers == ["scene-id"]) } @Test("연동 해제에 성공하면 선택한 제공자를 해제하고 제공자 목록을 다시 가져온다") @@ -90,7 +92,9 @@ struct AccountFeatureTests { let linkSpy = LinkAuthProviderUseCaseSpy() linkSpy.shouldSuspend = true let target = LoadingFeature.Target.default - let store = TestStore(initialState: AccountFeature.State()) { + var state = AccountFeature.State() + state.presentationContext = AuthPresentationContext(identifier: "scene-id") + let store = TestStore(initialState: state) { AccountFeature() } withDependencies: { $0.fetchAuthProvidersUseCase = fetchSpy @@ -282,7 +286,9 @@ private struct AccountTestDriver { linkUseCase: LinkAuthProviderUseCase = LinkAuthProviderUseCaseSpy(), unlinkUseCase: UnlinkAuthProviderUseCase = UnlinkAuthProviderUseCaseSpy() ) { - feature = Store(initialState: AccountFeature.State()) { + var state = AccountFeature.State() + state.presentationContext = AuthPresentationContext(identifier: "scene-id") + feature = Store(initialState: state) { AccountFeature() } withDependencies: { $0.fetchAuthProvidersUseCase = fetchUseCase @@ -345,11 +351,15 @@ private final class LinkAuthProviderUseCaseSpy: LinkAuthProviderUseCase { var linked = true var shouldSuspend = false private(set) var providers = [AuthProvider]() + private(set) var presentationContextIdentifiers = [String]() private var continuation: CheckedContinuation? private var shouldResume = false func execute(_ provider: AuthProvider) async throws -> Bool { providers.append(provider) + presentationContextIdentifiers.append( + AuthPresentationContext.current?.identifier ?? "" + ) if shouldSuspend { await withCheckedContinuation { continuation in diff --git a/README.md b/README.md index bfb146e0..a0317739 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, OrderedCollections, Nexa | +| External Packages | ComposableArchitecture, OrderedCollections, GoogleSignIn, 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 fc47cd49..1b6657cc 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -13,6 +13,10 @@ 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") @@ -31,6 +35,7 @@ public enum DevLogPackages { .package(product: "FirebaseCrashlytics"), .package(product: "FirebaseMessaging"), .package(product: "FirebaseFirestore"), + .package(product: "GoogleSignIn"), .package(product: "Nexa"), ] @@ -43,6 +48,7 @@ public enum DevLogPackages { public static let infraPackages: [Package] = [ firebasePackage, + googleSignInPackage, nexaPackage, ] } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9d942244..f17a9646 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -113,6 +113,37 @@ rescue SystemCallError => error UI.user_error!("Could not read app configuration file at #{expanded_path}: #{error.message}") end +def expected_google_sign_in_configuration_from_xcconfig(configuration) + expanded_path = File.expand_path(APP_CONFIG_XCCONFIG_PATH) + UI.user_error!("Missing app configuration file: #{expanded_path}") if !File.file?(expanded_path) + + escaped_configuration = Regexp.escape(configuration) + expected_settings = { + client_id: "CLIENT_ID", + reversed_client_id: "REVERSED_CLIENT_ID", + server_client_id: "GID_SERVER_CLIENT_ID" + } + + expected_settings.to_h do |key, setting| + assignments = File.foreach(expanded_path).filter_map do |line| + assignment = line.match( + /^\s*#{setting}\s*\[\s*config\s*=\s*#{escaped_configuration}\s*\]\s*=\s*(.*)$/ + ) + next if assignment.nil? + + assignment[1].strip + end + + if assignments.empty? || assignments.last.empty? + UI.user_error!("Missing #{setting} for #{configuration} in #{expanded_path}") + end + + [key, assignments.last] + end +rescue SystemCallError => error + UI.user_error!("Could not read app configuration file at #{expanded_path}: #{error.message}") +end + def verify_function_api_base_url_policy(function_api_base_url) require "uri" @@ -225,7 +256,10 @@ platform :ios do app_environment: "app environment", bundle_id: "bundle ID", database_id: "Firestore database ID", - function_api_base_url: "Function API base URL" + function_api_base_url: "Function API base URL", + client_id: "Google client ID", + reversed_client_id: "Google reversed client ID", + server_client_id: "Google server client ID" } required_expectations.each do |key, name| @@ -285,6 +319,28 @@ platform :ios do redact_values: true ) + verify_plist_value( + path: plist_path, + source: "app Info.plist", + key: "GIDClientID", + expected: expected_configuration[:client_id], + redact_values: true + ) + + verify_plist_value( + path: plist_path, + source: "app Info.plist", + key: "GIDServerClientID", + expected: expected_configuration[:server_client_id], + redact_values: true + ) + + verify_plist_url_scheme( + path: plist_path, + source: "app Info.plist", + expected: expected_configuration[:reversed_client_id] + ) + verify_plist_value( path: plist_path, source: "app Info.plist", @@ -311,6 +367,22 @@ platform :ios do expected: expected_configuration[:bundle_id] ) + verify_plist_value( + path: google_service_info_plist_path, + source: "GoogleService-Info.plist", + key: "CLIENT_ID", + expected: expected_configuration[:client_id], + redact_values: true + ) + + verify_plist_value( + path: google_service_info_plist_path, + source: "GoogleService-Info.plist", + key: "REVERSED_CLIENT_ID", + expected: expected_configuration[:reversed_client_id], + redact_values: true + ) + if !expected_firebase_project_id.empty? && actual_firebase_project_id != expected_firebase_project_id UI.user_error!("Unexpected PROJECT_ID in GoogleService-Info.plist") end @@ -324,6 +396,45 @@ platform :ios do end end + private_lane :verify_plist_url_scheme do |options| + require "json" + require "shellwords" + + path = options[:path] + source = options[:source] + expected_value = options[:expected].to_s.strip + + UI.user_error!("Missing expected Google URL scheme") if expected_value.empty? + + url_types = begin + JSON.parse( + sh( + "/usr/bin/plutil -extract CFBundleURLTypes json -o - #{Shellwords.escape(path)}", + log: false + ) + ) + rescue StandardError + UI.user_error!("Missing CFBundleURLTypes in #{source} at #{path}") + end + + if !url_types.is_a?(Array) + UI.user_error!("Invalid CFBundleURLTypes in #{source} at #{path}") + end + + url_schemes = url_types.flat_map do |url_type| + next [] if !url_type.is_a?(Hash) + + schemes = url_type["CFBundleURLSchemes"] + schemes.is_a?(Array) ? schemes : [] + end + + if !url_schemes.include?(expected_value) + UI.user_error!("Missing expected Google URL scheme in #{source}") + end + + UI.message("Verified Google URL scheme in #{source}") + end + private_lane :required_plist_value do |options| require "shellwords" @@ -383,6 +494,7 @@ platform :ios do ) firebase_configuration = expected_firebase_configuration_from_xcconfig(configuration) + google_sign_in_configuration = expected_google_sign_in_configuration_from_xcconfig(configuration) if ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"].to_s.strip.empty? ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "30" @@ -476,7 +588,10 @@ platform :ios do database_id: expected_database_id, function_api_base_url: expected_function_api_base_url, firebase_project_id: firebase_configuration[:firebase_project_id], - google_app_id: firebase_configuration[:google_app_id] + google_app_id: firebase_configuration[:google_app_id], + client_id: google_sign_in_configuration[:client_id], + reversed_client_id: google_sign_in_configuration[:reversed_client_id], + server_client_id: google_sign_in_configuration[:server_client_id] } ) @@ -542,6 +657,7 @@ platform :ios do lane :upload_testflight_build do api_key = asc_api_key firebase_configuration = expected_firebase_configuration_from_xcconfig(TESTFLIGHT_CONFIGURATION) + google_sign_in_configuration = expected_google_sign_in_configuration_from_xcconfig(TESTFLIGHT_CONFIGURATION) # lane_context는 같은 fastlane 실행 내에서만 유지되므로, 별도 CI step에서는 고정 ipa 경로를 사용한다. ipa_output_path = lane_context[SharedValues::IPA_OUTPUT_PATH].to_s ipa_output_path = TESTFLIGHT_IPA_OUTPUT_PATH if ipa_output_path.empty? @@ -556,7 +672,10 @@ platform :ios do database_id: TESTFLIGHT_DATABASE_ID, function_api_base_url: function_api_base_url_from_xcconfig(TESTFLIGHT_CONFIGURATION), firebase_project_id: firebase_configuration[:firebase_project_id], - google_app_id: firebase_configuration[:google_app_id] + google_app_id: firebase_configuration[:google_app_id], + client_id: google_sign_in_configuration[:client_id], + reversed_client_id: google_sign_in_configuration[:reversed_client_id], + server_client_id: google_sign_in_configuration[:server_client_id] } ) @@ -570,6 +689,7 @@ platform :ios do lane :upload_appstore_build do api_key = asc_api_key firebase_configuration = expected_firebase_configuration_from_xcconfig(APPSTORE_CONFIGURATION) + google_sign_in_configuration = expected_google_sign_in_configuration_from_xcconfig(APPSTORE_CONFIGURATION) # lane_context는 같은 fastlane 실행 내에서만 유지되므로, 별도 CI step에서는 고정 ipa 경로를 사용한다. ipa_output_path = lane_context[SharedValues::IPA_OUTPUT_PATH].to_s ipa_output_path = APPSTORE_IPA_OUTPUT_PATH if ipa_output_path.empty? @@ -584,7 +704,10 @@ platform :ios do database_id: APPSTORE_DATABASE_ID, function_api_base_url: function_api_base_url_from_xcconfig(APPSTORE_CONFIGURATION), firebase_project_id: firebase_configuration[:firebase_project_id], - google_app_id: firebase_configuration[:google_app_id] + google_app_id: firebase_configuration[:google_app_id], + client_id: google_sign_in_configuration[:client_id], + reversed_client_id: google_sign_in_configuration[:reversed_client_id], + server_client_id: google_sign_in_configuration[:server_client_id] } )