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