Kakao 로그인(카카오 SDK · 서버 커스텀 토큰)과 LINE 로그인(Firebase OIDC) 연결 · SNS 연동 · 인증 취소 처리

This commit is contained in:
Sn-Kinos
2026-10-05 19:01:28 +09:00
parent f483e8d94b
commit c301f3a1db
23 changed files with 1179 additions and 53 deletions
+3
View File
@@ -65,3 +65,6 @@ ios/config/Google.xcconfig
firebase/**/node_modules/
firebase/**/*-debug.log
firebase/functions/lib/
# 카카오 네이티브 앱 키. 다른 설정 파일(google-services.json 등)과 같이 저장소 밖에서 넣는다.
config/kakao.env
+19
View File
@@ -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` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다
+6 -6
View File
@@ -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주차 |
+11
View File
@@ -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 {
@@ -38,6 +38,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>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
+1
View File
@@ -0,0 +1 @@
FANUZU_KAKAO_NATIVE_APP_KEY=
+1 -1
View File
@@ -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",
+143
View File
@@ -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) };
});
+9 -4
View File
@@ -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<ErrorCode, Status> = {
'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<string, unknown> = {}) {
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<string, unknown>): never {
throw new CatalogError(code, message, extra);
}
/**
+1
View File
@@ -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';
@@ -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 });
});
});
+1 -1
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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"
+16
View File
@@ -89,6 +89,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>
</dict>
</plist>
+2 -2
View File
@@ -1,7 +1,7 @@
/// 인증 계약 — 제공자 인터페이스와 결과 타입.
///
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Apple·카카오·LINE 은 발주사 사업자
/// 정보로 각 개발자 콘솔에 앱을 등록해야 키가 나온다. "아직 준비가 안 됐다"이지
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Firebase 설정 파일이나 Apple
/// 웹 인증 값처럼 빌드에 들어가야 할 값이 없을 때가 그렇다. "아직 준비가 안 됐다"이지
/// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다.
/// 화면은 이걸 안내 다이얼로그로 보여 준다.
library;
+16 -7
View File
@@ -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<SocialAuthProvider> _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) 전용 이메일 로그인 — **디버그 빌드에서만**.
@@ -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<AuthOutcome> _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 <fb.UserInfo>[]) {
if (info.providerId == providerId) return info.email;
}
return null;
}
SocialAuthProvider? _implOf(AuthProviderKind provider) {
for (final candidate in _providers) {
if (candidate.provider == provider) return candidate;
+267 -20
View File
@@ -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<String> missingConfig;
/// Firebase 콘솔에 등록한 제공자 ID. `oidc.` 접두는 콘솔이 강제한다.
final String providerId;
/// `openid` 외에 더 받을 항목. Firebase 가 `openid` 는 알아서 붙인다.
/// **콘솔에 켠 동의 항목과 맞아야 한다** — 카카오는 설정에 없는 scope 를 요청하면
/// 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;
@override
bool get isLive => false;
bool get isLive => true;
@override
Future<AuthOutcome> signIn() async =>
AuthNotConfigured(provider, missingConfig);
List<String> 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<void> signOut() async {}
Future<AuthOutcome> 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<void> 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<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 const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
} on kakao.KakaoAuthException catch (error) {
// 동의 화면에서 「취소」.
if (error.error == kakao.AuthErrorCause.accessDenied) {
return const AuthCancelled();
}
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
} on FirebaseFunctionsException catch (error) {
final details = error.details;
final code = details is Map && details['code'] is String
? details['code'] as String
: 'E-AUTH-03';
return AuthFailed(code, switch (code) {
_ when details is Map && details['reason'] == 'different-email' =>
'가입하신 이메일과 다른 계정입니다.',
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
'E-AUTH-09' => '이미 연결된 계정입니다.',
_ =>
flow == AuthFlow.link
? '계정 연결에 실패했습니다.'
: '소셜 로그인에 실패했습니다.',
});
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow));
}
}
@override
Future<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은 비밀값이 아니지만 환경별 값이다.
@@ -336,14 +591,6 @@ final class EmailPasswordProvider implements EmailAuthProvider {
}
}
/// LINE — 발주사 키 대기.
///
/// 별첨 B v1.4 확정 4종에 LINE 이 있고 네이버·X 는 없다.
const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [
'LINE Login 채널 ID / 채널 시크릿',
'LINE Developers 콘솔 채널 등록(사업자 정보 필요)',
]);
/// SDK 코드를 에러 카탈로그 ID 로 옮긴다.
///
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
+223 -7
View File
@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.76"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
antlr4:
dependency: transitive
description:
@@ -33,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.0"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024"
url: "https://pub.dev"
source: hosted
version: "1.6.5"
async:
dependency: transitive
description:
@@ -162,13 +178,45 @@ packages:
source: hosted
version: "3.0.7"
dart_jsonwebtoken:
dependency: transitive
dependency: "direct overridden"
description:
name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
version: "2.17.0"
dio:
dependency: transitive
description:
name: dio
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.dev"
source: hosted
version: "5.11.1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
ed25519_edwards:
dependency: transitive
description:
name: ed25519_edwards
sha256: f3c3b109a0e470ee35f6346a155cab290578660852237fce10df6bbd92758534
url: "https://pub.dev"
source: hosted
version: "0.3.2"
encrypt:
dependency: transitive
description:
name: encrypt
sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2"
url: "https://pub.dev"
source: hosted
version: "5.0.3"
equatable:
dependency: transitive
description:
@@ -307,6 +355,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -407,6 +463,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
@@ -415,6 +479,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.12.0"
kakao_flutter_sdk_auth:
dependency: transitive
description:
name: kakao_flutter_sdk_auth
sha256: b9d982569462f6d31f97460f1d6f51acdc290f318967ee14009d20edc3006a12
url: "https://pub.dev"
source: hosted
version: "2.0.1"
kakao_flutter_sdk_common:
dependency: transitive
description:
name: kakao_flutter_sdk_common
sha256: "6a89e80ef6c080f434b05df87d9dd83323ff6d10ea34d9e0a6cd90d2015f7e48"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
kakao_flutter_sdk_user:
dependency: "direct main"
description:
name: kakao_flutter_sdk_user
sha256: "047ff5a82f52a7e1558086acd87b89b941b072e6029de3eee0b96dbce4ca1e48"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
leak_tracker:
dependency: transitive
description:
@@ -487,6 +575,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
mock_exceptions:
dependency: transitive
description:
@@ -511,6 +607,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
@@ -539,10 +667,10 @@ packages:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
version: "3.9.1"
posix:
dependency: transitive
description:
@@ -575,6 +703,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
url: "https://pub.dev"
source: hosted
version: "2.4.28"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sign_in_with_apple:
dependency: "direct main"
description:
@@ -676,6 +860,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
vector_math:
dependency: transitive
description:
@@ -708,6 +916,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
@@ -725,5 +941,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.5 <4.0.0"
flutter: ">=3.41.0"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+12 -1
View File
@@ -29,9 +29,14 @@ dependencies:
# Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다.
crypto: ^3.0.7
# 소셜 로그인 — Google·Apple 만 실 코드 경로. 카카오·네이버는 앱 키가 없어 스텁이다(Q3).
# 소셜 로그인 — Google·Apple 은 각 SDK, LINE 은 Firebase OIDC(SDK 없음), 카카오는 아래 SDK.
google_sign_in: ^7.2.0
sign_in_with_apple: ^8.1.0
# 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해
# 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 SDK 를 쓴다.
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
kakao_flutter_sdk_user: ^2.0.1
dev_dependencies:
flutter_test:
@@ -49,6 +54,12 @@ dev_dependencies:
# 재생성: dart run flutter_launcher_icons
flutter_launcher_icons: ^0.14.4
# 카카오 SDK(encrypt → pointycastle 3)와 firebase_auth_mocks 0.15(dart_jsonwebtoken 3 →
# pointycastle 4)가 갈린다. 테스트 전용인 쪽을 내린다 — 앱에 실리는 암호 라이브러리는 그대로 둔다.
# 둘 중 하나가 pointycastle 범위를 맞추면 지운다.
dependency_overrides:
dart_jsonwebtoken: ^2.17.0
# 앱 아이콘 — 2026-08-13 수령한 공식 `fanuzu logo only.png`의 중앙 1:1 영역을
# 픽셀 변경 없이 사용한다. `assets/brand/` 두 원본은 같은 공식 마크다.
flutter_launcher_icons:
+109
View File
@@ -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');
});
});
}
+177
View File
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/auth/firebase_account_linker.dart';
import 'package:fancake/features/auth/social_providers.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
/// LINE OIDC 제공자 — 실 로그인은 못 하고,
/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다.
void main() {
// Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned).
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트'));
tearDown(
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'),
);
OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider(
AuthProviderKind.line,
providerId: 'oidc.line',
firebaseAuth: auth,
);
test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () {
final providers = {
for (final p in AuthRepository().providers) p.provider: p,
};
final p = providers[AuthProviderKind.line];
expect(p, isA<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 line(_ThrowingAuth('should-not-call')).signIn();
expect(outcome, isA<AuthNotConfigured>());
});
test('성공하면 LINE 계정으로 로그인된다', () async {
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1'));
final outcome = await line(auth).signIn();
expect(outcome, isA<AuthSucceeded>());
expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.line);
expect(outcome.account.uid, 'k-1');
});
test('인가 화면을 닫으면 Android·iOS 철자 모두 취소로 본다', () async {
for (final code in ['web-context-canceled', 'web-context-cancelled']) {
final outcome = await line(_ThrowingAuth(code)).signIn();
expect(outcome, isA<AuthCancelled>(), reason: code);
}
});
test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async {
final outcome = await line(_ThrowingAuth('operation-not-allowed')).signIn();
expect((outcome as AuthFailed).code, 'E-AUTH-11');
});
test('Android 의 LINE 은 앱 자동 로그인을 끈다 · iOS 는 파라미터 없음', () {
final line =
AuthRepository().providers.firstWhere(
(p) => p.provider == AuthProviderKind.line,
)
as OidcSignInProvider;
expect(line.authProvider.parameters, {'disable_auto_login': 'true'});
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
expect(line.authProvider.parameters, isEmpty);
});
testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async {
late AuthOutcome outcome;
line(_HangingAuth()).signIn().then((o) => outcome = o);
void go(List<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.line 을 붙인다', () async {
final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr');
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
final linker = FirebaseAccountLinker(
providers: [line(auth)],
firebaseAuth: auth,
);
final outcome = await linker.link(AuthProviderKind.line);
expect(outcome, isA<AuthSucceeded>());
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
});
test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async {
final user = _LinkingUser(linkedEmail: 'someone@else.com');
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
final linker = FirebaseAccountLinker(
providers: [line(auth)],
firebaseAuth: auth,
);
final outcome = await linker.link(AuthProviderKind.line);
expect((outcome as AuthFailed).code, 'E-AUTH-08');
expect(
linker.linkedAccounts.keys,
isNot(contains(AuthProviderKind.line)),
);
});
});
}
class _ThrowingAuth extends MockFirebaseAuth {
_ThrowingAuth(this.code);
final String code;
@override
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) =>
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 {
_LinkingUser({required this.linkedEmail})
: super(
uid: 'u-1',
email: 'fan@fanuzu.co.kr',
providerData: [_info('google.com', 'fan@fanuzu.co.kr')],
);
final String linkedEmail;
@override
Future<fb.UserCredential> linkWithProvider(fb.AuthProvider provider) async {
final result = await super.linkWithProvider(provider);
providerData
..removeLast()
..add(_info(provider.providerId, linkedEmail));
return result;
}
}
fb.UserInfo _info(String providerId, String email) =>
// ignore: invalid_use_of_protected_member
fb.UserInfo.fromJson({
'uid': 'p-$providerId',
'email': email,
'providerId': providerId,
'isAnonymous': false,
'isEmailVerified': true,
});