diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index 244a415..ea0706f 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -1,7 +1,7 @@ /// 인증 계약 — 제공자 인터페이스와 결과 타입. /// -/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Apple·카카오·LINE 은 발주사 사업자 -/// 정보로 각 개발자 콘솔에 앱을 등록해야 키가 나온다. "아직 준비가 안 됐다"이지 +/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Firebase 설정 파일이나 Apple +/// 웹 인증 값처럼 빌드에 들어가야 할 값이 없을 때가 그렇다. "아직 준비가 안 됐다"이지 /// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. /// 화면은 이걸 안내 다이얼로그로 보여 준다. library; diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index 5c0559d..09d299b 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; +import 'firebase_account_linker.dart'; import 'social_providers.dart'; import '../../core/i18n/app_locale.dart'; @@ -56,18 +57,26 @@ class AuthRepository extends ChangeNotifier { /// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다. /// QA 경로는 [qaEmailProvider] 하나뿐이다. /// - /// Apple·카카오·LINE 은 발주사 키가 없어 「키 대기」 상태다. 눌러도 예외가 아니라 - /// [AuthNotConfigured] 가 돌아오고 화면이 안내 다이얼로그를 띄운다. + /// 카카오·LINE 은 Firebase OIDC 제공자다. 콘솔 설정 전이면 Firebase 가 + /// `operation-not-allowed`(E-AUTH-11)를 돌려준다. static List _defaultProviders() => [ const GoogleSignInProvider(), AppleSignInProvider( webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), ), - const KeylessSocialProvider(AuthProviderKind.kakao, [ - '카카오 네이티브 앱 키', - '카카오 개발자 콘솔 앱 등록(사업자 정보 필요)', - ]), - lineProvider, + // 동의 항목: 카카오 콘솔의 닉네임·이메일, LINE 의 profile·email(이메일 권한 신청 승인 후). + OidcSignInProvider( + AuthProviderKind.kakao, + providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.kakao]!, + // 이메일(account_email)은 비즈 앱 전환 후에만 요청할 수 있다. 전환 전에 요청하면 + // KOE205 로 로그인 자체가 막힌다 — 전환되면 여기에 다시 넣는다. + scopes: const ['profile_nickname'], + ), + OidcSignInProvider( + AuthProviderKind.line, + providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!, + scopes: const ['profile', 'email'], + ), ]; /// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**. diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index 98c9ea2..163accc 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -56,13 +56,13 @@ final class FirebaseAccountLinker implements AccountLinker { } final missing = impl.missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); - // 키 대기 제공자(카카오·LINE)는 자격증명을 만들 수 없다. - if (impl is! CredentialProvider) { + if (impl is! CredentialProvider && impl is! OidcSignInProvider) { return AuthNotConfigured(provider, const ['자격증명 구현']); } - final source = impl as CredentialProvider; try { + 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(); @@ -84,6 +84,7 @@ final class FirebaseAccountLinker implements AccountLinker { 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), @@ -121,6 +122,31 @@ final class FirebaseAccountLinker implements AccountLinker { } } + /// 카카오·LINE(OIDC) 연결. + /// + /// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다. + /// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다. + /// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의 + /// blocking 함수(beforeSignIn)로 서버에서 거른다. + Future _linkOidc(fb.User user, OidcSignInProvider impl) async { + final mine = user.email; + final result = await 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; diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 638d0e6..52c9b27 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -199,31 +199,87 @@ final class AppleSignInProvider kIsWeb || defaultTargetPlatform == TargetPlatform.android; } -/// 발주사 키를 기다리는 제공자 — 카카오·LINE. +/// 카카오·LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.kakao` · `oidc.line`). /// -/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려주고 -/// 로그인 화면이 그걸 안내 다이얼로그로 띄운다. 키가 들어오면 이 자리에 실 구현체를 -/// 넣고 [AuthRepository] 목록만 바꾸면 된다 — 화면·라우터는 그대로다. -final class KeylessSocialProvider implements SocialAuthProvider { - const KeylessSocialProvider(this.provider, this.missingConfig); +/// 두 회사 모두 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; - @override - final List missingConfig; + /// 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 => false; + bool get isLive => true; @override - Future signIn() async => - AuthNotConfigured(provider, missingConfig); + 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 signOut() async {} + Future signIn() async { + final missing = missingConfig; + if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); + try { + final result = await (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), + authMessage(error, flow: AuthFlow.signIn), + ); + } + } + + @override + Future signOut() async { + // 카카오·LINE 쪽 세션은 브라우저 쿠키라 앱이 지울 수 없다. Firebase 세션은 + // AuthSession 이 닫는다. + } } +/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다. +bool isWebFlowCancelled(fb.FirebaseAuthException error) => + error.code == 'web-context-canceled' || + error.code == 'web-context-cancelled'; + /// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다. /// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다. apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() { @@ -337,14 +393,6 @@ final class EmailPasswordProvider implements EmailAuthProvider { } } -/// LINE — 발주사 키 대기. -/// -/// 별첨 B v1.4 확정 4종에 LINE 이 있고 네이버·X 는 없다. -const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [ - 'LINE Login 채널 ID / 채널 시크릿', - 'LINE Developers 콘솔 채널 등록(사업자 정보 필요)', -]); - /// SDK 코드를 에러 카탈로그 ID 로 옮긴다. /// /// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart new file mode 100644 index 0000000..55553b2 --- /dev/null +++ b/app/test/auth_oidc_test.dart @@ -0,0 +1,141 @@ +import 'package:fancake/core/firebase/firebase_boot.dart'; +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/auth_repository.dart'; +import 'package:fancake/features/auth/firebase_account_linker.dart'; +import 'package:fancake/features/auth/social_providers.dart'; +import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 카카오·LINE OIDC 제공자 — 콘솔 설정 전이라 실 로그인은 못 하고, +/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다. +void main() { + setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트')); + tearDown( + () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'), + ); + + OidcSignInProvider kakao(fb.FirebaseAuth auth) => OidcSignInProvider( + AuthProviderKind.kakao, + providerId: 'oidc.kakao', + firebaseAuth: auth, + ); + + test('기본 목록의 카카오·LINE 은 Firebase OIDC 제공자다 — 「준비 중」이 아니다', () { + final providers = { + for (final p in AuthRepository().providers) p.provider: p, + }; + for (final kind in [AuthProviderKind.kakao, AuthProviderKind.line]) { + final p = providers[kind]; + expect(p, isA(), reason: kind.name); + expect(p!.isLive, isTrue); + expect( + (p as OidcSignInProvider).providerId, + FirebaseAccountLinker.providerIds[kind], + ); + expect(p.authProvider.providerId, 'oidc.${kind.name}'); + } + }); + + test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async { + FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음'); + final outcome = await kakao(_ThrowingAuth('should-not-call')).signIn(); + expect(outcome, isA()); + }); + + test('성공하면 카카오 계정으로 로그인된다', () async { + final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1')); + final outcome = await kakao(auth).signIn(); + expect(outcome, isA()); + expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.kakao); + expect(outcome.account.uid, 'k-1'); + }); + + test('인가 화면을 닫으면 Android·iOS 철자 모두 취소로 본다', () async { + for (final code in ['web-context-canceled', 'web-context-cancelled']) { + final outcome = await kakao(_ThrowingAuth(code)).signIn(); + expect(outcome, isA(), reason: code); + } + }); + + test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async { + final outcome = await kakao( + _ThrowingAuth('operation-not-allowed'), + ).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-11'); + }); + + group('계정 연결', () { + test('같은 UID 에 oidc.kakao 를 붙인다', () async { + final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr'); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final linker = FirebaseAccountLinker( + providers: [kakao(auth)], + firebaseAuth: auth, + ); + + final outcome = await linker.link(AuthProviderKind.kakao); + + expect(outcome, isA()); + expect(linker.linkedAccounts.keys, contains(AuthProviderKind.kakao)); + }); + + test('다른 이메일의 카카오 계정은 붙였다가 바로 떼고 E-AUTH-08', () async { + final user = _LinkingUser(linkedEmail: 'someone@else.com'); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final linker = FirebaseAccountLinker( + providers: [kakao(auth)], + firebaseAuth: auth, + ); + + final outcome = await linker.link(AuthProviderKind.kakao); + + expect((outcome as AuthFailed).code, 'E-AUTH-08'); + expect( + linker.linkedAccounts.keys, + isNot(contains(AuthProviderKind.kakao)), + ); + }); + }); +} + +class _ThrowingAuth extends MockFirebaseAuth { + _ThrowingAuth(this.code); + final String code; + + @override + Future signInWithProvider(fb.AuthProvider provider) => + throw fb.FirebaseAuthException(code: code); +} + +/// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다. +// ignore: must_be_immutable +class _LinkingUser extends MockUser { + _LinkingUser({required this.linkedEmail}) + : super( + uid: 'u-1', + email: 'fan@fanuzu.co.kr', + providerData: [_info('google.com', 'fan@fanuzu.co.kr')], + ); + + final String linkedEmail; + + @override + Future linkWithProvider(fb.AuthProvider provider) async { + final result = await super.linkWithProvider(provider); + providerData + ..removeLast() + ..add(_info(provider.providerId, linkedEmail)); + return result; + } +} + +fb.UserInfo _info(String providerId, String email) => + // ignore: invalid_use_of_protected_member + fb.UserInfo.fromJson({ + 'uid': 'p-$providerId', + 'email': email, + 'providerId': providerId, + 'isAnonymous': false, + 'isEmailVerified': true, + });