diff --git a/.gitignore b/.gitignore index 311a75b..6f77bea 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,8 @@ graph.dot *.log # Local Xcode derived data for CLI builds -.derivedData/ +# .derivedData, .derivedData-pr3, .derivedData-keycheck 등 CLI 임시 경로 전부 무시 +.derivedData*/ # macOS junk .DS_Store diff --git a/.package.resolved b/.package.resolved index be21391..eada9da 100644 --- a/.package.resolved +++ b/.package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "a233e486feeee6851825f048f7a7b0b0f1dc2fd69a98db3f78b3507baa03d09a", + "originHash" : "04f9c7beed1c00c645a6d4cc85b6dc9f57eb3b4efe2300fbb9aba9ab4b820bc7", "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" + } + }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", @@ -10,6 +19,15 @@ "version" : "1.2.0" } }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk", + "state" : { + "revision" : "2a68ca01e2d7900a1559b31d0d59843837f130f2", + "version" : "2.28.0" + } + }, { "identity" : "nuke", "kind" : "remoteSourceControl", diff --git a/.swiftlint.yml b/.swiftlint.yml index ab13113..75c9fcd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -106,6 +106,17 @@ excluded: - Derived - DerivedData - .derivedData + - .derivedData-app + - .derivedData-app2 + - .derivedData-coresocialauth + - .derivedData-data + - .derivedData-data2 + - .derivedData-feature + - .derivedData-feature2 + - .derivedData-feature3 + - .derivedData-networklog + - .derivedData-rename + - .derivedData-rename-app - .git - Mozi.xcworkspace - "**/*.xcodeproj" diff --git a/AGENTS.md b/AGENTS.md index 5b26023..831edb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Projects/ Shared/{Util,DesignSystem,Logger} ThirdParty/{ThirdParty,ThirdPartyUI,ThirdPartyCore} Domain/ - Core/{Network,Storage} + Core/{Network,Storage,SocialAuth} Data/ Feature/ App/ diff --git a/CLAUDE.md b/CLAUDE.md index ae7907d..37714f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Projects/ Shared/{Util,DesignSystem,Logger} ThirdParty/{ThirdParty,ThirdPartyUI,ThirdPartyCore} Domain/ - Core/{Network,Storage} + Core/{Network,Storage,SocialAuth} Data/ Feature/ App/ diff --git a/Config/Example.xcconfig b/Config/Example.xcconfig index dccd412..53543b1 100644 --- a/Config/Example.xcconfig +++ b/Config/Example.xcconfig @@ -1,3 +1,4 @@ // Debug.xcconfig / Release.xcconfig 로 복사해서 사용 // https:/$()/... 형태는 // 가 주석으로 파싱되지 않게 하기 위함 API_BASE_URL = +KAKAO_NATIVE_APP_KEY = diff --git a/Projects/App/Mozi.entitlements b/Projects/App/Mozi.entitlements new file mode 100644 index 0000000..a812db5 --- /dev/null +++ b/Projects/App/Mozi.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 1d785c7..b753290 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -8,11 +8,13 @@ let project = ProjectFactory.app( .domain, .coreNetwork, .coreStorage, + .coreSocialAuth, .sharedLogger, .sharedUtils, .sharedDesignSystem, .thirdParty, .thirdPartyUI, .thirdPartyCore, - ] + ], + entitlements: .file(path: "Mozi.entitlements") ) diff --git a/Projects/App/Sources/DI/AppBootstrap.swift b/Projects/App/Sources/DI/AppBootstrap.swift index af778cf..3a388e7 100644 --- a/Projects/App/Sources/DI/AppBootstrap.swift +++ b/Projects/App/Sources/DI/AppBootstrap.swift @@ -1,3 +1,4 @@ +import CoreSocialAuth import Foundation import SharedDesignSystem import SharedLogger @@ -8,9 +9,33 @@ enum AppBootstrap { static func run() { _ = DesignSystemFontRegistration.registerIfNeeded() let infra = InfraContainer.live() + let kakaoAppKey = requireKakaoNativeAppKeyIfNeeded( + infra.configuration.kakaoNativeAppKey + ) + let socialConfig = SocialAuthConfiguration( + kakaoAppKey: kakaoAppKey + ) + KakaoAuthBootstrap.initializeIfNeeded(appKey: socialConfig.kakaoAppKey) prepareDependencies { - Dependencies.register(&$0, infra: infra) + Dependencies.register(&$0, infra: infra, socialConfig: socialConfig) } Logger.shared.info("App bootstrap completed", category: .general) } + + /// Debug 에서는 카카오 키 누락을 부트 시점에 즉시 실패시킨다. + private static func requireKakaoNativeAppKeyIfNeeded(_ key: String?) -> String? { + #if DEBUG + guard let key, key.isEmpty == false else { + preconditionFailure( + """ + Missing KAKAO_NATIVE_APP_KEY. + Copy Config/Example.xcconfig to Config/Debug.xcconfig and set the native app key for this scheme. + """ + ) + } + return key + #else + return key + #endif + } } diff --git a/Projects/App/Sources/DI/AppConfiguration.swift b/Projects/App/Sources/DI/AppConfiguration.swift index f4beb2f..b86f0ea 100644 --- a/Projects/App/Sources/DI/AppConfiguration.swift +++ b/Projects/App/Sources/DI/AppConfiguration.swift @@ -5,12 +5,14 @@ struct AppConfiguration: Sendable { let baseURL: URL let bundleID: String let displayName: String + let kakaoNativeAppKey: String? static func make() -> AppConfiguration { AppConfiguration( baseURL: AppInfo.apiBaseURL, bundleID: AppInfo.bundleID, - displayName: AppInfo.displayName + displayName: AppInfo.displayName, + kakaoNativeAppKey: AppInfo.kakaoNativeAppKey ) } } diff --git a/Projects/App/Sources/DI/Dependencies.swift b/Projects/App/Sources/DI/Dependencies.swift index 493c737..40b2aa1 100644 --- a/Projects/App/Sources/DI/Dependencies.swift +++ b/Projects/App/Sources/DI/Dependencies.swift @@ -1,16 +1,22 @@ +import CoreSocialAuth import Data import Domain import ThirdParty enum Dependencies { + @MainActor static func register( _ values: inout DependencyValues, - infra: InfraContainer + infra: InfraContainer, + socialConfig: SocialAuthConfiguration ) { + let socialAuthServices = SocialAuthServiceFactory().make( + configuration: socialConfig + ) values.authClient = .live( baseURL: infra.configuration.baseURL, keychain: infra.keychain, - oauthServices: OAuthServiceFactory.makeStub() + socialAuthServices: socialAuthServices ) } } diff --git a/Projects/App/Sources/DI/InfraContainer.swift b/Projects/App/Sources/DI/InfraContainer.swift index b180f08..917b310 100644 --- a/Projects/App/Sources/DI/InfraContainer.swift +++ b/Projects/App/Sources/DI/InfraContainer.swift @@ -14,9 +14,7 @@ extension InfraContainer { let configuration = AppConfiguration.make() return InfraContainer( configuration: configuration, - userDefaults: DefaultUserDefaultsStorage( - suiteName: configuration.bundleID - ), + userDefaults: DefaultUserDefaultsStorage(), keychain: DefaultKeychainStorage( service: configuration.bundleID ) diff --git a/Projects/App/Sources/MoziApp.swift b/Projects/App/Sources/MoziApp.swift index 4ea8591..8ccdc81 100644 --- a/Projects/App/Sources/MoziApp.swift +++ b/Projects/App/Sources/MoziApp.swift @@ -1,3 +1,4 @@ +import CoreSocialAuth import Feature import SwiftUI import ThirdParty @@ -14,6 +15,12 @@ struct MoziApp: App { WindowGroup { CompositionRoot.rootView(store: store) .preferredColorScheme(.dark) + .onOpenURL { url in + if KakaoAuthRedirectHandler.handle(url: url) { + return + } + store.send(.appCoordinator(.deepLinkReceived(url))) + } } } } diff --git a/Projects/Core/Network/Sources/Logging/NetworkLog.swift b/Projects/Core/Network/Sources/Logging/NetworkLog.swift index aef3675..3fb6697 100644 --- a/Projects/Core/Network/Sources/Logging/NetworkLog.swift +++ b/Projects/Core/Network/Sources/Logging/NetworkLog.swift @@ -9,11 +9,11 @@ enum NetworkLog { #if DEBUG if let body = request.httpBody, let bodyText = String(data: body, encoding: .utf8) { - message += "\nBody: \(redact(bodyText))" + message += "\nBody:\n\(formattedBody(bodyText))" } #endif - Logger.shared.info(redact(message), category: .network) + Logger.shared.info(message, category: .network) } static func response( @@ -27,7 +27,7 @@ enum NetworkLog { #if DEBUG if let bodyText = String(data: data, encoding: .utf8), !bodyText.isEmpty { - message += "\nBody: \(redact(bodyText))" + message += "\nBody:\n\(formattedBody(bodyText))" } #endif @@ -53,23 +53,58 @@ enum NetworkLog { return components.string ?? url.absoluteString } + /// JSON body 는 여러 줄로 정리한 뒤 민감 값만 가린다. + static func formattedBody(_ text: String) -> String { + let pretty = prettyPrintedJSON(text) ?? text + return redact(pretty) + } + + static func prettyPrintedJSON(_ text: String) -> String? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let prettyData = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ), + let pretty = String(data: prettyData, encoding: .utf8) else { + return nil + } + return pretty + } + + /// 민감 값만 가리고 필드명은 남긴다. static func redact(_ text: String) -> String { var output = text - let patterns = [ - #"Bearer\s+[A-Za-z0-9\-._~+/]+=*"#, - #"(accessToken|refreshToken|Authorization)"\s*:\s*"[^"]+""#, - ] - for pattern in patterns { - if let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) { - let range = NSRange(output.startIndex.. + if let bearerRegex = try? NSRegularExpression( + pattern: #"Bearer\s+[A-Za-z0-9\-._~+/]+=*"#, + options: [.caseInsensitive] + ) { + let range = NSRange(output.startIndex..? + + override public init() { + super.init() + } + + public nonisolated func login() async throws -> String { + try await loginOnMainActor() + } + + private func loginOnMainActor() async throws -> String { + guard continuation == nil else { + throw SocialAuthError.cancelled + } + + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName, .email] + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + + private func finish(_ result: Result) { + guard let continuation else { return } + self.continuation = nil + + switch result { + case let .success(token): + continuation.resume(returning: token) + case let .failure(error): + continuation.resume(throwing: error) + } + } + + private static func resolvePresentationAnchor() -> ASPresentationAnchor { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + if let keyWindow = scenes.flatMap(\.windows).first(where: \.isKeyWindow) { + return keyWindow + } + if let firstWindow = scenes.flatMap(\.windows).first { + return firstWindow + } + return ASPresentationAnchor() + } +} + +extension AppleSocialAuthService: ASAuthorizationControllerDelegate { + public nonisolated func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + Task { @MainActor in + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + self.finish(.failure(SocialAuthError.failed)) + return + } + + guard let tokenData = credential.identityToken, + let identityToken = String(data: tokenData, encoding: .utf8), + identityToken.isEmpty == false else { + self.finish(.failure(SocialAuthError.failed)) + return + } + + self.finish(.success(identityToken)) + } + } + + public nonisolated func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + Task { @MainActor in + if let authError = error as? ASAuthorizationError, authError.code == .canceled { + self.finish(.failure(SocialAuthError.cancelled)) + return + } + self.finish(.failure(SocialAuthError.failed)) + } + } +} + +extension AppleSocialAuthService: ASAuthorizationControllerPresentationContextProviding { + public nonisolated func presentationAnchor( + for controller: ASAuthorizationController + ) -> ASPresentationAnchor { + if Thread.isMainThread { + return MainActor.assumeIsolated { + AppleSocialAuthService.resolvePresentationAnchor() + } + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated { + AppleSocialAuthService.resolvePresentationAnchor() + } + } + } +} diff --git a/Projects/Core/SocialAuth/Sources/Kakao/KakaoAuthBootstrap.swift b/Projects/Core/SocialAuth/Sources/Kakao/KakaoAuthBootstrap.swift new file mode 100644 index 0000000..b64be5a --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/Kakao/KakaoAuthBootstrap.swift @@ -0,0 +1,17 @@ +import Foundation +import SharedLogger +import ThirdPartyCore + +public enum KakaoAuthBootstrap { + @MainActor + public static func initializeIfNeeded(appKey: String?) { + guard let appKey, appKey.isEmpty == false else { + Logger.shared.info( + "Kakao SDK skipped: KAKAO_NATIVE_APP_KEY is empty", + category: .general + ) + return + } + KakaoSDK.initSDK(appKey: appKey) + } +} diff --git a/Projects/Core/SocialAuth/Sources/Kakao/KakaoAuthRedirectHandler.swift b/Projects/Core/SocialAuth/Sources/Kakao/KakaoAuthRedirectHandler.swift new file mode 100644 index 0000000..7c6b13b --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/Kakao/KakaoAuthRedirectHandler.swift @@ -0,0 +1,13 @@ +import Foundation +import ThirdPartyCore + +public enum KakaoAuthRedirectHandler { + @MainActor + @discardableResult + public static func handle(url: URL) -> Bool { + if AuthApi.isKakaoTalkLoginUrl(url) { + return AuthController.handleOpenUrl(url: url) + } + return false + } +} diff --git a/Projects/Core/SocialAuth/Sources/Kakao/KakaoSocialAuthService.swift b/Projects/Core/SocialAuth/Sources/Kakao/KakaoSocialAuthService.swift new file mode 100644 index 0000000..1ffc2aa --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/Kakao/KakaoSocialAuthService.swift @@ -0,0 +1,43 @@ +import Foundation +import ThirdPartyCore + +/// 카카오 SDK 로그인 후 access token 문자열을 반환한다. +public struct KakaoSocialAuthService: SocialAuthService { + public init() {} + + public func login() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + Task { @MainActor in + let handler: (OAuthToken?, Error?) -> Void = { token, error in + if let error { + continuation.resume(throwing: Self.mapError(error)) + return + } + + guard let accessToken = token?.accessToken, + accessToken.isEmpty == false else { + continuation.resume(throwing: SocialAuthError.failed) + return + } + + continuation.resume(returning: accessToken) + } + + if UserApi.isKakaoTalkLoginAvailable() { + UserApi.shared.loginWithKakaoTalk(completion: handler) + } else { + UserApi.shared.loginWithKakaoAccount(completion: handler) + } + } + } + } + + private static func mapError(_ error: Error) -> SocialAuthError { + if let sdkError = error as? SdkError, + sdkError.isClientFailed, + sdkError.getClientError().reason == .Cancelled { + return .cancelled + } + return .failed + } +} diff --git a/Projects/Core/SocialAuth/Sources/NotConfiguredSocialAuthService.swift b/Projects/Core/SocialAuth/Sources/NotConfiguredSocialAuthService.swift new file mode 100644 index 0000000..89fe743 --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/NotConfiguredSocialAuthService.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct NotConfiguredSocialAuthService: SocialAuthService { + private let message: String + + public init(message: String) { + self.message = message + } + + public func login() async throws -> String { + throw SocialAuthError.notConfigured(message: message) + } +} diff --git a/Projects/Core/SocialAuth/Sources/SocialAuthConfiguration.swift b/Projects/Core/SocialAuth/Sources/SocialAuthConfiguration.swift new file mode 100644 index 0000000..c80416f --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/SocialAuthConfiguration.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct SocialAuthConfiguration: Sendable { + public let kakaoAppKey: String? + + public init(kakaoAppKey: String?) { + self.kakaoAppKey = kakaoAppKey + } +} diff --git a/Projects/Core/SocialAuth/Sources/SocialAuthError.swift b/Projects/Core/SocialAuth/Sources/SocialAuthError.swift new file mode 100644 index 0000000..3de3bbf --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/SocialAuthError.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum SocialAuthError: Error, Sendable, Equatable { + case cancelled + case failed + case notConfigured(message: String) +} diff --git a/Projects/Data/Sources/Auth/Service/OAuth/OAuthService.swift b/Projects/Core/SocialAuth/Sources/SocialAuthService.swift similarity index 52% rename from Projects/Data/Sources/Auth/Service/OAuth/OAuthService.swift rename to Projects/Core/SocialAuth/Sources/SocialAuthService.swift index 5d9b1d3..e2e379f 100644 --- a/Projects/Data/Sources/Auth/Service/OAuth/OAuthService.swift +++ b/Projects/Core/SocialAuth/Sources/SocialAuthService.swift @@ -1,6 +1,5 @@ -import Domain import Foundation -public protocol OAuthService: Sendable { +public protocol SocialAuthService: Sendable { func login() async throws -> String } diff --git a/Projects/Core/SocialAuth/Sources/SocialAuthServiceFactory.swift b/Projects/Core/SocialAuth/Sources/SocialAuthServiceFactory.swift new file mode 100644 index 0000000..2b17476 --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/SocialAuthServiceFactory.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct SocialAuthServiceFactory: Sendable { + public init() {} + + @MainActor + public func make(configuration: SocialAuthConfiguration) -> SocialAuthServices { + let kakao: any SocialAuthService + if let key = configuration.kakaoAppKey, key.isEmpty == false { + kakao = KakaoSocialAuthService() + } else { + kakao = NotConfiguredSocialAuthService( + message: "Kakao login is not configured yet" + ) + } + + return SocialAuthServices( + kakao: kakao, + apple: AppleSocialAuthService() + ) + } +} diff --git a/Projects/Core/SocialAuth/Sources/SocialAuthServices.swift b/Projects/Core/SocialAuth/Sources/SocialAuthServices.swift new file mode 100644 index 0000000..ce22709 --- /dev/null +++ b/Projects/Core/SocialAuth/Sources/SocialAuthServices.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct SocialAuthServices: Sendable { + public let kakao: any SocialAuthService + public let apple: any SocialAuthService + + public init(kakao: any SocialAuthService, apple: any SocialAuthService) { + self.kakao = kakao + self.apple = apple + } +} diff --git a/Projects/Core/SocialAuth/Tests/SocialAuthServiceFactoryTests.swift b/Projects/Core/SocialAuth/Tests/SocialAuthServiceFactoryTests.swift new file mode 100644 index 0000000..c1afec8 --- /dev/null +++ b/Projects/Core/SocialAuth/Tests/SocialAuthServiceFactoryTests.swift @@ -0,0 +1,42 @@ +@testable import CoreSocialAuth +import XCTest + +final class SocialAuthServiceFactoryTests: XCTestCase { + @MainActor + func test_카카오키없으면_카카오는_notConfigured() async { + let services = SocialAuthServiceFactory().make( + configuration: SocialAuthConfiguration(kakaoAppKey: nil) + ) + + do { + _ = try await services.kakao.login() + XCTFail("expected notConfigured") + } catch let error as SocialAuthError { + XCTAssertEqual( + error, + .notConfigured(message: "Kakao login is not configured yet") + ) + } catch { + XCTFail("unexpected \(error)") + } + } + + @MainActor + func test_빈_카카오키면_카카오는_notConfigured() async { + let services = SocialAuthServiceFactory().make( + configuration: SocialAuthConfiguration(kakaoAppKey: "") + ) + + do { + _ = try await services.kakao.login() + XCTFail("expected notConfigured") + } catch let error as SocialAuthError { + XCTAssertEqual( + error, + .notConfigured(message: "Kakao login is not configured yet") + ) + } catch { + XCTFail("unexpected \(error)") + } + } +} diff --git a/Projects/Data/Project.swift b/Projects/Data/Project.swift index 4be2406..95ac16f 100644 --- a/Projects/Data/Project.swift +++ b/Projects/Data/Project.swift @@ -7,6 +7,7 @@ let project = ProjectFactory.framework( .domain, .coreNetwork, .coreStorage, + .coreSocialAuth, .sharedLogger, .sharedUtils, ], @@ -15,5 +16,6 @@ let project = ProjectFactory.framework( .domain, .coreNetwork, .coreStorage, + .coreSocialAuth, ] ) diff --git a/Projects/Data/README.md b/Projects/Data/README.md index 5b7599c..849d9ca 100644 --- a/Projects/Data/README.md +++ b/Projects/Data/README.md @@ -1,17 +1,17 @@ # Data ## 책임 -- DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`, live 조립 +- DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`, Domain 오케스트레이션(`*Client.live`) ## 현재 상태 -- Auth 구현 추가 +- Auth 구현 - DTO/Endpoint/Datasource - `AuthLocalDatasource` - `AuthTokenRefresher` - `AuthRepositoryImpl` - `AuthClientFactory` (repository → Domain client adapter) - `AuthClient.live` (plain/refresher/authed 풀 조립) - - `OAuthService` / `OAuthServiceFactory` (PR1 stub) + - `SocialAuthCredentialProvider` (`CoreSocialAuth` credential → Domain `AuthError` 매핑) ## 이후 패턴 - `Data//{Client,DTO,Datasource,Repository,Service}` @@ -23,13 +23,23 @@ - 금지: Feature ## 내부 규칙 -- Domain `*Client` 의 live 구현(`*Client.live`)은 Data 에서 제공 -- App 은 pure infra 준비 + `prepareDependencies` 등록만 담당 +- Domain `*Client` 의 live 오케스트레이션(`*Client.live`)은 Data 에서 제공 +- 기술 구현(SDK wrapper 등)은 Core/* 에 둔다 +- App 은 config/factory 조립 + `prepareDependencies` 등록만 담당 - Feature 가 Data 를 직접 import 하지 않음 - refresh 는 plain client + `AuthTokenRefresher` 경로 - refresh 실패 시 unauthorized 만 로컬 세션 삭제, badRequest/일시 네트워크 오류는 세션 유지 - request body encode 는 Data remote 에서 수행하고 실패 시 throw -- OAuth credential 은 `OAuthService` 가 담당 (Souzip 스타일) +- 소셜 credential 은 `CoreSocialAuth` 가 담당하고, Data 는 provider 선택과 `AuthError` 매핑만 한다 + + +## RemoteDatasource 규칙 +- 순수 서버 통신만 담당 +- request body encode 는 Remote 책임 +- encoder 는 Remote 프로퍼티 (`NetworkJSONCoding.makeEncoder()` 기본값) +- Endpoint 는 path/method/raw body only +- response 는 공통 envelope 없이 payload DTO 직접 decode +- OAuth/SDK, 로컬 저장, Domain 매핑은 Remote 밖 ## 주요 진입점 - `Sources/Auth/Client/AuthClient+Live.swift` @@ -38,8 +48,7 @@ - `Sources/Auth/Datasource/AuthRemoteDatasource.swift` - `Sources/Auth/Datasource/AuthLocalDatasource.swift` - `Sources/Auth/Service/AuthTokenRefresher.swift` -- `Sources/Auth/Service/OAuth/OAuthService.swift` -- `Sources/Auth/Service/OAuth/OAuthServiceFactory.swift` +- `Sources/Auth/Service/SocialAuth/SocialAuthCredentialProvider.swift` - `Sources/Auth/Endpoint/AuthEndpoint.swift` - `Sources/Auth/DTO/*` @@ -50,7 +59,8 @@ - token refresher rotation 교체 저장 - logout 시 로컬 세션 삭제 - factory credential → repository 연결 -- OAuth stub notConfigured +- SocialAuth provider 선택 / 에러 매핑 +- CoreSocialAuth live 구현은 수동 검증 (SDK/UI 의존) ## 관련 문서 - [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) diff --git a/Projects/Data/Sources/Auth/Client/AuthClient+Live.swift b/Projects/Data/Sources/Auth/Client/AuthClient+Live.swift index 177fd02..addf950 100644 --- a/Projects/Data/Sources/Auth/Client/AuthClient+Live.swift +++ b/Projects/Data/Sources/Auth/Client/AuthClient+Live.swift @@ -1,4 +1,5 @@ import CoreNetwork +import CoreSocialAuth import CoreStorage import Domain import Foundation @@ -8,7 +9,7 @@ public extension AuthClient { static func live( baseURL: URL, keychain: any KeychainStorage, - oauthServices: OAuthServices + socialAuthServices: SocialAuthServices ) -> AuthClient { let networkConfiguration = NetworkConfiguration(baseURL: baseURL) let plainNetworkClient = DefaultNetworkClient.plain( @@ -42,7 +43,10 @@ public extension AuthClient { return AuthClientFactory.make( repository: repository, credentialProvider: { provider in - try await oauthServices.service(for: provider).login() + try await SocialAuthCredentialProvider.credential( + for: provider, + services: socialAuthServices + ) } ) } diff --git a/Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift b/Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift index 42a4e52..aad4170 100644 --- a/Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift +++ b/Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift @@ -4,40 +4,39 @@ import Foundation public struct AuthRemoteDatasource: Sendable { private let plainClient: any NetworkClient private let authedClient: any NetworkClient + private let encoder: JSONEncoder public init( plainClient: any NetworkClient, - authedClient: any NetworkClient + authedClient: any NetworkClient, + encoder: JSONEncoder = NetworkJSONCoding.makeEncoder() ) { self.plainClient = plainClient self.authedClient = authedClient + self.encoder = encoder } public func loginWithKakao(accessToken: String) async throws -> LoginResponseDTO { - let body = try makeEncoder().encode(KakaoLoginRequestDTO(accessToken: accessToken)) + let body = try encoder.encode(KakaoLoginRequestDTO(accessToken: accessToken)) return try await plainClient.request(AuthEndpoint.loginKakao(body)) } public func loginWithApple(identityToken: String) async throws -> LoginResponseDTO { - let body = try makeEncoder().encode(AppleLoginRequestDTO(identityToken: identityToken)) + let body = try encoder.encode(AppleLoginRequestDTO(identityToken: identityToken)) return try await plainClient.request(AuthEndpoint.loginApple(body)) } public func loginWithDev() async throws -> LoginResponseDTO { - let body = try makeEncoder().encode(DevLoginRequestDTO()) + let body = try encoder.encode(DevLoginRequestDTO()) return try await plainClient.request(AuthEndpoint.loginDev(body)) } public func refresh(refreshToken: String) async throws -> TokenResponseDTO { - let body = try makeEncoder().encode(RefreshRequestDTO(refreshToken: refreshToken)) + let body = try encoder.encode(RefreshRequestDTO(refreshToken: refreshToken)) return try await plainClient.request(AuthEndpoint.refresh(body)) } public func logout() async throws { try await authedClient.request(AuthEndpoint.logout) } - - private func makeEncoder() -> JSONEncoder { - NetworkJSONCoding.makeEncoder() - } } diff --git a/Projects/Data/Sources/Auth/Service/OAuth/OAuthServiceFactory.swift b/Projects/Data/Sources/Auth/Service/OAuth/OAuthServiceFactory.swift deleted file mode 100644 index d5d92b7..0000000 --- a/Projects/Data/Sources/Auth/Service/OAuth/OAuthServiceFactory.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Domain -import Foundation - -public struct OAuthServices: Sendable { - public let kakao: any OAuthService - public let apple: any OAuthService - - public init(kakao: any OAuthService, apple: any OAuthService) { - self.kakao = kakao - self.apple = apple - } - - public func service(for provider: AuthProvider) -> any OAuthService { - switch provider { - case .kakao: - return kakao - case .apple: - return apple - } - } -} - -public enum OAuthServiceFactory { - /// PR1 stub. 실제 SDK 연동은 후속 구현으로 교체한다. - public static func makeStub() -> OAuthServices { - OAuthServices( - kakao: NotConfiguredOAuthService(provider: .kakao), - apple: NotConfiguredOAuthService(provider: .apple) - ) - } -} - -struct NotConfiguredOAuthService: OAuthService { - let provider: AuthProvider - - func login() async throws -> String { - switch provider { - case .kakao: - throw AuthError.notConfigured( - message: "Kakao login is not configured yet" - ) - case .apple: - throw AuthError.notConfigured( - message: "Apple login is not configured yet" - ) - } - } -} diff --git a/Projects/Data/Sources/Auth/Service/SocialAuth/SocialAuthCredentialProvider.swift b/Projects/Data/Sources/Auth/Service/SocialAuth/SocialAuthCredentialProvider.swift new file mode 100644 index 0000000..bff691f --- /dev/null +++ b/Projects/Data/Sources/Auth/Service/SocialAuth/SocialAuthCredentialProvider.swift @@ -0,0 +1,34 @@ +import CoreSocialAuth +import Domain +import Foundation + +enum SocialAuthCredentialProvider { + static func credential( + for provider: AuthProvider, + services: SocialAuthServices + ) async throws -> String { + do { + switch provider { + case .kakao: + return try await services.kakao.login() + case .apple: + return try await services.apple.login() + } + } catch let error as SocialAuthError { + throw map(error) + } catch { + throw AuthError.loginFailed + } + } + + private static func map(_ error: SocialAuthError) -> AuthError { + switch error { + case .cancelled: + return .cancelled + case .failed: + return .loginFailed + case let .notConfigured(message): + return .notConfigured(message: message) + } + } +} diff --git a/Projects/Data/Tests/Auth/OAuthServiceFactoryTests.swift b/Projects/Data/Tests/Auth/OAuthServiceFactoryTests.swift deleted file mode 100644 index 255c6f1..0000000 --- a/Projects/Data/Tests/Auth/OAuthServiceFactoryTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -@testable import Data -import Domain -import XCTest - -final class OAuthServiceFactoryTests: XCTestCase { - func test_카카오_stub_로그인이면_notConfigured() async { - let services = OAuthServiceFactory.makeStub() - - do { - _ = try await services.service(for: .kakao).login() - XCTFail("expected notConfigured") - } catch let error as AuthError { - XCTAssertEqual( - error, - .notConfigured(message: "Kakao login is not configured yet") - ) - } catch { - XCTFail("unexpected \(error)") - } - } - - func test_애플_stub_로그인이면_notConfigured() async { - let services = OAuthServiceFactory.makeStub() - - do { - _ = try await services.service(for: .apple).login() - XCTFail("expected notConfigured") - } catch let error as AuthError { - XCTAssertEqual( - error, - .notConfigured(message: "Apple login is not configured yet") - ) - } catch { - XCTFail("unexpected \(error)") - } - } -} diff --git a/Projects/Data/Tests/Auth/SocialAuthCredentialProviderTests.swift b/Projects/Data/Tests/Auth/SocialAuthCredentialProviderTests.swift new file mode 100644 index 0000000..2c5d9e8 --- /dev/null +++ b/Projects/Data/Tests/Auth/SocialAuthCredentialProviderTests.swift @@ -0,0 +1,122 @@ +@testable import Data +import CoreSocialAuth +import Domain +import XCTest + +final class SocialAuthCredentialProviderTests: XCTestCase { + func test_카카오_provider면_kakao_service를_호출() async throws { + let kakao = StubSocialAuthService(token: "kakao-token") + let apple = StubSocialAuthService(token: "apple-token") + let services = SocialAuthServices(kakao: kakao, apple: apple) + + let token = try await SocialAuthCredentialProvider.credential( + for: .kakao, + services: services + ) + + XCTAssertEqual(token, "kakao-token") + let kakaoCalls = await kakao.callCount + let appleCalls = await apple.callCount + XCTAssertEqual(kakaoCalls, 1) + XCTAssertEqual(appleCalls, 0) + } + + func test_애플_provider면_apple_service를_호출() async throws { + let kakao = StubSocialAuthService(token: "kakao-token") + let apple = StubSocialAuthService(token: "apple-token") + let services = SocialAuthServices(kakao: kakao, apple: apple) + + let token = try await SocialAuthCredentialProvider.credential( + for: .apple, + services: services + ) + + XCTAssertEqual(token, "apple-token") + } + + func test_cancelled는_AuthError_cancelled로_매핑() async { + let services = SocialAuthServices( + kakao: StubSocialAuthService(error: SocialAuthError.cancelled), + apple: StubSocialAuthService(token: "unused") + ) + + do { + _ = try await SocialAuthCredentialProvider.credential( + for: .kakao, + services: services + ) + XCTFail("expected cancelled") + } catch let error as AuthError { + XCTAssertEqual(error, .cancelled) + } catch { + XCTFail("unexpected \(error)") + } + } + + func test_notConfigured는_AuthError_notConfigured로_매핑() async { + let services = SocialAuthServices( + kakao: StubSocialAuthService( + error: .notConfigured(message: "Kakao login is not configured yet") + ), + apple: StubSocialAuthService(token: "unused") + ) + + do { + _ = try await SocialAuthCredentialProvider.credential( + for: .kakao, + services: services + ) + XCTFail("expected notConfigured") + } catch let error as AuthError { + XCTAssertEqual( + error, + .notConfigured(message: "Kakao login is not configured yet") + ) + } catch { + XCTFail("unexpected \(error)") + } + } + + func test_failed는_AuthError_loginFailed로_매핑() async { + let services = SocialAuthServices( + kakao: StubSocialAuthService(error: .failed), + apple: StubSocialAuthService(token: "unused") + ) + + do { + _ = try await SocialAuthCredentialProvider.credential( + for: .kakao, + services: services + ) + XCTFail("expected loginFailed") + } catch let error as AuthError { + XCTAssertEqual(error, .loginFailed) + } catch { + XCTFail("unexpected \(error)") + } + } +} + +private actor StubSocialAuthService: SocialAuthService { + private let token: String? + private let error: SocialAuthError? + private(set) var callCount = 0 + + init(token: String) { + self.token = token + self.error = nil + } + + init(error: SocialAuthError) { + self.token = nil + self.error = error + } + + func login() async throws -> String { + callCount += 1 + if let error { + throw error + } + return token ?? "" + } +} diff --git a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift index ce08f65..43a84ba 100644 --- a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift +++ b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift @@ -34,8 +34,5 @@ public struct AppCoordinatorView: View { .task { store.send(.onAppear) } - .onOpenURL { url in - store.send(.deepLinkReceived(url)) - } } } diff --git a/Projects/Shared/Util/Sources/Bundle/AppInfo.swift b/Projects/Shared/Util/Sources/Bundle/AppInfo.swift index 46a6fab..9b29ac2 100644 --- a/Projects/Shared/Util/Sources/Bundle/AppInfo.swift +++ b/Projects/Shared/Util/Sources/Bundle/AppInfo.swift @@ -49,6 +49,11 @@ public enum AppInfo { return url } + /// 카카오 네이티브 앱 키. 비어 있거나 없으면 nil. + public static var kakaoNativeAppKey: String? { + string(.kakaoNativeAppKey) + } + public static func string(_ key: InfoPlistKey) -> String? { nonEmptyString(fromInfoDictionaryKey: key.rawValue) } diff --git a/Projects/Shared/Util/Sources/Bundle/InfoPlistKey.swift b/Projects/Shared/Util/Sources/Bundle/InfoPlistKey.swift index 9bf8c3f..d9b0caa 100644 --- a/Projects/Shared/Util/Sources/Bundle/InfoPlistKey.swift +++ b/Projects/Shared/Util/Sources/Bundle/InfoPlistKey.swift @@ -2,6 +2,7 @@ import Foundation public enum InfoPlistKey: String, Sendable { case apiBaseURL = "API_BASE_URL" + case kakaoNativeAppKey = "KAKAO_NATIVE_APP_KEY" case appVersion = "CFBundleShortVersionString" case buildNumber = "CFBundleVersion" } diff --git a/Projects/ThirdParty/ThirdPartyCore/Project.swift b/Projects/ThirdParty/ThirdPartyCore/Project.swift index 6ed34aa..2c00d5d 100644 --- a/Projects/ThirdParty/ThirdPartyCore/Project.swift +++ b/Projects/ThirdParty/ThirdPartyCore/Project.swift @@ -1,7 +1,14 @@ import ProjectDescription import ProjectDescriptionHelpers -let project = ProjectFactory.framework( +let project = ProjectFactory.thirdParty( .thirdPartyCore, - dependencies: [] + packages: [ + .package(url: "https://github.com/kakao/kakao-ios-sdk", .exact("2.28.0")), + ], + productDependencies: [ + .package(product: "KakaoSDKCommon"), + .package(product: "KakaoSDKAuth"), + .package(product: "KakaoSDKUser"), + ] ) diff --git a/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift b/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift index 67d54cb..0413478 100644 --- a/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift +++ b/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift @@ -1,2 +1,3 @@ -/// UI 가 아닌 외부 패키지 re-export 진입점 -public enum ThirdPartyCorePlaceholder {} +@_exported import KakaoSDKAuth +@_exported import KakaoSDKCommon +@_exported import KakaoSDKUser diff --git a/Tuist/ProjectDescriptionHelpers/DefaultInfoPlist.swift b/Tuist/ProjectDescriptionHelpers/DefaultInfoPlist.swift index dcb6323..3420edf 100644 --- a/Tuist/ProjectDescriptionHelpers/DefaultInfoPlist.swift +++ b/Tuist/ProjectDescriptionHelpers/DefaultInfoPlist.swift @@ -7,6 +7,7 @@ public enum DefaultInfoPlist { "CFBundleVersion": .string(ProjectEnvironment.appBuildNumber), "API_BASE_URL": "$(API_BASE_URL)", + "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", "UILaunchStoryboardName": "LaunchScreen", "UIUserInterfaceStyle": "Dark", @@ -22,13 +23,26 @@ public enum DefaultInfoPlist { "UISceneConfigurations": [:], ], + // 카카오톡 로그인 가능 여부 조회용 스킴 + "LSApplicationQueriesSchemes": [ + "kakaokompassauth", + "kakaolink", + "kakaoplus", + ], + // 딥링크용 커스텀 스킴: mozi://home + // 카카오 로그인 콜백: kakao{NATIVE_APP_KEY}://oauth "CFBundleURLTypes": [ [ "CFBundleTypeRole": "Editor", "CFBundleURLName": "$(PRODUCT_BUNDLE_IDENTIFIER)", "CFBundleURLSchemes": ["mozi"], - ] + ], + [ + "CFBundleTypeRole": "Editor", + "CFBundleURLName": "kakao-$(PRODUCT_BUNDLE_IDENTIFIER)", + "CFBundleURLSchemes": ["kakao$(KAKAO_NATIVE_APP_KEY)"], + ], ], ]) diff --git a/Tuist/ProjectDescriptionHelpers/Module.swift b/Tuist/ProjectDescriptionHelpers/Module.swift index 0c7970f..774c7e3 100644 --- a/Tuist/ProjectDescriptionHelpers/Module.swift +++ b/Tuist/ProjectDescriptionHelpers/Module.swift @@ -15,6 +15,7 @@ public enum Module: String, CaseIterable { case app case coreNetwork case coreStorage + case coreSocialAuth public var targetName: String { switch self { @@ -30,6 +31,7 @@ public enum Module: String, CaseIterable { case .app: return "App" case .coreNetwork: return "CoreNetwork" case .coreStorage: return "CoreStorage" + case .coreSocialAuth: return "CoreSocialAuth" } } @@ -47,6 +49,7 @@ public enum Module: String, CaseIterable { case .app: return "Projects/App" case .coreNetwork: return "Projects/Core/Network" case .coreStorage: return "Projects/Core/Storage" + case .coreSocialAuth: return "Projects/Core/SocialAuth" } } @@ -68,6 +71,7 @@ public enum Module: String, CaseIterable { case .app: return "app" case .coreNetwork: return "core.network" case .coreStorage: return "core.storage" + case .coreSocialAuth: return "core.socialauth" } } @@ -93,4 +97,5 @@ public extension TargetDependency { static var feature: TargetDependency { Module.feature.dependency } static var coreNetwork: TargetDependency { Module.coreNetwork.dependency } static var coreStorage: TargetDependency { Module.coreStorage.dependency } + static var coreSocialAuth: TargetDependency { Module.coreSocialAuth.dependency } } diff --git a/Tuist/ProjectDescriptionHelpers/ProjectFactory.swift b/Tuist/ProjectDescriptionHelpers/ProjectFactory.swift index fba8a44..72046f8 100644 --- a/Tuist/ProjectDescriptionHelpers/ProjectFactory.swift +++ b/Tuist/ProjectDescriptionHelpers/ProjectFactory.swift @@ -168,7 +168,8 @@ public enum ProjectFactory { dependencies: [TargetDependency], infoPlist: InfoPlist = DefaultInfoPlist.app, sources: SourceFilesList = ["Sources/**"], - resources: ResourceFileElements = ["Resources/**"] + resources: ResourceFileElements = ["Resources/**"], + entitlements: Entitlements? = nil ) -> Project { let target = Target.target( name: name, @@ -179,6 +180,7 @@ public enum ProjectFactory { infoPlist: infoPlist, sources: sources, resources: resources, + entitlements: entitlements, dependencies: dependencies, settings: ProjectSettings.app() ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f741c17..81d68b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ 세로 계층 = 모듈 가로 Feature = 폴더 외부 의존성 = ThirdParty* -live 구현 = Data / live 등록 = App only +기술 구현 = Core/* / 도메인 오케스트레이션 = Data / live 등록 = App only ``` --- @@ -18,7 +18,7 @@ Projects/ Shared/{Util,DesignSystem,Logger} ThirdParty/{ThirdParty,ThirdPartyUI,ThirdPartyCore} Domain/ - Core/{Network,Storage} + Core/{Network,Storage,SocialAuth} Data/ Feature/ App/ @@ -31,8 +31,8 @@ Projects/ | SharedLogger | 전역 `Logger.shared` OSLog facade | | ThirdParty* | 외부 패키지 진입점 | | Domain | Entity, `*Client`, Error | -| Core/* | Network/Storage | -| Data | DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`, `*Client.live` | +| Core/* | Network/Storage/SocialAuth 등 기술 구현. Domain 모름 | +| Data | DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`, `*Client.live` (Remote는 순수 서버 통신) | | Feature | Root, AppCoordinator, Scene | | App | bootstrap, live 주입, root store | @@ -53,6 +53,7 @@ App → 조립 Feature → Data / Core* / ThirdPartyCore Domain → Data / Core* / Feature Data → Feature +Core/* → Domain / Data / Feature / App ``` --- @@ -80,7 +81,8 @@ bootstrapping profileCompleted == true → main(Placeholder) ``` -Auth 인프라와 로그인 게이트 UI 는 존재한다. 소셜 SDK 실연동과 MainTab 은 후속이다. +Auth 인프라 + 로그인 게이트 + CoreSocialAuth 기반 카카오/애플 연동이 존재한다. +App 은 SocialAuth factory 조립과 bootstrap/redirect 호출만 담당한다. MainTab 은 후속이다. --- @@ -91,7 +93,7 @@ Auth 인프라와 로그인 게이트 UI 는 존재한다. 소셜 SDK 실연동 | `Mozi-Debug` | Debug | `com.teamMozi.debug` | | `Mozi` | Release | `com.teamMozi.app` | -storage namespace 는 Bundle ID 재사용. +Keychain service 는 Bundle ID 를 사용하고, UserDefaults 는 standard 를 사용한다. --- @@ -120,6 +122,7 @@ storage namespace 는 Bundle ID 재사용. - [Domain](../Projects/Domain/README.md) - [Data](../Projects/Data/README.md) - [CoreNetwork](../Projects/Core/Network/README.md) +- [CoreSocialAuth](../Projects/Core/SocialAuth/README.md) ---