import 'dart:async'; import 'dart:convert'; import 'package:cloud_functions/cloud_functions.dart'; import 'package:crypto/crypto.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:google_sign_in/google_sign_in.dart' as gsi; import 'package:flutter_line_sdk/flutter_line_sdk.dart' as line; import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao; import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple; import '../../core/data/backend.dart'; import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; import '../../core/i18n/app_locale.dart'; /// Firebase 자격증명을 만들 수 있는 제공자. /// /// 로그인과 **계정 연결**(별첨 B v1.4 「복수 소셜 Provider 연결 허용」)은 자격증명을 /// 얻는 데까지 완전히 같고 그 뒤 `signInWithCredential` 이냐 `linkWithCredential` /// 이냐만 다르다. 그 공통 부분을 여기로 뺀다 — 두 벌로 적으면 nonce 같은 보안 /// 절차가 한쪽에서만 고쳐진다. abstract interface class CredentialProvider { /// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신 /// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다. /// /// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는 /// 쓰지 않는다(Q-E8 — 이메일 일치는 소유 확인이 아니다). Future obtainCredential(); } /// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만). /// /// [name] 은 제공자가 자격증명과 함께만 주는 표시 이름(Apple fullName — 최초 인가 때만). Firebase 가 /// 이 값을 계정 이름으로 옮기지 않아, 로그인 직후 우리가 계정에 써 둔다(온보딩 닉네임 기본값). typedef SocialCredential = ({ fb.AuthCredential credential, String? email, String? name, }); /// Google 로그인 — 실 코드 경로. /// /// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다. /// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다. final class GoogleSignInProvider implements SocialAuthProvider, CredentialProvider { const GoogleSignInProvider(); static Future? _initialization; @override SocialProvider get provider => SocialProvider.google; @override bool get isLive => true; // 화면에 그대로 노출되는 목록이라 리포 경로가 아니라 사람이 읽는 이름을 쓴다. // 실제 파일 경로는 FirebaseBoot.detail 로 로그에만 남는다. @override List get missingConfig => FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels; @override Future obtainCredential() async { final signIn = gsi.GoogleSignIn.instance; // clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다. await (_initialization ??= signIn.initialize()); final account = await signIn.authenticate(); final idToken = account.authentication.idToken; if (idToken == null) return null; return ( credential: fb.GoogleAuthProvider.credential(idToken: idToken), email: account.email, name: null, ); } @override Future signIn() async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { final obtained = await obtainCredential(); if (obtained == null) { return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); } final result = await fb.FirebaseAuth.instance.signInWithCredential( obtained.credential, ); return AuthSucceeded(_toAccount(result.user, provider)); } on gsi.GoogleSignInException catch (error) { if (error.code == gsi.GoogleSignInExceptionCode.canceled) { return const AuthCancelled(); } return AuthFailed('E-AUTH-03', error.description ?? error.code.name); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } } @override Future signOut() async { if (!FirebaseBoot.isLive) return; await (_initialization ??= gsi.GoogleSignIn.instance.initialize()); await gsi.GoogleSignIn.instance.signOut(); // Firebase 세션 종료는 AuthSession이 한 번만 담당한다. } } /// Apple 로그인 — 실 코드 경로. /// /// iOS 는 Xcode 에서 「Sign in with Apple」 Capability 를, Android·Web 은 /// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다. /// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다. final class AppleSignInProvider implements SocialAuthProvider, CredentialProvider { const AppleSignInProvider({this.webAuthenticationOptions}); /// Android·Web에서 필요한 Service ID와 검증된 callback URL. /// 값이 없으면 SDK를 호출하지 않고 [AuthNotConfigured]로 닫는다. final apple.WebAuthenticationOptions? webAuthenticationOptions; @override SocialProvider get provider => SocialProvider.apple; @override bool get isLive => true; @override List get missingConfig => [ if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels, if (_requiresWebAuthentication && webAuthenticationOptions == null) 'Apple Android 웹 인증 설정', if (!FirebaseBoot.isLive) 'Apple 개발자 설정', ]; /// 시도마다 새 nonce 를 만들고 SHA-256 해시를 Apple 에, 원문을 Firebase 에 준다. /// 재사용하면 탈취한 토큰을 되쏘는 replay 가 성립한다. @override Future obtainCredential() async { final rawNonce = apple.generateNonce(); final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); final credential = await apple.SignInWithApple.getAppleIDCredential( scopes: const [ apple.AppleIDAuthorizationScopes.email, apple.AppleIDAuthorizationScopes.fullName, ], webAuthenticationOptions: _requiresWebAuthentication ? webAuthenticationOptions : null, nonce: hashedNonce, ); final idToken = credential.identityToken; if (idToken == null) return null; return ( credential: fb.OAuthProvider('apple.com').credential( idToken: idToken, accessToken: credential.authorizationCode, rawNonce: rawNonce, ), // Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다. email: credential.email, name: [ credential.givenName, credential.familyName, ].whereType().where((part) => part.isNotEmpty).join(' '), ); } @override Future signIn() async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); if (!await apple.SignInWithApple.isAvailable()) { return AuthFailed('E-AUTH-03', appL10n.authAppleUnavailable); } try { final obtained = await obtainCredential(); if (obtained == null) { return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); } final result = await fb.FirebaseAuth.instance.signInWithCredential( obtained.credential, ); final name = obtained.name; if (name != null && name.isNotEmpty && (result.user?.displayName ?? '').isEmpty) { await result.user?.updateDisplayName(name); } return AuthSucceeded(_toAccount(result.user, provider)); } on apple.SignInWithAppleAuthorizationException catch (error) { if (error.code == apple.AuthorizationErrorCode.canceled) { return const AuthCancelled(); } return AuthFailed('E-AUTH-03', error.message); } on apple.SignInWithAppleException catch (error) { debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}'); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } } @override Future signOut() async { // Apple SDK에는 앱 로컬 로그아웃 API가 없다. Firebase 세션은 AuthSession이 닫는다. } bool get _requiresWebAuthentication => kIsWeb || defaultTargetPlatform == TargetPlatform.android; } /// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이 /// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀 /// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다). /// /// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을 /// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이 /// `signInWithProvider` 하나로 끝난다 — Android 는 Custom Tabs, iOS 는 /// SFSafariViewController 로 인가 화면을 띄우고 Firebase 핸들러 /// (`https://<프로젝트>.firebaseapp.com/__/auth/handler`)로 돌아온다. /// iOS 콜백 스킴은 Google 로그인용 REVERSED_CLIENT_ID 를 같이 쓴다. /// /// 콘솔에서 제공자를 켜지 않았으면 Firebase 가 `operation-not-allowed` 를 돌려주고 /// [authCatalogCode] 가 E-AUTH-11 로 옮긴다 — 앱이 따로 플래그를 들 필요가 없다. /// /// [CredentialProvider] 가 아니다. 웹 흐름이 자격증명 발급과 로그인·연결을 한 번에 /// 끝내서 자격증명만 먼저 받아 볼 수 없다. 계정 연결은 [FirebaseAccountLinker] 가 /// [authProvider] 로 `linkWithProvider` 를 부른다. final class OidcSignInProvider implements SocialAuthProvider { const OidcSignInProvider( this.provider, { required this.providerId, this.scopes = const [], this.firebaseAuth, }); @override final SocialProvider provider; /// Firebase 콘솔에 등록한 제공자 ID. `oidc.` 접두는 콘솔이 강제한다. final String providerId; /// `openid` 외에 더 받을 항목. Firebase 가 `openid` 는 알아서 붙인다. /// **콘솔에 켠 동의 항목과 맞아야 한다** — 카카오는 설정에 없는 scope 를 요청하면 /// KOE205 로 로그인 자체를 막는다. final List scopes; /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. final fb.FirebaseAuth? firebaseAuth; @override bool get isLive => true; @override List get missingConfig => FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels; fb.OAuthProvider get authProvider { final p = fb.OAuthProvider(providerId); for (final scope in scopes) { p.addScope(scope); } return p; } @override Future signIn() async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { final result = await retryStaleWebContext( () => untilAbandoned( (firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider( authProvider, ), ), ); return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { if (isWebFlowCancelled(error)) return const AuthCancelled(); return AuthFailed( authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } } @override Future signOut() async { // 카카오·LINE 쪽 세션은 브라우저 쿠키라 앱이 지울 수 없다. Firebase 세션은 // AuthSession 이 닫는다. } } /// 카카오 네이티브 앱 키. `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다 /// (같은 파일을 Android manifestPlaceholders · iOS xcconfig 가 읽는다). const kakaoNativeAppKey = String.fromEnvironment('FANUZU_KAKAO_NATIVE_APP_KEY'); /// 카카오 — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthKakao`). iOS·Android 같은 경로다. /// /// Firebase OIDC(`oidc.kakao`) 웹 흐름은 Android 에서 「카카오톡으로 로그인」을 고르면 /// 콜백이 Custom Tab 밖(일반 Chrome)에서 열려 「missing initial state」로 깨진다. 카카오에는 /// 앱 전환을 막는 파라미터가 없어서, SDK 가 카카오톡(없으면 카카오계정)으로 로그인해 받은 /// 액세스 토큰을 서버가 카카오 API 로 검증하고 커스텀 토큰을 내준다. /// /// 서버는 사용자 제공자 정보에 `oidc.kakao` 를 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 /// 들어오고, [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다. final class KakaoSignInProvider implements SocialAuthProvider { KakaoSignInProvider({ Future Function()? accessToken, Future> Function(Map payload)? exchange, this.firebaseAuth, this.nativeAppKey = kakaoNativeAppKey, }) : _accessToken = accessToken ?? _sdkAccessToken, _exchange = exchange ?? _callServer; final Future Function() _accessToken; final Future> Function(Map) _exchange; /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. final fb.FirebaseAuth? firebaseAuth; final String nativeAppKey; static Future? _init; @override SocialProvider get provider => SocialProvider.kakao; @override bool get isLive => true; @override List get missingConfig => [ if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels, if (nativeAppKey.isEmpty) '카카오 네이티브 앱 키', ]; fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance; /// 카카오톡이 있으면 카카오톡, 없거나 실패하면 카카오계정(공식 가이드 순서). /// 사용자 취소(`CANCELED`)는 계정 로그인으로 넘기지 않고 그대로 던진다. static Future _sdkAccessToken() async { await (_init ??= kakao.KakaoSdk.init(nativeAppKey: kakaoNativeAppKey)); final api = kakao.UserApi.instance; if (await kakao.isKakaoTalkInstalled()) { try { return (await api.loginWithKakaoTalk()).accessToken; } on PlatformException catch (error) { if (error.code == 'CANCELED') rethrow; } } return (await api.loginWithKakaoAccount()).accessToken; } static Future> _callServer( Map payload, ) async { final result = await FirebaseFunctions.instanceFor( region: Backend.region, ).httpsCallable('fnAuthKakao').call(payload); final data = result.data; return data is Map ? Map.from(data) : const {}; } @override Future signIn() => _run(AuthFlow.signIn, () async { final data = await _exchange({'accessToken': await _accessToken()}); final result = await _auth.signInWithCustomToken(data['token'] as String); return AuthSucceeded(_toAccount(result.user, provider)); }); /// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는 /// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다(Q-E8). Future link(fb.User user) => _run(AuthFlow.link, () async { await _exchange({'accessToken': await _accessToken(), 'link': true}); await user.reload(); return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider)); }); Future _run( AuthFlow flow, Future Function() body, ) async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { return await body(); } on PlatformException catch (error) { if (error.code == 'CANCELED') return const AuthCancelled(); debugPrint('[KakaoSignInProvider] sdk failed: ${error.code}'); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); } on kakao.KakaoAuthException catch (error) { // 동의 화면에서 「취소」. if (error.error == kakao.AuthErrorCause.accessDenied) { return const AuthCancelled(); } // 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다 — // 10/6 릴리스 빌드 E-AUTH-03 은 이 분기로 들어와 아무 로그도 남지 않았다. debugPrint( '[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}', ); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); } on kakao.KakaoException catch (error) { debugPrint('[KakaoSignInProvider] sdk error: $error'); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); } on FirebaseFunctionsException catch (error) { return _serverFailure(error, flow); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error, flow: flow), authMessage(error, flow: flow), ); } } @override Future signOut() async { // SDK 를 한 번도 띄우지 않았으면 지울 카카오 토큰도 없다. if (_init == null) return; try { await kakao.UserApi.instance.logout(); } on Object catch (error) { // 토큰이 이미 만료·폐기됐어도 SDK 는 로컬 토큰을 지운다. 로그아웃을 막을 이유가 없다. debugPrint('[KakaoSignInProvider] logout: ${error.runtimeType}'); } } } /// 커스텀 토큰 교환 서버(`fnAuthKakao` · `fnAuthLine`)의 거부를 카탈로그 코드 · 문구로 옮긴다. AuthFailed _serverFailure(FirebaseFunctionsException error, AuthFlow flow) { final details = error.details; final code = details is Map && details['code'] is String ? details['code'] as String : 'E-AUTH-03'; return AuthFailed(code, switch (code) { _ when details is Map && details['reason'] == 'session' => appL10n.authRecentLogin, 'E-AUTH-08' => appL10n.authCredentialInUse, 'E-AUTH-09' => appL10n.authAlreadyLinked, _ => flow == AuthFlow.link ? appL10n.authLinkFailed : appL10n.authSocialFailed, }); } /// LINE Login 채널 ID. 카카오 앱 키와 같은 `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다. const lineChannelId = String.fromEnvironment('FANUZU_LINE_CHANNEL_ID'); /// LINE 이 돌려준 토큰. ID 토큰은 `openid` 동의가 있을 때만 온다 — 서버는 있으면 ID 토큰으로 검증한다. typedef LineTokens = ({String? idToken, String accessToken}); /// LINE — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthLine`). [KakaoSignInProvider] 와 같은 구조다. /// /// Firebase OIDC(`oidc.line`) 웹 흐름은 iOS 에서 첫 시도에 「missing initial state」로 깨진다(10/11 /// iPhone 11 · iOS 26 — `disable_ios_auto_login` 을 붙여도 재현). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로 /// 로그인해 받은 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다. /// /// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고, /// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다. /// /// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에 /// 오고, 서버가 그때만 남긴다(C-143). 없어도 로그인 · 연결이 된다. final class LineSignInProvider implements SocialAuthProvider { LineSignInProvider({ Future Function()? login, Future> Function(Map payload)? exchange, this.firebaseAuth, this.channelId = lineChannelId, }) : _login = login ?? _sdkLogin, _exchange = exchange ?? _callServer; final Future Function() _login; final Future> Function(Map) _exchange; /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. final fb.FirebaseAuth? firebaseAuth; final String channelId; static Future? _setup; /// 사용자 취소 — Android `CANCEL`(LineApiResponseCode) · iOS `3003`(LineSDKError userCancelled). static const _cancelCodes = {'CANCEL', '3003'}; @override SocialProvider get provider => SocialProvider.line; @override bool get isLive => true; @override List get missingConfig => [ if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels, if (channelId.isEmpty) 'LINE 채널 ID', ]; fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance; static Future _sdkLogin() async { await (_setup ??= line.LineSDK.instance.setup(lineChannelId)); final result = await line.LineSDK.instance.login( scopes: const ['profile', 'openid', 'email'], ); return ( idToken: result.accessToken.idTokenRaw, accessToken: result.accessToken.value, ); } static Future> _callServer( Map payload, ) async { final result = await FirebaseFunctions.instanceFor( region: Backend.region, ).httpsCallable('fnAuthLine').call(payload); final data = result.data; return data is Map ? Map.from(data) : const {}; } Future> _tokens() async { final t = await _login(); return { if (t.idToken != null) 'idToken': t.idToken, 'accessToken': t.accessToken, }; } @override Future signIn() => _run(AuthFlow.signIn, () async { final data = await _exchange(await _tokens()); final result = await _auth.signInWithCustomToken(data['token'] as String); return AuthSucceeded(_toAccount(result.user, provider)); }); /// SNS 연동 — 로그인한 UID 에 LINE 을 붙인다. 현재 세션 · LINE 토큰 검증과 다른 UID 연결 거부는 /// 서버(`fnAuthLine` link)가 한다. 이메일은 보지 않는다(Q-E8). Future link(fb.User user) => _run(AuthFlow.link, () async { await _exchange({...await _tokens(), 'link': true}); await user.reload(); return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider)); }); Future _run( AuthFlow flow, Future Function() body, ) async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { return await body(); } on PlatformException catch (error) { if (_cancelCodes.contains(error.code)) return const AuthCancelled(); debugPrint( '[LineSignInProvider] sdk failed: ${error.code} ${error.message}', ); return AuthFailed( 'E-AUTH-03', flow == AuthFlow.link ? appL10n.authLinkFailed : appL10n.authSocialFailed, ); } on FirebaseFunctionsException catch (error) { return _serverFailure(error, flow); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error, flow: flow), authMessage(error, flow: flow), ); } } @override Future signOut() async { // SDK 를 한 번도 띄우지 않았으면 지울 LINE 토큰도 없다. if (_setup == null) return; try { await line.LineSDK.instance.logout(); } on Object catch (error) { // 토큰이 이미 만료 · 폐기됐어도 로그아웃을 막을 이유가 없다. debugPrint('[LineSignInProvider] logout: ${error.runtimeType}'); } } } /// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다. /// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름). bool isWebFlowCancelled(fb.FirebaseAuthException error) => error.code == 'web-context-canceled' || error.code == 'web-context-cancelled' || error.code == 'user-cancelled'; const _authChannel = MethodChannel('kr.co.fanuzu.app/auth'); /// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`). /// /// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을 /// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(10/8 실기기 — 다시 눌러도 로그인이 안 됨). /// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다. Future clearStaleWebSignIn() async { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; try { await _authChannel.invokeMethod('cancelPendingWebSignIn'); } on Object catch (error) { debugPrint('[Auth] clear stale web sign-in skipped: $error'); } } /// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다. /// 1차 검수 R01 「취소 · 실패 후 정상 재시도 가능」. Future retryStaleWebContext(Future Function() attempt) async { try { return await attempt(); } on fb.FirebaseAuthException catch (error) { if (error.code != 'web-context-already-presented') rethrow; await clearStaleWebSignIn(); return attempt(); } } /// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다. /// /// 콜백이 Custom Tab 밖(일반 Chrome)에서 열리면 Firebase 의 대기 Task 가 영영 /// 끝나지 않아 로그인 버튼이 잠긴다. 앱이 포그라운드로 돌아온 뒤 [grace] 안에 /// 결과가 없으면 사용자가 흐름을 버린 것으로 보고 기존 취소 경로([isWebFlowCancelled])로 /// 넘긴다. 정상 완료는 돌아온 직후 토큰 교환 한 번이라 [grace] 안에 끝난다. /// iOS 는 SFSafariViewController 가 취소를 스스로 알려 주므로 그대로 둔다. /// /// ponytail: 고정 대기 시간. 망이 아주 느려 [grace] 를 넘기면 취소로 보이지만 /// Firebase 세션은 나중에 열리므로 authStateChanges 가 따라잡는다. Future untilAbandoned( Future flow, { Duration grace = const Duration(seconds: 8), }) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return flow; final done = Completer(); Timer? timer; final listener = AppLifecycleListener( onStateChange: (state) { timer?.cancel(); if (state != AppLifecycleState.resumed) return; timer = Timer(grace, () { if (done.isCompleted) return; // Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다. unawaited(clearStaleWebSignIn()); done.completeError( fb.FirebaseAuthException(code: 'web-context-canceled'), ); }); }, ); flow.then( (value) { if (!done.isCompleted) done.complete(value); }, onError: (Object error, StackTrace stack) { if (!done.isCompleted) done.completeError(error, stack); }, ); return done.future.whenComplete(() { timer?.cancel(); listener.dispose(); }); } AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount( uid: user?.uid ?? '', provider: provider, displayName: user?.displayName, email: user?.email, ); /// 이메일 · 비밀번호 — **QA 계정 전용 내부 경로**. /// /// 별첨 B v1.4 가 1차 로그인을 Google/Apple/Kakao/LINE 4종으로 확정하고 /// 이메일/비밀번호 가입을 제외했다. 그래서 이 구현체는 /// [AuthRepository.providers] 에 들어가지 않는다 — 사용자가 닿을 수 있는 버튼이 /// 없다. 남겨 둔 이유는 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후 /// 화면을 확인해야 하기 때문이고, [AuthRepository.qaEmailProvider] 가 /// **디버그 빌드에서만** 이걸 돌려준다. /// /// **가입과 로그인을 한 버튼에 합치지 않는다.** 합치면 오타로 친 이메일이 /// 새 계정을 만들어 버리고, 사용자는 「비밀번호가 틀렸다」가 아니라 「내 데이터가 /// 사라졌다」를 겪는다. final class EmailPasswordProvider implements EmailAuthProvider { const EmailPasswordProvider(); @override AuthProviderKind get provider => AuthProviderKind.email; @override bool get isLive => true; @override List get missingConfig => FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels; /// 폼 없이 부르면 아무 일도 하지 않는다. 화면이 입력을 받아 /// [signInWithEmail] 을 부른다. @override Future signIn() async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); return const AuthCancelled(); } @override Future signInWithEmail(String email, String password) => _run( () => fb.FirebaseAuth.instance.signInWithEmailAndPassword( email: email.trim(), password: password, ), ); @override Future signUpWithEmail(String email, String password) => _run( () => fb.FirebaseAuth.instance.createUserWithEmailAndPassword( email: email.trim(), password: password, ), ); @override Future sendPasswordReset(String email) async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { await fb.FirebaseAuth.instance.sendPasswordResetEmail( email: email.trim(), ); return const AuthCancelled(); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error, flow: AuthFlow.email), authMessage(error, flow: AuthFlow.email), ); } } Future _run(Future Function() action) async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { final result = await action(); return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error, flow: AuthFlow.email), authMessage(error, flow: AuthFlow.email), ); } } @override Future signOut() async { // Firebase 세션 종료는 AuthSession 이 한 번만 담당한다. } } /// SDK 코드를 에러 카탈로그 ID 로 옮긴다. /// /// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 /// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다. /// /// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은 /// 소셜 실패 E-AUTH-03 이다. 예전에는 하나로 E-AUTH-01 이라 LINE 웹 로그인이 끊기면(`web-*`) /// 「이메일 또는 비밀번호를 확인해 주세요」가 떴다(10/8 실기기 · 1차 검수 R01). String authCatalogCode( fb.FirebaseAuthException error, { required AuthFlow flow, }) => switch (error.code) { 'email-already-in-use' => 'E-AUTH-04', 'weak-password' => 'E-AUTH-05', 'invalid-email' => 'E-AUTH-06', 'too-many-requests' => 'E-AUTH-07', // credential-already-in-use 는 계정 연결 경로에서만 나오지만, // account-exists-with-different-credential 은 로그인 경로에서도 난다. // 카탈로그 ID 는 같고 문구만 경로별로 갈린다(authMessage 참고). 'credential-already-in-use' || 'account-exists-with-different-credential' => 'E-AUTH-08', 'provider-already-linked' => 'E-AUTH-09', 'no-such-provider' => 'E-AUTH-10', // Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다. 'operation-not-allowed' => 'E-AUTH-11', // 현재 계정의 세션이 만료 · 폐기됐다(Q-E8 「세션 만료 · 인증 취소」). 다시 로그인해야 한다. 'requires-recent-login' || 'user-token-expired' || 'invalid-user-token' || 'user-mismatch' || 'user-disabled' => 'E-AUTH-12', _ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03', }; /// 오류 문구가 갈리는 경로. /// /// 같은 SDK 코드가 경로에 따라 **다른 사실**을 뜻하므로 호출부가 어느 경로인지 /// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지 /// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다. enum AuthFlow { /// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`). signIn, /// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다. email, /// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로 /// (`linkWithCredential` · `unlink`) — 별첨 B v1.4. link, } /// 사용자에게 띄우는 문구. /// /// `account-exists-with-different-credential` 이 경로에 따라 뒤집히는 대표 코드다. /// **로그인** 경로에서는 「같은 이메일의 계정이 다른 제공자로 가입돼 있다」이므로 /// 그 제공자로 들어가 달라고 안내해야 하고, **계정 연결** 경로에서는 「이 소셜 /// 계정이 다른 FANUZU 계정에 이미 붙어 있다」다. 한 벌로 적으면 로그인에 실패한 /// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다. String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => switch (error.code) { 'email-already-in-use' => appL10n.authEmailInUse, 'weak-password' => appL10n.authWeakPassword, 'invalid-email' => appL10n.authInvalidEmail, 'too-many-requests' => appL10n.commonTryLater, 'account-exists-with-different-credential' => switch (flow) { AuthFlow.signIn || AuthFlow.email => _otherProviderSignInGuide(error.email), AuthFlow.link => appL10n.authCredentialInUse, }, // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. 'credential-already-in-use' => appL10n.authCredentialInUse, 'provider-already-linked' => appL10n.authAlreadyLinked, 'no-such-provider' => appL10n.authNotLinked, 'operation-not-allowed' => appL10n.authMethodUnavailable, 'requires-recent-login' || 'user-token-expired' || 'invalid-user-token' || 'user-mismatch' || 'user-disabled' => appL10n.authRecentLogin, // 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다(R01). 'web-context-already-presented' => appL10n.authSocialCancelled, 'web-network-request-failed' || 'network-request-failed' => appL10n.authSocialNetwork, _ => switch (flow) { AuthFlow.email => appL10n.authCheckCredentials, AuthFlow.signIn => appL10n.authSocialFailed, AuthFlow.link => appL10n.authLinkFailed, }, }; /// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내. /// /// [email] 은 오류 응답의 힌트다(`FirebaseAuthException.email`). 사용자가 방금 /// 소셜 인증을 통과해서 받은 **자기 이메일**이라 되돌려 주는 것이 계정 열거가 /// 아니다. 이걸 빼면 계정이 여러 개인 사용자가 어느 계정으로 들어가야 하는지 /// 알 수 없다. /// /// ponytail: **기존 제공자 이름은 오류에 없다.** `error.credential` 은 이번에 /// 시도한 자격증명이고, 기존 제공자를 알려면 `fetchSignInMethodsForEmail` 이 /// 필요한데 이메일 열거 방지가 켜지면 빈 목록을 돌려준다. 이름까지 띄워야 하면 /// 서버에서 Admin SDK 로 조회해 내려주는 쪽으로 올린다. /// /// 이메일을 조사 앞에 바로 붙이지 않는다. 도메인 끝 글자가 모음인지 자음인지에 /// 따라 「은/는」이 갈려 `example.co은` 같은 문구가 나간다. 「계정」을 사이에 두면 /// 조사가 항상 맞는다. String _otherProviderSignInGuide(String? email) => appL10n.authOtherProviderGuide(email ?? appL10n.authThisAccount);