import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter/foundation.dart'; import 'auth.dart'; import 'social_providers.dart'; import '../../core/i18n/app_locale.dart'; /// 하나의 FANUZU UID 에 소셜 Provider 를 붙이고 떼는 Firebase 어댑터. /// /// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.* /// /// **새 계정을 만들지 않는다.** `signInWithCredential` 은 자격증명에 해당하는 계정이 /// 없으면 새로 만들어 버리지만 `linkWithCredential` 은 지금 로그인한 UID 에 붙이기만 /// 한다. 이 구분이 「UID 는 하나」를 지키는 지점이라 연결 경로는 링크 API 만 쓴다. final class FirebaseAccountLinker implements AccountLinker { FirebaseAccountLinker({ required List providers, fb.FirebaseAuth? firebaseAuth, }) : _providers = providers, _firebaseAuth = firebaseAuth ?? fb.FirebaseAuth.instance; final List _providers; final fb.FirebaseAuth _firebaseAuth; /// Firebase providerId ↔ 앱 제공자. `authStateChanges` 로 오는 값과 같은 표를 쓴다. static const Map providerIds = { AuthProviderKind.google: 'google.com', AuthProviderKind.apple: 'apple.com', // 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다. // 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을 // 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다. AuthProviderKind.kakao: 'oidc.kakao', AuthProviderKind.line: 'oidc.line', AuthProviderKind.email: 'password', }; @override Map get linkedAccounts { final data = _firebaseAuth.currentUser?.providerData; if (data == null) return const {}; final byId = {for (final d in data) d.providerId: d.email}; return { for (final entry in providerIds.entries) if (byId.containsKey(entry.value)) entry.key: byId[entry.value], }; } @override Future link(AuthProviderKind provider) async { final user = _firebaseAuth.currentUser; if (user == null) { return AuthFailed('E-AUTH-03', appL10n.authLoginRequired); } final impl = _implOf(provider); if (impl == null) { return AuthNotConfigured(provider, const ['연결 가능한 제공자 구현']); } final missing = impl.missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); if (impl is! CredentialProvider && impl is! OidcSignInProvider && impl is! KakaoSignInProvider) { return AuthNotConfigured(provider, const ['자격증명 구현']); } try { if (impl is KakaoSignInProvider) return await impl.link(user); if (impl is OidcSignInProvider) return await _linkOidc(user, impl); final source = impl as CredentialProvider; final obtained = await source.obtainCredential(); // 사용자가 소셜 창을 닫았다. 실패로 세지 않는다. if (obtained == null) return const AuthCancelled(); // 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다. // 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다. // // ponytail: 제공자가 이메일을 알려 줄 때만 판정한다. Apple 은 최초 인가 // 때만 이메일을 주므로 재연동 시 통과한다 — 서버(fn-account-link)에서 // 다시 보려면 Functions 층에 같은 판정을 둔다. final mine = user.email; final theirs = obtained.email; if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) { return AuthFailed('E-AUTH-08', appL10n.authDifferentEmail); } final result = await user.linkWithCredential(obtained.credential); return AuthSucceeded(_accountOf(result.user, provider)); } on fb.FirebaseAuthException catch (error) { if (isWebFlowCancelled(error)) return const AuthCancelled(); return AuthFailed( authCatalogCode(error), authMessage(error, flow: AuthFlow.link), ); } on Object catch (error) { // SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다. debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}'); return AuthFailed('E-AUTH-03', appL10n.authLinkFailed); } } @override Future unlink(AuthProviderKind provider) async { final user = _firebaseAuth.currentUser; if (user == null) { return AuthFailed('E-AUTH-03', appL10n.authLoginRequired); } // **마지막 하나는 떼지 않는다.** 떼는 순간 로그인 수단이 0개가 되어 계정에 // 다시 들어올 방법이 사라진다 — Firebase 는 이걸 막아 주지 않는다. if (linkedAccounts.length <= 1) { return AuthFailed('E-AUTH-13', appL10n.authLastProvider); } final id = providerIds[provider]; if (id == null) { return AuthFailed('E-AUTH-10', appL10n.authNotLinked); } try { await user.unlink(id); return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider)); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error), authMessage(error, flow: AuthFlow.link), ); } } /// 카카오·LINE(OIDC) 연결. /// /// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다. /// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다. /// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의 /// blocking 함수(beforeSignIn)로 서버에서 거른다. Future _linkOidc(fb.User user, OidcSignInProvider impl) async { final mine = user.email; final result = await untilAbandoned( user.linkWithProvider(impl.authProvider), ); // 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때). final theirs = _emailOf(result.user, impl.providerId); if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) { await user.unlink(impl.providerId); return AuthFailed('E-AUTH-08', appL10n.authDifferentEmail); } return AuthSucceeded(_accountOf(result.user, impl.provider)); } static String? _emailOf(fb.User? user, String providerId) { for (final info in user?.providerData ?? const []) { if (info.providerId == providerId) return info.email; } return null; } SocialAuthProvider? _implOf(AuthProviderKind provider) { for (final candidate in _providers) { if (candidate.provider == provider) return candidate; } return null; } static AuthAccount _accountOf(fb.User? user, AuthProviderKind provider) => AuthAccount( uid: user?.uid ?? '', provider: provider, displayName: user?.displayName, email: user?.email, ); }