Kakao 로그인을 SDK와 서버 커스텀 토큰으로 · sFPI 일 집계 직후 Stage 승급 판정
This commit is contained in:
@@ -78,3 +78,6 @@ docs/output/
|
||||
|
||||
# Cloud Functions 의 tsc 산출물. 소스는 firebase/functions/src 다.
|
||||
firebase/functions/lib/
|
||||
|
||||
# 카카오 네이티브 앱 키. 다른 설정 파일(google-services.json 등)과 같이 저장소 밖에서 넣는다.
|
||||
config/kakao.env
|
||||
|
||||
@@ -138,6 +138,13 @@ python3 tools/ios/inject_url_scheme.py # → ios/config/Google.xcconfig
|
||||
|
||||
> `apps:sdkconfig` 는 대상 파일이 이미 있으면 거부한다. 갱신하려면 먼저 지운다.
|
||||
|
||||
**카카오 네이티브 앱 키** — `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` 로 받는다.
|
||||
|
||||
Authentication 제공자는 이메일/비밀번호와 Google 둘 다 켜져 있다(2026-09-11). Google 을
|
||||
켜야 `oauth_client` 와 `REVERSED_CLIENT_ID` 가 채워지며, 콘솔에서만 켤 수 있다.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -48,6 +48,18 @@
|
||||
<data android:path="callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- 카카오 로그인 리다이렉트(카카오계정 웹 로그인 후 앱 복귀). Kakao Flutter SDK 공식 설정.
|
||||
키는 config/kakao.env → build.gradle.kts manifestPlaceholders. -->
|
||||
<activity
|
||||
android:name="com.kakao.sdk.flutter.auth.AuthCodeHandlerActivity"
|
||||
android:exported="true">
|
||||
<intent-filter android:label="flutter_web_auth">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="kakao${kakaoNativeAppKey}" android:host="oauth" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- AdMob 앱 ID — Google 공식 **테스트** 앱 ID(9주차 샌드박스). 발주사 AdMob 계정의
|
||||
실 앱 ID 로 바꿀 때 lib/core/monetization/rewarded_ad.dart 의 AdUnits 광고 단위도 함께 바꾼다. -->
|
||||
<meta-data
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
FANUZU_KAKAO_NATIVE_APP_KEY=
|
||||
@@ -12,7 +12,7 @@
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\""
|
||||
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore,auth --project demo-fanuzu \"npm test\""
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase-admin": "13.6.0",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
import { defineString } from 'firebase-functions/params';
|
||||
import { onCall } from 'firebase-functions/v2/https';
|
||||
|
||||
import { str } from '../core/callable.js';
|
||||
import { ERR, fail } from '../core/errors.js';
|
||||
|
||||
/**
|
||||
* `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다.
|
||||
*
|
||||
* **왜 OIDC 가 아니라 이 경로인가.** Android 에서 Firebase OIDC(`oidc.kakao`) 웹 흐름은
|
||||
* 「카카오톡으로 로그인」을 고르면 카카오톡 앱으로 넘어갔다가 콜백이 Custom Tab 이 아닌 일반
|
||||
* Chrome 에서 열려 Firebase 핸들러가 「missing initial state」로 끝난다. 카카오에는 앱 전환을
|
||||
* 막는 파라미터가 없어서, 앱이 카카오 공식 SDK 로 로그인하고 서버가 그 토큰을 검증해
|
||||
* 커스텀 토큰을 내준다(Firebase 「Authenticate with Firebase Using a Custom Authentication
|
||||
* System」).
|
||||
*
|
||||
* **사용자 식별은 기존 `oidc.kakao` 연결을 그대로 쓴다.** Firebase 사용자의 제공자 정보에
|
||||
* `oidc.kakao` / 카카오 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다. 그래서
|
||||
* - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고,
|
||||
* - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다.
|
||||
* 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명).
|
||||
*
|
||||
* 입력: `{ 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<KakaoProfile> {
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
const profile = (account.profile ?? {}) as Record<string, unknown>;
|
||||
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<string | null> {
|
||||
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:<id>` 를 만들고 연결한다. */
|
||||
export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
|
||||
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<void> {
|
||||
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<string, unknown>).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) };
|
||||
});
|
||||
@@ -35,6 +35,9 @@ export const ERR = {
|
||||
primaryCooldown: 'E-FDM-04',
|
||||
permissionDenied: 'E-PERM-01',
|
||||
authFailed: 'E-AUTH-03',
|
||||
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
|
||||
authCredentialInUse: 'E-AUTH-08',
|
||||
authAlreadyLinked: 'E-AUTH-09',
|
||||
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
|
||||
trnSelfMark: 'E-TRN-01',
|
||||
trnDailyCap: 'E-TRN-02',
|
||||
@@ -76,6 +79,8 @@ const STATUS: Record<ErrorCode, Status> = {
|
||||
'E-FDM-04': 'failed-precondition',
|
||||
'E-PERM-01': 'permission-denied',
|
||||
'E-AUTH-03': 'permission-denied',
|
||||
'E-AUTH-08': 'already-exists',
|
||||
'E-AUTH-09': 'already-exists',
|
||||
'E-TRN-01': 'permission-denied',
|
||||
'E-TRN-02': 'resource-exhausted',
|
||||
'E-TRN-03': 'not-found',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
|
||||
import { onCall } from 'firebase-functions/v2/https';
|
||||
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
|
||||
import { logger } from 'firebase-functions';
|
||||
|
||||
import { callerUid, requireRole, str } from '../core/callable.js';
|
||||
import { ERR, fail } from '../core/errors.js';
|
||||
import { sendFandomPush } from '../notify/push.js';
|
||||
|
||||
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */
|
||||
export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||
@@ -30,3 +37,93 @@ export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 별첨 B **B-53** — 정식 행성 6 단계 명칭. 성운(Stage0)은 `fn-planet-create` 가 다룬다. */
|
||||
const STAGE_NAMES = ['', '시작', '성장', '함께', '동행', '빛', '영원'];
|
||||
const FINAL_STAGE = 6;
|
||||
|
||||
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
|
||||
|
||||
/**
|
||||
* 정식 행성 Stage 승급 판정 — 원장 **B-11 · B-53 · B-54**.
|
||||
*
|
||||
* - **누적 Growth POP(`growthPop`) 과 sFPI(`sfpi`) 를 동시에** 다음 Stage 진입값
|
||||
* (`planetStages/{n}.requiredPop` · `requiredSfpi`, 설계서 §2.3 누계) 이상이어야 오른다.
|
||||
* 하나만 채우면 그대로 대기한다.
|
||||
* - 진입값이 누계라 초과분은 자연히 다음 Stage 진행도로 이월된다(B-54 Carry-over). 그래서
|
||||
* 한 번의 판정에서 여러 Stage 를 채웠으면 **채운 가장 높은 Stage 까지** 올린다.
|
||||
* - 강등하지 않는다(B-53 검증 기준 「Stage 강등 0건」). Stage6 이 마지막이다.
|
||||
* - 다음 Stage 마스터 문서가 없거나 값이 비면 그 Stage 에서 멈춘다 — 빈 값을 0 으로 읽어
|
||||
* 승급시키지 않는다.
|
||||
*
|
||||
* 트랜잭션 안에서 다시 읽고 쓰므로 같은 날 두 번 돌아도 두 번째는 할 일이 없다(멱등).
|
||||
* 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` 상승을 보고 띄운다.
|
||||
*/
|
||||
export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]> {
|
||||
const db = getFirestore();
|
||||
const stageDocs = await db.collection('planetStages').get();
|
||||
const required = new Map<number, { pop: number; sfpi: number }>();
|
||||
for (const doc of stageDocs.docs) {
|
||||
const pop = doc.get('requiredPop');
|
||||
const sfpi = doc.get('requiredSfpi');
|
||||
if (Number.isFinite(pop) && Number.isFinite(sfpi)) required.set(Number(doc.id), { pop, sfpi });
|
||||
}
|
||||
|
||||
const refs = fandomIds
|
||||
? fandomIds.map((id) => db.doc(`fandoms/${id}`))
|
||||
: (await db.collection('fandoms').where('stage', '>=', 1).select().get()).docs.map((d) => d.ref);
|
||||
|
||||
const promoted: Promotion[] = [];
|
||||
for (const ref of refs) {
|
||||
const result = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(ref);
|
||||
const from = snap.get('stage');
|
||||
if (!Number.isInteger(from) || from < 1 || from >= FINAL_STAGE) return null;
|
||||
const pop = Number(snap.get('growthPop') ?? 0);
|
||||
const sfpi = Number(snap.get('sfpi') ?? 0);
|
||||
let to = from as number;
|
||||
for (let next = to + 1; next <= FINAL_STAGE; next++) {
|
||||
const req = required.get(next);
|
||||
if (!req || pop < req.pop || sfpi < req.sfpi) break;
|
||||
to = next;
|
||||
}
|
||||
if (to === from) return null;
|
||||
tx.update(ref, { stage: to, stageChangedAt: FieldValue.serverTimestamp() });
|
||||
return { fandomId: ref.id, fromStage: from as number, toStage: to, name: snap.get('name') as string | undefined };
|
||||
});
|
||||
if (!result) continue;
|
||||
const { name, ...promotion } = result;
|
||||
promoted.push(promotion);
|
||||
// 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
|
||||
await sendFandomPush({
|
||||
fandomId: ref.id,
|
||||
category: 'fandomEvent',
|
||||
title: `${name ?? ref.id} 행성이 「${STAGE_NAMES[promotion.toStage]}」 단계로 진화했습니다`,
|
||||
body: '당신도 이 진화에 함께했습니다. 행성에서 확인해 보세요.',
|
||||
data: { kind: 'evolution', toStage: String(promotion.toStage) },
|
||||
});
|
||||
}
|
||||
if (promoted.length > 0) logger.info('[evolution] Stage 승급', { promoted });
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-admin-planet-evolution-run` — 승급 판정을 지금 한 팬덤에 돌린다(master 전용).
|
||||
*
|
||||
* 판정은 매일 KST 자정 sFPI 누적 직후(`fnFpiDailySnapshot`)에 돈다. 실기기 확인 때 하루를
|
||||
* 기다리지 않도록 같은 판정을 즉시 부르는 경로다. 조건을 우회하지 않으며 결과(승급 없음
|
||||
* 포함)를 `adminActionLogs` 에 남긴다.
|
||||
*/
|
||||
export const fnAdminPlanetEvolutionRun = onCall(async (req) => {
|
||||
const actorUid = callerUid(req);
|
||||
const role = requireRole(req, ['master']);
|
||||
const fandomId = str(req.data, 'fandomId');
|
||||
if (fandomId.includes('/')) fail(ERR.permissionDenied, '문서 경로가 아닙니다.');
|
||||
const [promotion] = await promoteStages([fandomId]);
|
||||
await getFirestore().collection('adminActionLogs').add({
|
||||
actorUid, role, targetPath: `fandoms/${fandomId}`, reason: 'PLANET_EVOLUTION_RUN',
|
||||
patch: promotion ? { fromStage: promotion.fromStage, stage: promotion.toStage } : { promoted: false },
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return { fandomId, promoted: promotion ?? null };
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { rankWithTies } from '../ledger/fpi.js';
|
||||
import { txIdOf } from '../core/ids.js';
|
||||
import { dayKey } from '../core/time.js';
|
||||
import { notifyEclipseWarning } from '../notify/push.js';
|
||||
import { promoteStages } from './evolution.js';
|
||||
import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
@@ -842,7 +843,7 @@ export const fnFpiDailySnapshot = onSchedule(
|
||||
* 인정해 남긴 FPI 원천 이벤트(`fpiEvents`, verified · uid 있음) 1건 이상** — S01 확정 전
|
||||
* 유지 기준. 활동 0명인 날도 `dau: 0` 으로 남긴다(C-27 「0명인 날 포함」).
|
||||
*/
|
||||
export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number }> {
|
||||
export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number; promoted: number }> {
|
||||
const db = getFirestore();
|
||||
const day = dayKey(new Date(now.getTime() - 24 * 60 * 60 * 1000));
|
||||
const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`));
|
||||
@@ -881,7 +882,9 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
||||
tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true });
|
||||
});
|
||||
}
|
||||
return { day, fandoms: fandoms.size };
|
||||
// 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
|
||||
const promoted = await promoteStages();
|
||||
return { day, fandoms: fandoms.size, promoted: promoted.length };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
|
||||
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole,
|
||||
} from './account/account.js';
|
||||
export { fnAuthKakao } from './account/kakao.js';
|
||||
export {
|
||||
fnEclipseDefend, fnEclipseAggregate, fnEclipseTrigger,
|
||||
fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot,
|
||||
@@ -49,5 +50,5 @@ export {
|
||||
} from './account/translation.js';
|
||||
export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js';
|
||||
|
||||
export { fnPlanetEvolutionRecord } from './fandom/evolution.js';
|
||||
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
|
||||
export { fnPlanetHistory } from './fandom/history.js';
|
||||
|
||||
@@ -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<typeof fnAuthKakao.run>[0];
|
||||
const call = (data: Record<string, unknown>, uid?: string) =>
|
||||
fnAuthKakao.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
|
||||
const codeOf = async (p: Promise<unknown>) => {
|
||||
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<string, string> }) => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { getFirestore } from 'firebase-admin/firestore';
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
|
||||
const push = vi.hoisted(() => vi.fn(async () => true));
|
||||
vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWarning: vi.fn() }));
|
||||
|
||||
const { fnAdminPlanetEvolutionRun, promoteStages } = await import('../src/fandom/evolution.js');
|
||||
const { closeDay } = await import('../src/fandom/fandom.js');
|
||||
|
||||
/** 설계서 §2.3 누계 — 발주사 수치표 v1.4 「Stage Summary」(seed-demo.py 와 같은 표). */
|
||||
const STAGES: Array<[number, number, number]> = [
|
||||
[1, 0, 0], [2, 100_000, 15_000], [3, 500_000, 75_000], [4, 2_000_000, 300_000],
|
||||
[5, 10_000_000, 1_500_000], [6, 50_000_000, 7_500_000],
|
||||
];
|
||||
const id = 'test-stage-promotion';
|
||||
const db = () => getFirestore();
|
||||
const stage = async () => (await db().doc(`fandoms/${id}`).get()).get('stage');
|
||||
const set = (data: Record<string, unknown>) => db().doc(`fandoms/${id}`).set({ name: '승급', ...data });
|
||||
|
||||
beforeAll(async () => {
|
||||
bootAdmin();
|
||||
for (const [n, pop, sfpi] of STAGES) {
|
||||
await db().doc(`planetStages/${n}`).set({ stageNo: n, requiredPop: pop, requiredSfpi: sfpi });
|
||||
}
|
||||
});
|
||||
beforeEach(async () => {
|
||||
push.mockClear();
|
||||
await db().recursiveDelete(db().doc(`fandoms/${id}`));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await db().recursiveDelete(db().doc(`fandoms/${id}`));
|
||||
await shutdownAdmin();
|
||||
});
|
||||
|
||||
it('조건 미달 · 한쪽만 충족이면 대기한다', async () => {
|
||||
for (const [pop, sfpi] of [[99_999, 14_999], [5_000_000, 14_999], [99_999, 9_000_000]]) {
|
||||
await set({ stage: 1, growthPop: pop, sfpi });
|
||||
expect(await promoteStages([id])).toEqual([]);
|
||||
expect(await stage()).toBe(1);
|
||||
}
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('둘 다 충족하면 오르고, 두 번 돌아도 한 번만 오른다', async () => {
|
||||
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
|
||||
await Promise.all([promoteStages([id]), promoteStages([id])]);
|
||||
await promoteStages([id]);
|
||||
expect(await stage()).toBe(2);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · 성운 · 최종 단계는 건드리지 않는다', async () => {
|
||||
// POP 은 Stage5 까지, sFPI 는 Stage3 까지 → Stage3.
|
||||
await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 });
|
||||
expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]);
|
||||
await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 });
|
||||
expect(await promoteStages([id])).toEqual([]);
|
||||
await set({ stage: 0, growthPop: 1e12, sfpi: 1e12 });
|
||||
expect(await promoteStages([id])).toEqual([]);
|
||||
expect(await stage()).toBe(0);
|
||||
});
|
||||
|
||||
it('일일 마감(sFPI 누적) 직후 판정한다 — 그날 누적으로 문턱을 넘으면 오른다', async () => {
|
||||
await set({ stage: 1, growthPop: 100_000, sfpi: 14_000, fpiScore: 1_000 });
|
||||
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
|
||||
expect(await stage()).toBe(2);
|
||||
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
|
||||
expect((await db().doc(`fandoms/${id}`).get()).get('sfpi')).toBe(15_000);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('운영자 즉시 실행은 master 만 · 결과를 adminActionLogs 에 남긴다', async () => {
|
||||
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
|
||||
const run = (role: string) => fnAdminPlanetEvolutionRun.run({
|
||||
data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } },
|
||||
} as unknown as Parameters<typeof fnAdminPlanetEvolutionRun.run>[0]);
|
||||
await expect(run('ops')).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
||||
expect(await stage()).toBe(1);
|
||||
expect((await run('master')).promoted).toEqual({ fandomId: id, fromStage: 1, toStage: 2 });
|
||||
const logs = await db().collection('adminActionLogs').where('actorUid', '==', 'evo-master').get();
|
||||
expect(logs.docs.some((d) => d.get('reason') === 'PLANET_EVOLUTION_RUN' && d.get('patch.stage') === 2)).toBe(true);
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
// `#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"
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
// `#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"
|
||||
|
||||
@@ -287,6 +287,22 @@
|
||||
<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- 카카오 로그인 복귀 스킴 kakao{네이티브 앱 키}. 값은 config/kakao.env(Flutter/*.xcconfig). -->
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>kakao-login</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>kakao$(FANUZU_KAKAO_NATIVE_APP_KEY)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>kakaokompassauth</string>
|
||||
</array>
|
||||
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
|
||||
@@ -57,25 +57,25 @@ class AuthRepository extends ChangeNotifier {
|
||||
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
|
||||
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
|
||||
///
|
||||
/// 카카오·LINE 은 Firebase OIDC 제공자다. 콘솔 설정 전이면 Firebase 가
|
||||
/// `operation-not-allowed`(E-AUTH-11)를 돌려준다.
|
||||
/// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC
|
||||
/// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를
|
||||
/// 돌려준다.
|
||||
static List<SocialAuthProvider> _defaultProviders() => [
|
||||
const GoogleSignInProvider(),
|
||||
AppleSignInProvider(
|
||||
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
|
||||
),
|
||||
// 동의 항목: 카카오 콘솔의 닉네임·이메일, LINE 의 profile·email(이메일 권한 신청 승인 후).
|
||||
OidcSignInProvider(
|
||||
AuthProviderKind.kakao,
|
||||
providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.kakao]!,
|
||||
// 이메일(account_email)은 비즈 앱 전환 후에만 요청할 수 있다. 전환 전에 요청하면
|
||||
// KOE205 로 로그인 자체가 막힌다 — 전환되면 여기에 다시 넣는다.
|
||||
scopes: const ['profile_nickname'],
|
||||
),
|
||||
// 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(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'},
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -27,7 +27,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',
|
||||
@@ -56,11 +57,14 @@ final class FirebaseAccountLinker implements AccountLinker {
|
||||
}
|
||||
final missing = impl.missingConfig;
|
||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||
if (impl is! CredentialProvider && impl is! OidcSignInProvider) {
|
||||
if (impl is! CredentialProvider &&
|
||||
impl is! OidcSignInProvider &&
|
||||
impl is! KakaoSignInProvider) {
|
||||
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -130,7 +134,9 @@ final class FirebaseAccountLinker implements AccountLinker {
|
||||
/// blocking 함수(beforeSignIn)로 서버에서 거른다.
|
||||
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
||||
final mine = user.email;
|
||||
final result = await user.linkWithProvider(impl.authProvider);
|
||||
final result = await untilAbandoned(
|
||||
user.linkWithProvider(impl.authProvider),
|
||||
);
|
||||
// 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때).
|
||||
final theirs = _emailOf(result.user, impl.providerId);
|
||||
if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) {
|
||||
|
||||
@@ -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';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -199,7 +205,8 @@ final class AppleSignInProvider
|
||||
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
||||
}
|
||||
|
||||
/// 카카오·LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.kakao` · `oidc.line`).
|
||||
/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나
|
||||
/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
|
||||
///
|
||||
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
|
||||
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
|
||||
@@ -219,6 +226,7 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
this.provider, {
|
||||
required this.providerId,
|
||||
this.scopes = const [],
|
||||
this.androidParameters = const {},
|
||||
this.firebaseAuth,
|
||||
});
|
||||
|
||||
@@ -233,6 +241,12 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
/// KOE205 로 로그인 자체를 막는다.
|
||||
final List<String> scopes;
|
||||
|
||||
/// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로
|
||||
/// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가
|
||||
/// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다.
|
||||
/// iOS 는 정상이라 건드리지 않는다.
|
||||
final Map<String, String> androidParameters;
|
||||
|
||||
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||
final fb.FirebaseAuth? firebaseAuth;
|
||||
|
||||
@@ -248,6 +262,9 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
for (final scope in scopes) {
|
||||
p.addScope(scope);
|
||||
}
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
p.setCustomParameters(androidParameters);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -256,8 +273,11 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
final missing = missingConfig;
|
||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||
try {
|
||||
final result = await (firebaseAuth ?? fb.FirebaseAuth.instance)
|
||||
.signInWithProvider(authProvider);
|
||||
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();
|
||||
@@ -275,11 +295,190 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 카카오 네이티브 앱 키. `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<String> Function()? accessToken,
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
|
||||
exchange,
|
||||
this.firebaseAuth,
|
||||
this.nativeAppKey = kakaoNativeAppKey,
|
||||
}) : _accessToken = accessToken ?? _sdkAccessToken,
|
||||
_exchange = exchange ?? _callServer;
|
||||
|
||||
final Future<String> Function() _accessToken;
|
||||
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
|
||||
|
||||
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||
final fb.FirebaseAuth? firebaseAuth;
|
||||
final String nativeAppKey;
|
||||
|
||||
static Future<void>? _init;
|
||||
|
||||
@override
|
||||
SocialProvider get provider => SocialProvider.kakao;
|
||||
|
||||
@override
|
||||
bool get isLive => true;
|
||||
|
||||
@override
|
||||
List<String> get missingConfig => [
|
||||
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
||||
if (nativeAppKey.isEmpty) '카카오 네이티브 앱 키',
|
||||
];
|
||||
|
||||
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
|
||||
|
||||
/// 카카오톡이 있으면 카카오톡, 없거나 실패하면 카카오계정(공식 가이드 순서).
|
||||
/// 사용자 취소(`CANCELED`)는 계정 로그인으로 넘기지 않고 그대로 던진다.
|
||||
static Future<String> _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<Map<String, dynamic>> _callServer(
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final result = await FirebaseFunctions.instanceFor(
|
||||
region: Backend.region,
|
||||
).httpsCallable('fnAuthKakao').call<Object?>(payload);
|
||||
final data = result.data;
|
||||
return data is Map ? Map<String, dynamic>.from(data) : const {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> 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<AuthOutcome> 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<AuthOutcome> _run(
|
||||
AuthFlow flow,
|
||||
Future<AuthOutcome> Function() body,
|
||||
) async {
|
||||
final missing = missingConfig;
|
||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||
try {
|
||||
return await body();
|
||||
} on PlatformException catch (error) {
|
||||
if (error.code == 'CANCELED') return const AuthCancelled();
|
||||
debugPrint('[KakaoSignInProvider] sdk failed: ${error.code}');
|
||||
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
|
||||
} on kakao.KakaoAuthException catch (error) {
|
||||
// 동의 화면에서 「취소」.
|
||||
if (error.error == kakao.AuthErrorCause.accessDenied) {
|
||||
return const AuthCancelled();
|
||||
}
|
||||
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
|
||||
} 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' =>
|
||||
appL10n.authDifferentEmail,
|
||||
'E-AUTH-08' => appL10n.authCredentialInUse,
|
||||
'E-AUTH-09' => appL10n.authAlreadyLinked,
|
||||
_ =>
|
||||
flow == AuthFlow.link
|
||||
? appL10n.authLinkFailed
|
||||
: appL10n.authSocialFailed,
|
||||
});
|
||||
} on fb.FirebaseAuthException catch (error) {
|
||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<T> untilAbandoned<T>(
|
||||
Future<T> flow, {
|
||||
Duration grace = const Duration(seconds: 8),
|
||||
}) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return flow;
|
||||
final done = Completer<T>();
|
||||
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은 비밀값이 아니지만 환경별 값이다.
|
||||
/// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다.
|
||||
apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() {
|
||||
|
||||
+125
-5
@@ -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:
|
||||
@@ -178,13 +194,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:
|
||||
@@ -416,6 +464,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.35"
|
||||
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
|
||||
@@ -708,6 +764,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.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:
|
||||
@@ -716,6 +780,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:
|
||||
@@ -836,6 +924,14 @@ 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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -968,10 +1064,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:
|
||||
@@ -1273,6 +1369,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:
|
||||
|
||||
@@ -107,6 +107,11 @@ dependencies:
|
||||
# 2026-10-05 대표 지시 「html 을 클론코딩하듯」). flutter.dev 팀 관리.
|
||||
# 최소 OS 하한 영향 없음: webview_flutter_android minSdk 24(앱 29) · wkwebview iOS 13.0(앱 15.0).
|
||||
webview_flutter: ^4.14.1
|
||||
# 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해
|
||||
# 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서
|
||||
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다.
|
||||
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
||||
kakao_flutter_sdk_user: ^2.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -124,6 +129,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:
|
||||
|
||||
@@ -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 = <Map<String, dynamic>>[];
|
||||
setUp(sent.clear);
|
||||
|
||||
KakaoSignInProvider kakao(
|
||||
MockFirebaseAuth auth, {
|
||||
Future<String> 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<AuthNotConfigured>());
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('카카오 로그인 취소(CANCELED)는 취소 · 서버를 부르지 않는다', () async {
|
||||
final outcome = await kakao(
|
||||
MockFirebaseAuth(),
|
||||
token: () async => throw PlatformException(code: 'CANCELED'),
|
||||
).signIn();
|
||||
expect(outcome, isA<AuthCancelled>());
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -5,95 +7,122 @@ 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 제공자 — 콘솔 설정 전이라 실 로그인은 못 하고,
|
||||
/// 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 kakao(fb.FirebaseAuth auth) => OidcSignInProvider(
|
||||
AuthProviderKind.kakao,
|
||||
providerId: 'oidc.kakao',
|
||||
OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider(
|
||||
AuthProviderKind.line,
|
||||
providerId: 'oidc.line',
|
||||
firebaseAuth: auth,
|
||||
);
|
||||
|
||||
test('기본 목록의 카카오·LINE 은 Firebase OIDC 제공자다 — 「준비 중」이 아니다', () {
|
||||
test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () {
|
||||
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<OidcSignInProvider>(), reason: kind.name);
|
||||
expect(p!.isLive, isTrue);
|
||||
expect(
|
||||
(p as OidcSignInProvider).providerId,
|
||||
FirebaseAccountLinker.providerIds[kind],
|
||||
);
|
||||
expect(p.authProvider.providerId, 'oidc.${kind.name}');
|
||||
}
|
||||
final p = providers[AuthProviderKind.line];
|
||||
expect(p, isA<OidcSignInProvider>());
|
||||
expect(p!.isLive, isTrue);
|
||||
expect(
|
||||
(p as OidcSignInProvider).providerId,
|
||||
FirebaseAccountLinker.providerIds[AuthProviderKind.line],
|
||||
);
|
||||
expect(p.authProvider.providerId, 'oidc.line');
|
||||
expect(providers[AuthProviderKind.kakao], isA<KakaoSignInProvider>());
|
||||
});
|
||||
|
||||
test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async {
|
||||
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음');
|
||||
final outcome = await kakao(_ThrowingAuth('should-not-call')).signIn();
|
||||
final outcome = await line(_ThrowingAuth('should-not-call')).signIn();
|
||||
expect(outcome, isA<AuthNotConfigured>());
|
||||
});
|
||||
|
||||
test('성공하면 카카오 계정으로 로그인된다', () async {
|
||||
test('성공하면 LINE 계정으로 로그인된다', () async {
|
||||
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1'));
|
||||
final outcome = await kakao(auth).signIn();
|
||||
final outcome = await line(auth).signIn();
|
||||
expect(outcome, isA<AuthSucceeded>());
|
||||
expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.kakao);
|
||||
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 kakao(_ThrowingAuth(code)).signIn();
|
||||
final outcome = await line(_ThrowingAuth(code)).signIn();
|
||||
expect(outcome, isA<AuthCancelled>(), reason: code);
|
||||
}
|
||||
});
|
||||
|
||||
test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async {
|
||||
final outcome = await kakao(
|
||||
_ThrowingAuth('operation-not-allowed'),
|
||||
).signIn();
|
||||
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<AppLifecycleState> 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<AuthCancelled>());
|
||||
});
|
||||
|
||||
group('계정 연결', () {
|
||||
test('같은 UID 에 oidc.kakao 를 붙인다', () async {
|
||||
test('같은 UID 에 oidc.line 을 붙인다', () async {
|
||||
final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr');
|
||||
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||
final linker = FirebaseAccountLinker(
|
||||
providers: [kakao(auth)],
|
||||
providers: [line(auth)],
|
||||
firebaseAuth: auth,
|
||||
);
|
||||
|
||||
final outcome = await linker.link(AuthProviderKind.kakao);
|
||||
final outcome = await linker.link(AuthProviderKind.line);
|
||||
|
||||
expect(outcome, isA<AuthSucceeded>());
|
||||
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.kakao));
|
||||
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
|
||||
});
|
||||
|
||||
test('다른 이메일의 카카오 계정은 붙였다가 바로 떼고 E-AUTH-08', () async {
|
||||
test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async {
|
||||
final user = _LinkingUser(linkedEmail: 'someone@else.com');
|
||||
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||
final linker = FirebaseAccountLinker(
|
||||
providers: [kakao(auth)],
|
||||
providers: [line(auth)],
|
||||
firebaseAuth: auth,
|
||||
);
|
||||
|
||||
final outcome = await linker.link(AuthProviderKind.kakao);
|
||||
final outcome = await linker.link(AuthProviderKind.line);
|
||||
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-08');
|
||||
expect(
|
||||
linker.linkedAccounts.keys,
|
||||
isNot(contains(AuthProviderKind.kakao)),
|
||||
isNot(contains(AuthProviderKind.line)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -108,6 +137,13 @@ class _ThrowingAuth extends MockFirebaseAuth {
|
||||
throw fb.FirebaseAuthException(code: code);
|
||||
}
|
||||
|
||||
/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task.
|
||||
class _HangingAuth extends MockFirebaseAuth {
|
||||
@override
|
||||
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) =>
|
||||
Completer<fb.UserCredential>().future;
|
||||
}
|
||||
|
||||
/// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다.
|
||||
// ignore: must_be_immutable
|
||||
class _LinkingUser extends MockUser {
|
||||
|
||||
Reference in New Issue
Block a user