LINE 로그인을 LINE SDK와 서버 인증 토큰 방식으로 변경 — iOS·Android 웹 로그인 중단 문제 해결
This commit is contained in:
+4
-5
@@ -67,11 +67,10 @@ Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받
|
|||||||
fvm flutter build apk --release --dart-define-from-file=config/kakao.env
|
fvm flutter build apk --release --dart-define-from-file=config/kakao.env
|
||||||
```
|
```
|
||||||
|
|
||||||
**LINE** 은 Firebase Authentication 의 OpenID Connect 제공자 `oidc.line` 이다. 앱에 넣을 값은 없고,
|
**LINE 채널 ID** — 같은 `config/kakao.env` 에 `FANUZU_LINE_CHANNEL_ID=<LINE Login 채널 ID(숫자)>`. Dart 만 읽는다
|
||||||
콘솔 Authentication → 로그인 방법에 LINE Login 채널 ID · 채널 시크릿 · issuer
|
(iOS 복귀 스킴은 `line3rdp.<번들 ID>` 라 키가 필요 없다). 없으면 LINE 로그인만 「설정 전」으로 막힌다. 서버
|
||||||
`https://access.line.me` 를 등록하고, LINE Developers 콘솔의 Callback URL 에
|
`fnAuthLine` 은 같은 값을 `firebase/functions/.env.<프로젝트>` 의 `LINE_CHANNEL_ID` 로 받는다(저장소 밖).
|
||||||
`https://<프로젝트 ID>.firebaseapp.com/__/auth/handler` 를 넣는다. 콘솔에서 켜지 않았으면 로그인이
|
LINE Developers 콘솔의 LINE Login 채널에 Android 패키지명 · iOS 번들 ID 를 등록한다.
|
||||||
E-AUTH-11 로 끝난다.
|
|
||||||
|
|
||||||
## Android Apple 로그인 — 앱에 넣을 값 없음
|
## Android Apple 로그인 — 앱에 넣을 값 없음
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
FANUZU_KAKAO_NATIVE_APP_KEY=
|
FANUZU_KAKAO_NATIVE_APP_KEY=
|
||||||
|
FANUZU_LINE_CHANNEL_ID=
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
|||||||
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||||
// 이메일 — 플랫폼이 이메일 제공을 승인하고 사용자가 동의한 경우에만 수집한다. 없어도 가입·로그인된다.
|
// 이메일 — 플랫폼이 이메일 제공을 승인하고 사용자가 동의한 경우에만 수집한다. 없어도 가입·로그인된다.
|
||||||
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
||||||
// 계정을 만든 뒤에 연결 정보가 붙으므로 `fnAuthKakao` 가 따로 쓴다.
|
// 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다.
|
||||||
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
||||||
const defaults: Record<string, unknown> = {
|
const defaults: Record<string, unknown> = {
|
||||||
uid: user.uid,
|
uid: user.uid,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { getAuth } from 'firebase-admin/auth';
|
|
||||||
import { getFirestore } from 'firebase-admin/firestore';
|
|
||||||
import { defineString } from 'firebase-functions/params';
|
import { defineString } from 'firebase-functions/params';
|
||||||
import { onCall } from 'firebase-functions/v2/https';
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
|
||||||
import { str } from '../core/callable.js';
|
import { str } from '../core/callable.js';
|
||||||
import { ERR, fail } from '../core/errors.js';
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { exchangeSocial, type SocialSpec } from './social_token.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다.
|
* `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다.
|
||||||
@@ -21,6 +20,7 @@ import { ERR, fail } from '../core/errors.js';
|
|||||||
* - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고,
|
* - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고,
|
||||||
* - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다.
|
* - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다.
|
||||||
* 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명).
|
* 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명).
|
||||||
|
* UID 찾기 · SNS 연동 규칙은 LINE 과 같아 `social_token.ts` 에 있다.
|
||||||
*
|
*
|
||||||
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
|
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
|
||||||
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||||
@@ -74,92 +74,9 @@ export async function verifyKakaoToken(accessToken: string, appId: string): Prom
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 이 카카오 계정이 붙은 Firebase UID. 없으면 null. */
|
const KAKAO: SocialSpec = { providerId: KAKAO_PROVIDER_ID, uidPrefix: 'kakao', label: '카카오' };
|
||||||
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] 에 카카오를 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
|
|
||||||
*
|
|
||||||
* 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다.
|
|
||||||
* 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 카카오 계정도 연결된다).
|
|
||||||
* - 새 Provider 자격: [verifyKakaoToken] 이 우리 앱에서 발급된 유효 토큰인지 카카오 API 로 본다.
|
|
||||||
* - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이
|
|
||||||
* 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」.
|
|
||||||
* - 그 카카오 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도
|
|
||||||
* (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다.
|
|
||||||
*/
|
|
||||||
export async function linkKakao(uid: string, p: KakaoProfile, authTime?: number): Promise<void> {
|
|
||||||
const owner = await ownerOf(p.id);
|
|
||||||
if (owner === uid) return;
|
|
||||||
if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
|
||||||
const user = await getAuth().getUser(uid);
|
|
||||||
const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0;
|
|
||||||
if (user.disabled || (authTime !== undefined && authTime * 1000 < revokedAt)) {
|
|
||||||
fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' });
|
|
||||||
}
|
|
||||||
if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) {
|
|
||||||
fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await getAuth().updateUser(uid, { providerToLink: linkInfo(p) });
|
|
||||||
} catch (e) {
|
|
||||||
const code = (e as { code?: string }).code ?? '';
|
|
||||||
if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) {
|
|
||||||
fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
if ((await ownerOf(p.id)) !== uid) {
|
|
||||||
fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fnAuthKakao = onCall(async (req) => {
|
export const fnAuthKakao = onCall(async (req) => {
|
||||||
const profile = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value());
|
const p = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value());
|
||||||
if ((req.data as Record<string, unknown>).link === true) {
|
return exchangeSocial(KAKAO, { id: p.id, displayName: p.nickname, email: p.email }, req);
|
||||||
const uid = req.auth?.uid;
|
|
||||||
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
|
|
||||||
const authTime = req.auth?.token?.auth_time;
|
|
||||||
await linkKakao(uid, profile, typeof authTime === 'number' ? authTime : undefined);
|
|
||||||
return { linked: true };
|
|
||||||
}
|
|
||||||
const uid = await resolveKakaoUid(profile);
|
|
||||||
// 동의한 이메일만 사용자 문서에 남긴다. Auth 이메일은 다른 제공자 계정과 겹치면 생성이
|
|
||||||
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
|
|
||||||
if (profile.email) {
|
|
||||||
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
|
|
||||||
}
|
|
||||||
return { token: await getAuth().createCustomToken(uid) };
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { defineString } from 'firebase-functions/params';
|
||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
|
||||||
|
import { optionalStr } from '../core/callable.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { exchangeSocial, type SocialProfile, type SocialSpec } from './social_token.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-auth-line` — LINE 로그인 · SNS 연동을 Firebase 커스텀 토큰으로 잇는다. 카카오(`fnAuthKakao`)와 같은 구조다.
|
||||||
|
*
|
||||||
|
* **왜 OIDC 가 아니라 이 경로인가.** iOS 에서 Firebase OIDC(`oidc.line`) 웹 흐름은 첫 시도에 Firebase
|
||||||
|
* 핸들러가 「missing initial state」로 끝난다(`disable_ios_auto_login` 을 붙여도 같다). 앱이 LINE 공식
|
||||||
|
* SDK 로 로그인하고 서버가 그 토큰을 LINE API 로 검증해 커스텀 토큰을 내준다.
|
||||||
|
*
|
||||||
|
* **사용자 식별은 기존 `oidc.line` 연결을 그대로 쓴다** — OIDC 시절 가입자는 같은 UID 로 들어오고, SNS 연동
|
||||||
|
* 화면이 지금처럼 「연결됨」을 그린다. 신규 가입자의 UID 는 `line:<LINE userId>` 다.
|
||||||
|
*
|
||||||
|
* 입력: `{ idToken?, accessToken?, link? }` — 둘 중 하나는 있어야 한다. ID 토큰이 있으면 그것으로 검증한다
|
||||||
|
* (이메일은 ID 토큰에만 온다). `link: true` 면 로그인한 호출자 UID 에 LINE 을 붙이고 `{ linked: true }`,
|
||||||
|
* 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LINE_PROVIDER_ID = 'oidc.line';
|
||||||
|
|
||||||
|
/** LINE Login 채널 ID(숫자). 비밀값이 아니다 — 토큰이 우리 채널에서 발급됐는지 본다. */
|
||||||
|
const lineChannelId = defineString('LINE_CHANNEL_ID', { default: '' });
|
||||||
|
|
||||||
|
const LINE: SocialSpec = { providerId: LINE_PROVIDER_ID, uidPrefix: 'line', label: 'LINE' };
|
||||||
|
|
||||||
|
export const lineUidOf = (lineId: string) => `${LINE.uidPrefix}:${lineId}`;
|
||||||
|
|
||||||
|
const API = 'https://api.line.me';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LINE 토큰을 LINE API 로 검증한다. 남의 채널에서 받은 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을
|
||||||
|
* 막는 지점이다.
|
||||||
|
*
|
||||||
|
* - ID 토큰: `POST /oauth2/v2.1/verify` — 서명 · 만료 · `client_id`(우리 채널) 검증을 LINE 이 하고 `sub` ·
|
||||||
|
* `name` · `email`(동의했을 때만)을 돌려준다.
|
||||||
|
* - 액세스 토큰: `GET /oauth2/v2.1/verify` 로 우리 채널 발급 · 남은 유효 시간을 보고 `/v2/profile` 로 회원번호를 읽는다.
|
||||||
|
*/
|
||||||
|
export async function verifyLineToken(
|
||||||
|
token: { idToken?: string | undefined; accessToken?: string | undefined },
|
||||||
|
channelId: string,
|
||||||
|
): Promise<SocialProfile> {
|
||||||
|
if (!channelId) fail(ERR.authFailed, 'LINE 로그인 설정이 끝나지 않았습니다.');
|
||||||
|
const bad = (): never => fail(ERR.authFailed, 'LINE 인증을 확인하지 못했습니다.');
|
||||||
|
const json = async (res: Response) => {
|
||||||
|
if (!res.ok) bad();
|
||||||
|
return (await res.json()) as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token.idToken) {
|
||||||
|
const claims = await json(await fetch(`${API}/oauth2/v2.1/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ id_token: token.idToken, client_id: channelId }).toString(),
|
||||||
|
}));
|
||||||
|
if (String(claims.aud) !== channelId || typeof claims.sub !== 'string' || !claims.sub) bad();
|
||||||
|
return {
|
||||||
|
id: claims.sub as string,
|
||||||
|
...(typeof claims.name === 'string' && claims.name ? { displayName: claims.name } : {}),
|
||||||
|
...(typeof claims.email === 'string' && claims.email ? { email: claims.email } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.accessToken) fail(ERR.authFailed, 'LINE 인증 정보가 없습니다.');
|
||||||
|
const info = await json(await fetch(
|
||||||
|
`${API}/oauth2/v2.1/verify?access_token=${encodeURIComponent(token.accessToken)}`,
|
||||||
|
));
|
||||||
|
if (String(info.client_id) !== channelId || !(Number(info.expires_in) > 0)) bad();
|
||||||
|
const me = await json(await fetch(`${API}/v2/profile`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.accessToken}` },
|
||||||
|
}));
|
||||||
|
if (typeof me.userId !== 'string' || !me.userId) bad();
|
||||||
|
return {
|
||||||
|
id: me.userId as string,
|
||||||
|
...(typeof me.displayName === 'string' && me.displayName ? { displayName: me.displayName } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fnAuthLine = onCall(async (req) => {
|
||||||
|
const profile = await verifyLineToken(
|
||||||
|
{ idToken: optionalStr(req.data, 'idToken'), accessToken: optionalStr(req.data, 'accessToken') },
|
||||||
|
lineChannelId.value(),
|
||||||
|
);
|
||||||
|
return exchangeSocial(LINE, profile, req);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공식 SDK 로그인 + 서버 커스텀 토큰 제공자(카카오 `fnAuthKakao` · LINE `fnAuthLine`)의 공통 부분 —
|
||||||
|
* 검증된 제공자 계정으로 로그인할 UID 를 찾거나 만들고, SNS 연동으로 붙인다.
|
||||||
|
*
|
||||||
|
* **사용자 식별은 OIDC 시절 제공자 ID(`oidc.kakao` · `oidc.line`)를 그대로 쓴다.** Firebase 사용자의
|
||||||
|
* 제공자 정보에 그 ID / 제공자 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다.
|
||||||
|
* OIDC 시절 가입자는 같은 UID 로 로그인되고, SNS 연동 화면이 지금처럼 「연결됨」을 그린다.
|
||||||
|
*/
|
||||||
|
export interface SocialSpec {
|
||||||
|
/** Firebase 제공자 ID — `oidc.kakao` · `oidc.line`. */
|
||||||
|
providerId: string;
|
||||||
|
/** 신규 가입자 UID 접두 — `kakao` · `line`. UID 는 `<접두>:<회원번호>`. */
|
||||||
|
uidPrefix: string;
|
||||||
|
/** 오류 문구에 쓰는 이름 — 「카카오」 · 「LINE」. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialProfile {
|
||||||
|
/** 제공자 회원번호(OIDC `sub`). */
|
||||||
|
id: string;
|
||||||
|
displayName?: string | undefined;
|
||||||
|
/** 동의했고 제공자가 준 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */
|
||||||
|
email?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이 제공자 계정이 붙은 Firebase UID. 없으면 null. */
|
||||||
|
async function ownerOf(spec: SocialSpec, id: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return (await getAuth().getUserByProviderUid(spec.providerId, id)).uid;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as { code?: string }).code === 'auth/user-not-found') return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkInfo(spec: SocialSpec, p: SocialProfile) {
|
||||||
|
return {
|
||||||
|
providerId: spec.providerId,
|
||||||
|
uid: p.id,
|
||||||
|
...(p.displayName ? { displayName: p.displayName } : {}),
|
||||||
|
...(p.email ? { email: p.email } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `<접두>:<id>` 를 만들고 연결한다. */
|
||||||
|
export async function resolveSocialUid(spec: SocialSpec, p: SocialProfile): Promise<string> {
|
||||||
|
const existing = await ownerOf(spec, p.id);
|
||||||
|
if (existing) return existing;
|
||||||
|
const uid = `${spec.uidPrefix}:${p.id}`;
|
||||||
|
const auth = getAuth();
|
||||||
|
try {
|
||||||
|
await auth.createUser({ uid, ...(p.displayName ? { displayName: p.displayName } : {}) });
|
||||||
|
} catch (e) {
|
||||||
|
// 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다.
|
||||||
|
if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e;
|
||||||
|
}
|
||||||
|
await auth.updateUser(uid, { providerToLink: linkInfo(spec, p) });
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 연동 — 로그인한 [uid] 에 제공자 계정을 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
|
||||||
|
*
|
||||||
|
* 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다.
|
||||||
|
* 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 계정도 연결된다).
|
||||||
|
* - 새 Provider 자격: 호출부가 제공자 API 로 우리 앱(채널)에서 발급된 유효 토큰인지 먼저 본다.
|
||||||
|
* - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이
|
||||||
|
* 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」.
|
||||||
|
* - 그 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도
|
||||||
|
* (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다.
|
||||||
|
*/
|
||||||
|
export async function linkSocial(
|
||||||
|
spec: SocialSpec, uid: string, p: SocialProfile, authTime?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const inUse = `이미 다른 계정에 연결된 ${spec.label} 계정입니다.`;
|
||||||
|
const owner = await ownerOf(spec, p.id);
|
||||||
|
if (owner === uid) return;
|
||||||
|
if (owner) fail(ERR.authCredentialInUse, inUse);
|
||||||
|
const user = await getAuth().getUser(uid);
|
||||||
|
const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0;
|
||||||
|
if (user.disabled || (authTime !== undefined && authTime * 1000 < revokedAt)) {
|
||||||
|
fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' });
|
||||||
|
}
|
||||||
|
if (user.providerData.some((d) => d.providerId === spec.providerId)) {
|
||||||
|
fail(ERR.authAlreadyLinked, `이미 다른 ${spec.label} 계정이 연결되어 있습니다.`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await getAuth().updateUser(uid, { providerToLink: linkInfo(spec, p) });
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: string }).code ?? '';
|
||||||
|
if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) {
|
||||||
|
fail(ERR.authCredentialInUse, inUse);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if ((await ownerOf(spec, p.id)) !== uid) fail(ERR.authCredentialInUse, inUse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* callable 본문 — `link: true` 면 로그인한 호출자 UID 에 붙이고 `{ linked: true }`, 아니면 로그인할 UID 를
|
||||||
|
* 찾거나 만들어 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||||
|
*/
|
||||||
|
export async function exchangeSocial(
|
||||||
|
spec: SocialSpec,
|
||||||
|
profile: SocialProfile,
|
||||||
|
req: { data: unknown; auth?: { uid: string; token?: { auth_time?: unknown } } },
|
||||||
|
): Promise<{ linked: true } | { token: string }> {
|
||||||
|
if ((req.data as Record<string, unknown>).link === true) {
|
||||||
|
const uid = req.auth?.uid;
|
||||||
|
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
|
||||||
|
const authTime = req.auth?.token?.auth_time;
|
||||||
|
await linkSocial(spec, uid, profile, typeof authTime === 'number' ? authTime : undefined);
|
||||||
|
return { linked: true };
|
||||||
|
}
|
||||||
|
const uid = await resolveSocialUid(spec, profile);
|
||||||
|
// 동의한 이메일만 사용자 문서에 남긴다. Auth 이메일은 다른 제공자 계정과 겹치면 생성이
|
||||||
|
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
|
||||||
|
if (profile.email) {
|
||||||
|
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
|
||||||
|
}
|
||||||
|
return { token: await getAuth().createCustomToken(uid) };
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ export {
|
|||||||
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
|
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
|
||||||
} from './account/account.js';
|
} from './account/account.js';
|
||||||
export { fnAuthKakao } from './account/kakao.js';
|
export { fnAuthKakao } from './account/kakao.js';
|
||||||
|
export { fnAuthLine } from './account/line.js';
|
||||||
export { fnAdminWrite } from './admin/admin.js';
|
export { fnAdminWrite } from './admin/admin.js';
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import { fnAuthLine, LINE_PROVIDER_ID, lineUidOf } from '../src/account/line.js';
|
||||||
|
|
||||||
|
/** LINE 커스텀 토큰 — ID 토큰 · 액세스 토큰 검증 · 기존 oidc.line 사용자 매핑 · 신규 UID · 이메일 · SNS 연동. */
|
||||||
|
|
||||||
|
const CHANNEL = '2000000001';
|
||||||
|
const LINE_ID = 'U0123456789abcdef0123456789abcdef';
|
||||||
|
const OLD_UID = 'test-line-oidc-user';
|
||||||
|
const GOOGLE_UID = 'test-line-google-user';
|
||||||
|
|
||||||
|
type Req = Parameters<typeof fnAuthLine.run>[0];
|
||||||
|
const call = (data: Record<string, unknown>, uid?: string) =>
|
||||||
|
fnAuthLine.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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const uidOf = (token: string) => JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LINE API 흉내. ID 토큰 'good' · 액세스 토큰 'good' 만 우리 채널 것, 'other-app' 은 남의 채널, 'expired' 는 만료.
|
||||||
|
* 실제 LINE 처럼 ID 토큰 검증은 보낸 client_id 와 aud 가 다르면 400 이다.
|
||||||
|
*/
|
||||||
|
function stubLine(email?: string) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: { method?: string; body?: string; headers?: Record<string, string> }) => {
|
||||||
|
if (init?.method === 'POST' && url.endsWith('/oauth2/v2.1/verify')) {
|
||||||
|
const form = new URLSearchParams(init.body);
|
||||||
|
const token = form.get('id_token');
|
||||||
|
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
|
||||||
|
const aud = token === 'other-app' ? '1' : CHANNEL;
|
||||||
|
if (form.get('client_id') !== aud) return Response.json({ error: 'invalid_request' }, { status: 400 });
|
||||||
|
return Response.json({ iss: 'https://access.line.me', sub: LINE_ID, aud, name: 'LINE팬', ...(email ? { email } : {}) });
|
||||||
|
}
|
||||||
|
if (url.includes('/oauth2/v2.1/verify?access_token=')) {
|
||||||
|
const token = new URL(url).searchParams.get('access_token');
|
||||||
|
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
|
||||||
|
return Response.json({ scope: 'profile openid', client_id: token === 'other-app' ? '1' : CHANNEL, expires_in: 100 });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/v2/profile')) return Response.json({ userId: LINE_ID, displayName: 'LINE팬' });
|
||||||
|
return new Response('{}', { status: 404 });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipe() {
|
||||||
|
await getAuth().deleteUsers([OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]);
|
||||||
|
for (const uid of [OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]) await getFirestore().doc(`users/${uid}`).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) {
|
||||||
|
throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.');
|
||||||
|
}
|
||||||
|
process.env.LINE_CHANNEL_ID = CHANNEL;
|
||||||
|
bootAdmin();
|
||||||
|
});
|
||||||
|
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => { await wipe(); stubLine(); });
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
describe('토큰 검증', () => {
|
||||||
|
it('만료 · 남의 채널 토큰(ID · 액세스 모두)과 토큰 없음은 E-AUTH-03', async () => {
|
||||||
|
for (const key of ['idToken', 'accessToken']) {
|
||||||
|
expect(await codeOf(call({ [key]: 'expired' }))).toBe('E-AUTH-03');
|
||||||
|
expect(await codeOf(call({ [key]: 'other-app' }))).toBe('E-AUTH-03');
|
||||||
|
}
|
||||||
|
expect(await codeOf(call({}))).toBe('E-AUTH-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ID 토큰이 있으면 ID 토큰으로 검증한다(액세스 토큰이 남의 채널 것이어도 보지 않는다)', async () => {
|
||||||
|
const { token } = await call({ idToken: 'good', accessToken: 'other-app' }) as { token: string };
|
||||||
|
expect(uidOf(token)).toBe(lineUidOf(LINE_ID));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('로그인', () => {
|
||||||
|
it('OIDC 시절 oidc.line 가입자는 같은 UID 로 들어온다', async () => {
|
||||||
|
await getAuth().createUser({ uid: OLD_UID });
|
||||||
|
await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: LINE_ID } });
|
||||||
|
const { token } = await call({ idToken: 'good' }) as { token: string };
|
||||||
|
expect(uidOf(token)).toBe(OLD_UID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('신규는 line:<userId> 로 만들고 oidc.line 을 붙인다 · 액세스 토큰만으로도 같은 UID', async () => {
|
||||||
|
const first = await call({ idToken: 'good' }) as { token: string };
|
||||||
|
const second = await call({ accessToken: 'good' }) as { token: string };
|
||||||
|
expect(uidOf(first.token)).toBe(lineUidOf(LINE_ID));
|
||||||
|
expect(uidOf(second.token)).toBe(lineUidOf(LINE_ID));
|
||||||
|
const user = await getAuth().getUser(lineUidOf(LINE_ID));
|
||||||
|
expect(user.displayName).toBe('LINE팬');
|
||||||
|
expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[LINE_PROVIDER_ID, LINE_ID]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이메일은 LINE 이 줄 때(동의)만 사용자 문서에 남긴다 — 없어도 로그인된다', async () => {
|
||||||
|
await call({ idToken: 'good' });
|
||||||
|
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBeUndefined();
|
||||||
|
stubLine('fan@line.me');
|
||||||
|
await call({ idToken: 'good' });
|
||||||
|
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBe('fan@line.me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SNS 연동', () => {
|
||||||
|
it('로그인 없이 link 는 E-AUTH-03', async () => {
|
||||||
|
expect(await codeOf(call({ idToken: 'good', link: true }))).toBe('E-AUTH-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Google 사용자에 붙인다(이메일이 달라도 · 없어도) · 다른 UID 가 쓰는 LINE 은 E-AUTH-08', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||||
|
stubLine('other@line.me');
|
||||||
|
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
const user = await getAuth().getUser(GOOGLE_UID);
|
||||||
|
expect(user.providerData.some((d) => d.providerId === LINE_PROVIDER_ID && d.uid === LINE_ID)).toBe(true);
|
||||||
|
|
||||||
|
await getAuth().createUser({ uid: OLD_UID });
|
||||||
|
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
const before = Math.floor(Date.now() / 1000) - 60;
|
||||||
|
await getAuth().revokeRefreshTokens(GOOGLE_UID);
|
||||||
|
const linkAs = (authTime: number) => fnAuthLine.run(
|
||||||
|
{ data: { idToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
|
||||||
|
);
|
||||||
|
expect(await codeOf(linkAs(before))).toBe('session');
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
|
||||||
|
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
|
||||||
|
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이미 다른 LINE 이 붙어 있으면 E-AUTH-09 · 같은 LINE 이면 그대로 성공', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: 'U-other' } });
|
||||||
|
expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
|
||||||
|
await getAuth().deleteUser(GOOGLE_UID);
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,6 +126,15 @@
|
|||||||
"version" : "1.22.5"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "line-sdk-ios-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/line/line-sdk-ios-swift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352",
|
||||||
|
"version" : "5.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "nanopb",
|
"identity" : "nanopb",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -126,6 +126,15 @@
|
|||||||
"version" : "1.22.5"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "line-sdk-ios-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/line/line-sdk-ios-swift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352",
|
||||||
|
"version" : "5.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "nanopb",
|
"identity" : "nanopb",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -100,11 +100,24 @@
|
|||||||
<string>kakao$(FANUZU_KAKAO_NATIVE_APP_KEY)</string>
|
<string>kakao$(FANUZU_KAKAO_NATIVE_APP_KEY)</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<!-- LINE 로그인 복귀 스킴 line3rdp.{번들 ID}(flutter_line_sdk 공식 설정). -->
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>line-login</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
|
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>kakaokompassauth</string>
|
<string>kakaokompassauth</string>
|
||||||
|
<!-- LINE 앱으로 로그인 — LINE 설치 여부 확인·호출(flutter_line_sdk 공식 설정). -->
|
||||||
|
<string>lineauth2</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../../core/firebase/firebase_boot.dart';
|
import '../../core/firebase/firebase_boot.dart';
|
||||||
import 'auth.dart';
|
import 'auth.dart';
|
||||||
import 'firebase_account_linker.dart';
|
|
||||||
import 'social_providers.dart';
|
import 'social_providers.dart';
|
||||||
|
|
||||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
||||||
@@ -45,9 +44,7 @@ class AuthRepository extends ChangeNotifier {
|
|||||||
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
|
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
|
||||||
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
|
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
|
||||||
///
|
///
|
||||||
/// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC
|
/// 카카오 · LINE 은 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider] · [LineSignInProvider])이다.
|
||||||
/// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를
|
|
||||||
/// 돌려준다.
|
|
||||||
static List<SocialAuthProvider> _defaultProviders() => [
|
static List<SocialAuthProvider> _defaultProviders() => [
|
||||||
const GoogleSignInProvider(),
|
const GoogleSignInProvider(),
|
||||||
// Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 —
|
// Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 —
|
||||||
@@ -63,18 +60,8 @@ class AuthRepository extends ChangeNotifier {
|
|||||||
// 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱
|
// 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱
|
||||||
// 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다.
|
// 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다.
|
||||||
KakaoSignInProvider(),
|
KakaoSignInProvider(),
|
||||||
// LINE 동의 항목: profile·email(이메일 권한 신청 승인 후).
|
// LINE 동의 항목: profile · openid · email(이메일 권한 신청 승인 후에만 온다 — 없어도 로그인된다).
|
||||||
OidcSignInProvider(
|
LineSignInProvider(),
|
||||||
AuthProviderKind.line,
|
|
||||||
providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!,
|
|
||||||
scopes: const ['profile', 'email'],
|
|
||||||
// LINE 앱 자동 로그인으로 넘어가면 콜백이 웹 로그인 창 밖에서 열려 로그인이
|
|
||||||
// 깨진다(Android · iOS). LINE Login 공식 파라미터로 끈다.
|
|
||||||
appParameters: const {
|
|
||||||
'disable_auto_login': 'true',
|
|
||||||
'disable_ios_auto_login': 'true',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
|
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
AuthProviderKind.apple: 'apple.com',
|
AuthProviderKind.apple: 'apple.com',
|
||||||
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
|
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
|
||||||
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을
|
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을
|
||||||
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다.
|
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao · fnAuthLine)가 같은 ID 로 연결을 남긴다.
|
||||||
AuthProviderKind.kakao: 'oidc.kakao',
|
AuthProviderKind.kakao: 'oidc.kakao',
|
||||||
AuthProviderKind.line: 'oidc.line',
|
AuthProviderKind.line: 'oidc.line',
|
||||||
AuthProviderKind.email: 'password',
|
AuthProviderKind.email: 'password',
|
||||||
@@ -58,12 +58,14 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||||
if (impl is! CredentialProvider &&
|
if (impl is! CredentialProvider &&
|
||||||
impl is! OidcSignInProvider &&
|
impl is! OidcSignInProvider &&
|
||||||
impl is! KakaoSignInProvider) {
|
impl is! KakaoSignInProvider &&
|
||||||
|
impl is! LineSignInProvider) {
|
||||||
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (impl is KakaoSignInProvider) return await impl.link(user);
|
if (impl is KakaoSignInProvider) return await impl.link(user);
|
||||||
|
if (impl is LineSignInProvider) return await impl.link(user);
|
||||||
if (impl is OidcSignInProvider) return await _linkOidc(user, impl);
|
if (impl is OidcSignInProvider) return await _linkOidc(user, impl);
|
||||||
final source = impl as CredentialProvider;
|
final source = impl as CredentialProvider;
|
||||||
final obtained = await source.obtainCredential();
|
final obtained = await source.obtainCredential();
|
||||||
@@ -114,7 +116,7 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
/// 웹 흐름(OIDC — Android Apple) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
||||||
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
||||||
final result = await retryStaleWebContext(
|
final result = await retryStaleWebContext(
|
||||||
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
|
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
||||||
|
import 'package:flutter_line_sdk/flutter_line_sdk.dart' as line;
|
||||||
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao;
|
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 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
|
||||||
|
|
||||||
@@ -203,8 +204,9 @@ final class AppleSignInProvider
|
|||||||
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나
|
/// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이
|
||||||
/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
|
/// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀
|
||||||
|
/// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
|
||||||
///
|
///
|
||||||
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
|
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
|
||||||
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
|
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
|
||||||
@@ -224,7 +226,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
|||||||
this.provider, {
|
this.provider, {
|
||||||
required this.providerId,
|
required this.providerId,
|
||||||
this.scopes = const [],
|
this.scopes = const [],
|
||||||
this.appParameters = const {},
|
|
||||||
this.firebaseAuth,
|
this.firebaseAuth,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,12 +240,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
|||||||
/// KOE205 로 로그인 자체를 막는다.
|
/// KOE205 로 로그인 자체를 막는다.
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
|
|
||||||
/// 앱(Android · iOS)에서 인가 URL 에 붙일 파라미터. 웹 로그인 도중 제공자 앱(LINE 등)으로
|
|
||||||
/// 넘어갔다 돌아오면 콜백이 원래 브라우저 세션 밖에서 열려 Firebase 핸들러가
|
|
||||||
/// 「missing initial state」로 끝난다(Android 10/5 · iOS 10/11 실기기) — 앱 전환을 막는
|
|
||||||
/// 제공자 공식 파라미터를 여기 둔다.
|
|
||||||
final Map<String, String> appParameters;
|
|
||||||
|
|
||||||
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||||
final fb.FirebaseAuth? firebaseAuth;
|
final fb.FirebaseAuth? firebaseAuth;
|
||||||
|
|
||||||
@@ -260,7 +255,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
|||||||
for (final scope in scopes) {
|
for (final scope in scopes) {
|
||||||
p.addScope(scope);
|
p.addScope(scope);
|
||||||
}
|
}
|
||||||
if (!kIsWeb) p.setCustomParameters(appParameters);
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,17 +399,7 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
|||||||
debugPrint('[KakaoSignInProvider] sdk error: $error');
|
debugPrint('[KakaoSignInProvider] sdk error: $error');
|
||||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||||
} on FirebaseFunctionsException catch (error) {
|
} on FirebaseFunctionsException catch (error) {
|
||||||
final details = error.details;
|
return _serverFailure(error, flow);
|
||||||
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'] == 'session' =>
|
|
||||||
'보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
|
||||||
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
|
||||||
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
|
||||||
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
|
||||||
});
|
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error, flow: flow),
|
authCatalogCode(error, flow: flow),
|
||||||
@@ -437,6 +421,158 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 커스텀 토큰 교환 서버(`fnAuthKakao` · `fnAuthLine`)의 거부를 카탈로그 코드 · 문구로 옮긴다.
|
||||||
|
AuthFailed _serverFailure(FirebaseFunctionsException error, AuthFlow flow) {
|
||||||
|
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'] == 'session' =>
|
||||||
|
'보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||||
|
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
|
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
||||||
|
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LINE Login 채널 ID. 카카오 앱 키와 같은 `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다.
|
||||||
|
const lineChannelId = String.fromEnvironment('FANUZU_LINE_CHANNEL_ID');
|
||||||
|
|
||||||
|
/// LINE 이 돌려준 토큰. ID 토큰은 `openid` 동의가 있을 때만 온다 — 서버는 있으면 ID 토큰으로 검증한다.
|
||||||
|
typedef LineTokens = ({String? idToken, String accessToken});
|
||||||
|
|
||||||
|
/// LINE — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthLine`). [KakaoSignInProvider] 와 같은 구조다.
|
||||||
|
///
|
||||||
|
/// Firebase OIDC(`oidc.line`) 웹 흐름은 iOS 에서 첫 시도에 「missing initial state」로 깨진다
|
||||||
|
/// (`disable_ios_auto_login` 을 붙여도 같다). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로 로그인해 받은
|
||||||
|
/// 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다.
|
||||||
|
///
|
||||||
|
/// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고,
|
||||||
|
/// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
|
||||||
|
///
|
||||||
|
/// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에
|
||||||
|
/// 오고, 서버가 그때만 남긴다. 없어도 로그인 · 연결이 된다.
|
||||||
|
final class LineSignInProvider implements SocialAuthProvider {
|
||||||
|
LineSignInProvider({
|
||||||
|
Future<LineTokens> Function()? login,
|
||||||
|
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
|
||||||
|
exchange,
|
||||||
|
this.firebaseAuth,
|
||||||
|
this.channelId = lineChannelId,
|
||||||
|
}) : _login = login ?? _sdkLogin,
|
||||||
|
_exchange = exchange ?? _callServer;
|
||||||
|
|
||||||
|
final Future<LineTokens> Function() _login;
|
||||||
|
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
|
||||||
|
|
||||||
|
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||||
|
final fb.FirebaseAuth? firebaseAuth;
|
||||||
|
final String channelId;
|
||||||
|
|
||||||
|
static Future<void>? _setup;
|
||||||
|
|
||||||
|
/// 사용자 취소 — Android `CANCEL`(LineApiResponseCode) · iOS `3003`(LineSDKError userCancelled).
|
||||||
|
static const _cancelCodes = {'CANCEL', '3003'};
|
||||||
|
|
||||||
|
@override
|
||||||
|
SocialProvider get provider => SocialProvider.line;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get missingConfig => [
|
||||||
|
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
||||||
|
if (channelId.isEmpty) 'LINE 채널 ID',
|
||||||
|
];
|
||||||
|
|
||||||
|
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
|
||||||
|
|
||||||
|
static Future<LineTokens> _sdkLogin() async {
|
||||||
|
await (_setup ??= line.LineSDK.instance.setup(lineChannelId));
|
||||||
|
final result = await line.LineSDK.instance.login(
|
||||||
|
scopes: const ['profile', 'openid', 'email'],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
idToken: result.accessToken.idTokenRaw,
|
||||||
|
accessToken: result.accessToken.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> _callServer(
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
final result = await FirebaseFunctions.instanceFor(
|
||||||
|
region: Backend.region,
|
||||||
|
).httpsCallable('fnAuthLine').call<Object?>(payload);
|
||||||
|
final data = result.data;
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _tokens() async {
|
||||||
|
final t = await _login();
|
||||||
|
return {
|
||||||
|
if (t.idToken != null) 'idToken': t.idToken,
|
||||||
|
'accessToken': t.accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthOutcome> signIn() => _run(AuthFlow.signIn, () async {
|
||||||
|
final data = await _exchange(await _tokens());
|
||||||
|
final result = await _auth.signInWithCustomToken(data['token'] as String);
|
||||||
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// SNS 연동 — 로그인한 UID 에 LINE 을 붙인다. 현재 세션 · LINE 토큰 검증과 다른 UID 연결 거부는
|
||||||
|
/// 서버(`fnAuthLine` link)가 한다. 이메일은 보지 않는다.
|
||||||
|
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
|
||||||
|
await _exchange({...await _tokens(), '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 (_cancelCodes.contains(error.code)) return const AuthCancelled();
|
||||||
|
debugPrint(
|
||||||
|
'[LineSignInProvider] sdk failed: ${error.code} ${error.message}',
|
||||||
|
);
|
||||||
|
return AuthFailed(
|
||||||
|
'E-AUTH-03',
|
||||||
|
flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||||
|
);
|
||||||
|
} on FirebaseFunctionsException catch (error) {
|
||||||
|
return _serverFailure(error, flow);
|
||||||
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
|
return AuthFailed(
|
||||||
|
authCatalogCode(error, flow: flow),
|
||||||
|
authMessage(error, flow: flow),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {
|
||||||
|
// SDK 를 한 번도 띄우지 않았으면 지울 LINE 토큰도 없다.
|
||||||
|
if (_setup == null) return;
|
||||||
|
try {
|
||||||
|
await line.LineSDK.instance.logout();
|
||||||
|
} on Object catch (error) {
|
||||||
|
// 토큰이 이미 만료 · 폐기됐어도 로그아웃을 막을 이유가 없다.
|
||||||
|
debugPrint('[LineSignInProvider] logout: ${error.runtimeType}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
|
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
|
||||||
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
|
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
|
||||||
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
|
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
|
||||||
@@ -676,8 +812,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
|||||||
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
||||||
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
||||||
'account-exists-with-different-credential' => switch (flow) {
|
'account-exists-with-different-credential' => switch (flow) {
|
||||||
AuthFlow.signIn || AuthFlow.email =>
|
AuthFlow.signIn ||
|
||||||
_otherProviderSignInGuide(error.email),
|
AuthFlow.email => _otherProviderSignInGuide(error.email),
|
||||||
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
},
|
},
|
||||||
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
||||||
|
|||||||
@@ -347,6 +347,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.4"
|
version: "0.14.4"
|
||||||
|
flutter_line_sdk:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_line_sdk
|
||||||
|
sha256: a311289a2f38d1c1c5ea05fc6b421185e571752f51b84df8e01c195e8e890d02
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.2"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ dependencies:
|
|||||||
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 SDK 를 쓴다.
|
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 SDK 를 쓴다.
|
||||||
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
||||||
kakao_flutter_sdk_user: ^2.0.1
|
kakao_flutter_sdk_user: ^2.0.1
|
||||||
|
# LINE 로그인 — LINE 공식 SDK. LINE 앱이 있으면 앱으로, 없으면 SDK 웹 화면으로 로그인해 ID · 액세스 토큰을
|
||||||
|
# 받고 서버(fnAuthLine)가 Firebase 커스텀 토큰으로 바꿔 준다. iOS 에서 Firebase OIDC 웹 흐름이 첫 시도에
|
||||||
|
# 「missing initial state」로 깨져서 SDK 를 쓴다. 3.x 는 AGP 9 · Dart 3.12 전제라 2.x 를 쓴다.
|
||||||
|
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
||||||
|
flutter_line_sdk: ^2.7.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/// LINE — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약.
|
||||||
|
/// 토큰 검증·기존 oidc.line 사용자 매핑은 서버 테스트(functions/test/auth_line.test.ts)가 본다.
|
||||||
|
void main() {
|
||||||
|
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'LINE 테스트'));
|
||||||
|
tearDown(
|
||||||
|
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'LINE 테스트 끝'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sent = <Map<String, dynamic>>[];
|
||||||
|
setUp(sent.clear);
|
||||||
|
|
||||||
|
LineSignInProvider line(
|
||||||
|
MockFirebaseAuth auth, {
|
||||||
|
Future<LineTokens> Function()? login,
|
||||||
|
Object? serverError,
|
||||||
|
String channelId = '2000000001',
|
||||||
|
}) => LineSignInProvider(
|
||||||
|
login:
|
||||||
|
login ?? () async => (idToken: 'line-id', accessToken: 'line-access'),
|
||||||
|
exchange: (payload) async {
|
||||||
|
sent.add(payload);
|
||||||
|
if (serverError != null) throw serverError;
|
||||||
|
return {
|
||||||
|
'token': 'custom-token',
|
||||||
|
if (payload['link'] == true) 'linked': true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
firebaseAuth: auth,
|
||||||
|
channelId: channelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('성공 — LINE 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async {
|
||||||
|
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1'));
|
||||||
|
final outcome = await line(auth).signIn();
|
||||||
|
expect(sent.single, {'idToken': 'line-id', 'accessToken': 'line-access'});
|
||||||
|
expect((outcome as AuthSucceeded).account.uid, 'line:U1');
|
||||||
|
expect(outcome.account.provider, AuthProviderKind.line);
|
||||||
|
expect(auth.currentUser?.uid, 'line:U1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ID 토큰이 없으면(openid 미동의) 액세스 토큰만 넘긴다', () async {
|
||||||
|
await line(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
login: () async => (idToken: null, accessToken: 'line-access'),
|
||||||
|
).signIn();
|
||||||
|
expect(sent.single, {'accessToken': 'line-access'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('채널 ID 가 없으면 SDK·서버를 부르지 않고 미설정', () async {
|
||||||
|
final outcome = await line(MockFirebaseAuth(), channelId: '').signIn();
|
||||||
|
expect(outcome, isA<AuthNotConfigured>());
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'LINE 로그인 취소(Android CANCEL · iOS 3003)는 취소 · 서버를 부르지 않는다 · 다시 시도하면 된다',
|
||||||
|
() async {
|
||||||
|
for (final code in ['CANCEL', '3003']) {
|
||||||
|
final outcome = await line(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
login: () async => throw PlatformException(code: code),
|
||||||
|
).signIn();
|
||||||
|
expect(outcome, isA<AuthCancelled>(), reason: code);
|
||||||
|
}
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
final retry = await line(
|
||||||
|
MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1')),
|
||||||
|
).signIn();
|
||||||
|
expect(retry, isA<AuthSucceeded>());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('SDK 실패는 소셜 문구 E-AUTH-03', () async {
|
||||||
|
final outcome = await line(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
login: () async =>
|
||||||
|
throw PlatformException(code: 'AUTHENTICATION_AGENT_ERROR'),
|
||||||
|
).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '소셜 로그인에 실패했습니다.');
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async {
|
||||||
|
final auth = MockFirebaseAuth();
|
||||||
|
final outcome = await line(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'permission-denied',
|
||||||
|
message: 'LINE 인증을 확인하지 못했습니다.',
|
||||||
|
details: {'code': 'E-AUTH-03'},
|
||||||
|
),
|
||||||
|
).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '소셜 로그인에 실패했습니다.');
|
||||||
|
expect(auth.currentUser, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SNS 연동', () {
|
||||||
|
test('로그인한 사용자에 link: true 로 붙인다', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
final linker = FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
final outcome = await linker.link(AuthProviderKind.line);
|
||||||
|
expect(sent.single, {
|
||||||
|
'idToken': 'line-id',
|
||||||
|
'accessToken': 'line-access',
|
||||||
|
'link': true,
|
||||||
|
});
|
||||||
|
expect((outcome as AuthSucceeded).account.uid, 'u-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('연결 중 취소는 취소 · 서버를 부르지 않는다', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
final outcome = await line(
|
||||||
|
auth,
|
||||||
|
login: () async => throw PlatformException(code: 'CANCEL'),
|
||||||
|
).link(auth.currentUser!);
|
||||||
|
expect(outcome, isA<AuthCancelled>());
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'다른 계정에 붙은 LINE 이면 E-AUTH-08 · 이미 다른 LINE 이면 E-AUTH-09 · 세션 만료면 다시 로그인 안내',
|
||||||
|
() async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
Future<AuthOutcome> failWith(Map<String, String> details) => line(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'x',
|
||||||
|
message: 'x',
|
||||||
|
details: details,
|
||||||
|
),
|
||||||
|
).link(auth.currentUser!);
|
||||||
|
|
||||||
|
final inUse = await failWith({'code': 'E-AUTH-08'});
|
||||||
|
expect((inUse as AuthFailed).code, 'E-AUTH-08');
|
||||||
|
expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||||
|
expect(
|
||||||
|
((await failWith({'code': 'E-AUTH-09'})) as AuthFailed).code,
|
||||||
|
'E-AUTH-09',
|
||||||
|
);
|
||||||
|
final expired = await failWith({
|
||||||
|
'code': 'E-AUTH-03',
|
||||||
|
'reason': 'session',
|
||||||
|
});
|
||||||
|
expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
/// LINE OIDC 제공자 — 실 로그인은 못 하고,
|
/// Firebase 웹 흐름(OIDC) 제공자 — 지금은 Android Apple 이 쓴다(LINE 을 예시 제공자로 둔다). 실 로그인은 못 하고,
|
||||||
/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다.
|
/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다.
|
||||||
void main() {
|
void main() {
|
||||||
// Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned).
|
// Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned).
|
||||||
@@ -28,18 +28,11 @@ void main() {
|
|||||||
firebaseAuth: auth,
|
firebaseAuth: auth,
|
||||||
);
|
);
|
||||||
|
|
||||||
test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () {
|
test('기본 목록: 카카오 · LINE 은 SDK + 서버 커스텀 토큰', () {
|
||||||
final providers = {
|
final providers = {
|
||||||
for (final p in AuthRepository().providers) p.provider: p,
|
for (final p in AuthRepository().providers) p.provider: p,
|
||||||
};
|
};
|
||||||
final p = providers[AuthProviderKind.line];
|
expect(providers[AuthProviderKind.line], isA<LineSignInProvider>());
|
||||||
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>());
|
expect(providers[AuthProviderKind.kakao], isA<KakaoSignInProvider>());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,24 +80,6 @@ void main() {
|
|||||||
expect((outcome as AuthFailed).code, 'E-AUTH-11');
|
expect((outcome as AuthFailed).code, 'E-AUTH-11');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('LINE 은 Android · iOS 모두 앱 자동 로그인을 끈다', () {
|
|
||||||
final line =
|
|
||||||
AuthRepository().providers.firstWhere(
|
|
||||||
(p) => p.provider == AuthProviderKind.line,
|
|
||||||
)
|
|
||||||
as OidcSignInProvider;
|
|
||||||
expect(line.authProvider.parameters, {
|
|
||||||
'disable_auto_login': 'true',
|
|
||||||
'disable_ios_auto_login': 'true',
|
|
||||||
});
|
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
||||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
||||||
expect(line.authProvider.parameters, {
|
|
||||||
'disable_auto_login': 'true',
|
|
||||||
'disable_ios_auto_login': 'true',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async {
|
testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async {
|
||||||
late AuthOutcome outcome;
|
late AuthOutcome outcome;
|
||||||
line(_HangingAuth()).signIn().then((o) => outcome = o);
|
line(_HangingAuth()).signIn().then((o) => outcome = o);
|
||||||
|
|||||||
Reference in New Issue
Block a user