diff --git a/Application/Data/Sources/Common/DataLayerError.swift b/Application/Data/Sources/Common/DataLayerError.swift index 937c4449..42b9f673 100644 --- a/Application/Data/Sources/Common/DataLayerError.swift +++ b/Application/Data/Sources/Common/DataLayerError.swift @@ -16,6 +16,7 @@ public enum EmailError: Error, Equatable { public enum DataLayerError: Error { case notAuthenticated + case failedToUnlinkLastProvider case linkCredentialAlreadyInUse case invalidData(context: String) diff --git a/Application/Data/Sources/Mapper/ErrorMapping.swift b/Application/Data/Sources/Mapper/ErrorMapping.swift index ac85f06c..7fd286c2 100644 --- a/Application/Data/Sources/Mapper/ErrorMapping.swift +++ b/Application/Data/Sources/Mapper/ErrorMapping.swift @@ -12,6 +12,8 @@ extension Error { switch self as? DataLayerError { case .notAuthenticated: return AuthError.notAuthenticated + case .failedToUnlinkLastProvider: + return AuthError.failedToUnlinkLastProvider case .linkCredentialAlreadyInUse: return AuthError.linkCredentialAlreadyInUse case .invalidData(let context): diff --git a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift index 70c2f8ae..c5dfc743 100644 --- a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift @@ -79,7 +79,11 @@ final class AuthDataRepositoryImpl: AuthDataRepository { service = githubAuthService } - try await service.unlink(uid) + do { + try await service.unlink(uid) + } catch { + throw error.toDomain() + } } } diff --git a/Application/Data/Tests/Repository/AuthDataRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthDataRepositoryImplTests.swift new file mode 100644 index 00000000..d92db11e --- /dev/null +++ b/Application/Data/Tests/Repository/AuthDataRepositoryImplTests.swift @@ -0,0 +1,135 @@ +// +// AuthDataRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 7/11/26. +// + +import Testing +import Domain +@testable import Data + +struct AuthDataRepositoryImplTests { + @Test("Apple 계정 연결 취소는 false를 반환한다") + func Apple_계정_연결_취소는_false를_반환한다() async throws { + let fixture = makeFixture(linkResult: .success(false)) + + let result = try await fixture.repository.linkProvider(.apple) + + #expect(result == false) + #expect(fixture.events.values() == ["apple.link"]) + } + + @Test("Apple 계정 연결의 이메일 없음 오류를 기존 앱 오류로 변환한다") + func Apple_계정_연결의_이메일_없음_오류를_기존_앱_오류로_변환한다() async { + let fixture = makeFixture(linkResult: .failure(EmailError.notFound)) + + await expectAuthError(.linkEmailNotFound) { + _ = try await fixture.repository.linkProvider(.apple) + } + } + + @Test("Apple 계정 연결의 이메일 불일치 오류를 기존 앱 오류로 변환한다") + func Apple_계정_연결의_이메일_불일치_오류를_기존_앱_오류로_변환한다() async { + let fixture = makeFixture(linkResult: .failure(EmailError.mismatch)) + + await expectAuthError(.linkEmailMismatch) { + _ = try await fixture.repository.linkProvider(.apple) + } + } + + @Test("Apple 계정 연결 충돌을 기존 app credential 오류로 변환한다") + func Apple_계정_연결_충돌을_기존_app_credential_오류로_변환한다() async { + let fixture = makeFixture( + linkResult: .failure(DataLayerError.linkCredentialAlreadyInUse) + ) + + await expectAuthError(.linkCredentialAlreadyInUse) { + _ = try await fixture.repository.linkProvider(.apple) + } + } + + @Test("Apple 마지막 provider 연결 해제는 서비스 호출 전에 차단한다") + func Apple_마지막_provider_연결_해제는_서비스_호출_전에_차단한다() async { + let fixture = makeFixture(providerCount: 1) + + await expectAuthError(.failedToUnlinkLastProvider) { + try await fixture.repository.unlinkProvider(.apple) + } + #expect(fixture.events.values().isEmpty) + } + + @Test("Apple 서버의 마지막 provider 오류를 기존 앱 오류로 변환한다") + func Apple_서버의_마지막_provider_오류를_기존_앱_오류로_변환한다() async { + let fixture = makeFixture( + unlinkError: DataLayerError.failedToUnlinkLastProvider + ) + + await expectAuthError(.failedToUnlinkLastProvider) { + try await fixture.repository.unlinkProvider(.apple) + } + } + + @Test("Apple 계정 연결 해제는 Apple 서비스를 호출한다") + func Apple_계정_연결_해제는_Apple_서비스를_호출한다() async throws { + let fixture = makeFixture() + + try await fixture.repository.unlinkProvider(.apple) + + #expect(fixture.events.values() == ["apple.unlink"]) + } + + private func makeFixture( + providerCount: Int = 2, + linkResult: Result = .success(true), + unlinkError: Error? = nil + ) -> AuthDataRepositoryFixture { + let events = AuthenticationRepositoryEventRecorder() + let authService = AuthenticationRepositoryAuthServiceSpy( + uid: "user-id", + providerID: "apple.com", + providerIDs: ["apple.com", "google.com"], + currentUserEmail: "apple@example.com", + providerCount: providerCount, + events: events + ) + let appleAuthService = AuthenticationServiceSpy( + provider: "apple", + linkResult: linkResult, + unlinkError: unlinkError, + events: events + ) + let githubAuthService = AuthenticationServiceSpy(provider: "github", events: events) + let googleAuthService = AuthenticationServiceSpy(provider: "google", events: events) + let repository = AuthDataRepositoryImpl( + authService: authService, + appleAuthService: appleAuthService, + githubAuthService: githubAuthService, + googleAuthService: googleAuthService + ) + + return AuthDataRepositoryFixture( + events: events, + repository: repository + ) + } + + private func expectAuthError( + _ expected: AuthError, + operation: () async throws -> Void + ) async { + do { + try await operation() + Issue.record("예상한 AuthError가 발생하지 않음") + } catch let error as AuthError { + #expect(String(describing: error) == String(describing: expected)) + } catch { + Issue.record("예상하지 않은 오류: \(error)") + } + } +} + +private struct AuthDataRepositoryFixture { + let events: AuthenticationRepositoryEventRecorder + let repository: AuthDataRepositoryImpl +} diff --git a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift new file mode 100644 index 00000000..4739ca26 --- /dev/null +++ b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift @@ -0,0 +1,327 @@ +// +// AuthenticationRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 7/11/26. +// + +import Combine +import Foundation +import Testing +import Core +import Domain +@testable import Data + +struct AuthenticationRepositoryImplTests { + @Test("Apple 로그인 성공은 사용자 정보를 저장하고 로그인을 완료한다") + func Apple_로그인_성공은_사용자_정보를_저장하고_로그인을_완료한다() async throws { + let fixture = makeAuthenticationRepositoryFixture( + signInResult: .success(makeAppleAuthDataResponse()) + ) + + let result = try await fixture.repository.signIn(.apple) + + #expect(result) + #expect(fixture.events.values() == [ + "auth.beginSignIn", + "apple.signIn", + "user.upsertUser", + "auth.completeSignIn" + ]) + } + + @Test("Apple 로그인 취소는 사용자 저장 없이 로그인을 취소한다") + func Apple_로그인_취소는_사용자_저장_없이_로그인을_취소한다() async throws { + let fixture = makeAuthenticationRepositoryFixture(signInResult: .success(nil)) + + let result = try await fixture.repository.signIn(.apple) + + #expect(result == false) + #expect(fixture.events.values() == [ + "auth.beginSignIn", + "apple.signIn", + "auth.cancelSignIn" + ]) + } + + @Test("Apple 로그아웃은 provider 로그아웃 뒤 위젯 데이터를 정리한다") + func Apple_로그아웃은_provider_로그아웃_뒤_위젯_데이터를_정리한다() async throws { + let fixture = makeAuthenticationRepositoryFixture( + uid: "user-id", + providerID: "apple.com" + ) + + try await fixture.repository.signOut() + + #expect(fixture.events.values() == [ + "apple.signOut", + "widget.clear" + ]) + } + + @Test("Apple 회원탈퇴는 grant 정리 후 사용자와 세션과 위젯 데이터를 정리한다") + func Apple_회원탈퇴는_grant_정리_후_사용자와_세션과_위젯_데이터를_정리한다() async throws { + let fixture = makeAuthenticationRepositoryFixture( + uid: "user-id", + providerIDs: ["apple.com"] + ) + + try await fixture.repository.delete() + + #expect(fixture.events.values() == [ + "apple.deleteAuth", + "auth.deleteCurrentUser", + "auth.clearCurrentSession", + "widget.clear" + ]) + } + + @Test("Apple 회원탈퇴의 최근 로그인 오류는 세션 정리 없이 전달한다") + func Apple_회원탈퇴의_최근_로그인_오류는_세션_정리_없이_전달한다() async { + let fixture = makeAuthenticationRepositoryFixture( + uid: "user-id", + providerIDs: ["apple.com"], + deleteCurrentUserError: AuthenticationRepositoryTestError.requiresRecentLogin + ) + + await #expect(throws: AuthenticationRepositoryTestError.requiresRecentLogin) { + try await fixture.repository.delete() + } + #expect(fixture.events.values() == [ + "apple.deleteAuth", + "auth.deleteCurrentUser" + ]) + } + + private func makeAuthenticationRepositoryFixture( + uid: String? = nil, + providerID: String? = nil, + providerIDs: [String] = [], + signInResult: Result = .success(nil), + deleteCurrentUserError: Error? = nil + ) -> AuthenticationRepositoryFixture { + let events = AuthenticationRepositoryEventRecorder() + let authService = AuthenticationRepositoryAuthServiceSpy( + uid: uid, + providerID: providerID, + providerIDs: providerIDs, + deleteCurrentUserError: deleteCurrentUserError, + events: events + ) + let appleAuthService = AuthenticationServiceSpy( + provider: "apple", + signInResult: signInResult, + events: events + ) + let githubAuthService = AuthenticationServiceSpy(provider: "github", events: events) + let googleAuthService = AuthenticationServiceSpy(provider: "google", events: events) + let userService = AuthenticationRepositoryUserServiceSpy(events: events) + let widgetSnapshotUpdater = AuthWidgetSnapshotUpdaterSpy(events: events) + let repository = AuthenticationRepositoryImpl( + authService: authService, + appleAuthService: appleAuthService, + githubAuthService: githubAuthService, + googleAuthService: googleAuthService, + userService: userService, + widgetSnapshotUpdater: widgetSnapshotUpdater + ) + + return AuthenticationRepositoryFixture( + events: events, + repository: repository + ) + } + + private func makeAppleAuthDataResponse() -> AuthDataResponse { + AuthDataResponse( + uid: "user-id", + displayName: "Apple User", + email: "apple@example.com", + providers: ["apple.com"], + providerID: "apple.com" + ) + } +} + +private struct AuthenticationRepositoryFixture { + let events: AuthenticationRepositoryEventRecorder + let repository: AuthenticationRepositoryImpl +} + +private enum AuthenticationRepositoryTestError: Error, Equatable { + case requiresRecentLogin +} + +final class AuthenticationRepositoryEventRecorder { + private let lock = NSLock() + private var events = [String]() + + func record(_ event: String) { + lock.withLock { + events.append(event) + } + } + + func values() -> [String] { + lock.withLock { events } + } +} + +final class AuthenticationRepositoryAuthServiceSpy: AuthService { + private let subject: CurrentValueSubject + private let providerID: String? + private let deleteCurrentUserError: Error? + private let events: AuthenticationRepositoryEventRecorder + + var uid: String? + let providerIDs: [String] + let currentUserEmail: String? + let providerCount: Int + + init( + uid: String?, + providerID: String?, + providerIDs: [String], + currentUserEmail: String? = nil, + providerCount: Int? = nil, + deleteCurrentUserError: Error? = nil, + events: AuthenticationRepositoryEventRecorder + ) { + self.uid = uid + self.providerID = providerID + self.providerIDs = providerIDs + self.currentUserEmail = currentUserEmail + self.providerCount = providerCount ?? providerIDs.count + self.deleteCurrentUserError = deleteCurrentUserError + self.events = events + self.subject = CurrentValueSubject(uid != nil) + } + + func observeSignedIn() -> AnyPublisher { + subject.eraseToAnyPublisher() + } + + func beginSignIn() { + events.record("auth.beginSignIn") + } + + func completeSignIn() { + events.record("auth.completeSignIn") + } + + func cancelSignIn() { + events.record("auth.cancelSignIn") + } + + func getProviderID() async throws -> String? { + providerID + } + + func deleteCurrentUser() async throws { + events.record("auth.deleteCurrentUser") + if let deleteCurrentUserError { + throw deleteCurrentUserError + } + } + + func clearCurrentSession() async throws { + events.record("auth.clearCurrentSession") + } +} + +actor AuthenticationServiceSpy: AuthenticationService { + private let provider: String + private let signInResult: Result + private let linkResult: Result + private let unlinkError: Error? + private let events: AuthenticationRepositoryEventRecorder + + init( + provider: String, + signInResult: Result = .success(nil), + linkResult: Result = .success(true), + unlinkError: Error? = nil, + events: AuthenticationRepositoryEventRecorder + ) { + self.provider = provider + self.signInResult = signInResult + self.linkResult = linkResult + self.unlinkError = unlinkError + self.events = events + } + + func signIn() async throws -> AuthDataResponse? { + events.record("\(provider).signIn") + return try signInResult.get() + } + + func signOut(_ uid: String) async throws { + events.record("\(provider).signOut") + } + + func deleteAuth(_ uid: String) async throws { + events.record("\(provider).deleteAuth") + } + + func link(uid: String, email: String) async throws -> Bool { + events.record("\(provider).link") + return try linkResult.get() + } + + func unlink(_ uid: String) async throws { + events.record("\(provider).unlink") + if let unlinkError { + throw unlinkError + } + } +} + +private actor AuthenticationRepositoryUserServiceSpy: UserService { + private let events: AuthenticationRepositoryEventRecorder + + init(events: AuthenticationRepositoryEventRecorder) { + self.events = events + } + + func upsertUser(_ response: AuthDataResponse) async throws { + events.record("user.upsertUser") + } + + func fetchUserProfile() async throws -> UserProfileResponse { + fatalError("사용하지 않는 테스트 경로") + } + + func upsertStatusMessage(_ message: String) async throws { } + func updateFCMToken(_ fcmToken: String) async throws { } + func updateUserTimeZone() async throws { } +} + +private final class AuthWidgetSnapshotUpdaterSpy: WidgetSnapshotUpdater { + private let events: AuthenticationRepositoryEventRecorder + + init(events: AuthenticationRepositoryEventRecorder) { + self.events = events + } + + func updateTodaySnapshot( + todos: [WidgetTodoSnapshot]?, + displayOptions: TodayDisplayOptions?, + now: Date + ) { } + + func updateHeatmapSnapshot( + createdTodos: [WidgetTodoSnapshot]?, + completedTodos: [WidgetTodoSnapshot]?, + deletedTodos: [WidgetTodoSnapshot]?, + quarterStart: Date?, + now: Date + ) { } + + func upsertTodoSnapshot(_ todo: WidgetTodoSnapshot, now: Date) { } + func deleteTodoSnapshot(todoId: String, deletedAt: Date, now: Date) { } + func restoreTodoSnapshot(todoId: String, now: Date) { } + + func clear() { + events.record("widget.clear") + } +} diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index 45c8a228..cd880e2a 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -79,11 +79,14 @@ final class FunctionAPIClient { _ endpoint: FunctionAPIEndpoint, requiresAuthentication: Bool = true ) async throws -> Response { - try await send( - endpoint, - payload: EmptyPayload(), - requiresAuthentication: requiresAuthentication - ) + var request = try client() + .request(endpoint) + + if requiresAuthentication { + request = request.authorized() + } + + return try await request.send() } private func client() throws -> NXAPIClient { @@ -96,16 +99,36 @@ struct FunctionAPIEndpoint: NXEndpoint { let path: String } -struct FunctionAPIResponse: Decodable { +struct GithubAuthenticationResponse: Decodable { let accessToken: String? let customToken: String? - let refreshToken: String? - let token: String? } -struct EmptyAPIResponse: Decodable {} +struct AppleChallengeResponse: Decodable { + let challengeId: String + let hashedNonce: String +} + +struct AppleCustomTokenResponse: Decodable { + let customToken: String +} + +struct AppleOperationResponse: Decodable { + let success: Bool +} + +struct AppleCustomTokenRequest: Encodable { + let challengeId: String + let authorizationCode: String +} -private struct EmptyPayload: Encodable {} +struct AppleAccountLinkRequest: Encodable { + let challengeId: String + let authorizationCode: String + let credentialEmail: String? +} + +struct EmptyAPIResponse: Decodable {} private struct FunctionAPIErrorBody: Decodable { let code: String @@ -116,9 +139,16 @@ private enum FunctionAPIErrorCode: String { case emailNotFound = "email-not-found" case emailMismatch = "email-mismatch" case githubEmailConflict = "github-email-changed-account-conflict" + case appleProviderLinkConflict = "apple-provider-link-conflict" + case lastProvider = "last-provider" } -private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { +enum AppleAuthenticationAPIError: Error, Equatable { + case providerLinkConflict + case lastProvider +} + +struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { func decodeServerError( data: Data, response: HTTPURLResponse, @@ -136,6 +166,10 @@ private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { return EmailError.mismatch case .githubEmailConflict: return EmailError.githubEmailConflict + case .appleProviderLinkConflict: + return AppleAuthenticationAPIError.providerLinkConflict + case .lastProvider: + return AppleAuthenticationAPIError.lastProvider } } } @@ -152,13 +186,19 @@ private actor FirebaseAuthTokenProvider: NXAuthTokenProvider { extension Error { var apiEmailError: EmailError? { + functionAPIUnderlyingError as? EmailError + } + + var apiAppleAuthenticationError: AppleAuthenticationAPIError? { + functionAPIUnderlyingError as? AppleAuthenticationAPIError + } + + private var functionAPIUnderlyingError: (any Error)? { guard let error = self as? NXError, - case let .server( - statusCode: _, - data: _, - underlying: underlying - ) = error else { return nil } + case let .server(statusCode: _, data: _, underlying: underlying) = error else { + return nil + } - return underlying as? EmailError + return underlying } } diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index 8a6ff347..a63f9ab5 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -34,14 +34,24 @@ extension FunctionAPIEndpoint where Response == EmptyAPIResponse { Self(method: .delete, path: "/push-notifications/\(functionAPIPathSegment(id))/deletion-request") } - static let revokeAppleAccessToken = Self(method: .delete, path: "/auth/apple/access-token") static let revokeGithubAccessToken = Self(method: .delete, path: "/auth/github/access-token") } -extension FunctionAPIEndpoint where Response == FunctionAPIResponse { +extension FunctionAPIEndpoint where Response == AppleChallengeResponse { + static let requestAppleChallenge = Self(method: .post, path: "/auth/apple/challenges") +} + +extension FunctionAPIEndpoint where Response == AppleCustomTokenResponse { static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token") - static let refreshAppleAccessToken = Self(method: .post, path: "/auth/apple/access-token") - static let requestAppleRefreshToken = Self(method: .post, path: "/auth/apple/refresh-token") +} + +extension FunctionAPIEndpoint where Response == AppleOperationResponse { + static let linkAppleAccount = Self(method: .put, path: "/auth/apple/account-link") + static let unlinkAppleAccount = Self(method: .delete, path: "/auth/apple/account-link") + static let revokeAppleAccessToken = Self(method: .delete, path: "/auth/apple/access-token") +} + +extension FunctionAPIEndpoint where Response == GithubAuthenticationResponse { static let linkGithubProvider = Self(method: .post, path: "/auth/github/link") static let requestGithubTokens = Self(method: .post, path: "/auth/github/tokens") } diff --git a/Application/Infra/Sources/Service/SocialLogin/AppleAuthResponse.swift b/Application/Infra/Sources/Service/SocialLogin/AppleAuthResponse.swift index 36683a30..6147ed70 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthResponse.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthResponse.swift @@ -6,11 +6,9 @@ // import Foundation -import AuthenticationServices struct AppleAuthResponse { - let nonce: String - let credential: ASAuthorizationAppleIDCredential - let authorizationCode: Data - let idTokenString: String + let authorizationCode: String + let fullName: PersonNameComponents? + let email: String? } diff --git a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 45959f70..5cbb068d 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -6,7 +6,6 @@ // import AuthenticationServices -import CryptoKit import FirebaseAuth import FirebaseFirestore import FirebaseMessaging @@ -39,29 +38,24 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { logger.info("Starting Apple sign in") do { - let response = try await authenticateWithAppleAsync() - - let nonce = response.nonce - let credential = response.credential - let authorizationCode = response.authorizationCode - let idTokenString = response.idTokenString + let challenge = try await requestAppleChallenge() + let response = try await authenticateWithAppleAsync( + hashedNonce: challenge.hashedNonce + ) - // Firebase Function을 통해 customToken 요청 logger.debug("Requesting custom token from Firebase Function") let customToken = try await requestAppleCustomToken( - idToken: idTokenString, - authorizationCode: authorizationCode + challengeId: challenge.challengeId, + authorizationCode: response.authorizationCode ) - // customToken으로 Firebase 로그인 logger.debug("Signing in with custom token") let result = try await Auth.auth().signIn(withCustomToken: customToken) let changeRequest = result.user.createProfileChangeRequest() var displayName: String? - // 최초 사용자 가입 시 사용자 이름 설정 - if let fullName = credential.fullName { + if let fullName = response.fullName { let formatter = PersonNameComponentsFormatter() formatter.style = .long let formattedName = formatter.string(from: fullName) @@ -70,7 +64,6 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - // 이미 가입된 사용자일 경우 Firestore에서 사용자 이름 가져오기 if displayName == nil { let doc = try await store .document(FirestorePath.userData(result.user.uid, document: .info)) @@ -78,20 +71,9 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { displayName = doc.data()?["appleName"] as? String } - // FirebaseAuth 사용자 프로필 업데이트 changeRequest.displayName = displayName ?? "" - changeRequest.photoURL = nil // Apple ID 프로필 사진 URL은 제공되지 않음 + changeRequest.photoURL = nil // Apple ID 프로필 사진 URL은 제공되지 않음 try await changeRequest.commitChanges() - - // FirebaseAuth 계정에 Apple ID 연결 - if !result.user.providerData.contains(where: { $0.providerID == providerID.rawValue }) { - let appleCredential = OAuthProvider.credential( - providerID: providerID, - idToken: idTokenString, - rawNonce: nonce - ) - try await result.user.link(with: appleCredential) - } logger.info("Successfully signed in with Apple") return result.user.makeResponse(providerID: .apple) @@ -127,9 +109,9 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { func deleteAuth(_ uid: String) async throws { do { - let token = try await refreshAppleAccessToken() - - try await revokeAppleAccessToken(token: token) + logger.info("Deleting Apple grant for user: \(uid)") + let response = try await FunctionAPIClient.shared.send(.revokeAppleAccessToken) + try validate(response) } catch { logger.error("Failed to delete Apple auth", error: error) record(error, code: .deleteAuth) @@ -137,84 +119,55 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - func link(uid: String, email: String) async throws -> Bool { + func link(uid: String, email _: String) async throws -> Bool { do { - let response = try await authenticateWithAppleAsync() - - let nonce = response.nonce - let credential = response.credential - let authorizationCode = response.authorizationCode - let idTokenString = response.idTokenString - - let refreshToken = try await requestAppleRefreshToken(authorizationCode: authorizationCode) - - guard let appleEmail = credential.email else { - try await revokeAppleAccessToken(token: refreshToken) - throw EmailError.notFound - } - - if appleEmail != email { - try await revokeAppleAccessToken(token: refreshToken) - throw EmailError.mismatch - } - - let appleCredential = OAuthProvider.credential( - providerID: providerID, - idToken: idTokenString, - rawNonce: nonce + logger.info("Linking Apple account for user: \(uid)") + let challenge = try await requestAppleChallenge() + let response = try await authenticateWithAppleAsync(hashedNonce: challenge.hashedNonce) + + let operation = try await FunctionAPIClient.shared.send( + .linkAppleAccount, + payload: AppleAccountLinkRequest( + challengeId: challenge.challengeId, + authorizationCode: response.authorizationCode, + credentialEmail: response.email + ) ) - - try await user?.link(with: appleCredential) + try validate(operation) + try await user?.reload() return true } catch { if error.isSocialLoginCancelled { return false } - logger.error("Failed to link Apple account", error: error) - record(error, code: .link) - if error.isFirebaseCredentialAlreadyInUse { - throw DataLayerError.linkCredentialAlreadyInUse - } - throw error + let mappedError = mapAppleAPIError(error) + logger.error("Failed to link Apple account", error: mappedError) + record(mappedError, code: .link) + throw mappedError } } func unlink(_ uid: String) async throws { do { - logger.info("Starting Apple access token refresh for unlink. uid: \(uid)") - let accessToken = try await refreshAppleAccessToken() - - logger.info("Starting Apple access token revocation for unlink. uid: \(uid)") - try await revokeAppleAccessToken(token: accessToken) - - let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) - - logger.info("Starting Apple refresh token deletion from Firestore for unlink. uid: \(uid)") - try await deleteAppleRefreshToken(from: tokensRef) - - logger.info("Starting Firebase Apple provider unlink. uid: \(uid)") - _ = try await user?.unlink(fromProvider: providerID.rawValue) + logger.info("Unlinking Apple account for user: \(uid)") + let response = try await FunctionAPIClient.shared.send(.unlinkAppleAccount) + try validate(response) + try await user?.reload() } catch { - logger.error("Failed to unlink Apple account", error: error) - record(error, code: .unlink) - throw error + let mappedError = mapAppleAPIError(error) + logger.error("Failed to unlink Apple account", error: mappedError) + record(mappedError, code: .unlink) + throw mappedError } } // Apple 인증 메서드 @MainActor - func authenticateWithAppleAsync() async throws -> AppleAuthResponse { + func authenticateWithAppleAsync(hashedNonce: String) async throws -> AppleAuthResponse { guard appleSignInDelegate == nil, appleSignInContinuation == nil else { throw SocialLoginError.authenticationAlreadyInProgress } - // 자체 nonce 생성 및 해시화 - let nonce = UUID().uuidString - let hashedNonce = SHA256.hash(data: Data(nonce.utf8)).map { String(format: "%02x", $0) }.joined() - - let provider = ASAuthorizationAppleIDProvider() - let request = provider.createRequest() - request.requestedScopes = [.fullName, .email] // 사용자 정보 요청 - request.nonce = hashedNonce // Apple API는 SHA256 해시값을 요구함 + let request = Self.makeAuthorizationRequest(hashedNonce: hashedNonce) let controller = ASAuthorizationController(authorizationRequests: [request]) @@ -229,22 +182,27 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { controller.performRequests() } - // Apple ID 인증 결과 처리 guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, - let appleIdToken = credential.identityToken, let authorizationCode = credential.authorizationCode, - let idTokenString = String(data: appleIdToken, encoding: .utf8) else { + let authorizationCode = String(data: authorizationCode, encoding: .utf8) else { throw URLError(.badServerResponse) } return AppleAuthResponse( - nonce: nonce, - credential: credential, - authorizationCode: authorizationCode, - idTokenString: idTokenString + authorizationCode: authorizationCode, + fullName: credential.fullName, + email: credential.email ) } + @MainActor + static func makeAuthorizationRequest(hashedNonce: String) -> ASAuthorizationAppleIDRequest { + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName, .email] + request.nonce = hashedNonce + return request + } + @MainActor private func completeAppleSignIn(with result: Result) { guard let continuation = appleSignInContinuation else { return } @@ -260,84 +218,49 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - // Apple CustomToken 발급 메서드 - private func requestAppleCustomToken(idToken: String, authorizationCode: Data) async throws -> String { - guard let authorizationCode = String(data: authorizationCode, encoding: .utf8) else { - throw URLError(.badServerResponse) - } - - let response = try await FunctionAPIClient.shared.send( - .requestAppleCustomToken, - payload: [ - "idToken": idToken, - "authorizationCode": authorizationCode - ], + private func requestAppleChallenge() async throws -> AppleChallengeResponse { + try await FunctionAPIClient.shared.send( + .requestAppleChallenge, requiresAuthentication: false ) - - if let customToken = response.customToken { - return customToken - } - throw URLError(.badServerResponse) - } - - // Apple AceessToken 재발급 메서드 - private func refreshAppleAccessToken() async throws -> String { - let response = try await FunctionAPIClient.shared.send(.refreshAppleAccessToken) - - guard let accessToken = response.token else { - throw URLError(.cannotParseResponse) - } - - return accessToken } - // Apple RefreshToken 발급 메서드 - func requestAppleRefreshToken(authorizationCode: Data) async throws -> String { - guard let authorizationCode = String(data: authorizationCode, encoding: .utf8) else { - throw URLError(.userAuthenticationRequired) - } - + private func requestAppleCustomToken( + challengeId: String, + authorizationCode: String + ) async throws -> String { let response = try await FunctionAPIClient.shared.send( - .requestAppleRefreshToken, - payload: ["authorizationCode": authorizationCode] + .requestAppleCustomToken, + payload: AppleCustomTokenRequest( + challengeId: challengeId, + authorizationCode: authorizationCode + ), + requiresAuthentication: false ) - if let refreshToken = response.refreshToken { - return refreshToken - } - throw URLError(.badServerResponse) - } - - // Apple AccessToken 취소 메서드 - func revokeAppleAccessToken(token: String) async throws { - try await FunctionAPIClient.shared.send( - .revokeAppleAccessToken, - payload: ["token": token] - ) + return response.customToken } } private extension AppleAuthenticationServiceImpl { - func deleteAppleRefreshToken(from tokensRef: DocumentReference) async throws { - _ = try await store.runTransaction { transaction, errorPointer in - let snapshot: DocumentSnapshot - - do { - snapshot = try transaction.getDocument(tokensRef) - } catch let error as NSError { - errorPointer?.pointee = error - return nil - } + func validate(_ response: AppleOperationResponse) throws { + guard response.success else { + throw TokenError.invalidResponse + } + } - if snapshot.exists { - transaction.updateData( - ["appleRefreshToken": FieldValue.delete()], - forDocument: tokensRef - ) - } + func mapAppleAPIError(_ error: Error) -> Error { + if let emailError = error.apiEmailError { + return emailError + } - return nil + switch error.apiAppleAuthenticationError { + case .providerLinkConflict: + return DataLayerError.linkCredentialAlreadyInUse + case .lastProvider: + return DataLayerError.failedToUnlinkLastProvider + case .none: + return error } } diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift index 0755ff8c..bb42c8fc 100644 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -5,15 +5,148 @@ // Created by opfic on 7/10/26. // +import AuthenticationServices +import Foundation import Testing @testable import Infra struct FunctionAPIEndpointTests { + @Test("Apple 인증 challenge endpoint는 인증 challenge 경로를 사용한다") + func Apple_인증_challenge_endpoint는_인증_challenge_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestAppleChallenge + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/apple/challenges") + } + + @Test("Apple custom token endpoint는 인증 custom token 경로를 사용한다") + func Apple_custom_token_endpoint는_인증_custom_token_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.requestAppleCustomToken + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/apple/custom-token") + } + + @Test("Apple 계정 연결 endpoint는 PUT 인증 account link 경로를 사용한다") + func Apple_계정_연결_endpoint는_PUT_인증_account_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.linkAppleAccount + + #expect(endpoint.method.rawValue == "PUT") + #expect(endpoint.path == "/auth/apple/account-link") + } + + @Test("Apple 계정 연결 해제 endpoint는 DELETE 인증 account link 경로를 사용한다") + func Apple_계정_연결_해제_endpoint는_DELETE_인증_account_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.unlinkAppleAccount + + #expect(endpoint.method.rawValue == "DELETE") + #expect(endpoint.path == "/auth/apple/account-link") + } + + @Test("Apple grant 정리 endpoint는 DELETE 인증 access token 경로를 사용한다") + func Apple_grant_정리_endpoint는_DELETE_인증_access_token_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.revokeAppleAccessToken + + #expect(endpoint.method.rawValue == "DELETE") + #expect(endpoint.path == "/auth/apple/access-token") + } + + @Test("Apple custom token 요청은 challenge와 authorization code만 인코딩한다") + func Apple_custom_token_요청은_challenge와_authorization_code만_인코딩한다() throws { + let request = AppleCustomTokenRequest( + challengeId: "challenge-id", + authorizationCode: "authorization-code" + ) + + #expect(try encodedKeys(request) == ["challengeId", "authorizationCode"]) + } + + @Test("Apple 계정 연결 요청은 credential email을 포함할 수 있다") + func Apple_계정_연결_요청은_credential_email을_포함할_수_있다() throws { + let request = AppleAccountLinkRequest( + challengeId: "challenge-id", + authorizationCode: "authorization-code", + credentialEmail: "apple@example.com" + ) + + #expect( + try encodedKeys(request) == [ + "challengeId", + "authorizationCode", + "credentialEmail" + ] + ) + } + + @Test("Apple 계정 연결 요청은 없는 credential email을 인코딩하지 않는다") + func Apple_계정_연결_요청은_없는_credential_email을_인코딩하지_않는다() throws { + let request = AppleAccountLinkRequest( + challengeId: "challenge-id", + authorizationCode: "authorization-code", + credentialEmail: nil + ) + + #expect(try encodedKeys(request) == ["challengeId", "authorizationCode"]) + } + + @Test("Apple 인증 요청 nonce는 서버 hashed nonce를 사용한다") + @MainActor + func Apple_인증_요청_nonce는_서버_hashed_nonce를_사용한다() { + let request = AppleAuthenticationServiceImpl.makeAuthorizationRequest( + hashedNonce: "server-hashed-nonce" + ) + + #expect(request.nonce == "server-hashed-nonce") + #expect(request.requestedScopes == [.fullName, .email]) + } + + @Test( + "Apple 서버 오류를 앱 인증 오류 원인으로 분류한다", + arguments: [ + ("apple-provider-link-conflict", AppleAuthenticationAPIError.providerLinkConflict), + ("last-provider", AppleAuthenticationAPIError.lastProvider) + ] + ) + func Apple_서버_오류를_앱_인증_오류_원인으로_분류한다( + code: String, + expected: AppleAuthenticationAPIError + ) throws { + let data = try JSONEncoder().encode(FunctionAPIErrorFixture(code: code)) + let response = try #require( + HTTPURLResponse( + url: URL(string: "https://example.com")!, + statusCode: 409, + httpVersion: nil, + headerFields: nil + ) + ) + + let error = FunctionAPIServerErrorDecoder().decodeServerError( + data: data, + response: response, + decoder: JSONDecoder() + ) + + #expect(error as? AppleAuthenticationAPIError == expected) + } + @Test("GitHub provider 연결 endpoint는 인증 link 경로를 사용한다") func 깃허브_provider_연결_endpoint는_인증_link_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint.linkGithubProvider + let endpoint = FunctionAPIEndpoint.linkGithubProvider #expect(endpoint.method.rawValue == "POST") #expect(endpoint.path == "/auth/github/link") } } + +private struct FunctionAPIErrorFixture: Encodable { + let code: String +} + +private func encodedKeys(_ value: some Encodable) throws -> Set { + let data = try JSONEncoder().encode(value) + let object = try JSONSerialization.jsonObject(with: data) + let dictionary = try #require(object as? [String: Any]) + return Set(dictionary.keys) +}