1차 보완(0.1.1+14) 내용 합치기

This commit is contained in:
Sn-Kinos
2026-10-16 17:58:55 +09:00
62 changed files with 2480 additions and 447 deletions
+260 -57
View File
@@ -8,6 +8,7 @@ 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:flutter_line_sdk/flutter_line_sdk.dart' as line;
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;
@@ -26,12 +27,12 @@ abstract interface class CredentialProvider {
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
///
/// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
/// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는
/// 쓰지 않는다(Q-E8 — 이메일 일치는 소유 확인이 아니다).
Future<SocialCredential?> obtainCredential();
}
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만).
///
/// [name] 은 제공자가 자격증명과 함께만 주는 표시 이름(Apple fullName — 최초 인가 때만). Firebase 가
/// 이 값을 계정 이름으로 옮기지 않아, 로그인 직후 우리가 계정에 써 둔다(온보딩 닉네임 기본값).
@@ -99,7 +100,7 @@ final class GoogleSignInProvider
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -165,13 +166,12 @@ final class AppleSignInProvider
accessToken: credential.authorizationCode,
rawNonce: rawNonce,
),
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라
// 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다.
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다.
email: credential.email,
name: [credential.givenName, credential.familyName]
.whereType<String>()
.where((part) => part.isNotEmpty)
.join(' '),
name: [
credential.givenName,
credential.familyName,
].whereType<String>().where((part) => part.isNotEmpty).join(' '),
);
}
@@ -192,7 +192,9 @@ final class AppleSignInProvider
obtained.credential,
);
final name = obtained.name;
if (name != null && name.isNotEmpty && (result.user?.displayName ?? '').isEmpty) {
if (name != null &&
name.isNotEmpty &&
(result.user?.displayName ?? '').isEmpty) {
await result.user?.updateDisplayName(name);
}
return AuthSucceeded(_toAccount(result.user, provider));
@@ -206,7 +208,7 @@ final class AppleSignInProvider
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -221,8 +223,9 @@ final class AppleSignInProvider
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
}
/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나
/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
/// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이
/// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀
/// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
///
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
@@ -242,7 +245,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
this.provider, {
required this.providerId,
this.scopes = const [],
this.androidParameters = const {},
this.firebaseAuth,
});
@@ -257,12 +259,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
/// KOE205 로 로그인 자체를 막는다.
final List<String> scopes;
/// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로
/// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가
/// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다.
/// iOS 는 정상이라 건드리지 않는다.
final Map<String, String> androidParameters;
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
final fb.FirebaseAuth? firebaseAuth;
@@ -278,9 +274,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
for (final scope in scopes) {
p.addScope(scope);
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
p.setCustomParameters(androidParameters);
}
return p;
}
@@ -289,16 +282,18 @@ final class OidcSignInProvider implements SocialAuthProvider {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
final result = await untilAbandoned(
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
authProvider,
final result = await retryStaleWebContext(
() => 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),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -389,7 +384,8 @@ final class KakaoSignInProvider implements SocialAuthProvider {
return AuthSucceeded(_toAccount(result.user, provider));
});
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다.
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는
/// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다(Q-E8).
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
await _exchange({'accessToken': await _accessToken(), 'link': true});
await user.reload();
@@ -415,28 +411,20 @@ final class KakaoSignInProvider implements SocialAuthProvider {
}
// 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다 —
// 10/6 릴리스 빌드 E-AUTH-03 은 이 분기로 들어와 아무 로그도 남지 않았다.
debugPrint('[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}');
debugPrint(
'[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}',
);
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on kakao.KakaoException catch (error) {
debugPrint('[KakaoSignInProvider] sdk error: $error');
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on FirebaseFunctionsException catch (error) {
final details = error.details;
final code = details is Map && details['code'] is String
? details['code'] as String
: 'E-AUTH-03';
return AuthFailed(code, switch (code) {
_ when details is Map && details['reason'] == 'different-email' =>
appL10n.authDifferentEmail,
'E-AUTH-08' => appL10n.authCredentialInUse,
'E-AUTH-09' => appL10n.authAlreadyLinked,
_ =>
flow == AuthFlow.link
? appL10n.authLinkFailed
: appL10n.authSocialFailed,
});
return _serverFailure(error, flow);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow));
return AuthFailed(
authCatalogCode(error, flow: flow),
authMessage(error, flow: flow),
);
}
}
@@ -453,10 +441,195 @@ 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' =>
appL10n.authRecentLogin,
'E-AUTH-08' => appL10n.authCredentialInUse,
'E-AUTH-09' => appL10n.authAlreadyLinked,
_ =>
flow == AuthFlow.link ? appL10n.authLinkFailed : appL10n.authSocialFailed,
});
}
/// 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」로 깨진다(10/11
/// iPhone 11 · iOS 26 — `disable_ios_auto_login` 을 붙여도 재현). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로
/// 로그인해 받은 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다.
///
/// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고,
/// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
///
/// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에
/// 오고, 서버가 그때만 남긴다(C-143). 없어도 로그인 · 연결이 된다.
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)가 한다. 이메일은 보지 않는다(Q-E8).
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
? appL10n.authLinkFailed
: appL10n.authSocialFailed,
);
} 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` 철자다.
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
error.code == 'web-context-canceled' ||
error.code == 'web-context-cancelled';
error.code == 'web-context-cancelled' ||
error.code == 'user-cancelled';
const _authChannel = MethodChannel('kr.co.fanuzu.app/auth');
/// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`).
///
/// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을
/// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(10/8 실기기 — 다시 눌러도 로그인이 안 됨).
/// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다.
Future<void> clearStaleWebSignIn() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
try {
await _authChannel.invokeMethod<void>('cancelPendingWebSignIn');
} on Object catch (error) {
debugPrint('[Auth] clear stale web sign-in skipped: $error');
}
}
/// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다.
/// 1차 검수 R01 「취소 · 실패 후 정상 재시도 가능」.
Future<T> retryStaleWebContext<T>(Future<T> Function() attempt) async {
try {
return await attempt();
} on fb.FirebaseAuthException catch (error) {
if (error.code != 'web-context-already-presented') rethrow;
await clearStaleWebSignIn();
return attempt();
}
}
/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다.
///
@@ -481,6 +654,8 @@ Future<T> untilAbandoned<T>(
if (state != AppLifecycleState.resumed) return;
timer = Timer(grace, () {
if (done.isCompleted) return;
// Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다.
unawaited(clearStaleWebSignIn());
done.completeError(
fb.FirebaseAuthException(code: 'web-context-canceled'),
);
@@ -569,8 +744,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
return const AuthCancelled();
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authMessage(error, flow: AuthFlow.signIn),
authCatalogCode(error, flow: AuthFlow.email),
authMessage(error, flow: AuthFlow.email),
);
}
}
@@ -583,8 +758,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authMessage(error, flow: AuthFlow.signIn),
authCatalogCode(error, flow: AuthFlow.email),
authMessage(error, flow: AuthFlow.email),
);
}
}
@@ -599,7 +774,14 @@ final class EmailPasswordProvider implements EmailAuthProvider {
///
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
///
/// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은
/// 소셜 실패 E-AUTH-03 이다. 예전에는 하나로 E-AUTH-01 이라 LINE 웹 로그인이 끊기면(`web-*`)
/// 「이메일 또는 비밀번호를 확인해 주세요」가 떴다(10/8 실기기 · 1차 검수 R01).
String authCatalogCode(
fb.FirebaseAuthException error, {
required AuthFlow flow,
}) => switch (error.code) {
'email-already-in-use' => 'E-AUTH-04',
'weak-password' => 'E-AUTH-05',
'invalid-email' => 'E-AUTH-06',
@@ -613,8 +795,13 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
'no-such-provider' => 'E-AUTH-10',
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
'operation-not-allowed' => 'E-AUTH-11',
'requires-recent-login' => 'E-AUTH-12',
_ => 'E-AUTH-01',
// 현재 계정의 세션이 만료 · 폐기됐다(Q-E8 「세션 만료 · 인증 취소」). 다시 로그인해야 한다.
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => 'E-AUTH-12',
_ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03',
};
/// 오류 문구가 갈리는 경로.
@@ -623,9 +810,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
enum AuthFlow {
/// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`).
/// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`).
signIn,
/// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다.
email,
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
link,
@@ -645,7 +835,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
'invalid-email' => appL10n.authInvalidEmail,
'too-many-requests' => appL10n.commonTryLater,
'account-exists-with-different-credential' => switch (flow) {
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
AuthFlow.signIn ||
AuthFlow.email => _otherProviderSignInGuide(error.email),
AuthFlow.link => appL10n.authCredentialInUse,
},
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
@@ -653,8 +844,20 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
'provider-already-linked' => appL10n.authAlreadyLinked,
'no-such-provider' => appL10n.authNotLinked,
'operation-not-allowed' => appL10n.authMethodUnavailable,
'requires-recent-login' => appL10n.authRecentLogin,
_ => appL10n.authCheckCredentials,
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => appL10n.authRecentLogin,
// 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다(R01).
'web-context-already-presented' => appL10n.authSocialCancelled,
'web-network-request-failed' ||
'network-request-failed' => appL10n.authSocialNetwork,
_ => switch (flow) {
AuthFlow.email => appL10n.authCheckCredentials,
AuthFlow.signIn => appL10n.authSocialFailed,
AuthFlow.link => appL10n.authLinkFailed,
},
};
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.