Flutter
flutter_secure_storage (토큰 저장) + local_auth (biometric) + flutter_web_auth_2 (ASWebAuthenticationSession / Custom Tabs).
Callback scheme 규칙
- Custom scheme 권장:
com.example.app://oauth/1pass/callback. Universal Link (https://) 는 무관한 UL 가 OAuth 파서에 주입돼 가짜missingCode유발 (ainote 2026-05-15). - iOS 는 underscore scheme 거부:
my_app://❌ →myapp://✅ - 진단: https://api.1pass.dev/diagnose
패턴 선택
| 시나리오 | client_type | 책임 |
|---|---|---|
| 백엔드 + Flutter (권장) | confidential | BFF — code 전달, 백엔드가 secret/JWT |
| Flutter 단독 | public | PKCE-only — 직접 토큰 교환 |
의존성
# 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+, iOS 13+, Android API 23+.
iOS Associated Domains
ios/Runner/Runner.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.example.com</string>
<string>webcredentials:app.example.com</string>
</array>https://app.example.com/.well-known/apple-app-site-association (Content-Type: application/json, no extension):
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.app",
"paths": ["/auth/logi/callback", "/auth/logi/device-callback"]
}
]
},
"webcredentials": {
"apps": ["TEAMID.com.example.app"]
}
}AASA 변경은 Apple CDN + 디바이스 캐시 ~24h. 재설치 필요.
Android App Links + custom scheme
android/app/src/main/AndroidManifest.xml:
<activity android:name=".MainActivity" ...>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.example.com" />
<data android:pathPrefix="/auth/logi/callback" />
</intent-filter>
</activity>
<!-- flutter_web_auth_2 custom scheme (필수) -->
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.example.app" />
</intent-filter>
</activity>https://app.example.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:..."]
}
}
]keytool -list -v -keystore release.keystore | grep SHA256Flow A: BFF (Confidential)
Flutter 는 PKCE 시작 + code 수령만. 토큰 교환은 백엔드.
Future<AuthResult> signInViaBff() async {
final state = _randomString(32);
final verifier = _randomString(64);
final challenge = _s256(verifier);
const redirectUri = 'com.example.app://auth/logi/callback';
final authUri = Uri.parse('https://api.1pass.dev/oauth/authorize').replace(
queryParameters: {
'response_type': 'code',
'client_id': 'logi_<your_confidential_client_id>',
'redirect_uri': redirectUri,
'scope': 'openid profile:basic email',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
},
);
// callbackUrlScheme 은 redirect_uri scheme 과 일치해야 함.
final callback = await FlutterWebAuth2.authenticate(
url: authUri.toString(),
callbackUrlScheme: 'com.example.app',
);
final cb = Uri.parse(callback);
if (cb.queryParameters['state'] != state) throw 'state mismatch';
final code = cb.queryParameters['code']!;
// 백엔드가 logi 와 토큰 교환 + 자체 JWT 발급
final resp = await http.post(
Uri.parse('https://api.example.com/api/auth/logi/exchange'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'code': code,
'code_verifier': verifier,
'redirect_uri': redirectUri, // byte-exact 비교
}),
);
final json = jsonDecode(resp.body);
return AuthResult(
accessToken: json['access_token'], // 백엔드 JWT (logi 토큰 아님)
refreshToken: json['refresh_token'],
user: json['user'],
);
}Flow B: Public client (PKCE-only)
Future<Map<String, dynamic>> signInPublic() async {
const issuer = 'https://api.1pass.dev';
const clientId = 'logi_<your_public_client_id>';
const redirectUri = 'com.example.app://auth/callback';
final state = _randomString(32);
final verifier = _randomString(64);
final challenge = _s256(verifier);
final callback = await FlutterWebAuth2.authenticate(
url: Uri.parse('$issuer/oauth/authorize').replace(queryParameters: {
'response_type': 'code',
'client_id': clientId,
'redirect_uri': redirectUri,
'scope': 'openid profile:basic email',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
}).toString(),
callbackUrlScheme: Uri.parse(redirectUri).scheme,
);
final cb = Uri.parse(callback);
if (cb.queryParameters['state'] != state) throw 'state mismatch';
// public client: client_secret 없음 (보내면 거절됨)
final tokenResp = await http.post(
Uri.parse('$issuer/oauth/token'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': cb.queryParameters['code']!,
'redirect_uri': redirectUri,
'client_id': clientId,
'code_verifier': verifier,
},
);
return jsonDecode(tokenResp.body) as Map<String, dynamic>;
}Refresh token rotation: Public Clients 문서.
Token Storage (flutter_secure_storage)
반드시 옵션 명시 — 기본값은 iOS iCloud sync 됨.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class LogiTokenStorage {
static const _refreshKey = 'logi.refresh_token';
static const _deviceSecretKey = 'logi.device_secret';
final _storage = const FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
// first_unlock_this_device: 첫 잠금해제 후 background 접근 + iCloud sync 차단
),
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
);
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<String?> loadDeviceSecret() => _storage.read(key: _deviceSecretKey);
Future<void> clearAll() => _storage.deleteAll();
}Android backup 제외 (필수):
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml" />
</full-backup-content><!-- AndroidManifest.xml -->
<application android:fullBackupContent="@xml/backup_rules" ...>Biometric 게이팅 (local_auth)
토큰 키에 직접 거는 대신 "재인증 → 토큰 사용" 2단계.
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,
),
);
}Device Bootstrap (device_secret)
- 첫 호출:
POST /api/v1/deviceswith{device_uuid, platform}→device_secret1회 노출 + PAK - 이후:
{device_uuid, platform, device_secret}→ 새 PAK. 불일치 시 401.
final resp = await http.post(
Uri.parse('$base/api/v1/devices'),
body: {'device_uuid': uuid, 'platform': 'ios'},
);
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},
);❌ SharedPreferences 에 저장 금지.
트러블슈팅
invalid_client (release 빌드)
가장 흔한 RP 통합 실수
String.fromEnvironment() 는 컴파일타임 상수. --dart-define 누락 시 defaultValue 가 AOT 에 박힘 → placeholder 가 client_id 로 전송됨.
Pattern A (권장): defaultValue 에 진짜 client_id (public PKCE 는 secret 없으므로 commit 안전):
static const String clientId = String.fromEnvironment(
'ONE_PASS_CLIENT_ID',
defaultValue: 'logi_<your_public_client_id>',
);Pattern B: fail-fast 가드:
class LogiConfig {
static const String _clientId = String.fromEnvironment(
'ONE_PASS_CLIENT_ID',
defaultValue: 'REPLACE_WITH_LOGI_CLIENT_ID',
);
static String get clientId {
if (_clientId.startsWith('REPLACE_WITH') || !_clientId.startsWith('logi_')) {
throw StateError(
'logi client_id 미주입. `--dart-define=ONE_PASS_CLIENT_ID=logi_xxx` 추가.',
);
}
return _clientId;
}
}컴파일타임 폴딩 → 런타임 env 로 못 바꿈. Makefile/CI 수정 → 재빌드 필수.
Universal Link 미가로채기
- AASA Content-Type 이
application/json아님 (CDN 경유 시 흔함) - AASA 에 BOM/extra bytes —
file -i apple-app-site-association로 확인 paths에/auth/logi/callback미포함 + 재설치- iOS Sim 은 AASA 신뢰성 낮음 → 실기기/TestFlight 검증
invalid_grant: redirect_uri mismatch
authorize 와 token 호출의 redirect_uri 가 byte-exact 일치해야 함. 한 변수에 보관.
invalid_client: client credentials missing (public client)
등록 시 client_type=public 미명시 → confidential 분류. /developer/applications/<id> 확인, 변경 불가 시 새 RP 등록.
Custom scheme 클릭 시 브라우저로
AndroidManifest 에 flutter_web_auth_2.CallbackActivity 인텐트 필터 누락. §Android 섹션 블록 추가.
Sim 정상, 실기기 비정상
iOS Sim 의 UL 가로채기 한계. 실기기/TestFlight. CI 는 xcrun simctl openurl 로 deep link 강제.
점검 체크리스트
- [ ]
IOSOptions에KeychainAccessibility.first_unlock_this_device - [ ]
backup_rules.xml에FlutterSecureStorage.xml제외 - [ ] AndroidManifest 에
android:fullBackupContent - [ ] PKCE S256 + state 검증
- [ ]
callbackUrlScheme이redirect_urischeme 과 일치 - [ ] biometric 은
local_auth별도 게이트 - [ ]
device_secret과 refresh token 분리 저장 - [ ] client_id 가
logi_prefix 로 시작하는지 앱 시작 시 assert