diff --git a/Application/App/Sources/Resource/Info.plist b/Application/App/Sources/Resource/Info.plist index cfc4fe6a..27a437a2 100644 --- a/Application/App/Sources/Resource/Info.plist +++ b/Application/App/Sources/Resource/Info.plist @@ -4,8 +4,6 @@ TESTFLIGHT_URL $(TESTFLIGHT_URL) - APP_REDIRECT_URL - $(APP_REDIRECT_URL) CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -46,8 +44,6 @@ $(FUNCTION_API_BASE_URL) GIDClientID $(CLIENT_ID) - GITHUB_CLIENT_ID - $(GITHUB_CLIENT_ID) ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/Application/Data/Sources/DTO/AuthDataResponse.swift b/Application/Data/Sources/DTO/AuthDataResponse.swift index 403fe06c..5721ebe8 100644 --- a/Application/Data/Sources/DTO/AuthDataResponse.swift +++ b/Application/Data/Sources/DTO/AuthDataResponse.swift @@ -15,7 +15,6 @@ public struct AuthDataResponse { public let providers: [String] public let providerID: String public let fcmToken: String? - public let accessToken: String? public init( uid: String, @@ -23,8 +22,7 @@ public struct AuthDataResponse { email: String?, providers: [String], providerID: String, - fcmToken: String? = nil, - accessToken: String? = nil + fcmToken: String? = nil ) { self.uid = uid self.displayName = displayName @@ -32,6 +30,5 @@ public struct AuthDataResponse { self.providers = providers self.providerID = providerID self.fcmToken = fcmToken - self.accessToken = accessToken } } diff --git a/Application/Infra/Sources/Common/InfraLayerError.swift b/Application/Infra/Sources/Common/InfraLayerError.swift index 94c6783f..ed9d4ca8 100644 --- a/Application/Infra/Sources/Common/InfraLayerError.swift +++ b/Application/Infra/Sources/Common/InfraLayerError.swift @@ -30,6 +30,7 @@ enum TokenError: Error { enum SocialLoginError: Error { case invalidOAuthState + case invalidOAuthCallback case failedToStartWebAuthenticationSession case authenticationAlreadyInProgress } diff --git a/Application/Infra/Sources/Extension/FirebaseAuthUser+.swift b/Application/Infra/Sources/Extension/FirebaseAuthUser+.swift index 0e82c5fa..44cd7791 100644 --- a/Application/Infra/Sources/Extension/FirebaseAuthUser+.swift +++ b/Application/Infra/Sources/Extension/FirebaseAuthUser+.swift @@ -10,17 +10,13 @@ import FirebaseAuth import Data extension FirebaseAuth.User { - func makeResponse( - providerID: AuthProviderID, - accessToken: String? = nil - ) -> AuthDataResponse { + func makeResponse(providerID: AuthProviderID) -> AuthDataResponse { return AuthDataResponse( uid: self.uid, displayName: self.displayName, email: self.email, providers: self.providerData.map { $0.providerID }, - providerID: providerID.rawValue, - accessToken: accessToken + providerID: providerID.rawValue ) } } diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index cd880e2a..54812124 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -99,9 +99,17 @@ struct FunctionAPIEndpoint: NXEndpoint { let path: String } -struct GithubAuthenticationResponse: Decodable { - let accessToken: String? - let customToken: String? +struct OAuthAuthenticationSessionRequest: Encodable { + let appChallenge: String +} + +struct OAuthAuthenticationSessionResponse: Decodable { + let authorizationURL: URL +} + +struct OAuthAuthenticationTicketRequest: Encodable { + let ticket: String + let appVerifier: String } struct AppleChallengeResponse: Decodable { @@ -109,14 +117,10 @@ struct AppleChallengeResponse: Decodable { let hashedNonce: String } -struct AppleCustomTokenResponse: Decodable { +struct FirebaseCustomTokenResponse: Decodable { let customToken: String } -struct AppleOperationResponse: Decodable { - let success: Bool -} - struct AppleCustomTokenRequest: Encodable { let challengeId: String let authorizationCode: String @@ -145,6 +149,9 @@ private enum FunctionAPIErrorCode: String { enum AppleAuthenticationAPIError: Error, Equatable { case providerLinkConflict +} + +enum AuthenticationAPIError: Error, Equatable { case lastProvider } @@ -169,7 +176,7 @@ struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { case .appleProviderLinkConflict: return AppleAuthenticationAPIError.providerLinkConflict case .lastProvider: - return AppleAuthenticationAPIError.lastProvider + return AuthenticationAPIError.lastProvider } } } @@ -193,6 +200,10 @@ extension Error { functionAPIUnderlyingError as? AppleAuthenticationAPIError } + var apiAuthenticationError: AuthenticationAPIError? { + functionAPIUnderlyingError as? AuthenticationAPIError + } + private var functionAPIUnderlyingError: (any Error)? { guard let error = self as? NXError, case let .server(statusCode: _, data: _, underlying: underlying) = error else { diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index a63f9ab5..008153f6 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -35,25 +35,31 @@ extension FunctionAPIEndpoint where Response == EmptyAPIResponse { } static let revokeGithubAccessToken = Self(method: .delete, path: "/auth/github/access-token") + static let linkGithubAccount = Self(method: .put, path: "/auth/github/account-link") + static let unlinkGithubAccount = Self(method: .delete, path: "/auth/github/account-link") + 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 == AppleChallengeResponse { static let requestAppleChallenge = Self(method: .post, path: "/auth/apple/challenges") } -extension FunctionAPIEndpoint where Response == AppleCustomTokenResponse { +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") } -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") +extension FunctionAPIEndpoint where Response == OAuthAuthenticationSessionResponse { + static let requestGithubSignInSession = Self( + method: .post, + path: "/auth/github/sign-in-sessions" + ) + static let requestGithubAccountLinkSession = Self( + method: .post, + path: "/auth/github/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 5cbb068d..17f25610 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -110,8 +110,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { func deleteAuth(_ uid: String) async throws { do { logger.info("Deleting Apple grant for user: \(uid)") - let response = try await FunctionAPIClient.shared.send(.revokeAppleAccessToken) - try validate(response) + try await FunctionAPIClient.shared.send(.revokeAppleAccessToken) } catch { logger.error("Failed to delete Apple auth", error: error) record(error, code: .deleteAuth) @@ -125,7 +124,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { let challenge = try await requestAppleChallenge() let response = try await authenticateWithAppleAsync(hashedNonce: challenge.hashedNonce) - let operation = try await FunctionAPIClient.shared.send( + try await FunctionAPIClient.shared.send( .linkAppleAccount, payload: AppleAccountLinkRequest( challengeId: challenge.challengeId, @@ -133,7 +132,6 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { credentialEmail: response.email ) ) - try validate(operation) try await user?.reload() return true } catch { @@ -149,8 +147,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { func unlink(_ uid: String) async throws { do { logger.info("Unlinking Apple account for user: \(uid)") - let response = try await FunctionAPIClient.shared.send(.unlinkAppleAccount) - try validate(response) + try await FunctionAPIClient.shared.send(.unlinkAppleAccount) try await user?.reload() } catch { let mappedError = mapAppleAPIError(error) @@ -243,22 +240,18 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } private extension AppleAuthenticationServiceImpl { - func validate(_ response: AppleOperationResponse) throws { - guard response.success else { - throw TokenError.invalidResponse - } - } - func mapAppleAPIError(_ error: Error) -> Error { if let emailError = error.apiEmailError { return emailError } + if error.apiAuthenticationError == .lastProvider { + return DataLayerError.failedToUnlinkLastProvider + } + switch error.apiAppleAuthenticationError { case .providerLinkConflict: return DataLayerError.linkCredentialAlreadyInUse - case .lastProvider: - return DataLayerError.failedToUnlinkLastProvider case .none: return error } diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index 2164fb59..554580e9 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -5,16 +5,13 @@ // Created by opfic on 6/4/25. // -import AuthenticationServices -import Foundation import FirebaseAuth import FirebaseFirestore import FirebaseMessaging -import Nexa import Core import Data -final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { +final class GithubAuthenticationServiceImpl: AuthenticationService { private enum CrashlyticsError { static let domain = "DevLogInfra.GithubAuthenticationServiceImpl" @@ -27,67 +24,37 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } } - private enum GitHubAPI { - static let baseURL = URL(string: "https://api.github.com")! - static let acceptHeader = "application/vnd.github.v3+json" - } - private let store = FirebaseConfiguration.firestore private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } - private let providerID = AuthProviderID.gitHub - private let provider = TopViewControllerProvider() private let logger = Logger(category: "GithubAuthService") - private let gitHubApiClient = NXAPIClient( - configuration: NXClientConfiguration( - baseURL: GitHubAPI.baseURL, - headers: ["Accept": GitHubAPI.acceptHeader] - ) - ) func signIn() async throws -> AuthDataResponse? { logger.info("Starting GitHub sign in") - + do { - // 1. GitHub OAuth 로그인 요청 - logger.debug("Requesting authorization code") - let authorizationCode = try await requestAuthorizationCode() + let request = try await requestAuthenticationTicket( + endpoint: .requestGithubSignInSession, + requiresAuthentication: false + ) + let response = try await FunctionAPIClient.shared.send( + .requestGithubCustomToken, + payload: request, + requiresAuthentication: false + ) - // 2. Firebase Functions를 통해 customToken 발급 요청 - logger.debug("Requesting tokens from Firebase Function") - let (accessToken, customToken) = try await requestTokens(authorizationCode: authorizationCode) - - // 3. Firebase 로그인 logger.debug("Signing in with custom token") - let result = try await Auth.auth().signIn(withCustomToken: customToken) - - // 4. Firebase Auth 사용자 프로필 업데이트 - let githubUser = try await requestUserProfile(accessToken: accessToken) - - if let photoURL = githubUser.avatarURL, let url = URL(string: photoURL) { - let changeRequest = result.user.createProfileChangeRequest() - changeRequest.photoURL = url - changeRequest.displayName = githubUser.name ?? githubUser.login - try await changeRequest.commitChanges() - } - - // 5. GitHub 계정과 Firebase Auth 계정 연결 - if !result.user.providerData.contains(where: { $0.providerID == providerID.rawValue }) { - let credential = OAuthProvider.credential(providerID: providerID, accessToken: accessToken) - try await result.user.link(with: credential) - } + let result = try await Auth.auth().signIn(withCustomToken: response.customToken) logger.info("Successfully signed in with GitHub") - return result.user.makeResponse( - providerID: .gitHub, - accessToken: accessToken - ) + return result.user.makeResponse(providerID: .gitHub) } catch { if error.isSocialLoginCancelled { return nil } - logger.error("Failed to sign in with GitHub", error: error) - record(error, code: .signIn) - throw error + let mappedError = mapGithubAPIError(error) + logger.error("Failed to sign in with GitHub", error: mappedError) + record(mappedError, code: .signIn) + throw mappedError } } @@ -114,7 +81,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { func deleteAuth(_ uid: String) async throws { do { - try await revokeAccessToken() + try await FunctionAPIClient.shared.send(.revokeGithubAccessToken) } catch { logger.error("Failed to delete GitHub auth", error: error) record(error, code: .deleteAuth) @@ -124,202 +91,80 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { func link(uid: String, email _: String) async throws -> Bool { logger.info("Linking GitHub account for user: \(uid)") - - do { - let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) - let authorizationCode = try await requestAuthorizationCode() - let accessToken = try await requestProviderLink(authorizationCode: authorizationCode) - try await tokensRef.setData(["githubAccessToken": accessToken], merge: true) + do { + let request = try await requestAuthenticationTicket( + endpoint: .requestGithubAccountLinkSession, + requiresAuthentication: true + ) + try await FunctionAPIClient.shared.send( + .linkGithubAccount, + payload: request + ) try await user?.reload() - + logger.info("Successfully linked GitHub account") return true } catch { if error.isSocialLoginCancelled { return false } - logger.error("Failed to link GitHub account", error: error) - record(error, code: .link) - if error.isFirebaseCredentialAlreadyInUse { - throw DataLayerError.linkCredentialAlreadyInUse - } - throw error + let mappedError = mapGithubAPIError(error) + logger.error("Failed to link GitHub account", error: mappedError) + record(mappedError, code: .link) + throw mappedError } } func unlink(_ uid: String) async throws { do { - logger.info("Starting GitHub access token revocation for unlink. uid: \(uid)") - try await revokeAccessToken() - - let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) - - logger.info("Starting GitHub access token deletion from Firestore for unlink. uid: \(uid)") - try await tokensRef.updateData(["githubAccessToken": FieldValue.delete()]) - - logger.info("Starting Firebase GitHub provider unlink. uid: \(uid)") - _ = try await user?.unlink(fromProvider: providerID.rawValue) - } catch { - logger.error("Failed to unlink GitHub account", error: error) - record(error, code: .unlink) - throw error - } - } - - @MainActor - private func requestAuthorizationCode() async throws -> String { - guard let clientID = Bundle.main.object(forInfoDictionaryKey: "GITHUB_CLIENT_ID") as? String, - let redirectURL = Bundle.main.object(forInfoDictionaryKey: "APP_REDIRECT_URL") as? String, - let urlComponents = URLComponents(string: redirectURL), - let callbackURLScheme = urlComponents.scheme else { - throw URLError(.badURL) - } - - // state: CSRF(사이트 간 요청 위조) 공격 방지용 랜덤 문자열 - let state = UUID().uuidString - let scope = "read:user user:email" // 공개된 정보와 이메일 요청 - - // Use URLComponents for proper encoding - var components = URLComponents(string: "https://github.com/login/oauth/authorize")! - components.queryItems = [ - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "scope", value: scope), - URLQueryItem(name: "redirect_url", value: redirectURL), - URLQueryItem(name: "state", value: state) - ] - - guard let authURL = components.url else { - throw URLError(.badURL) - } - - return try await withCheckedThrowingContinuation { continuation in - let session = ASWebAuthenticationSession( - url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in - if let error = error { - continuation.resume(throwing: error) - return - } - - guard let callbackURL = callbackURL, - let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems, - let code = queryItems.first(where: { $0.name == "code" })?.value else { - continuation.resume(throwing: URLError(.badServerResponse)) - return - } - - // 반환된 state 값 확인 / 받아온 값이 다르면 CSRF 공격 가능성 있음 - guard let returnedState = queryItems.first(where: { $0.name == "state" })?.value, - returnedState == state else { - continuation.resume(throwing: SocialLoginError.invalidOAuthState) - return - } - - continuation.resume(returning: code) - } - - session.presentationContextProvider = self - session.prefersEphemeralWebBrowserSession = false // 웹에서 깃헙 로그인 후 세션 유지 - - if !session.start() { - continuation.resume(throwing: SocialLoginError.failedToStartWebAuthenticationSession) - } - } - } - - // Firebase Function 호출: Custom Token 발급 - private func requestTokens(authorizationCode: String) async throws -> (String, String) { - do { - let response = try await FunctionAPIClient.shared.send( - .requestGithubTokens, - payload: ["code": authorizationCode], - requiresAuthentication: false - ) - - if let accessToken = response.accessToken, - let customToken = response.customToken { - return (accessToken, customToken) - } - throw TokenError.invalidResponse + logger.info("Unlinking GitHub account for user: \(uid)") + try await FunctionAPIClient.shared.send(.unlinkGithubAccount) + try await user?.reload() } catch { - throw mapAPIEmailError(error) + let mappedError = mapGithubAPIError(error) + logger.error("Failed to unlink GitHub account", error: mappedError) + record(mappedError, code: .unlink) + throw mappedError } } +} - private func requestProviderLink(authorizationCode: String) async throws -> String { - do { - let response = try await FunctionAPIClient.shared.send( - .linkGithubProvider, - payload: ["code": authorizationCode], - requiresAuthentication: true - ) - - guard let accessToken = response.accessToken else { - throw TokenError.invalidResponse - } - return accessToken - } catch { - throw mapAPIEmailError(error) - } - } - - private func revokeAccessToken(accessToken: String? = nil) async throws { - var param: [String: String] = [:] - - if let accessToken = accessToken { - param["accessToken"] = accessToken - } - - try await FunctionAPIClient.shared.send( - .revokeGithubAccessToken, - payload: param +private extension GithubAuthenticationServiceImpl { + func requestAuthenticationTicket( + endpoint: FunctionAPIEndpoint, + requiresAuthentication: Bool + ) async throws -> OAuthAuthenticationTicketRequest { + let proof = OAuthWebAuthenticationProof() + let response = try await FunctionAPIClient.shared.send( + endpoint, + payload: OAuthAuthenticationSessionRequest( + appChallenge: proof.appChallenge + ), + requiresAuthentication: requiresAuthentication ) - } - - // GitHub API로 사용자 프로필 정보 가져오기 - private func requestUserProfile(accessToken: String) async throws -> GitHubUser { - let gitHubUser = try await gitHubApiClient - .get("/user", as: GitHubUser.self) - .header("Authorization", "Bearer \(accessToken)") - .validate(.statusCodes([200])) - .send() - - if gitHubUser.email != nil { - return gitHubUser - } - - let email = try await requestPrimaryVerifiedEmail(accessToken: accessToken) - return GitHubUser( - login: gitHubUser.login, - name: gitHubUser.name, - avatarURL: gitHubUser.avatarURL, - email: email + let callbackURL = try await OAuthWebAuthenticationSession.authenticate( + url: response.authorizationURL, + callbackURLScheme: OAuthWebAuthenticationProof.callbackURLScheme ) - } - - private func requestPrimaryVerifiedEmail(accessToken: String) async throws -> String? { - let gitHubEmails = try await gitHubApiClient - .get("/user/emails", as: [GitHubEmail].self) - .header("Authorization", "Bearer \(accessToken)") - .validate(.statusCodes([200])) - .send() - if let primaryVerifiedEmail = gitHubEmails.first(where: { $0.primary && $0.verified }) { - return primaryVerifiedEmail.email - } - - return gitHubEmails.first(where: { $0.verified })?.email + return OAuthAuthenticationTicketRequest( + ticket: try proof.ticket(from: callbackURL), + appVerifier: proof.appVerifier + ) } - private func mapAPIEmailError(_ error: Error) -> Error { + func mapGithubAPIError(_ error: Error) -> Error { if let emailError = error.apiEmailError { return emailError } + if error.apiAuthenticationError == .lastProvider { + return DataLayerError.failedToUnlinkLastProvider + } + return error } -} -private extension GithubAuthenticationServiceImpl { private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( error, @@ -331,30 +176,4 @@ private extension GithubAuthenticationServiceImpl { private func record(_ error: Error, code: CrashlyticsError.Code) { Self.record(error, code: code) } - - struct GitHubUser: Codable { - let login: String - let name: String? - let avatarURL: String? - let email: String? - - enum CodingKeys: String, CodingKey { - case login - case name - case avatarURL = "avatar_url" - case email - } - } - - struct GitHubEmail: Codable { - let email: String - let primary: Bool - let verified: Bool - } -} - -extension GithubAuthenticationServiceImpl: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return provider.keyWindow() ?? ASPresentationAnchor() - } } diff --git a/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationProof.swift b/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationProof.swift new file mode 100644 index 00000000..a73b6880 --- /dev/null +++ b/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationProof.swift @@ -0,0 +1,63 @@ +// +// OAuthWebAuthenticationProof.swift +// Infra +// +// Created by opfic on 7/12/26. +// + +import CryptoKit +import Foundation + +struct OAuthWebAuthenticationProof { + static let callbackURLScheme = "DevLog" + + let appVerifier: String + let appChallenge: String + + init() { + let verifier = Self.makeVerifier() + appVerifier = verifier + appChallenge = Self.challenge(for: verifier) + } + + init(appVerifier: String) { + self.appVerifier = appVerifier + appChallenge = Self.challenge(for: appVerifier) + } + + func ticket(from callbackURL: URL) throws -> String { + guard callbackURL.scheme?.caseInsensitiveCompare(Self.callbackURLScheme) == .orderedSame, + callbackURL.host?.caseInsensitiveCompare("oauth-callback") == .orderedSame, + let components = URLComponents( + url: callbackURL, + resolvingAgainstBaseURL: false + ), + let ticket = components.queryItems? + .first(where: { $0.name == "ticket" })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines), + !ticket.isEmpty else { + throw SocialLoginError.invalidOAuthCallback + } + + return ticket + } +} + +private extension OAuthWebAuthenticationProof { + static func makeVerifier() -> String { + [UUID().uuidString, UUID().uuidString] + .joined() + .replacingOccurrences(of: "-", with: "") + .lowercased() + } + + static func challenge(for verifier: String) -> String { + let digest = SHA256.hash(data: Data(verifier.utf8)) + return Data(digest) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationSession.swift b/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationSession.swift new file mode 100644 index 00000000..594ebde7 --- /dev/null +++ b/Application/Infra/Sources/Service/SocialLogin/OAuthWebAuthenticationSession.swift @@ -0,0 +1,69 @@ +// +// OAuthWebAuthenticationSession.swift +// Infra +// +// Created by opfic on 7/12/26. +// + +import AuthenticationServices +import Foundation + +@MainActor +final class OAuthWebAuthenticationSession: NSObject { + private let provider = TopViewControllerProvider() + private var session: ASWebAuthenticationSession? + + static func authenticate( + url: URL, + callbackURLScheme: String + ) async throws -> URL { + let authenticationSession = OAuthWebAuthenticationSession() + return try await authenticationSession.authenticate( + url: url, + callbackURLScheme: callbackURLScheme + ) + } + + private func authenticate( + url: URL, + callbackURLScheme: String + ) async throws -> URL { + defer { session = nil } + + return try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackURLScheme + ) { callbackURL, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let callbackURL else { + continuation.resume(throwing: SocialLoginError.invalidOAuthCallback) + return + } + + continuation.resume(returning: callbackURL) + } + + self.session = session + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + + if !session.start() { + self.session = nil + continuation.resume( + throwing: SocialLoginError.failedToStartWebAuthenticationSession + ) + } + } + } +} + +extension OAuthWebAuthenticationSession: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + provider.keyWindow() ?? ASPresentationAnchor() + } +} diff --git a/Application/Infra/Sources/Service/UserServiceImpl.swift b/Application/Infra/Sources/Service/UserServiceImpl.swift index 5cf69dd4..55b6c2d5 100644 --- a/Application/Infra/Sources/Service/UserServiceImpl.swift +++ b/Application/Infra/Sources/Service/UserServiceImpl.swift @@ -62,11 +62,6 @@ final class UserServiceImpl: UserService { tokenField["fcmToken"] = fcmToken } - // 깃헙 로그인 시 추가 정보 저장 - if response.providerID == "github.com", let accessToken = response.accessToken { - tokenField["githubAccessToken"] = accessToken - } - try await upsertUserDocuments( uid: user.uid, userField: userField, diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift index bb42c8fc..ad7a1844 100644 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -22,7 +22,7 @@ struct FunctionAPIEndpointTests { @Test("Apple custom token endpoint는 인증 custom token 경로를 사용한다") func Apple_custom_token_endpoint는_인증_custom_token_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint.requestAppleCustomToken + let endpoint = FunctionAPIEndpoint.requestAppleCustomToken #expect(endpoint.method.rawValue == "POST") #expect(endpoint.path == "/auth/apple/custom-token") @@ -30,7 +30,7 @@ struct FunctionAPIEndpointTests { @Test("Apple 계정 연결 endpoint는 PUT 인증 account link 경로를 사용한다") func Apple_계정_연결_endpoint는_PUT_인증_account_link_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint.linkAppleAccount + let endpoint = FunctionAPIEndpoint.linkAppleAccount #expect(endpoint.method.rawValue == "PUT") #expect(endpoint.path == "/auth/apple/account-link") @@ -38,7 +38,7 @@ struct FunctionAPIEndpointTests { @Test("Apple 계정 연결 해제 endpoint는 DELETE 인증 account link 경로를 사용한다") func Apple_계정_연결_해제_endpoint는_DELETE_인증_account_link_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint.unlinkAppleAccount + let endpoint = FunctionAPIEndpoint.unlinkAppleAccount #expect(endpoint.method.rawValue == "DELETE") #expect(endpoint.path == "/auth/apple/account-link") @@ -46,7 +46,7 @@ struct FunctionAPIEndpointTests { @Test("Apple grant 정리 endpoint는 DELETE 인증 access token 경로를 사용한다") func Apple_grant_정리_endpoint는_DELETE_인증_access_token_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint.revokeAppleAccessToken + let endpoint = FunctionAPIEndpoint.revokeAppleAccessToken #expect(endpoint.method.rawValue == "DELETE") #expect(endpoint.path == "/auth/apple/access-token") @@ -104,8 +104,7 @@ struct FunctionAPIEndpointTests { @Test( "Apple 서버 오류를 앱 인증 오류 원인으로 분류한다", arguments: [ - ("apple-provider-link-conflict", AppleAuthenticationAPIError.providerLinkConflict), - ("last-provider", AppleAuthenticationAPIError.lastProvider) + ("apple-provider-link-conflict", AppleAuthenticationAPIError.providerLinkConflict) ] ) func Apple_서버_오류를_앱_인증_오류_원인으로_분류한다( @@ -131,12 +130,87 @@ struct FunctionAPIEndpointTests { #expect(error as? AppleAuthenticationAPIError == expected) } - @Test("GitHub provider 연결 endpoint는 인증 link 경로를 사용한다") - func 깃허브_provider_연결_endpoint는_인증_link_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint.linkGithubProvider + @Test("마지막 provider 서버 오류를 공통 인증 오류로 분류한다") + func 마지막_provider_서버_오류를_공통_인증_오류로_분류한다() throws { + let data = try JSONEncoder().encode( + FunctionAPIErrorFixture(code: "last-provider") + ) + let response = try #require( + HTTPURLResponse( + url: URL(string: "https://example.com")!, + statusCode: 412, + httpVersion: nil, + headerFields: nil + ) + ) + + let error = FunctionAPIServerErrorDecoder().decodeServerError( + data: data, + response: response, + decoder: JSONDecoder() + ) + + #expect(error as? AuthenticationAPIError == .lastProvider) + } + + @Test("OAuth 인증 session 요청은 app challenge만 인코딩한다") + func OAuth_인증_session_요청은_app_challenge만_인코딩한다() throws { + let request = OAuthAuthenticationSessionRequest(appChallenge: "app-challenge") + + #expect(try encodedKeys(request) == ["appChallenge"]) + } + + @Test("OAuth 인증 ticket 요청은 ticket과 app verifier만 인코딩한다") + func OAuth_인증_ticket_요청은_ticket과_app_verifier만_인코딩한다() throws { + let request = OAuthAuthenticationTicketRequest( + ticket: "ticket", + appVerifier: "app-verifier" + ) + + #expect(try encodedKeys(request) == ["ticket", "appVerifier"]) + } + + @Test("GitHub 로그인 session endpoint는 sign-in-sessions 경로를 사용한다") + func GitHub_로그인_session_endpoint는_sign_in_sessions_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestGithubSignInSession + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/github/sign-in-sessions") + } + + @Test("GitHub custom token endpoint는 custom-token 경로를 사용한다") + func GitHub_custom_token_endpoint는_custom_token_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestGithubCustomToken + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/github/custom-token") + } + + @Test("GitHub 계정 연결 session endpoint는 account-link-sessions 경로를 사용한다") + func GitHub_계정_연결_session_endpoint는_account_link_sessions_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestGithubAccountLinkSession #expect(endpoint.method.rawValue == "POST") - #expect(endpoint.path == "/auth/github/link") + #expect(endpoint.path == "/auth/github/account-link-sessions") + } + + @Test("GitHub 계정 연결 endpoint는 PUT account-link 경로를 사용한다") + func GitHub_계정_연결_endpoint는_PUT_account_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.linkGithubAccount + + #expect(endpoint.method.rawValue == "PUT") + #expect(endpoint.path == "/auth/github/account-link") + } + + @Test("GitHub 계정 연결 해제 endpoint는 DELETE account-link 경로를 사용한다") + func GitHub_계정_연결_해제_endpoint는_DELETE_account_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.unlinkGithubAccount + + #expect(endpoint.method.rawValue == "DELETE") + #expect(endpoint.path == "/auth/github/account-link") } } diff --git a/Application/Infra/Tests/Service/OAuthWebAuthenticationProofTests.swift b/Application/Infra/Tests/Service/OAuthWebAuthenticationProofTests.swift new file mode 100644 index 00000000..961f671e --- /dev/null +++ b/Application/Infra/Tests/Service/OAuthWebAuthenticationProofTests.swift @@ -0,0 +1,62 @@ +// +// OAuthWebAuthenticationProofTests.swift +// InfraTests +// +// Created by opfic on 7/12/26. +// + +import Foundation +import Testing +@testable import Infra + +struct OAuthWebAuthenticationProofTests { + @Test("app challenge는 app verifier의 SHA-256 base64url 값이다") + func app_challenge는_app_verifier의_SHA_256_base64url_값이다() { + let proof = OAuthWebAuthenticationProof( + appVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + ) + + #expect(proof.appChallenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + } + + @Test("생성된 app verifier는 64자리 소문자 16진수다") + func 생성된_app_verifier는_64자리_소문자_16진수다() { + let proof = OAuthWebAuthenticationProof() + let allowedCharacters = CharacterSet(charactersIn: "0123456789abcdef") + + #expect(proof.appVerifier.count == 64) + #expect(proof.appVerifier.unicodeScalars.allSatisfy(allowedCharacters.contains)) + } + + @Test("DevLog OAuth callback에서 ticket을 추출한다") + func DevLog_OAuth_callback에서_ticket을_추출한다() throws { + let proof = OAuthWebAuthenticationProof(appVerifier: "verifier") + let callbackURL = try #require( + URL(string: "DevLog://oauth-callback?ticket=one-time-ticket") + ) + + #expect(try proof.ticket(from: callbackURL) == "one-time-ticket") + } + + @Test("다른 callback host는 거부한다") + func 다른_callback_host는_거부한다() throws { + let proof = OAuthWebAuthenticationProof(appVerifier: "verifier") + let callbackURL = try #require( + URL(string: "DevLog://unexpected?ticket=one-time-ticket") + ) + + #expect(throws: SocialLoginError.self) { + try proof.ticket(from: callbackURL) + } + } + + @Test("ticket이 없는 callback은 거부한다") + func ticket이_없는_callback은_거부한다() throws { + let proof = OAuthWebAuthenticationProof(appVerifier: "verifier") + let callbackURL = try #require(URL(string: "DevLog://oauth-callback")) + + #expect(throws: SocialLoginError.self) { + try proof.ticket(from: callbackURL) + } + } +}