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