diff --git a/docs/auto-login-policy-p0-mobile-installed-webapp.md b/docs/auto-login-policy-p0-mobile-installed-webapp.md new file mode 100644 index 00000000..2fa56abc --- /dev/null +++ b/docs/auto-login-policy-p0-mobile-installed-webapp.md @@ -0,0 +1,79 @@ +# Auto Login Policy (P0: Mobile Installed Browser App) + +## 1. 목적 +- 모바일 환경에서 브라우저 앱 설치(standalone/PWA, 홈 화면 실행)를 **최우선 경로(P0)** 로 정의합니다. +- 사용자 관점에서 앱 재실행/새로고침/짧은 백그라운드 복귀 시 재로그인을 최소화합니다. +- 기존 IDP abstraction 원칙을 유지하면서 자동 로그인 동작을 표준화합니다. + +## 2. 범위 +- 포함: + - UserFront 웹 실행 컨텍스트(설치형 앱, 일반 모바일 브라우저 탭, 데스크톱 브라우저) + - 백엔드 `/api/v1/auth/*`, `/api/v1/user/me` 기반 세션 확인/유지 정책 +- 제외: + - 네이티브 Flutter 앱 바이너리 영속 저장소 상세 설계 + - Ory 내부 스택 교체/확장 설계 + +## 3. 플랫폼 우선순위 +1. P0: 모바일 브라우저 앱 설치(standalone/PWA) +2. P1: 모바일 브라우저 탭(Safari/Chrome 일반 탭) +3. P2: 데스크톱 브라우저 +4. P3: 네이티브 Flutter 앱 + +## 4. 세션 정책 + +### 4.1 기본 원칙 +- 자동 로그인 판단은 `cookie-first`로 수행합니다. +- token은 보조 수단이며, cookie 세션 확인 성공 시 cookie mode를 우선 적용합니다. +- 사용자 액션 기반 슬라이딩(30일)은 서버를 SoT로 유지합니다. + +### 4.2 저장소 fallback 정책 (웹) +- 인증 보조 상태 저장소는 아래 순서를 따릅니다. + - `localStorage -> sessionStorage -> memory` +- 저장소 접근 실패(브라우저 정책/권한/프라이버시 모드) 시 다음 저장소로 자동 fallback 합니다. + +### 4.3 금지사항 +- Front에서 특정 IDP 벤더 SDK를 직접 연동해 세션 로직을 구현하지 않습니다. +- 자동 로그인 유지 로직을 클라이언트 로컬 타이머/임의 만료 계산만으로 확정하지 않습니다. + - 만료 판단은 반드시 서버 응답(예: `/api/v1/user/me`) 기준으로 확정합니다. + +## 5. 수용기준 (Acceptance Criteria) +1. 설치형 모바일 브라우저 앱에서 로그인 후 앱 재실행 시 `signin`으로 튕기지 않고 보호 화면으로 진입합니다. +2. 최근 30일 내 활동이 있으면 세션 만료가 연장됩니다. +3. 30일 초과 무활동이면 세션 만료(401) 후 로그인 화면으로 이동합니다. +4. OIDC `login_challenge` 플로우에서 자동 승인/리다이렉트 회귀가 없어야 합니다. +5. cookie 세션과 token 세션이 충돌하지 않아야 합니다. + +## 6. 테스트 정책 (Failing Test First) + +### 6.1 단위 테스트 +- 저장소 fallback 정책: + - local 실패 시 session에서 read/write 가능해야 합니다. + - clear 시 local/session/memory가 함께 정리되어야 합니다. +- cookie 승격 정책: + - 일반 로그인/`login_challenge` 유무에 따른 승격 조건 회귀를 방지합니다. + +### 6.2 E2E 테스트 (WASM) +- 현재 한계: + - `userfront-e2e` 기본 설정은 `Desktop Chrome` + `serviceWorkers: block`입니다. + - 설치형 앱 시나리오를 직접 재현하기 어렵습니다. +- 보완 방향: + - 모바일 viewport 프로젝트 추가 + - service worker 허용 시나리오 추가 + - standalone 실행 가정 시나리오 추가 + +### 6.3 실기기 검증 (필수) +- Android A2HS, iOS 홈 화면 앱 각각에서 최소 1회 검증합니다. +- 이유: + - 브라우저 엔진/OS별 저장소/쿠키 정책 차이를 Playwright만으로 완전 대체할 수 없습니다. + +## 7. 구현 체크리스트 +- [ ] 웹 저장소 fallback 정책 구현/테스트 +- [ ] cookie-first 자동 로그인 경로 회귀 테스트 +- [ ] 슬라이딩 30일 서버 정책 테스트(단위 + E2E) +- [ ] 실기기 검증 결과를 `docs/test-plan/*`에 기록 + +## 8. 관련 문서 +- `docs/AGENTS.md` +- `docs/auth-flow.md` +- `docs/consent_loop_fix_report.md` +- `docs/test-plan.md` diff --git a/userfront-e2e/playwright.config.ts b/userfront-e2e/playwright.config.ts index fcc38967..d755d944 100644 --- a/userfront-e2e/playwright.config.ts +++ b/userfront-e2e/playwright.config.ts @@ -21,13 +21,20 @@ export default defineConfig({ screenshot: 'only-on-failure', video: 'retain-on-failure', locale: process.env.LOCALE ?? 'ko-KR', - serviceWorkers: 'block', }, projects: [ { - name: 'chromium', + name: 'chromium-desktop', use: { ...devices['Desktop Chrome'], + serviceWorkers: 'block', + }, + }, + { + name: 'chromium-mobile-webapp', + use: { + ...devices['Pixel 7'], + serviceWorkers: 'allow', }, }, ], diff --git a/userfront-e2e/tests/auth-routing.spec.ts b/userfront-e2e/tests/auth-routing.spec.ts index a68f6684..79e4a0b3 100644 --- a/userfront-e2e/tests/auth-routing.spec.ts +++ b/userfront-e2e/tests/auth-routing.spec.ts @@ -14,6 +14,19 @@ async function seedTokenLogin(page: Page): Promise { }); } +async function seedSessionTokenLogin(page: Page): Promise { + await page.addInitScript(() => { + window.sessionStorage.setItem('baron_auth_token', 'e30.e30.e30'); + window.sessionStorage.setItem('baron_auth_provider', 'ory'); + window.sessionStorage.removeItem('baron_auth_cookie_mode'); + window.sessionStorage.removeItem('baron_auth_pending_provider'); + window.localStorage.removeItem('baron_auth_token'); + window.localStorage.removeItem('baron_auth_provider'); + window.localStorage.removeItem('baron_auth_cookie_mode'); + window.localStorage.removeItem('baron_auth_pending_provider'); + }); +} + async function mockUserfrontApis( page: Page, options: MockOptions = {}, @@ -125,6 +138,16 @@ test.describe('UserFront WASM auth routing', () => { await expect(page).toHaveURL(/\/ko\/dashboard$/); }); + test('sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다', async ({ + page, + }) => { + await seedSessionTokenLogin(page); + await mockUserfrontApis(page); + + await page.goto('/ko'); + await expect(page).toHaveURL(/\/ko\/dashboard$/); + }); + test('비로그인 /ko/approve 는 signin(+notice)으로 이동한다', async ({ page }) => { await mockUserfrontApis(page, { sessionStatus: 401 }); diff --git a/userfront/lib/core/services/auth_token_store_backend.dart b/userfront/lib/core/services/auth_token_store_backend.dart new file mode 100644 index 00000000..fcd132e2 --- /dev/null +++ b/userfront/lib/core/services/auth_token_store_backend.dart @@ -0,0 +1,113 @@ +abstract class AuthTokenStorageTarget { + String? read(String key); + void write(String key, String value); + void remove(String key); +} + +class AuthTokenStoreBackend { + AuthTokenStoreBackend({ + required AuthTokenStorageTarget localTarget, + required AuthTokenStorageTarget sessionTarget, + }) : _targets = [ + localTarget, + sessionTarget, + _MemoryStorageTarget(), + ]; + + static const _tokenKey = 'baron_auth_token'; + static const _providerKey = 'baron_auth_provider'; + static const _cookieModeKey = 'baron_auth_cookie_mode'; + static const _pendingProviderKey = 'baron_auth_pending_provider'; + + final List _targets; + + String? getToken() => _readFirst(_tokenKey); + + String? getProvider() => _readFirst(_providerKey); + + bool usesCookie() => _readFirst(_cookieModeKey) == '1'; + + void setToken(String token, {String? provider}) { + _writeAll(_tokenKey, token); + _removeAll(_cookieModeKey); + if (provider != null) { + _writeAll(_providerKey, provider); + } + } + + void setCookieMode({String? provider}) { + _writeAll(_cookieModeKey, '1'); + _removeAll(_tokenKey); + if (provider != null) { + _writeAll(_providerKey, provider); + } + } + + String? getPendingProvider() => _readFirst(_pendingProviderKey); + + void setPendingProvider(String? provider) { + if (provider == null || provider.isEmpty) { + _removeAll(_pendingProviderKey); + return; + } + _writeAll(_pendingProviderKey, provider); + } + + void clear() { + _removeAll(_tokenKey); + _removeAll(_providerKey); + _removeAll(_cookieModeKey); + _removeAll(_pendingProviderKey); + } + + String? _readFirst(String key) { + for (final target in _targets) { + try { + final value = target.read(key); + if (value != null && value.isNotEmpty) { + return value; + } + } catch (_) { + continue; + } + } + return null; + } + + void _writeAll(String key, String value) { + for (final target in _targets) { + try { + target.write(key, value); + } catch (_) { + continue; + } + } + } + + void _removeAll(String key) { + for (final target in _targets) { + try { + target.remove(key); + } catch (_) { + continue; + } + } + } +} + +class _MemoryStorageTarget implements AuthTokenStorageTarget { + final Map _memory = {}; + + @override + String? read(String key) => _memory[key]; + + @override + void remove(String key) { + _memory.remove(key); + } + + @override + void write(String key, String value) { + _memory[key] = value; + } +} diff --git a/userfront/lib/core/services/auth_token_store_web.dart b/userfront/lib/core/services/auth_token_store_web.dart index 0f828e7a..b2e3d9ac 100644 --- a/userfront/lib/core/services/auth_token_store_web.dart +++ b/userfront/lib/core/services/auth_token_store_web.dart @@ -1,10 +1,14 @@ // ignore_for_file: avoid_web_libraries_in_flutter import 'dart:js_interop'; +import 'auth_token_store_backend.dart'; @JS('window.localStorage') external _JSStorage get _localStorage; +@JS('window.sessionStorage') +external _JSStorage get _sessionStorage; + @JS() extension type _JSStorage(JSObject _) implements JSObject { external String? getItem(String key); @@ -12,83 +16,32 @@ extension type _JSStorage(JSObject _) implements JSObject { external void removeItem(String key); } -class AuthTokenStore { - static const _tokenKey = 'baron_auth_token'; - static const _providerKey = 'baron_auth_provider'; - static const _cookieModeKey = 'baron_auth_cookie_mode'; - static const _pendingProviderKey = 'baron_auth_pending_provider'; +class AuthTokenStore extends AuthTokenStoreBackend { + AuthTokenStore() + : super( + localTarget: _JsStorageTarget(_localStorage), + sessionTarget: _JsStorageTarget(_sessionStorage), + ); +} - String? getToken() { - try { - return _localStorage.getItem(_tokenKey); - } catch (_) { - return null; - } +class _JsStorageTarget implements AuthTokenStorageTarget { + _JsStorageTarget(this._storage); + + final _JSStorage _storage; + + @override + String? read(String key) { + return _storage.getItem(key); } - String? getProvider() { - try { - return _localStorage.getItem(_providerKey); - } catch (_) { - return null; - } + @override + void remove(String key) { + _storage.removeItem(key); } - bool usesCookie() { - try { - return _localStorage.getItem(_cookieModeKey) == '1'; - } catch (_) { - return false; - } - } - - void setToken(String token, {String? provider}) { - try { - _localStorage.setItem(_tokenKey, token); - _localStorage.removeItem(_cookieModeKey); - if (provider != null) { - _localStorage.setItem(_providerKey, provider); - } - } catch (e) { - // ignore - } - } - - void setCookieMode({String? provider}) { - try { - _localStorage.setItem(_cookieModeKey, '1'); - _localStorage.removeItem(_tokenKey); - if (provider != null) { - _localStorage.setItem(_providerKey, provider); - } - } catch (_) {} - } - - String? getPendingProvider() { - try { - return _localStorage.getItem(_pendingProviderKey); - } catch (_) { - return null; - } - } - - void setPendingProvider(String? provider) { - try { - if (provider == null || provider.isEmpty) { - _localStorage.removeItem(_pendingProviderKey); - return; - } - _localStorage.setItem(_pendingProviderKey, provider); - } catch (_) {} - } - - void clear() { - try { - _localStorage.removeItem(_tokenKey); - _localStorage.removeItem(_providerKey); - _localStorage.removeItem(_cookieModeKey); - _localStorage.removeItem(_pendingProviderKey); - } catch (_) {} + @override + void write(String key, String value) { + _storage.setItem(key, value); } } diff --git a/userfront/test/auth_token_store_backend_test.dart b/userfront/test/auth_token_store_backend_test.dart new file mode 100644 index 00000000..8a2b2940 --- /dev/null +++ b/userfront/test/auth_token_store_backend_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/auth_token_store_backend.dart'; + +void main() { + group('AuthTokenStoreBackend', () { + test('local 저장소가 실패하면 session 저장소에서 토큰을 읽는다', () { + final local = _FakeTarget(throwsOnRead: true); + final session = _FakeTarget(readSeed: {'baron_auth_token': 'session-jwt'}); + final store = AuthTokenStoreBackend( + localTarget: local, + sessionTarget: session, + ); + + expect(store.getToken(), 'session-jwt'); + }); + + test('local 저장소가 실패하면 session 저장소에 토큰을 저장한다', () { + final local = _FakeTarget(throwsOnWrite: true); + final session = _FakeTarget(); + final store = AuthTokenStoreBackend( + localTarget: local, + sessionTarget: session, + ); + + store.setToken('new-token', provider: 'ory'); + + expect(session.read('baron_auth_token'), 'new-token'); + expect(session.read('baron_auth_provider'), 'ory'); + }); + + test('clear 호출 시 local/session/memory 모두 정리된다', () { + final local = _FakeTarget( + readSeed: { + 'baron_auth_token': 'local-token', + 'baron_auth_provider': 'ory', + }, + ); + final session = _FakeTarget( + readSeed: { + 'baron_auth_token': 'session-token', + 'baron_auth_provider': 'ory', + 'baron_auth_cookie_mode': '1', + }, + ); + final store = AuthTokenStoreBackend( + localTarget: local, + sessionTarget: session, + ); + + store.clear(); + + expect(local.read('baron_auth_token'), isNull); + expect(local.read('baron_auth_provider'), isNull); + expect(session.read('baron_auth_token'), isNull); + expect(session.read('baron_auth_provider'), isNull); + expect(session.read('baron_auth_cookie_mode'), isNull); + expect(store.getToken(), isNull); + expect(store.getProvider(), isNull); + expect(store.usesCookie(), isFalse); + }); + }); +} + +class _FakeTarget implements AuthTokenStorageTarget { + _FakeTarget({ + this.throwsOnRead = false, + this.throwsOnWrite = false, + Map? readSeed, + }) : _data = {...?readSeed}; + + final bool throwsOnRead; + final bool throwsOnWrite; + final Map _data; + + @override + String? read(String key) { + if (throwsOnRead) { + throw Exception('read failed'); + } + return _data[key]; + } + + @override + void remove(String key) { + _data.remove(key); + } + + @override + void write(String key, String value) { + if (throwsOnWrite) { + throw Exception('write failed'); + } + _data[key] = value; + } +}