import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter/foundation.dart'; import 'package:google_sign_in/google_sign_in.dart' as gsi; import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple; import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; /// Google 로그인 — 실 코드 경로. /// /// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다. /// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다. final class GoogleSignInProvider implements SocialAuthProvider { 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 signIn() async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { 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 const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } final credential = fb.GoogleAuthProvider.credential(idToken: idToken); final result = await fb.FirebaseAuth.instance.signInWithCredential( 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('E-AUTH-03', error.message ?? error.code); } } @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 { 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 개발자 설정', ]; @override Future signIn() async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); if (!await apple.SignInWithApple.isAvailable()) { return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.'); } try { 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 const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } final oauth = fb.OAuthProvider('apple.com').credential( idToken: idToken, accessToken: credential.authorizationCode, rawNonce: rawNonce, ); final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth); 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 const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } on fb.FirebaseAuthException catch (error) { return AuthFailed('E-AUTH-03', error.message ?? error.code); } } @override Future signOut() async { // Apple SDK에는 앱 로컬 로그아웃 API가 없다. Firebase 세션은 AuthSession이 닫는다. } bool get _requiresWebAuthentication => kIsWeb || defaultTargetPlatform == TargetPlatform.android; } /// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버. /// /// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다. /// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다. final class KeylessSocialProvider implements SocialAuthProvider { const KeylessSocialProvider(this.provider, this.missingConfig); @override final SocialProvider provider; @override final List missingConfig; @override bool get isLive => false; @override Future signIn() async => AuthNotConfigured(provider, missingConfig); @override Future signOut() async {} } /// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다. /// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다. apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() { const clientId = String.fromEnvironment('FANUZU_APPLE_SERVICE_ID'); const redirect = String.fromEnvironment('FANUZU_APPLE_REDIRECT_URI'); if (clientId.isEmpty || redirect.isEmpty) return null; final redirectUri = Uri.tryParse(redirect); if (redirectUri == null || !redirectUri.hasScheme || !redirectUri.hasAuthority) { return null; } return apple.WebAuthenticationOptions( clientId: clientId, redirectUri: redirectUri, ); } AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount( uid: user?.uid ?? '', provider: provider, displayName: user?.displayName, email: user?.email, );