소셜 로그인 인증 경로 · 세션 전이 · Apple 로그인 콜백 · 인증 테스트

This commit is contained in:
Sn-Kinos
2026-08-18 02:06:29 +09:00
parent 97ea5fad35
commit 357f5d759d
20 changed files with 729 additions and 127 deletions
+12
View File
@@ -100,3 +100,15 @@ abstract interface class SocialAuthProvider {
Future<void> signOut();
}
/// 앱 재실행·토큰 만료·다른 기기 로그아웃을 인증 상태에 반영하는 세션 경계.
///
/// 저장소는 이 인터페이스만 알며 Firebase SDK 자체는 어댑터 뒤에 둔다. 덕분에
/// 외부 설정 파일 없이도 세션 복원과 만료 전이를 단위 테스트할 수 있다.
abstract interface class AuthSession {
AuthAccount? get currentAccount;
Stream<AuthAccount?> get accountChanges;
Future<void> signOut();
}
+77 -16
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../core/firebase/firebase_boot.dart';
@@ -6,13 +8,22 @@ import 'social_providers.dart';
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
class AuthRepository extends ChangeNotifier {
AuthRepository({List<SocialAuthProvider>? providers, bool? releaseMode})
: providers = providers ?? _defaultProviders(),
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
_releaseMode = releaseMode ?? kReleaseMode;
AuthRepository({
List<SocialAuthProvider>? providers,
AuthSession? session,
bool? releaseMode,
}) : providers = providers ?? _defaultProviders(),
_session = session,
_account = session?.currentAccount,
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
_releaseMode = releaseMode ?? kReleaseMode {
_sessionSubscription = session?.accountChanges.listen(_replaceAccount);
}
final bool _releaseMode;
final AuthSession? _session;
StreamSubscription<AuthAccount?>? _sessionSubscription;
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
final List<SocialAuthProvider> providers;
@@ -21,27 +32,38 @@ class AuthRepository extends ChangeNotifier {
AuthAccount? get account => _account;
bool get isSignedIn => _account != null;
static List<SocialAuthProvider> _defaultProviders() => const [
KeylessSocialProvider(SocialProvider.kakao, [
static List<SocialAuthProvider> _defaultProviders() => [
const KeylessSocialProvider(SocialProvider.kakao, [
'카카오 네이티브 앱 키',
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
]),
KeylessSocialProvider(SocialProvider.naver, [
const KeylessSocialProvider(SocialProvider.naver, [
'네이버 client ID / client secret',
'네이버 개발자센터 앱 등록(사업자 정보 필요)',
]),
AppleSignInProvider(),
GoogleSignInProvider(),
AppleSignInProvider(
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
),
const GoogleSignInProvider(),
// 이메일 가입 구현체는 여기 없다. CD-18 미확정(Q2) — 회신이 오면 이 목록에 추가한다.
// 자세한 사유는 auth.dart 상단 「이메일 가입에 대하여」 참고.
];
Future<AuthOutcome> signIn(SocialProvider provider) async {
final impl = providers.firstWhere((p) => p.provider == provider);
final outcome = await impl.signIn();
late final AuthOutcome outcome;
try {
outcome = await impl.signIn();
} on Object catch (error) {
// SDK가 문서화하지 않은 플랫폼 예외를 던져도 로그인 화면이 busy 상태에
// 고착되지 않게 사용자용 결과로 닫는다. 원문 예외·토큰은 화면에 노출하지 않는다.
debugPrint(
'[AuthRepository] ${provider.name} sign-in failed: ${error.runtimeType}',
);
outcome = const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
if (outcome is AuthSucceeded) {
_account = outcome.account;
notifyListeners();
_replaceAccount(outcome.account);
}
return outcome;
}
@@ -71,10 +93,49 @@ class AuthRepository extends ChangeNotifier {
}
Future<void> signOut() async {
for (final p in providers) {
await p.signOut();
final currentProvider = _account?.provider;
SocialAuthProvider? provider;
if (currentProvider != null) {
for (final candidate in providers) {
if (candidate.provider == currentProvider) {
provider = candidate;
break;
}
}
}
_account = null;
try {
await Future.wait([
if (provider != null) provider.signOut(),
if (_session != null) _session.signOut(),
], eagerError: false);
} finally {
// 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다.
_replaceAccount(null);
}
}
void _replaceAccount(AuthAccount? next) {
if (_sameAccount(_account, next)) return;
_account = next;
notifyListeners();
}
bool _sameAccount(AuthAccount? a, AuthAccount? b) =>
identical(a, b) ||
(a != null &&
b != null &&
a.uid == b.uid &&
a.provider == b.provider &&
a.displayName == b.displayName &&
a.email == b.email &&
a.isMock == b.isMock);
@override
void dispose() {
final subscription = _sessionSubscription;
_sessionSubscription = null;
if (subscription != null) unawaited(subscription.cancel());
super.dispose();
}
}
@@ -0,0 +1,47 @@
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'auth.dart';
/// Firebase Auth가 보존한 세션을 앱의 인증 계약으로 변환한다.
///
/// [FirebaseBoot]가 live인 경우에만 생성해야 한다. 설정이 없는 목 모드에서는
/// Firebase 인스턴스에 접근하지 않고 [AuthRepository]에 null 세션을 전달한다.
final class FirebaseAuthSession implements AuthSession {
FirebaseAuthSession({fb.FirebaseAuth? firebaseAuth})
: _firebaseAuth = firebaseAuth ?? fb.FirebaseAuth.instance;
final fb.FirebaseAuth _firebaseAuth;
@override
AuthAccount? get currentAccount => _toAccount(_firebaseAuth.currentUser);
@override
Stream<AuthAccount?> get accountChanges =>
_firebaseAuth.authStateChanges().map(_toAccount);
@override
Future<void> signOut() => _firebaseAuth.signOut();
}
AuthAccount? _toAccount(fb.User? user) {
if (user == null) return null;
return AuthAccount(
uid: user.uid,
provider: _providerOf(user),
displayName: user.displayName,
email: user.email,
);
}
SocialProvider? _providerOf(fb.User user) {
final ids = user.providerData.map((data) => data.providerId).toSet();
if (ids.contains('google.com')) return SocialProvider.google;
if (ids.contains('apple.com')) return SocialProvider.apple;
if (ids.any((id) => id.toLowerCase().contains('kakao'))) {
return SocialProvider.kakao;
}
if (ids.any((id) => id.toLowerCase().contains('naver'))) {
return SocialProvider.naver;
}
return null;
}
+58 -10
View File
@@ -1,4 +1,8 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart' as gsi;
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
@@ -12,6 +16,8 @@ import 'auth.dart';
final class GoogleSignInProvider implements SocialAuthProvider {
const GoogleSignInProvider();
static Future<void>? _initialization;
@override
SocialProvider get provider => SocialProvider.google;
@@ -32,14 +38,16 @@ final class GoogleSignInProvider implements SocialAuthProvider {
try {
final signIn = gsi.GoogleSignIn.instance;
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
await signIn.initialize();
await (_initialization ??= signIn.initialize());
final account = await signIn.authenticate();
final idToken = account.authentication.idToken;
if (idToken == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final credential = fb.GoogleAuthProvider.credential(idToken: idToken);
final result = await fb.FirebaseAuth.instance.signInWithCredential(credential);
final result = await fb.FirebaseAuth.instance.signInWithCredential(
credential,
);
return AuthSucceeded(_toAccount(result.user, provider));
} on gsi.GoogleSignInException catch (error) {
if (error.code == gsi.GoogleSignInExceptionCode.canceled) {
@@ -54,8 +62,9 @@ final class GoogleSignInProvider implements SocialAuthProvider {
@override
Future<void> signOut() async {
if (!FirebaseBoot.isLive) return;
await (_initialization ??= gsi.GoogleSignIn.instance.initialize());
await gsi.GoogleSignIn.instance.signOut();
await fb.FirebaseAuth.instance.signOut();
// Firebase 세션 종료는 AuthSession이 한 번만 담당한다.
}
}
@@ -65,7 +74,11 @@ final class GoogleSignInProvider implements SocialAuthProvider {
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
final class AppleSignInProvider implements SocialAuthProvider {
const AppleSignInProvider();
const AppleSignInProvider({this.webAuthenticationOptions});
/// Android·Web에서 필요한 Service ID와 검증된 callback URL.
/// 값이 없으면 SDK를 호출하지 않고 [AuthNotConfigured]로 닫는다.
final apple.WebAuthenticationOptions? webAuthenticationOptions;
@override
SocialProvider get provider => SocialProvider.apple;
@@ -74,9 +87,12 @@ final class AppleSignInProvider implements SocialAuthProvider {
bool get isLive => true;
@override
List<String> get missingConfig => FirebaseBoot.isLive
? const []
: [...FirebaseBoot.missingLabels, 'Apple 개발자 설정'];
List<String> get missingConfig => [
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
if (_requiresWebAuthentication && webAuthenticationOptions == null)
'Apple Android 웹 인증 설정',
if (!FirebaseBoot.isLive) 'Apple 개발자 설정',
];
@override
Future<AuthOutcome> signIn() async {
@@ -87,11 +103,17 @@ final class AppleSignInProvider implements SocialAuthProvider {
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.');
}
try {
final rawNonce = apple.generateNonce();
final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
final credential = await apple.SignInWithApple.getAppleIDCredential(
scopes: const [
apple.AppleIDAuthorizationScopes.email,
apple.AppleIDAuthorizationScopes.fullName,
],
webAuthenticationOptions: _requiresWebAuthentication
? webAuthenticationOptions
: null,
nonce: hashedNonce,
);
final idToken = credential.identityToken;
if (idToken == null) {
@@ -100,6 +122,7 @@ final class AppleSignInProvider implements SocialAuthProvider {
final oauth = fb.OAuthProvider('apple.com').credential(
idToken: idToken,
accessToken: credential.authorizationCode,
rawNonce: rawNonce,
);
final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth);
return AuthSucceeded(_toAccount(result.user, provider));
@@ -108,6 +131,9 @@ final class AppleSignInProvider implements SocialAuthProvider {
return const AuthCancelled();
}
return AuthFailed('E-AUTH-03', error.message);
} on apple.SignInWithAppleException catch (error) {
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
}
@@ -115,9 +141,11 @@ final class AppleSignInProvider implements SocialAuthProvider {
@override
Future<void> signOut() async {
if (!FirebaseBoot.isLive) return;
await fb.FirebaseAuth.instance.signOut();
// Apple SDK에는 앱 로컬 로그아웃 API가 없다. Firebase 세션은 AuthSession이 닫는다.
}
bool get _requiresWebAuthentication =>
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
}
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버.
@@ -137,12 +165,32 @@ final class KeylessSocialProvider implements SocialAuthProvider {
bool get isLive => false;
@override
Future<AuthOutcome> signIn() async => AuthNotConfigured(provider, missingConfig);
Future<AuthOutcome> signIn() async =>
AuthNotConfigured(provider, missingConfig);
@override
Future<void> signOut() async {}
}
/// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다.
/// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다.
apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() {
const clientId = String.fromEnvironment('FANUZU_APPLE_SERVICE_ID');
const redirect = String.fromEnvironment('FANUZU_APPLE_REDIRECT_URI');
if (clientId.isEmpty || redirect.isEmpty) return null;
final redirectUri = Uri.tryParse(redirect);
if (redirectUri == null ||
!redirectUri.hasScheme ||
!redirectUri.hasAuthority) {
return null;
}
return apple.WebAuthenticationOptions(
clientId: clientId,
redirectUri: redirectUri,
);
}
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
uid: user?.uid ?? '',
provider: provider,
@@ -11,6 +11,5 @@ class GalaxyScreen extends StatelessWidget {
tab: 'GALAXY',
screenId: 'S-G-PLANET',
screenName: '내 행성',
specPath: 'wireframe/planning/screen-spec/galaxy/S-G-PLANET.md',
);
}
-1
View File
@@ -11,6 +11,5 @@ class HomeScreen extends StatelessWidget {
tab: 'HOME',
screenId: 'S-H-HOME',
screenName: '오늘의 퀘스트',
specPath: 'wireframe/planning/screen-spec/home/S-H-HOME.md',
);
}
@@ -11,6 +11,5 @@ class InsideScreen extends StatelessWidget {
tab: 'INSIDE',
screenId: 'S-I-LOUNGE',
screenName: '팬덤 라운지',
specPath: 'wireframe/planning/screen-spec/inside/S-I-LOUNGE.md',
);
}
-1
View File
@@ -11,6 +11,5 @@ class MyScreen extends StatelessWidget {
tab: 'MY',
screenId: 'S-M-HOME',
screenName: '마이 페이지',
specPath: 'wireframe/planning/screen-spec/my/S-M-HOME.md',
);
}
@@ -11,6 +11,5 @@ class StationScreen extends StatelessWidget {
tab: 'STATION',
screenId: 'S-S-HUB',
screenName: 'STATION 허브',
specPath: 'wireframe/planning/screen-spec/station/S-S-HUB.md',
);
}