From 33660cfdcf6630ab34fcba7db249e502050e82cc Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 19 Feb 2026 13:54:40 +0900 Subject: [PATCH] =?UTF-8?q?OIDC=EB=A1=9C=20=EB=B9=A0=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=A0=90=EA=B2=80=20login=5Fchallenge=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20fallback=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/core/services/web_window_stub.dart | 8 + .../lib/core/services/web_window_web.dart | 42 ++++ .../auth/domain/login_challenge_resolver.dart | 195 ++++++++++++++++++ .../auth/presentation/login_screen.dart | 49 ++++- userfront/lib/main.dart | 5 +- .../test/login_challenge_resolver_test.dart | 69 +++++++ .../test/router_redirect_widget_test.dart | 36 +++- 7 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 userfront/lib/features/auth/domain/login_challenge_resolver.dart create mode 100644 userfront/test/login_challenge_resolver_test.dart diff --git a/userfront/lib/core/services/web_window_stub.dart b/userfront/lib/core/services/web_window_stub.dart index e97c02e2..39efb82d 100644 --- a/userfront/lib/core/services/web_window_stub.dart +++ b/userfront/lib/core/services/web_window_stub.dart @@ -3,6 +3,14 @@ class WebWindow { void redirectTo(String url) {} + String currentHref() { + return ''; + } + + String currentSearch() { + return ''; + } + void alert(String message) {} void close() {} diff --git a/userfront/lib/core/services/web_window_web.dart b/userfront/lib/core/services/web_window_web.dart index dc23777c..fc1659be 100644 --- a/userfront/lib/core/services/web_window_web.dart +++ b/userfront/lib/core/services/web_window_web.dart @@ -1,6 +1,7 @@ // ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use import 'dart:html' as html; +import 'package:flutter/foundation.dart'; class WebWindow { void setTitle(String title) { @@ -8,7 +9,48 @@ class WebWindow { } void redirectTo(String url) { + final currentHref = html.window.location.href; + Uri? targetUri; + try { + targetUri = Uri.parse(url); + } catch (_) { + debugPrint("[WebWindow] redirectTo parse failed: url=$url"); + } + + final currentPort = int.tryParse(html.window.location.port); + final sameOrigin = + targetUri != null && + targetUri.scheme == html.window.location.protocol.replaceAll(':', '') && + targetUri.host == html.window.location.hostname && + (!targetUri.hasPort || targetUri.port == currentPort); + + debugPrint( + "[WebWindow] redirectTo start: current=$currentHref, target=$url, target_host=${targetUri?.host ?? ''}, target_path=${targetUri?.path ?? ''}, same_origin=$sameOrigin", + ); + html.window.location.href = url; + + // 이동이 차단되거나 즉시 원위치되는 경우를 추적하기 위한 후속 로그입니다. + Future.delayed(const Duration(milliseconds: 800), () { + final nowHref = html.window.location.href; + if (nowHref == currentHref) { + debugPrint( + "[WebWindow] redirectTo no-op detected: current URL did not change after navigation attempt", + ); + } else { + debugPrint( + "[WebWindow] redirectTo post-check: location changed to $nowHref", + ); + } + }); + } + + String currentHref() { + return html.window.location.href; + } + + String currentSearch() { + return html.window.location.search ?? ''; } void alert(String message) { diff --git a/userfront/lib/features/auth/domain/login_challenge_resolver.dart b/userfront/lib/features/auth/domain/login_challenge_resolver.dart new file mode 100644 index 00000000..ba067100 --- /dev/null +++ b/userfront/lib/features/auth/domain/login_challenge_resolver.dart @@ -0,0 +1,195 @@ +enum LoginChallengeSource { widget, uriQuery, rawSearch, rawHref, missing } + +class LoginChallengeResolution { + final String? value; + final LoginChallengeSource source; + final bool uriHasLoginChallenge; + final bool rawSearchHasLoginChallenge; + final bool rawHrefHasLoginChallenge; + + const LoginChallengeResolution({ + required this.value, + required this.source, + required this.uriHasLoginChallenge, + required this.rawSearchHasLoginChallenge, + required this.rawHrefHasLoginChallenge, + }); + + Map toDiagnostics() { + return { + 'resolved_value_len': value?.length ?? 0, + 'resolved_source': source.name, + 'uri_has_login_challenge': uriHasLoginChallenge, + 'raw_search_has_login_challenge': rawSearchHasLoginChallenge, + 'raw_href_has_login_challenge': rawHrefHasLoginChallenge, + }; + } +} + +LoginChallengeResolution resolveLoginChallenge({ + String? widgetLoginChallenge, + required Uri uri, + String? rawSearch, + String? rawHref, +}) { + final widgetValue = _normalizeChallenge(widgetLoginChallenge); + if (widgetValue != null) { + return const LoginChallengeResolution( + value: null, + source: LoginChallengeSource.widget, + uriHasLoginChallenge: false, + rawSearchHasLoginChallenge: false, + rawHrefHasLoginChallenge: false, + ).copyWith(value: widgetValue); + } + + final uriValue = _normalizeChallenge(uri.queryParameters['login_challenge']); + if (uriValue != null) { + return const LoginChallengeResolution( + value: null, + source: LoginChallengeSource.uriQuery, + uriHasLoginChallenge: true, + rawSearchHasLoginChallenge: false, + rawHrefHasLoginChallenge: false, + ).copyWith(value: uriValue); + } + + final rawSearchValue = _normalizeChallenge( + _extractQueryParamFromRawQuery(rawSearch, 'login_challenge'), + ); + if (rawSearchValue != null) { + return const LoginChallengeResolution( + value: null, + source: LoginChallengeSource.rawSearch, + uriHasLoginChallenge: false, + rawSearchHasLoginChallenge: true, + rawHrefHasLoginChallenge: false, + ).copyWith(value: rawSearchValue); + } + + final rawHrefValue = _normalizeChallenge( + _extractQueryParamFromRawHref(rawHref, 'login_challenge'), + ); + if (rawHrefValue != null) { + return const LoginChallengeResolution( + value: null, + source: LoginChallengeSource.rawHref, + uriHasLoginChallenge: false, + rawSearchHasLoginChallenge: false, + rawHrefHasLoginChallenge: true, + ).copyWith(value: rawHrefValue); + } + + return const LoginChallengeResolution( + value: null, + source: LoginChallengeSource.missing, + uriHasLoginChallenge: false, + rawSearchHasLoginChallenge: false, + rawHrefHasLoginChallenge: false, + ); +} + +String? _normalizeChallenge(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + return trimmed; +} + +String? _extractQueryParamFromRawHref(String? rawHref, String key) { + final href = rawHref?.trim(); + if (href == null || href.isEmpty) { + return null; + } + + final parsed = Uri.tryParse(href); + final fromParsed = parsed?.queryParameters[key]; + final normalizedParsed = _normalizeChallenge(fromParsed); + if (normalizedParsed != null) { + return normalizedParsed; + } + + final question = href.indexOf('?'); + if (question < 0) { + return null; + } + final hash = href.indexOf('#', question + 1); + final rawQuery = hash < 0 + ? href.substring(question + 1) + : href.substring(question + 1, hash); + return _extractQueryParamFromRawQuery(rawQuery, key); +} + +String? _extractQueryParamFromRawQuery(String? rawQuery, String key) { + final query = rawQuery?.trim(); + if (query == null || query.isEmpty) { + return null; + } + + final normalizedQuery = query.startsWith('?') ? query.substring(1) : query; + if (normalizedQuery.isEmpty) { + return null; + } + + try { + final parsed = Uri.splitQueryString(normalizedQuery); + final value = _normalizeChallenge(parsed[key]); + if (value != null) { + return value; + } + } catch (_) { + // URI 파싱이 실패하면 수동 파싱으로 보완합니다. + } + + for (final pair in normalizedQuery.split('&')) { + if (pair.isEmpty) { + continue; + } + final equalIndex = pair.indexOf('='); + final rawKey = equalIndex < 0 ? pair : pair.substring(0, equalIndex); + final decodedKey = _decodeQueryComponentSafe(rawKey); + if (decodedKey != key) { + continue; + } + if (equalIndex < 0) { + return null; + } + final rawValue = pair.substring(equalIndex + 1); + final decodedValue = _normalizeChallenge( + _decodeQueryComponentSafe(rawValue), + ); + if (decodedValue != null) { + return decodedValue; + } + } + return null; +} + +String _decodeQueryComponentSafe(String value) { + try { + return Uri.decodeQueryComponent(value); + } catch (_) { + return value; + } +} + +extension on LoginChallengeResolution { + LoginChallengeResolution copyWith({ + String? value, + LoginChallengeSource? source, + bool? uriHasLoginChallenge, + bool? rawSearchHasLoginChallenge, + bool? rawHrefHasLoginChallenge, + }) { + return LoginChallengeResolution( + value: value ?? this.value, + source: source ?? this.source, + uriHasLoginChallenge: uriHasLoginChallenge ?? this.uriHasLoginChallenge, + rawSearchHasLoginChallenge: + rawSearchHasLoginChallenge ?? this.rawSearchHasLoginChallenge, + rawHrefHasLoginChallenge: + rawHrefHasLoginChallenge ?? this.rawHrefHasLoginChallenge, + ); + } +} diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index a0b4ffcd..db8e0eac 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -11,6 +11,7 @@ import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; import '../../../core/services/oidc_redirect_guard.dart'; import '../../../core/notifiers/auth_notifier.dart'; +import '../domain/login_challenge_resolver.dart'; import '../domain/password_login_flow_policy.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../../core/services/web_window.dart'; @@ -99,8 +100,12 @@ class _LoginScreenState extends ConsumerState } } - _loginChallenge = - widget.loginChallenge ?? uri.queryParameters['login_challenge']; + final challengeResolution = _resolveLoginChallenge(uri); + _loginChallenge = challengeResolution.value; + _logLoginChallengeDiagnostics( + phase: 'init', + resolution: challengeResolution, + ); final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; final pendingRefParam = uri.queryParameters['pendingRef']; @@ -273,6 +278,32 @@ class _LoginScreenState extends ConsumerState bool get _hasLoginChallenge => _loginChallenge != null && _loginChallenge!.isNotEmpty; + LoginChallengeResolution _resolveLoginChallenge(Uri uri) { + return resolveLoginChallenge( + widgetLoginChallenge: widget.loginChallenge, + uri: uri, + rawSearch: webWindow.currentSearch(), + rawHref: webWindow.currentHref(), + ); + } + + void _logLoginChallengeDiagnostics({ + required String phase, + required LoginChallengeResolution resolution, + }) { + final current = Uri.base; + final currentQueryKeys = current.queryParameters.keys.toList()..sort(); + final payload = { + 'phase': phase, + 'current_path': current.path, + 'current_query_keys': currentQueryKeys, + 'stored_has_login_challenge': _hasLoginChallenge, + 'stored_login_challenge_len': _loginChallenge?.length ?? 0, + ...resolution.toDiagnostics(), + }; + debugPrint("[Auth] login_challenge diagnostics: ${jsonEncode(payload)}"); + } + void _logOidcRedirectDiagnostics({ required String source, required OidcRedirectCheckResult checked, @@ -864,6 +895,15 @@ class _LoginScreenState extends ConsumerState } try { + final challengeResolution = _resolveLoginChallenge(Uri.base); + if (!_hasLoginChallenge && challengeResolution.value != null) { + _loginChallenge = challengeResolution.value; + } + _logLoginChallengeDiagnostics( + phase: 'password_submit', + resolution: challengeResolution, + ); + final res = await AuthProxyService.loginWithPassword( loginId, password, @@ -883,6 +923,11 @@ class _LoginScreenState extends ConsumerState debugPrint( "[Auth] Password login outcome: has_login_challenge=$_hasLoginChallenge, next_action=$nextAction, has_jwt=$hasJwt", ); + if (!_hasLoginChallenge) { + debugPrint( + "[Auth] WARNING: password login proceeded without login_challenge; treated as local login flow", + ); + } switch (nextAction) { case PasswordLoginNextAction.redirectToOidc: diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 43d38f95..4e6b3333 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -147,14 +147,17 @@ final _router = GoRouter( GoRoute( path: 'login', builder: (context, state) { + final loginChallenge = + state.uri.queryParameters['login_challenge']; final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; _routerLogger.info( - "Navigating to /login, redirect: $redirectUrl", + "Navigating to /login with login_challenge: $loginChallenge, redirect: $redirectUrl", ); return LoginScreen( key: state.pageKey, + loginChallenge: loginChallenge, redirectUrl: redirectUrl, ); }, diff --git a/userfront/test/login_challenge_resolver_test.dart b/userfront/test/login_challenge_resolver_test.dart new file mode 100644 index 00000000..fa9603cb --- /dev/null +++ b/userfront/test/login_challenge_resolver_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/auth/domain/login_challenge_resolver.dart'; + +void main() { + group('login_challenge_resolver', () { + test('widget 값이 있으면 최우선으로 사용', () { + final resolved = resolveLoginChallenge( + widgetLoginChallenge: 'widget-challenge', + uri: Uri.parse('/ko/login'), + rawSearch: '?login_challenge=raw-search', + rawHref: 'https://sso-test.hmac.kr/ko/login?login_challenge=raw-href', + ); + + expect(resolved.value, 'widget-challenge'); + expect(resolved.source, LoginChallengeSource.widget); + }); + + test('widget 값이 없으면 URI query에서 복구', () { + final resolved = resolveLoginChallenge( + widgetLoginChallenge: null, + uri: Uri.parse('/ko/login?login_challenge=uri-query'), + rawSearch: '', + rawHref: '', + ); + + expect(resolved.value, 'uri-query'); + expect(resolved.source, LoginChallengeSource.uriQuery); + }); + + test('URI query가 비어 있으면 raw search에서 복구', () { + final resolved = resolveLoginChallenge( + widgetLoginChallenge: null, + uri: Uri.parse('/ko/login'), + rawSearch: '?login_challenge=raw-search-value&x=1', + rawHref: '', + ); + + expect(resolved.value, 'raw-search-value'); + expect(resolved.source, LoginChallengeSource.rawSearch); + expect(resolved.rawSearchHasLoginChallenge, isTrue); + }); + + test('raw search도 비어 있으면 raw href에서 복구', () { + final resolved = resolveLoginChallenge( + widgetLoginChallenge: null, + uri: Uri.parse('/ko/login'), + rawSearch: '', + rawHref: + 'https://sso-test.hmac.kr/ko/login?a=1&login_challenge=raw-href-value#fragment', + ); + + expect(resolved.value, 'raw-href-value'); + expect(resolved.source, LoginChallengeSource.rawHref); + expect(resolved.rawHrefHasLoginChallenge, isTrue); + }); + + test('값이 전부 없으면 missing', () { + final resolved = resolveLoginChallenge( + widgetLoginChallenge: null, + uri: Uri.parse('/ko/login'), + rawSearch: '', + rawHref: 'https://sso-test.hmac.kr/ko/login?x=1', + ); + + expect(resolved.value, isNull); + expect(resolved.source, LoginChallengeSource.missing); + }); + }); +} diff --git a/userfront/test/router_redirect_widget_test.dart b/userfront/test/router_redirect_widget_test.dart index 4ecbb160..105d547e 100644 --- a/userfront/test/router_redirect_widget_test.dart +++ b/userfront/test/router_redirect_widget_test.dart @@ -28,6 +28,21 @@ Widget _buildTestApp(String initialLocation) { ); }, ), + GoRoute( + path: 'login', + builder: (context, state) { + final challenge = state.uri.queryParameters['login_challenge']; + final redirect = + state.uri.queryParameters['redirect_uri'] ?? + state.uri.queryParameters['redirect_url'] ?? + ''; + return Scaffold( + body: Text( + 'login|challenge=${challenge ?? ''}|redirect=$redirect', + ), + ); + }, + ), GoRoute( path: 'profile', builder: (context, state) => @@ -45,7 +60,7 @@ Widget _buildTestApp(String initialLocation) { final isLoggedIn = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie(); final path = stripLocalePath(state.uri); - final isPublicPath = path == '/signin'; + final isPublicPath = path == '/signin' || path == '/login'; if (isPublicPath) { return null; } @@ -70,6 +85,25 @@ void main() { LocaleRegistry.resetForTest(); }); + testWidgets('/login: login_challenge와 redirect_uri를 전달', (tester) async { + final encodedRedirectUri = Uri.encodeComponent( + 'https://rp.example.com/callback?x=1', + ); + await tester.pumpWidget( + _buildTestApp( + '/en/login?login_challenge=lc_999&redirect_uri=$encodedRedirectUri', + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text( + 'login|challenge=lc_999|redirect=https://rp.example.com/callback?x=1', + ), + findsOneWidget, + ); + }); + testWidgets('비로그인: redirect_uri/login_challenge가 signin으로 전달', ( tester, ) async {