diff --git a/userfront-e2e/scripts/serve-userfront-build.mjs b/userfront-e2e/scripts/serve-userfront-build.mjs index 0b8e5f85..76f52107 100644 --- a/userfront-e2e/scripts/serve-userfront-build.mjs +++ b/userfront-e2e/scripts/serve-userfront-build.mjs @@ -35,7 +35,7 @@ const contentTypes = { const server = createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); const pathname = decodeURIComponent(url.pathname); - if (pathname === '/') { + if (pathname === '/' && url.search === '') { res.statusCode = 302; res.setHeader('Location', '/ko/signin'); res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate'); diff --git a/userfront-e2e/tests/auth-routing.spec.ts b/userfront-e2e/tests/auth-routing.spec.ts index 66a3e645..fc46aa2c 100644 --- a/userfront-e2e/tests/auth-routing.spec.ts +++ b/userfront-e2e/tests/auth-routing.spec.ts @@ -252,9 +252,8 @@ test.describe('UserFront WASM auth routing', () => { await page.goto('/ko/l/AB123456'); - await expect(page).toHaveURL(/\/ko\/l\/AB123456$/); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); - await expect(page).toHaveURL(/\/ko\/l\/AB123456$/); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); expect(verifyRequests[0].path).toContain( '/api/v1/auth/login/code/verify-short', @@ -270,7 +269,7 @@ test.describe('UserFront WASM auth routing', () => { }); await page.waitForTimeout(300); - await expect(page).toHaveURL(/\/ko\/l\/AB123456$/); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); @@ -295,8 +294,8 @@ test.describe('UserFront WASM auth routing', () => { await page.goto('/ko/l/AB123456'); - await expect(page).toHaveURL(/\/ko\/l\/AB123456$/); await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); const viewport = page.viewportSize(); @@ -311,11 +310,85 @@ test.describe('UserFront WASM auth routing', () => { await page.waitForTimeout(300); expect(userMeCalls).toBe(0); - await expect(page).toHaveURL(/\/ko\/l\/AB123456$/); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); + test('루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다', async ({ + page, + }) => { + let userMeCalls = 0; + const verifyRequests: Array<{ + path: string; + body: Record; + }> = []; + const clientFailures = collectClientFailures(page); + + await mockUserfrontApis(page, { + sessionStatus: 401, + captureUserMe: () => { + userMeCalls += 1; + }, + captureVerify: (path, body) => { + verifyRequests.push({ path, body }); + }, + }); + + await page.goto( + '/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop', + ); + await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); + expect(userMeCalls).toBe(0); + expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify'); + expect(verifyRequests[0].body).toMatchObject({ + loginId: 'e2e@example.com', + code: '654321', + pendingRef: 'pending-root', + verifyOnly: true, + }); + expect(page.url()).not.toContain('loginId='); + expect(page.url()).not.toContain('code='); + expect(page.url()).not.toContain('pendingRef='); + expect(page.url()).not.toContain('utm='); + expect(clientFailures).toEqual([]); + }); + + test('로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다', async ({ + page, + }) => { + let userMeCalls = 0; + const verifyRequests: Array<{ + path: string; + body: Record; + }> = []; + const clientFailures = collectClientFailures(page); + + await mockUserfrontApis(page, { + sessionStatus: 401, + captureUserMe: () => { + userMeCalls += 1; + }, + captureVerify: (path, body) => { + verifyRequests.push({ path, body }); + }, + }); + + await page.goto('/ko/signin?loginId=e2e%40example.com&code=999999'); + await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); + expect(userMeCalls).toBe(0); + expect(verifyRequests[0].body).toMatchObject({ + loginId: 'e2e@example.com', + code: '999999', + verifyOnly: true, + }); + expect(page.url()).not.toContain('loginId='); + expect(page.url()).not.toContain('code='); + expect(clientFailures).toEqual([]); + }); + test('verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다', async ({ page, }, testInfo) => { @@ -346,8 +419,8 @@ test.describe('UserFront WASM auth routing', () => { }, popupURL); const popup = await popupPromise; - await expect(popup).toHaveURL(/\/ko\/l\/AB123456$/); await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1); + await expect(popup).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); const viewport = popup.viewportSize(); @@ -390,8 +463,8 @@ test.describe('UserFront WASM auth routing', () => { await page.goto('/ko/verify/e2e-email-token'); - await expect(page).toHaveURL(/\/ko\/verify\/e2e-email-token$/); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify'); expect(verifyRequests[0].body).toMatchObject({ @@ -411,7 +484,7 @@ test.describe('UserFront WASM auth routing', () => { await page.waitForTimeout(300); expect(userMeCalls).toBe(0); - await expect(page).toHaveURL(/\/ko\/verify\/e2e-email-token$/); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); @@ -441,10 +514,8 @@ test.describe('UserFront WASM auth routing', () => { '/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email', ); - await expect(page).toHaveURL( - /\/ko\/verify\?loginId=e2e(?:%40|@)example\.com&code=654321&pendingRef=pending-email$/, - ); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify'); expect(verifyRequests[0].body).toMatchObject({ @@ -466,9 +537,7 @@ test.describe('UserFront WASM auth routing', () => { await page.waitForTimeout(300); expect(userMeCalls).toBe(0); - await expect(page).toHaveURL( - /\/ko\/verify\?loginId=e2e(?:%40|@)example\.com&code=654321&pendingRef=pending-email$/, - ); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); diff --git a/userfront-e2e/tests/route-inventory.spec.ts b/userfront-e2e/tests/route-inventory.spec.ts index 79f7b4b2..a1b8d02a 100644 --- a/userfront-e2e/tests/route-inventory.spec.ts +++ b/userfront-e2e/tests/route-inventory.spec.ts @@ -226,6 +226,11 @@ test.describe('UserFront WASM route inventory (unauth)', () => { await expect(page).toHaveURL(/\/ko\/verification$/); }); + test('route: /ko/verify-complete', async ({ page }) => { + await page.goto('/ko/verify-complete'); + await expect(page).toHaveURL(/\/ko\/verify-complete$/); + }); + test('route: /ko/l/:shortCode', async ({ page }) => { await page.goto('/ko/l/AB123456'); await expect(page).toHaveURL(/\/ko\/l\/AB123456$/); diff --git a/userfront/lib/features/auth/domain/login_link_route_policy.dart b/userfront/lib/features/auth/domain/login_link_route_policy.dart index 0a3b1a5f..832aa89b 100644 --- a/userfront/lib/features/auth/domain/login_link_route_policy.dart +++ b/userfront/lib/features/auth/domain/login_link_route_policy.dart @@ -7,6 +7,7 @@ bool isPublicAuthPath(String path, Uri uri) { path == '/registration' || path == '/verify' || path == '/verification' || + path == '/verify-complete' || path.startsWith('/verify/') || path.startsWith('/l/') || path == '/approve' || diff --git a/userfront/lib/features/auth/domain/verification_completion_route.dart b/userfront/lib/features/auth/domain/verification_completion_route.dart new file mode 100644 index 00000000..7adf7010 --- /dev/null +++ b/userfront/lib/features/auth/domain/verification_completion_route.dart @@ -0,0 +1,67 @@ +import '../../../core/i18n/locale_utils.dart'; + +const verificationRoutePath = '/verify'; +const verificationCompletionRoutePath = '/verify-complete'; +const verificationCompletionRouteName = 'verify-complete'; + +String buildLocalizedVerificationCompletePath(String localeCode) { + return '/$localeCode$verificationCompletionRoutePath'; +} + +bool isDedicatedVerificationRoute(Uri uri) { + final path = stripLocalePath(uri); + return path == verificationRoutePath || + path == '/verification' || + path.startsWith('/verify/') || + path.startsWith('/l/'); +} + +bool hasVerificationPayload(Uri uri) { + final query = uri.queryParameters; + final token = query['t']; + final loginId = query['loginId']; + final code = query['code']; + return (token != null && token.isNotEmpty) || + (loginId != null && + loginId.isNotEmpty && + code != null && + code.isNotEmpty); +} + +String? buildDedicatedVerificationRedirect( + Uri uri, { + required String localeCode, +}) { + if (isDedicatedVerificationRoute(uri)) { + return null; + } + + final query = uri.queryParameters; + final token = query['t']; + final loginId = query['loginId']; + final code = query['code']; + final pendingRef = query['pendingRef']; + final sanitizedQuery = {}; + + if (token != null && token.isNotEmpty) { + sanitizedQuery['t'] = token; + } else if (loginId != null && + loginId.isNotEmpty && + code != null && + code.isNotEmpty) { + sanitizedQuery['loginId'] = loginId; + sanitizedQuery['code'] = code; + if (pendingRef != null && pendingRef.isNotEmpty) { + sanitizedQuery['pendingRef'] = pendingRef; + } + } + + if (sanitizedQuery.isEmpty) { + return null; + } + + return Uri( + path: '/$localeCode$verificationRoutePath', + queryParameters: sanitizedQuery, + ).toString(); +} diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 8b1a7866..5ab46fb0 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -18,6 +18,7 @@ import '../../../core/notifiers/auth_notifier.dart'; import '../domain/login_challenge_resolver.dart'; import '../domain/cookie_session_policy.dart'; import '../domain/login_link_route_policy.dart'; +import '../domain/verification_completion_route.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../../core/services/web_window.dart'; import '../../../core/ui/toast_service.dart'; @@ -26,12 +27,14 @@ class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; final String? loginChallenge; final String? redirectUrl; + final bool verificationCompleteOnly; const LoginScreen({ super.key, this.verificationToken, this.loginChallenge, this.redirectUrl, + this.verificationCompleteOnly = false, }); @override @@ -88,6 +91,7 @@ class _LoginScreenState extends ConsumerState bool _noticeHandled = false; bool _drySendEnabled = false; bool _oidcAutoAcceptTried = false; + bool _verificationHandoffStarted = false; @override void initState() { @@ -100,7 +104,8 @@ class _LoginScreenState extends ConsumerState _redirectUrl = widget.redirectUrl; _passwordFocusNode.addListener(_handlePasswordFocusChange); HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent); - _verificationOnly = _isVerificationOnlyUri(Uri.base); + _verificationOnly = + widget.verificationCompleteOnly || _isVerificationOnlyUri(Uri.base); WidgetsBinding.instance.addPostFrameCallback((_) async { final uri = Uri.base; @@ -124,12 +129,43 @@ class _LoginScreenState extends ConsumerState final pendingRefParam = uri.queryParameters['pendingRef']; final shortCodeFromPath = extractLoginShortCode(uri); final hasShortCodePath = shortCodeFromPath != null; - final hasTokenParam = uri.queryParameters.containsKey('t'); + final acceptsVerificationPayload = isDedicatedVerificationRoute(uri); + final waitsForVerificationHandoff = + !acceptsVerificationPayload && hasVerificationPayload(uri); + final hasTokenParam = + acceptsVerificationPayload && uri.queryParameters.containsKey('t'); final hasVerificationToken = widget.verificationToken != null || hasTokenParam; - final hasLoginCode = loginIdParam != null && codeParam != null; + final hasLoginCode = + acceptsVerificationPayload && + loginIdParam != null && + codeParam != null; final notice = uri.queryParameters['notice']; + if (widget.verificationCompleteOnly) { + _markVerificationApproved( + tr('msg.userfront.login.verification.approved_remote'), + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: _closeVerificationWindowIfPossible, + ); + return; + } + + if (waitsForVerificationHandoff) { + final localeCode = + extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(); + final target = buildDedicatedVerificationRedirect( + uri, + localeCode: localeCode, + ); + if (target != null && mounted) { + context.go(target); + _startVerificationHandoff(Uri.parse(target)); + } + return; + } + if (hasShortCodePath) { _verifyShortCode(shortCodeFromPath); } @@ -174,6 +210,10 @@ class _LoginScreenState extends ConsumerState } bool _isVerificationOnlyUri(Uri uri) { + if (!isDedicatedVerificationRoute(uri) && + widget.verificationToken == null) { + return false; + } final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; return widget.verificationToken != null || @@ -182,6 +222,33 @@ class _LoginScreenState extends ConsumerState extractLoginShortCode(uri) != null; } + void _startVerificationHandoff(Uri targetUri) { + if (_verificationHandoffStarted) { + return; + } + _verificationHandoffStarted = true; + if (mounted) { + setState(() { + _verificationOnly = true; + }); + } else { + _verificationOnly = true; + } + + final loginIdParam = targetUri.queryParameters['loginId']; + final codeParam = targetUri.queryParameters['code']; + final pendingRefParam = targetUri.queryParameters['pendingRef']; + final tokenParam = targetUri.queryParameters['t']; + + if (loginIdParam != null && codeParam != null) { + _verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam); + return; + } + if (tokenParam != null && tokenParam.isNotEmpty) { + _verifyToken(tokenParam); + } + } + void _handlePasswordFocusChange() { if (!mounted) { return; @@ -730,6 +797,16 @@ class _LoginScreenState extends ConsumerState return false; } + bool _moveVerificationOnlyResultToCleanRoute() { + if (!_verificationOnly || widget.verificationCompleteOnly) { + return false; + } + final localeCode = + extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode(); + context.go(buildLocalizedVerificationCompletePath(localeCode)); + return true; + } + void _markVerificationApproved( String message, { String? title, @@ -746,6 +823,9 @@ class _LoginScreenState extends ConsumerState pageTitle ?? tr('ui.userfront.login.verification.page_title'); final resolvedActionLabel = actionLabel ?? tr('ui.userfront.login.verification.action_label'); + if (_moveVerificationOnlyResultToCleanRoute()) { + return; + } setState(() { _verificationApproved = true; _verificationMessage = message; diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index c35f45f6..6c1510d4 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -16,6 +16,7 @@ import 'features/auth/presentation/forgot_password_screen.dart'; import 'features/auth/presentation/reset_password_screen.dart'; import 'features/auth/presentation/error_screen.dart'; import 'features/auth/domain/login_link_route_policy.dart'; +import 'features/auth/domain/verification_completion_route.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; import 'features/profile/presentation/pages/profile_page.dart'; @@ -154,10 +155,19 @@ Future _silentSessionRecovery() async { bool _shouldRunStartupSessionRecovery(Uri uri) { final requestedLocale = extractLocaleFromPath(uri); + final path = stripLocalePath(uri); + final verificationPayloadRedirect = buildDedicatedVerificationRedirect( + uri, + localeCode: requestedLocale ?? resolvePreferredLocaleCode(), + ); + if (verificationPayloadRedirect != null || + isDedicatedVerificationRoute(uri) || + path == verificationCompletionRoutePath) { + return false; + } if (requestedLocale == null) { return true; } - final path = stripLocalePath(uri); return !isPublicAuthPath(path, uri); } @@ -283,6 +293,18 @@ final _router = GoRouter( ); }, ), + GoRoute( + path: verificationCompletionRouteName, + builder: (context, state) { + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen( + key: state.pageKey, + verificationCompleteOnly: true, + ), + ); + }, + ), GoRoute( path: 'login', builder: (context, state) { @@ -482,6 +504,13 @@ final _router = GoRouter( final uri = state.uri; final requestedLocale = extractLocaleFromPath(uri); final preferredLocale = resolvePreferredLocaleCode(); + final verificationPayloadRedirect = buildDedicatedVerificationRedirect( + uri, + localeCode: requestedLocale ?? preferredLocale, + ); + if (verificationPayloadRedirect != null) { + return verificationPayloadRedirect; + } if (requestedLocale == null) { final localizedPath = buildLocalizedPath(preferredLocale, uri); diff --git a/userfront/nginx.conf b/userfront/nginx.conf index 44c152e5..4bc2d74c 100644 --- a/userfront/nginx.conf +++ b/userfront/nginx.conf @@ -48,7 +48,11 @@ server { # --- UserFront Static Files --- location = / { - return 302 /ko/signin; + if ($args = "") { + return 302 /ko/signin; + } + add_header Cache-Control "no-cache, max-age=0, must-revalidate"; + try_files /index.html =404; } # App shell and Flutter bootstrap files must revalidate on each deployment. diff --git a/userfront/test/verification_route_policy_test.dart b/userfront/test/verification_route_policy_test.dart new file mode 100644 index 00000000..d1d9b995 --- /dev/null +++ b/userfront/test/verification_route_policy_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/i18n/locale_registry.dart'; +import 'package:userfront/features/auth/domain/verification_completion_route.dart'; + +void main() { + setUpAll(LocaleRegistry.primeWithDefaults); + + group('verification route policy', () { + test('루트 인증 payload는 전용 verify 라우트로 정리한다', () { + final redirect = buildDedicatedVerificationRedirect( + Uri.parse( + '/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop', + ), + localeCode: 'ko', + ); + + expect( + redirect, + '/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-root', + ); + }); + + test('signin 인증 payload도 로그인 화면에서 직접 소비하지 않는다', () { + final redirect = buildDedicatedVerificationRedirect( + Uri.parse('/ko/signin?t=magic-token&utm=drop'), + localeCode: 'ko', + ); + + expect(redirect, '/ko/verify?t=magic-token'); + }); + + test('인증 payload 여부를 식별한다', () { + expect(hasVerificationPayload(Uri.parse('/?t=magic-token')), isTrue); + expect( + hasVerificationPayload( + Uri.parse('/ko/signin?loginId=e2e%40example.com&code=654321'), + ), + isTrue, + ); + expect( + hasVerificationPayload(Uri.parse('/ko/signin?code=654321')), + isFalse, + ); + }); + + test('이미 전용 verify 라우트면 다시 리다이렉트하지 않는다', () { + final redirect = buildDedicatedVerificationRedirect( + Uri.parse('/ko/verify?loginId=e2e%40example.com&code=654321'), + localeCode: 'ko', + ); + + expect(redirect, isNull); + }); + }); +}