From c301f3a1db7253c8498be6afb2c7f041e7618d62 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 19:01:28 +0900 Subject: [PATCH] =?UTF-8?q?Kakao=20=EB=A1=9C=EA=B7=B8=EC=9D=B8(=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20SDK=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=86=A0=ED=81=B0)=EA=B3=BC=20LI?= =?UTF-8?q?NE=20=EB=A1=9C=EA=B7=B8=EC=9D=B8(Firebase=20OIDC)=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20SNS=20=EC=97=B0=EB=8F=99=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=B7=A8=EC=86=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.gitignore | 3 + app/BUILD.md | 19 ++ app/README.md | 12 +- app/android/app/build.gradle.kts | 11 + app/android/app/src/main/AndroidManifest.xml | 12 + app/config/kakao.env.example | 1 + app/firebase/functions/package.json | 2 +- app/firebase/functions/src/account/kakao.ts | 143 +++++++++ app/firebase/functions/src/core/errors.ts | 13 +- app/firebase/functions/src/index.ts | 1 + .../functions/test/auth_kakao.test.ts | 106 +++++++ app/firebase/package.json | 2 +- app/ios/Flutter/Debug.xcconfig | 4 + app/ios/Flutter/Release.xcconfig | 4 + app/ios/Runner/Info.plist | 16 + app/lib/features/auth/auth.dart | 4 +- app/lib/features/auth/auth_repository.dart | 23 +- .../auth/firebase_account_linker.dart | 40 ++- app/lib/features/auth/social_providers.dart | 287 ++++++++++++++++-- app/pubspec.lock | 230 +++++++++++++- app/pubspec.yaml | 13 +- app/test/auth_kakao_test.dart | 109 +++++++ app/test/auth_oidc_test.dart | 177 +++++++++++ 23 files changed, 1179 insertions(+), 53 deletions(-) create mode 100644 app/config/kakao.env.example create mode 100644 app/firebase/functions/src/account/kakao.ts create mode 100644 app/firebase/functions/test/auth_kakao.test.ts create mode 100644 app/test/auth_kakao_test.dart create mode 100644 app/test/auth_oidc_test.dart diff --git a/app/.gitignore b/app/.gitignore index 578cdc8..82a6df1 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -65,3 +65,6 @@ ios/config/Google.xcconfig firebase/**/node_modules/ firebase/**/*-debug.log firebase/functions/lib/ + +# 카카오 네이티브 앱 키. 다른 설정 파일(google-services.json 등)과 같이 저장소 밖에서 넣는다. +config/kakao.env diff --git a/app/BUILD.md b/app/BUILD.md index 761cf8e..97624c9 100644 --- a/app/BUILD.md +++ b/app/BUILD.md @@ -54,6 +54,25 @@ fvm flutter build apk --release # build/app/outputs/flutter-apk/app-relea fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab ``` +## 카카오 · LINE 로그인 설정 + +**카카오 네이티브 앱 키** — `config/kakao.env`(저장소 밖, `config/kakao.env.example` 복사) 한 줄 +`FANUZU_KAKAO_NATIVE_APP_KEY=<키>`. 주석을 넣지 않는다(iOS xcconfig 가 그대로 include 한다). +Android `build.gradle.kts`(리다이렉트 스킴) · iOS `Flutter/*.xcconfig`(URL 스킴) 가 이 파일을 읽고, +Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받는다. 없으면 카카오 로그인만 +「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>` +의 `KAKAO_APP_ID` 로 받는다(저장소 밖). + +```bash +fvm flutter build apk --release --dart-define-from-file=config/kakao.env +``` + +**LINE** 은 Firebase Authentication 의 OpenID Connect 제공자 `oidc.line` 이다. 앱에 넣을 값은 없고, +콘솔 Authentication → 로그인 방법에 LINE Login 채널 ID · 채널 시크릿 · issuer +`https://access.line.me` 를 등록하고, LINE Developers 콘솔의 Callback URL 에 +`https://<프로젝트 ID>.firebaseapp.com/__/auth/handler` 를 넣는다. 콘솔에서 켜지 않았으면 로그인이 +E-AUTH-11 로 끝난다. + ## 서명 `android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다 diff --git a/app/README.md b/app/README.md index 3cbbfd7..65dc606 100644 --- a/app/README.md +++ b/app/README.md @@ -7,7 +7,7 @@ | | 내용 | |---|---| -| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) | +| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google·Apple·카카오·LINE 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) | | **미포함** | 각 탭의 실제 화면(3주차 이후) · 온보딩 나머지 6화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS) · 도메인 로직(POP·FPI·퀘스트·이클립스) · 상태관리 라이브러리 | 미포함 항목을 지금 만들지 않은 이유: **RN/Flutter 승인이 아직 없다(2주차_확인요청항목 Q1).** @@ -113,8 +113,8 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; | ID | 항목 | 투입 위치 | 없으면 | |---|---|---|---| -| Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | -| Q3 | LINE Login 채널 ID / 시크릿 | 미정 | LINE 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | +| Q3 | 카카오 네이티브 앱 키 | `config/kakao.env`(저장소 밖) | 카카오 버튼이 「설정 전」 안내 다이얼로그를 띄운다 | +| Q3 | LINE Login 채널 ID / 시크릿 | Firebase 콘솔 `oidc.line` 제공자 | LINE 로그인이 E-AUTH-11 로 끝난다 | | Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 | | ~~Q14~~ | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | **2026-09-11 확정 — 별첨 C 4번 `kr.co.fanuzu.app`.** Android applicationId·namespace·Kotlin 패키지, iOS bundle id 를 모두 옮겼다. **스토어 등록 후에는 변경 불가** | | ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 | @@ -145,8 +145,8 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; |---|---|---|---| | Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 | | Apple | 실 코드 경로 | `AppleSignInProvider` | Apple 개발자 키 + Xcode Capability + Android Service ID/redirect URI | -| 카카오 | 키 대기 | `KeylessSocialProvider` | 카카오 네이티브 앱 키(Q3) | -| LINE | 키 대기 | `KeylessSocialProvider` | LINE Login 채널 ID/시크릿(Q3) | +| 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` | +| LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 | 키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지 **안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다. @@ -316,7 +316,7 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail |---|---|---|---| | L1 | Firebase 설정 파일 미투입 → 목 모드 | **실 소셜 로그인 불가.** 로그인 버튼은 「키 미투입」 안내를 반환 | S2 (3주차 빌드 전) | | L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 | -| L3 | 카카오·LINE SDK 미도입 | 「키 대기」 배지 + 안내 다이얼로그까지만 있다. 키가 오면 `kakao_flutter_sdk_user` · LINE SDK 를 넣고 `KeylessSocialProvider` 를 `CredentialProvider` 구현체로 교체하면 로그인·계정 연결이 함께 열린다. Firebase 쪽은 OIDC 커스텀 제공자(`oidc.kakao` · `oidc.line`)로 등록한다 | Q3 회신 후 | +| L3 | 카카오 · LINE 로그인 연결 | 카카오는 SDK + 서버 커스텀 토큰(`fnAuthKakao`, 사용자 제공자 정보는 `oidc.kakao`), LINE 은 Firebase OIDC(`oidc.line`)다. 설정 방법은 `BUILD.md` 「카카오 · LINE 로그인 설정」 | 반영 완료 | | L3-1 | 「동일 이메일만 연동」이 Apple 재연동에서는 판정되지 않는다 | Apple 이 **최초 인가 때만** 이메일을 준다. 클라이언트에서 볼 값이 없어 통과시킨다 — 서버(fn-account-link)에 같은 판정을 두어야 완결된다 | Firestore 설계서 v1.1 | | L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 | | L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 | diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index d3c1fbe..639f9e6 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -17,6 +17,14 @@ val keystoreProperties = Properties().apply { if (f.exists()) f.inputStream().use { load(it) } } +// 카카오 네이티브 앱 키 — `config/kakao.env` 한 파일이 Dart(--dart-define-from-file)· +// Android(여기)·iOS(Flutter/*.xcconfig)에 같은 값을 준다. 파일은 저장소에 넣지 않는다. +// 없으면 빈 값으로 빌드되고 카카오 로그인만 「설정 전」으로 막힌다. +val kakaoProperties = Properties().apply { + val f = rootProject.file("../config/kakao.env") + if (f.exists()) f.inputStream().use { load(it) } +} + /// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가. val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) } @@ -53,6 +61,9 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + // AndroidManifest 의 카카오 로그인 리다이렉트 스킴 `kakao{네이티브 앱 키}`. + manifestPlaceholders["kakaoNativeAppKey"] = + kakaoProperties.getProperty("FANUZU_KAKAO_NATIVE_APP_KEY", "") } signingConfigs { diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 6552c21..177d060 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,18 @@ + + + + + + + + + ` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명). + * + * 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고 + * `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다. + */ + +export const KAKAO_PROVIDER_ID = 'oidc.kakao'; + +/** 카카오 Developers 「앱 ID」(숫자). 비밀값이 아니다 — 토큰이 우리 앱에서 발급됐는지 본다. */ +const kakaoAppId = defineString('KAKAO_APP_ID', { default: '' }); + +export interface KakaoProfile { + /** 카카오 회원번호. */ + id: string; + nickname?: string; + /** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */ + email?: string; +} + +export const kakaoUidOf = (kakaoId: string) => `kakao:${kakaoId}`; + +/** + * 카카오 액세스 토큰을 카카오 API 로 검증한다. + * + * 1. `/v1/user/access_token_info` — 유효성 + **우리 앱 ID 로 발급됐는가**. 남의 앱에서 받은 + * 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을 막는 지점이다. + * 2. `/v2/user/me` — 닉네임·이메일. 회원번호가 1 과 같아야 한다. + */ +export async function verifyKakaoToken(accessToken: string, appId: string): Promise { + if (!appId) fail(ERR.authFailed, '카카오 로그인 설정이 끝나지 않았습니다.'); + const get = async (path: string) => { + const res = await fetch(`https://kapi.kakao.com${path}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.'); + return (await res.json()) as Record; + }; + const info = await get('/v1/user/access_token_info'); + if (String(info.app_id) !== appId) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.'); + const me = await get('/v2/user/me'); + if (String(me.id) !== String(info.id)) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.'); + + const account = (me.kakao_account ?? {}) as Record; + const profile = (account.profile ?? {}) as Record; + const email = account.is_email_valid === true && account.is_email_verified === true + && typeof account.email === 'string' ? account.email : undefined; + const nickname = typeof profile.nickname === 'string' ? profile.nickname : undefined; + return { + id: String(info.id), + ...(nickname ? { nickname } : {}), + ...(email ? { email } : {}), + }; +} + +/** 이 카카오 계정이 붙은 Firebase UID. 없으면 null. */ +async function ownerOf(kakaoId: string): Promise { + try { + return (await getAuth().getUserByProviderUid(KAKAO_PROVIDER_ID, kakaoId)).uid; + } catch (e) { + if ((e as { code?: string }).code === 'auth/user-not-found') return null; + throw e; + } +} + +function linkInfo(p: KakaoProfile) { + return { + providerId: KAKAO_PROVIDER_ID, + uid: p.id, + ...(p.nickname ? { displayName: p.nickname } : {}), + ...(p.email ? { email: p.email } : {}), + }; +} + +/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `kakao:` 를 만들고 연결한다. */ +export async function resolveKakaoUid(p: KakaoProfile): Promise { + const existing = await ownerOf(p.id); + if (existing) return existing; + const uid = kakaoUidOf(p.id); + const auth = getAuth(); + try { + await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) }); + } catch (e) { + // 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다. + if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e; + } + await auth.updateUser(uid, { providerToLink: linkInfo(p) }); + return uid; +} + +/** + * SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않는다. + * + * 「동일 이메일만 연동」(명세 `554:22797`)은 붙이기 **전에** 서버에서 본다. 둘 중 하나라도 + * 이메일이 없으면 건너뛴다(앱과 같은 규칙 — 카카오 이메일은 비즈 앱 동의가 있을 때만 온다). + */ +export async function linkKakao(uid: string, p: KakaoProfile): Promise { + const owner = await ownerOf(p.id); + if (owner === uid) return; + if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); + const user = await getAuth().getUser(uid); + if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) { + fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.'); + } + const mine = user.email?.trim().toLowerCase(); + const theirs = p.email?.trim().toLowerCase(); + if (mine && theirs && mine !== theirs) { + fail(ERR.authCredentialInUse, '같은 이메일의 계정만 연결할 수 있습니다.', { reason: 'different-email' }); + } + await getAuth().updateUser(uid, { providerToLink: linkInfo(p) }); +} + +export const fnAuthKakao = onCall(async (req) => { + const profile = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value()); + if ((req.data as Record).link === true) { + const uid = req.auth?.uid; + if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.'); + await linkKakao(uid, profile); + return { linked: true }; + } + const uid = await resolveKakaoUid(profile); + return { token: await getAuth().createCustomToken(uid) }; +}); diff --git a/app/firebase/functions/src/core/errors.ts b/app/firebase/functions/src/core/errors.ts index ea327e0..fa9b911 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -9,6 +9,9 @@ import { HttpsError } from 'firebase-functions/v2/https'; export const ERR = { permissionDenied: 'E-PERM-01', authFailed: 'E-AUTH-03', + // 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다. + authCredentialInUse: 'E-AUTH-08', + authAlreadyLinked: 'E-AUTH-09', } as const; export type ErrorCode = (typeof ERR)[keyof typeof ERR]; @@ -20,12 +23,14 @@ type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists' const STATUS: Record = { 'E-PERM-01': 'permission-denied', 'E-AUTH-03': 'permission-denied', + 'E-AUTH-08': 'already-exists', + 'E-AUTH-09': 'already-exists', }; /** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */ export class CatalogError extends HttpsError { - constructor(readonly catalogCode: ErrorCode, message: string) { - super(STATUS[catalogCode], message, { code: catalogCode }); + constructor(readonly catalogCode: ErrorCode, message: string, extra: Record = {}) { + super(STATUS[catalogCode], message, { ...extra, code: catalogCode }); } } @@ -36,8 +41,8 @@ export class CatalogError extends HttpsError { * => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의 * 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다. */ -export function fail(code: ErrorCode, message: string): never { - throw new CatalogError(code, message); +export function fail(code: ErrorCode, message: string, extra?: Record): never { + throw new CatalogError(code, message, extra); } /** diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 71df7df..4330ebd 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -6,4 +6,5 @@ import './core/bootstrap.js'; export { fnAuthOnCreate, fnConsentRecord, fnAdminSetRole, } from './account/account.js'; +export { fnAuthKakao } from './account/kakao.js'; export { fnAdminWrite } from './admin/admin.js'; diff --git a/app/firebase/functions/test/auth_kakao.test.ts b/app/firebase/functions/test/auth_kakao.test.ts new file mode 100644 index 0000000..4dcdeb7 --- /dev/null +++ b/app/firebase/functions/test/auth_kakao.test.ts @@ -0,0 +1,106 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAuth } from 'firebase-admin/auth'; + +import { bootAdmin, shutdownAdmin } from './support.js'; +import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js'; + +/** 카카오 커스텀 토큰 — 토큰 검증 · 기존 oidc.kakao 사용자 매핑 · 신규 UID · SNS 연동. */ + +const APP_ID = '123456'; +const KAKAO_ID = '987654321'; +const OLD_UID = 'test-kakao-oidc-user'; +const GOOGLE_UID = 'test-kakao-google-user'; + +type Req = Parameters[0]; +const call = (data: Record, uid?: string) => + fnAuthKakao.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req); +const codeOf = async (p: Promise) => { + try { await p; return 'ok'; } catch (e) { + const d = (e as { details?: { code?: string; reason?: string } }).details; + return d?.reason ?? d?.code; + } +}; + +/** 카카오 API 흉내. 토큰 'good' 만 우리 앱 것, 'other-app' 은 남의 앱 토큰이다. */ +function stubKakao(email?: string) { + vi.stubGlobal('fetch', vi.fn(async (url: string, init: { headers: Record }) => { + const token = init.headers.Authorization?.replace('Bearer ', ''); + if (token === 'expired') return new Response('{}', { status: 401 }); + if (url.endsWith('/v1/user/access_token_info')) { + return Response.json({ id: Number(KAKAO_ID), expires_in: 100, app_id: token === 'other-app' ? 1 : Number(APP_ID) }); + } + return Response.json({ + id: Number(KAKAO_ID), + kakao_account: { + profile: { nickname: '카카오팬' }, + ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}), + }, + }); + })); +} + +async function wipe() { + const uids = [OLD_UID, GOOGLE_UID, kakaoUidOf(KAKAO_ID)]; + await getAuth().deleteUsers(uids); +} + +beforeAll(() => { + if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) { + throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.'); + } + process.env.KAKAO_APP_ID = APP_ID; + bootAdmin(); +}); +afterAll(async () => { await wipe(); await shutdownAdmin(); }); +beforeEach(async () => { await wipe(); stubKakao(); }); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('토큰 검증', () => { + it('만료·무효 토큰과 남의 앱 토큰은 E-AUTH-03', async () => { + expect(await codeOf(call({ accessToken: 'expired' }))).toBe('E-AUTH-03'); + expect(await codeOf(call({ accessToken: 'other-app' }))).toBe('E-AUTH-03'); + }); +}); + +describe('로그인', () => { + it('OIDC 시절 oidc.kakao 가입자는 같은 UID 로 들어온다', async () => { + await getAuth().createUser({ uid: OLD_UID }); + await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: KAKAO_ID } }); + const { token } = await call({ accessToken: 'good' }) as { token: string }; + expect(JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid).toBe(OLD_UID); + }); + + it('신규는 kakao:<회원번호> 로 만들고 oidc.kakao 를 붙인다 · 두 번째도 같은 UID', async () => { + const first = await call({ accessToken: 'good' }) as { token: string }; + await call({ accessToken: 'good' }); + const uid = JSON.parse(Buffer.from(first.token.split('.')[1]!, 'base64url').toString()).uid; + expect(uid).toBe(kakaoUidOf(KAKAO_ID)); + const user = await getAuth().getUser(uid); + expect(user.displayName).toBe('카카오팬'); + expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[KAKAO_PROVIDER_ID, KAKAO_ID]]); + }); +}); + +describe('SNS 연동', () => { + it('로그인 없이 link 는 E-AUTH-03', async () => { + expect(await codeOf(call({ accessToken: 'good', link: true }))).toBe('E-AUTH-03'); + }); + + it('Google 사용자에 붙인다 · 다른 UID 가 쓰는 카카오는 E-AUTH-08', async () => { + await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); + expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); + const user = await getAuth().getUser(GOOGLE_UID); + expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true); + + await getAuth().createUser({ uid: OLD_UID }); + expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08'); + }); + + it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => { + await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); + stubKakao('other@kakao.com'); + expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('different-email'); + stubKakao('FAN@fanuzu.co.kr'); + expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); + }); +}); diff --git a/app/firebase/package.json b/app/firebase/package.json index 6695919..8fe849b 100644 --- a/app/firebase/package.json +++ b/app/firebase/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "npm run test:rules && npm run test:functions", "test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"", - "test:functions": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix functions test\"" + "test:functions": "firebase emulators:exec --only firestore,auth --project demo-fanuzu \"npm --prefix functions test\"" }, "devDependencies": { "firebase-tools": "15.28.2" diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig index 093bdc1..470c539 100644 --- a/app/ios/Flutter/Debug.xcconfig +++ b/app/ios/Flutter/Debug.xcconfig @@ -4,3 +4,7 @@ // Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다. // `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다. #include? "../config/Google.xcconfig" + +// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴. +// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다. +#include? "../../config/kakao.env" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig index 02fef1a..056b1e0 100644 --- a/app/ios/Flutter/Release.xcconfig +++ b/app/ios/Flutter/Release.xcconfig @@ -4,3 +4,7 @@ // Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다. // `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다. #include? "../config/Google.xcconfig" + +// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴. +// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다. +#include? "../../config/kakao.env" diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 8798140..0bf47dc 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -89,6 +89,22 @@ $(GOOGLE_REVERSED_CLIENT_ID) + + + CFBundleTypeRole + Editor + CFBundleURLName + kakao-login + CFBundleURLSchemes + + kakao$(FANUZU_KAKAO_NATIVE_APP_KEY) + + + + + LSApplicationQueriesSchemes + + kakaokompassauth diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index 6c4196f..1b9462b 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 fe4cbdf..ad09900 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'; /// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. @@ -44,18 +45,26 @@ class AuthRepository extends ChangeNotifier { /// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다. /// QA 경로는 [qaEmailProvider] 하나뿐이다. /// - /// Apple·카카오·LINE 은 발주사 키가 없어 「키 대기」 상태다. 눌러도 예외가 아니라 - /// [AuthNotConfigured] 가 돌아오고 화면이 안내 다이얼로그를 띄운다. + /// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC + /// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를 + /// 돌려준다. static List _defaultProviders() => [ const GoogleSignInProvider(), AppleSignInProvider( webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), ), - const KeylessSocialProvider(AuthProviderKind.kakao, [ - '카카오 네이티브 앱 키', - '카카오 개발자 콘솔 앱 등록(사업자 정보 필요)', - ]), - lineProvider, + // 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱 + // 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다. + KakaoSignInProvider(), + // LINE 동의 항목: profile·email(이메일 권한 신청 승인 후). + OidcSignInProvider( + AuthProviderKind.line, + providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!, + scopes: const ['profile', 'email'], + // LINE 앱 자동 로그인으로 넘어가면 콜백이 Custom Tab 밖에서 열려 로그인이 + // 깨진다(Android). LINE Login 공식 파라미터로 끈다. + androidParameters: const {'disable_auto_login': 'true'}, + ), ]; /// 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 d3b4f11..4e01972 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -26,7 +26,8 @@ final class FirebaseAccountLinker implements AccountLinker { AuthProviderKind.google: 'google.com', AuthProviderKind.apple: 'apple.com', // 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다. - // 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. + // 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을 + // 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다. AuthProviderKind.kakao: 'oidc.kakao', AuthProviderKind.line: 'oidc.line', AuthProviderKind.email: 'password', @@ -55,13 +56,16 @@ 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 && + impl is! KakaoSignInProvider) { return AuthNotConfigured(provider, const ['자격증명 구현']); } - final source = impl as CredentialProvider; 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(); @@ -83,6 +87,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), @@ -120,6 +125,33 @@ 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 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 const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.'); + } + 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 4bb0041..08f0bd6 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -1,11 +1,17 @@ +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: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'; @@ -198,29 +204,278 @@ final class AppleSignInProvider kIsWeb || defaultTargetPlatform == TargetPlatform.android; } -/// 발주사 키를 기다리는 제공자 — 카카오·LINE. +/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나 +/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다). /// -/// 예외를 던지지 않는다. [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.androidParameters = 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; + + /// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로 + /// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가 + /// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다. + /// iOS 는 정상이라 건드리지 않는다. + final Map androidParameters; + + /// 테스트 주입용. 기본은 [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); + } + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + p.setCustomParameters(androidParameters); + } + return p; + } @override - Future signOut() async {} + Future signIn() async { + final missing = missingConfig; + if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); + try { + final result = await 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), + 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 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다. + 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 const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); + } on kakao.KakaoAuthException catch (error) { + // 동의 화면에서 「취소」. + if (error.error == kakao.AuthErrorCause.accessDenied) { + return const AuthCancelled(); + } + return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); + } on FirebaseFunctionsException catch (error) { + 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'] == 'different-email' => + '가입하신 이메일과 다른 계정입니다.', + 'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + 'E-AUTH-09' => '이미 연결된 계정입니다.', + _ => + flow == AuthFlow.link + ? '계정 연결에 실패했습니다.' + : '소셜 로그인에 실패했습니다.', + }); + } on fb.FirebaseAuthException catch (error) { + return AuthFailed(authCatalogCode(error), 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}'); + } + } +} + +/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다. +bool isWebFlowCancelled(fb.FirebaseAuthException error) => + error.code == 'web-context-canceled' || + error.code == 'web-context-cancelled'; + +/// 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; + 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(); + }); } /// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다. @@ -336,14 +591,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/pubspec.lock b/app/pubspec.lock index a8e2be4..0dad837 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.76" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" antlr4: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -162,13 +178,45 @@ packages: source: hosted version: "3.0.7" dart_jsonwebtoken: - dependency: transitive + dependency: "direct overridden" description: name: dart_jsonwebtoken - sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "2.17.0" + dio: + dependency: transitive + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: f3c3b109a0e470ee35f6346a155cab290578660852237fce10df6bbd92758534 + url: "https://pub.dev" + source: hosted + version: "0.3.2" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" equatable: dependency: transitive description: @@ -307,6 +355,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -407,6 +463,14 @@ packages: description: flutter source: sdk version: "0.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -415,6 +479,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + kakao_flutter_sdk_auth: + dependency: transitive + description: + name: kakao_flutter_sdk_auth + sha256: b9d982569462f6d31f97460f1d6f51acdc290f318967ee14009d20edc3006a12 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + kakao_flutter_sdk_common: + dependency: transitive + description: + name: kakao_flutter_sdk_common + sha256: "6a89e80ef6c080f434b05df87d9dd83323ff6d10ea34d9e0a6cd90d2015f7e48" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + kakao_flutter_sdk_user: + dependency: "direct main" + description: + name: kakao_flutter_sdk_user + sha256: "047ff5a82f52a7e1558086acd87b89b941b072e6029de3eee0b96dbce4ca1e48" + url: "https://pub.dev" + source: hosted + version: "2.0.1" leak_tracker: dependency: transitive description: @@ -487,6 +575,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" mock_exceptions: dependency: transitive description: @@ -511,6 +607,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -539,10 +667,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "3.9.1" posix: dependency: transitive description: @@ -575,6 +703,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sign_in_with_apple: dependency: "direct main" description: @@ -676,6 +860,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: @@ -708,6 +916,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -725,5 +941,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.5 <4.0.0" - flutter: ">=3.41.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ebf0a12..bacda37 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -29,9 +29,14 @@ dependencies: # Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다. crypto: ^3.0.7 - # 소셜 로그인 — Google·Apple 만 실 코드 경로. 카카오·네이버는 앱 키가 없어 스텁이다(Q3). + # 소셜 로그인 — Google·Apple 은 각 SDK, LINE 은 Firebase OIDC(SDK 없음), 카카오는 아래 SDK. google_sign_in: ^7.2.0 sign_in_with_apple: ^8.1.0 + # 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해 + # 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서 + # Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 SDK 를 쓴다. + # 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0). + kakao_flutter_sdk_user: ^2.0.1 dev_dependencies: flutter_test: @@ -49,6 +54,12 @@ dev_dependencies: # 재생성: dart run flutter_launcher_icons flutter_launcher_icons: ^0.14.4 +# 카카오 SDK(encrypt → pointycastle 3)와 firebase_auth_mocks 0.15(dart_jsonwebtoken 3 → +# pointycastle 4)가 갈린다. 테스트 전용인 쪽을 내린다 — 앱에 실리는 암호 라이브러리는 그대로 둔다. +# 둘 중 하나가 pointycastle 범위를 맞추면 지운다. +dependency_overrides: + dart_jsonwebtoken: ^2.17.0 + # 앱 아이콘 — 2026-08-13 수령한 공식 `fanuzu logo only.png`의 중앙 1:1 영역을 # 픽셀 변경 없이 사용한다. `assets/brand/` 두 원본은 같은 공식 마크다. flutter_launcher_icons: diff --git a/app/test/auth_kakao_test.dart b/app/test/auth_kakao_test.dart new file mode 100644 index 0000000..6af6143 --- /dev/null +++ b/app/test/auth_kakao_test.dart @@ -0,0 +1,109 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:fancake/core/firebase/firebase_boot.dart'; +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/firebase_account_linker.dart'; +import 'package:fancake/features/auth/social_providers.dart'; +import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 카카오 — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약. +/// 토큰 검증·기존 oidc.kakao 사용자 매핑은 서버 테스트(functions/test/auth_kakao.test.ts)가 본다. +void main() { + setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: '카카오 테스트')); + tearDown( + () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '카카오 테스트 끝'), + ); + + final sent = >[]; + setUp(sent.clear); + + KakaoSignInProvider kakao( + MockFirebaseAuth auth, { + Future Function()? token, + Object? serverError, + String appKey = 'test-key', + }) => KakaoSignInProvider( + accessToken: token ?? () async => 'kakao-access', + exchange: (payload) async { + sent.add(payload); + if (serverError != null) throw serverError; + return { + 'token': 'custom-token', + if (payload['link'] == true) 'linked': true, + }; + }, + firebaseAuth: auth, + nativeAppKey: appKey, + ); + + test('성공 — 카카오 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async { + final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'kakao:1')); + final outcome = await kakao(auth).signIn(); + expect(sent.single, {'accessToken': 'kakao-access'}); + expect((outcome as AuthSucceeded).account.uid, 'kakao:1'); + expect(outcome.account.provider, AuthProviderKind.kakao); + expect(auth.currentUser?.uid, 'kakao:1'); + }); + + test('앱 키가 없으면 SDK·서버를 부르지 않고 미설정', () async { + final outcome = await kakao(MockFirebaseAuth(), appKey: '').signIn(); + expect(outcome, isA()); + expect(sent, isEmpty); + }); + + test('카카오 로그인 취소(CANCELED)는 취소 · 서버를 부르지 않는다', () async { + final outcome = await kakao( + MockFirebaseAuth(), + token: () async => throw PlatformException(code: 'CANCELED'), + ).signIn(); + expect(outcome, isA()); + expect(sent, isEmpty); + }); + + test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async { + final auth = MockFirebaseAuth(); + final outcome = await kakao( + auth, + serverError: FirebaseFunctionsException( + code: 'permission-denied', + message: '카카오 인증을 확인하지 못했습니다.', + details: {'code': 'E-AUTH-03'}, + ), + ).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(auth.currentUser, isNull); + }); + + group('SNS 연동', () { + test('로그인한 사용자에 link: true 로 붙인다', () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1'), + ); + final linker = FirebaseAccountLinker( + providers: [kakao(auth)], + firebaseAuth: auth, + ); + final outcome = await linker.link(AuthProviderKind.kakao); + expect(sent.single, {'accessToken': 'kakao-access', 'link': true}); + expect((outcome as AuthSucceeded).account.uid, 'u-1'); + }); + + test('다른 이메일이면 E-AUTH-08 · 이메일 안내 문구', () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1'), + ); + final outcome = await kakao( + auth, + serverError: FirebaseFunctionsException( + code: 'already-exists', + message: 'x', + details: {'code': 'E-AUTH-08', 'reason': 'different-email'}, + ), + ).link(auth.currentUser!); + expect((outcome as AuthFailed).code, 'E-AUTH-08'); + }); + }); +} diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart new file mode 100644 index 0000000..6c4fc23 --- /dev/null +++ b/app/test/auth_oidc_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +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/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// LINE OIDC 제공자 — 실 로그인은 못 하고, +/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다. +void main() { + // Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned). + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트')); + tearDown( + () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'), + ); + + OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider( + AuthProviderKind.line, + providerId: 'oidc.line', + firebaseAuth: auth, + ); + + test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () { + final providers = { + for (final p in AuthRepository().providers) p.provider: p, + }; + final p = providers[AuthProviderKind.line]; + expect(p, isA()); + expect(p!.isLive, isTrue); + expect( + (p as OidcSignInProvider).providerId, + FirebaseAccountLinker.providerIds[AuthProviderKind.line], + ); + expect(p.authProvider.providerId, 'oidc.line'); + expect(providers[AuthProviderKind.kakao], isA()); + }); + + test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async { + FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음'); + final outcome = await line(_ThrowingAuth('should-not-call')).signIn(); + expect(outcome, isA()); + }); + + test('성공하면 LINE 계정으로 로그인된다', () async { + final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1')); + final outcome = await line(auth).signIn(); + expect(outcome, isA()); + expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.line); + expect(outcome.account.uid, 'k-1'); + }); + + test('인가 화면을 닫으면 Android·iOS 철자 모두 취소로 본다', () async { + for (final code in ['web-context-canceled', 'web-context-cancelled']) { + final outcome = await line(_ThrowingAuth(code)).signIn(); + expect(outcome, isA(), reason: code); + } + }); + + test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async { + final outcome = await line(_ThrowingAuth('operation-not-allowed')).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-11'); + }); + + test('Android 의 LINE 은 앱 자동 로그인을 끈다 · iOS 는 파라미터 없음', () { + final line = + AuthRepository().providers.firstWhere( + (p) => p.provider == AuthProviderKind.line, + ) + as OidcSignInProvider; + expect(line.authProvider.parameters, {'disable_auto_login': 'true'}); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + expect(line.authProvider.parameters, isEmpty); + }); + + testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async { + late AuthOutcome outcome; + line(_HangingAuth()).signIn().then((o) => outcome = o); + void go(List states) => + states.forEach(tester.binding.handleAppLifecycleStateChanged); + const inactive = AppLifecycleState.inactive; + const hidden = AppLifecycleState.hidden; + go([inactive, hidden, AppLifecycleState.paused]); // Custom Tab 으로 나감 + await tester.pump(const Duration(minutes: 1)); // 브라우저에 오래 있어도 그대로 + go([hidden, inactive, AppLifecycleState.resumed]); // 앱으로 돌아옴 + await tester.pump(const Duration(seconds: 9)); + expect(outcome, isA()); + }); + + group('계정 연결', () { + test('같은 UID 에 oidc.line 을 붙인다', () async { + final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr'); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final linker = FirebaseAccountLinker( + providers: [line(auth)], + firebaseAuth: auth, + ); + + final outcome = await linker.link(AuthProviderKind.line); + + expect(outcome, isA()); + expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line)); + }); + + test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async { + final user = _LinkingUser(linkedEmail: 'someone@else.com'); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final linker = FirebaseAccountLinker( + providers: [line(auth)], + firebaseAuth: auth, + ); + + final outcome = await linker.link(AuthProviderKind.line); + + expect((outcome as AuthFailed).code, 'E-AUTH-08'); + expect( + linker.linkedAccounts.keys, + isNot(contains(AuthProviderKind.line)), + ); + }); + }); +} + +class _ThrowingAuth extends MockFirebaseAuth { + _ThrowingAuth(this.code); + final String code; + + @override + Future signInWithProvider(fb.AuthProvider provider) => + throw fb.FirebaseAuthException(code: code); +} + +/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task. +class _HangingAuth extends MockFirebaseAuth { + @override + Future signInWithProvider(fb.AuthProvider provider) => + Completer().future; +} + +/// 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, + });