forked from baron/baron-sso
fix(auth): add sessionStorage fallback for web auto-login
- add shared token store backend with local/session/memory fallback - cover fallback behavior with flutter unit tests - add wasm e2e coverage for sessionStorage login state - document mobile installed webapp auto-login policy
This commit is contained in:
79
docs/auto-login-policy-p0-mobile-installed-webapp.md
Normal file
79
docs/auto-login-policy-p0-mobile-installed-webapp.md
Normal file
@@ -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`
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -14,6 +14,19 @@ async function seedTokenLogin(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function seedSessionTokenLogin(page: Page): Promise<void> {
|
||||
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 });
|
||||
|
||||
|
||||
113
userfront/lib/core/services/auth_token_store_backend.dart
Normal file
113
userfront/lib/core/services/auth_token_store_backend.dart
Normal file
@@ -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<AuthTokenStorageTarget> _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<String, String> _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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
95
userfront/test/auth_token_store_backend_test.dart
Normal file
95
userfront/test/auth_token_store_backend_test.dart
Normal file
@@ -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<String, String>? readSeed,
|
||||
}) : _data = {...?readSeed};
|
||||
|
||||
final bool throwsOnRead;
|
||||
final bool throwsOnWrite;
|
||||
final Map<String, String> _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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user