Flutter
flutter_secure_storage로 양 플랫폼 토큰 저장, local_auth로 biometric 게이팅, flutter_web_auth_2로 ASWebAuthenticationSession / Custom Tabs 통합 OAuth + PKCE.
의존성
yaml
# pubspec.yaml
dependencies:
flutter_secure_storage: ^10.0.0
local_auth: ^2.3.0
flutter_web_auth_2: ^4.0.0
crypto: ^3.0.5최소 버전 요구
- Flutter 3.16+ (flutter_secure_storage 10.x 요구사항)
- iOS 13+, Android API 23+ (biometric 사용 시)
OAuth + PKCE 플로우
dart
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
class LogiOAuth {
static const _base = 'https://logi.example.com';
static const _clientId = 'logi_...';
static const _redirectScheme = 'com.example.myapp';
static const _redirectUri = '$_redirectScheme://callback';
String _b64url(List<int> bytes) =>
base64UrlEncode(bytes).replaceAll('=', '');
Future<({String accessToken, String refreshToken})> signIn() async {
// 1. PKCE
final rng = Random.secure();
final verifier = _b64url(List.generate(32, (_) => rng.nextInt(256)));
final challenge = _b64url(sha256.convert(utf8.encode(verifier)).bytes);
final state = _b64url(List.generate(16, (_) => rng.nextInt(256)));
// 2. authorize URL
final authUrl = Uri.parse('$_base/oauth/authorize').replace(queryParameters: {
'client_id': _clientId,
'redirect_uri': _redirectUri,
'response_type': 'code',
'scope': 'profile email',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
});
// 3. ASWebAuthSession (iOS) / Custom Tabs (Android)
final result = await FlutterWebAuth2.authenticate(
url: authUrl.toString(),
callbackUrlScheme: _redirectScheme,
);
final params = Uri.parse(result).queryParameters;
if (params['state'] != state) {
throw Exception('CSRF: state mismatch');
}
// 4. token exchange
final resp = await http.post(
Uri.parse('$_base/oauth/token'),
body: {
'grant_type': 'authorization_code',
'code': params['code']!,
'redirect_uri': _redirectUri,
'code_verifier': verifier,
'client_id': _clientId,
},
);
final json = jsonDecode(resp.body);
return (
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
);
}
}Refresh Token 저장 (flutter_secure_storage)
플랫폼 위임이 자동이지만 반드시 옵션을 명시하세요. 기본값은 안전하지 않습니다.
dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class LogiTokenStorage {
static const _refreshKey = 'logi.refresh_token';
static const _deviceSecretKey = 'logi.device_secret';
// ⚠️ 옵션 명시 — 기본값으로는 iOS에서 iCloud sync됨
final _storage = const FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
// ↑ first_unlock_this_device:
// - 첫 잠금해제 후 background에서 접근 가능 (refresh에 적합)
// - ThisDeviceOnly → iCloud sync 차단
),
aOptions: AndroidOptions(
encryptedSharedPreferences: true, // EncryptedSharedPreferences 백엔드 강제
// 라이브러리 내부적으로 Tink 사용. EncryptedSharedPreferences는 Jetpack의
// 그것이 아니라 flutter_secure_storage 자체 구현이라 deprecation 영향 없음.
),
);
Future<void> saveRefreshToken(String token) =>
_storage.write(key: _refreshKey, value: token);
Future<String?> loadRefreshToken() => _storage.read(key: _refreshKey);
Future<void> saveDeviceSecret(String secret) =>
_storage.write(key: _deviceSecretKey, value: secret);
Future<void> clearAll() => _storage.deleteAll();
}⚠️ Android backup 제외 설정 (필수)
flutter_secure_storage는 backup 제외를 자동 적용하지 않습니다. 직접 manifest와 backup rules를 설정해야 복원 시 토큰 무효화 사고를 막을 수 있습니다:
xml
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml" />
</full-backup-content>xml
<!-- android/app/src/main/AndroidManifest.xml -->
<application android:fullBackupContent="@xml/backup_rules" ...>민감 작업에 biometric 추가 (local_auth)
flutter_secure_storage 자체는 biometric 게이팅을 일관되게 제공하지 않습니다. high-assurance 액션 직전에 local_auth로 인증한 뒤 토큰을 사용하세요:
dart
import 'package:local_auth/local_auth.dart';
final _auth = LocalAuthentication();
Future<bool> authenticateForSensitiveAction() async {
final canCheck = await _auth.canCheckBiometrics;
final isSupported = await _auth.isDeviceSupported();
if (!canCheck || !isSupported) return false;
return await _auth.authenticate(
localizedReason: '계정 삭제를 진행하려면 인증해주세요',
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
);
}
// 사용
if (await authenticateForSensitiveAction()) {
final token = await tokenStorage.loadRefreshToken();
// ... high-assurance API 호출
}두 패키지 역할 분리
flutter_secure_storage→ 토큰 자체의 보관local_auth→ 사용자 재인증 게이트
biometric을 토큰 키 자체에 거는 패턴은 Flutter에서 양 플랫폼 일관성이 떨어지므로, "인증 → 토큰 사용" 2단계로 분리하는 것이 현실적입니다.
Device Bootstrap (device_secret)
logi의 device bootstrap은 dual mode입니다:
- 첫 호출 (bootstrap):
POST /api/v1/deviceswith{device_uuid, platform}→ 응답에device_secret1회 노출 + PAK 발급 - 이후 호출 (refresh): 같은 endpoint에
{device_uuid, platform, device_secret}→ 서버가 digest 검증 후 새 PAK 발급. 누락/불일치 시 401.
device_secret을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다.
dart
// 첫 호출 응답 처리
final resp = await http.post(
Uri.parse('$base/api/v1/devices'),
body: {'device_uuid': uuid, 'platform': 'ios'}, // 또는 'android'
);
final json = jsonDecode(resp.body);
if (json['device_secret'] != null) {
await tokenStorage.saveDeviceSecret(json['device_secret']);
}
// 이후 PAK 갱신 시
final secret = await tokenStorage.loadDeviceSecret(); // null이면 OAuth 재로그인
await http.post(
Uri.parse('$base/api/v1/devices'),
body: {'device_uuid': uuid, 'platform': 'ios', 'device_secret': secret},
);flutter_secure_storage로 동일하게 저장하되 별도 key 사용:
dart
await tokenStorage.saveDeviceSecret(deviceSecret);❌ SharedPreferences(shared_preferences 패키지)에 절대 저장하지 마세요.
점검 체크리스트
- [ ]
IOSOptions에KeychainAccessibility.first_unlock_this_device명시 - [ ]
backup_rules.xml에FlutterSecureStorage.xml제외 명시 - [ ] AndroidManifest에
android:fullBackupContent적용 - [ ] PKCE S256 + state 검증
- [ ]
flutter_web_auth_2의 callback scheme이redirect_uri와 일치 - [ ] biometric은
local_auth로 별도 게이트 (토큰 키에 거는 대신) - [ ]
device_secret과 refresh token 분리 저장