Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Application/App/Sources/App/Delegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
opficdev marked this conversation as resolved.
}

func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
Expand Down
5 changes: 5 additions & 0 deletions Application/App/Sources/Resource/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(REVERSED_CLIENT_ID)</string>
<string>DevLog</string>
</array>
</dict>
Expand All @@ -53,6 +54,10 @@
<string>$(FIRESTORE_DATABASE_ID)</string>
<key>FUNCTION_API_BASE_URL</key>
<string>$(FUNCTION_API_BASE_URL)</string>
<key>GIDClientID</key>
<string>$(CLIENT_ID)</string>
<key>GIDServerClientID</key>
<string>$(GID_SERVER_CLIENT_ID)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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()
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
])
}
Expand All @@ -98,14 +132,16 @@ struct AuthenticationRepositoryImplTests {
providerID: String? = nil,
providerIDs: [String] = [],
signInResult: Result<AuthDataResponse?, Error> = .success(nil),
deleteCurrentUserError: Error? = nil
deleteCurrentUserError: Error? = nil,
clearCurrentSessionError: Error? = nil
) -> AuthenticationRepositoryFixture {
let events = AuthenticationRepositoryEventRecorder()
let authService = AuthenticationRepositoryAuthServiceSpy(
uid: uid,
providerID: providerID,
providerIDs: providerIDs,
deleteCurrentUserError: deleteCurrentUserError,
clearCurrentSessionError: clearCurrentSessionError,
events: events
)
let appleAuthService = AuthenticationServiceSpy(
Expand Down Expand Up @@ -149,6 +185,7 @@ private struct AuthenticationRepositoryFixture {
}

private enum AuthenticationRepositoryTestError: Error, Equatable {
case clearCurrentSession
case requiresRecentLogin
}

Expand All @@ -171,6 +208,7 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService {
private let subject: CurrentValueSubject<Bool, Never>
private let providerID: String?
private let deleteCurrentUserError: Error?
private let clearCurrentSessionError: Error?
private let events: AuthenticationRepositoryEventRecorder

var uid: String?
Expand All @@ -183,13 +221,15 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService {
providerIDs: [String],
providerCount: Int? = nil,
deleteCurrentUserError: Error? = nil,
clearCurrentSessionError: Error? = nil,
events: AuthenticationRepositoryEventRecorder
) {
self.uid = uid
self.providerID = providerID
self.providerIDs = providerIDs
self.providerCount = providerCount ?? providerIDs.count
self.deleteCurrentUserError = deleteCurrentUserError
self.clearCurrentSessionError = clearCurrentSessionError
self.events = events
self.subject = CurrentValueSubject<Bool, Never>(uid != nil)
}
Expand Down Expand Up @@ -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<AuthDataResponse?, Error>
private let linkResult: Result<Bool, Error>
Expand All @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion Application/Infra/Sources/Common/InfraLayerError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ enum FirestoreError: Error, LocalizedError {
}
}

enum UIError: Error {
case notFoundTopViewController
}

enum SocialLoginError: Error {
case invalidOAuthCallback
case failedToStartWebAuthenticationSession
Expand All @@ -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
}
}
}
65 changes: 65 additions & 0 deletions Application/Infra/Sources/Common/TopViewControllerProvider.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions Application/Infra/Sources/Service/AuthServiceImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions Application/Infra/Sources/Service/FunctionAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading