import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter/foundation.dart'; import 'auth.dart'; import 'social_providers.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 const AuthFailed('E-AUTH-03', '로그인이 필요합니다.'); } 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(); // 이메일을 비교하지 않는다 — 이메일 일치는 소유 확인이 아니다. Firebase 가 지금 로그인 토큰과 // 새 자격증명을 서버에서 함께 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면 // credential-already-in-use 로 거부한다. 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 const AuthFailed('E-AUTH-03', '계정 연결에 실패했습니다.'); } } @override Future unlink(AuthProviderKind provider) async { final user = _firebaseAuth.currentUser; if (user == null) { return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.'); } // **마지막 하나는 떼지 않는다.** 떼는 순간 로그인 수단이 0개가 되어 계정에 // 다시 들어올 방법이 사라진다 — Firebase 는 이걸 막아 주지 않는다. if (linkedAccounts.length <= 1) { return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.'); } final id = providerIds[provider]; if (id == null) { return const AuthFailed('E-AUTH-10', '연결되어 있지 않은 계정입니다.'); } 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) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다. Future _linkOidc(fb.User user, OidcSignInProvider impl) async { final result = await untilAbandoned( user.linkWithProvider(impl.authProvider), ); return AuthSucceeded(_accountOf(result.user, impl.provider)); } 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, ); }