Skip to content

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 (권장)confidentialBFF — code 전달, 백엔드가 secret/JWT
Flutter 단독publicPKCE-only — 직접 토큰 교환

상세: Public vs Confidential.

의존성

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+, iOS 13+, Android API 23+.

iOS Associated Domains

ios/Runner/Runner.entitlements:

xml
<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):

json
{
  "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/src/main/AndroidManifest.xml:

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:

json
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["AB:CD:..."]
    }
  }
]
bash
keytool -list -v -keystore release.keystore | grep SHA256

Flow A: BFF (Confidential)

Flutter 는 PKCE 시작 + code 수령만. 토큰 교환은 백엔드.

dart
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)

dart
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 됨.

dart
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 제외 (필수):

xml
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<full-backup-content>
  <exclude domain="sharedpref" path="FlutterSecureStorage.xml" />
</full-backup-content>
xml
<!-- AndroidManifest.xml -->
<application android:fullBackupContent="@xml/backup_rules" ...>

Biometric 게이팅 (local_auth)

토큰 키에 직접 거는 대신 "재인증 → 토큰 사용" 2단계.

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,
    ),
  );
}

Device Bootstrap (device_secret)

  1. 첫 호출: POST /api/v1/devices with {device_uuid, platform}device_secret 1회 노출 + PAK
  2. 이후: {device_uuid, platform, device_secret} → 새 PAK. 불일치 시 401.
dart
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 안전):

dart
static const String clientId = String.fromEnvironment(
  'ONE_PASS_CLIENT_ID',
  defaultValue: 'logi_<your_public_client_id>',
);

Pattern B: fail-fast 가드:

dart
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 수정 → 재빌드 필수.

  1. AASA Content-Type 이 application/json 아님 (CDN 경유 시 흔함)
  2. AASA 에 BOM/extra bytes — file -i apple-app-site-association 로 확인
  3. paths/auth/logi/callback 미포함 + 재설치
  4. 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 강제.

점검 체크리스트

  • [ ] IOSOptionsKeychainAccessibility.first_unlock_this_device
  • [ ] backup_rules.xmlFlutterSecureStorage.xml 제외
  • [ ] AndroidManifest 에 android:fullBackupContent
  • [ ] PKCE S256 + state 검증
  • [ ] callbackUrlSchemeredirect_uri scheme 과 일치
  • [ ] biometric 은 local_auth 별도 게이트
  • [ ] device_secret 과 refresh token 분리 저장
  • [ ] client_id 가 logi_ prefix 로 시작하는지 앱 시작 시 assert

Identity가 제품의 신뢰를 만듭니다.