Skip to content

Flutter

flutter_secure_storage (token storage) + local_auth (biometric) + flutter_web_auth_2 (ASWebAuthenticationSession / Custom Tabs).

Callback scheme rules

  • Prefer a custom scheme: com.example.app://oauth/1pass/callback. With a Universal Link (https://), an unrelated UL can be injected into the OAuth parser and cause a spurious missingCode (ainote, 2026-05-15).
  • iOS rejects underscore schemes: my_app:// ❌ → myapp://
  • Diagnostics: https://api.1pass.dev/diagnose

Choosing a pattern

Scenarioclient_typeResponsibility
Backend + Flutter (recommended)confidentialBFF — passes the code; the backend handles secret/JWT
Flutter standalonepublicPKCE-only — exchanges tokens directly

Details: Public vs Confidential.

Dependencies

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

Requirements: 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"]
  }
}

An AASA change takes ~24h to propagate through Apple's CDN + device cache. A reinstall is required.

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 (required) -->
<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 only starts PKCE and receives the code. The token exchange happens on the backend.

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 must match the 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']!;

  // The backend exchanges the code with logi and issues its own 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 comparison
    }),
  );
  final json = jsonDecode(resp.body);
  return AuthResult(
    accessToken: json['access_token'],   // backend JWT (not the logi token)
    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: no client_secret (rejected if sent)
  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 docs.

Token Storage (flutter_secure_storage)

Always specify the options — the default syncs to iCloud on iOS.

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 access after first unlock + blocks 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 exclusion (required):

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 gating (local_auth)

Instead of binding biometric directly to the token key, use a two-step "re-authenticate → use token" approach.

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: 'Authenticate to delete your account',
    options: const AuthenticationOptions(
      biometricOnly: true,
      stickyAuth: true,
    ),
  );
}

Device Bootstrap (device_secret)

  1. First call: POST /api/v1/devices with {device_uuid, platform}; device_secret is revealed once, and a PAK is issued.
  2. Afterward: send {device_uuid, platform, device_secret} to receive a new PAK. 401 on mismatch.
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']);
}

// later, refreshing the PAK
final secret = await tokenStorage.loadDeviceSecret();  // null → re-login via OAuth
await http.post(
  Uri.parse('$base/api/v1/devices'),
  body: {'device_uuid': uuid, 'platform': 'ios', 'device_secret': secret},
);

❌ Do not store it in SharedPreferences.

Troubleshooting

invalid_client (release build)

The most common RP integration mistake

String.fromEnvironment() is a compile-time constant. If --dart-define is missing, the defaultValue is baked into the AOT build → the placeholder is sent as the client_id.

Pattern A (recommended): put the real client_id in defaultValue (a public PKCE client has no secret, so it is safe to commit):

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

Pattern B: a fail-fast guard:

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 not injected. Add `--dart-define=ONE_PASS_CLIENT_ID=logi_xxx`.',
      );
    }
    return _clientId;
  }
}

The value is folded at compile time → it cannot be changed via runtime env. You must edit the Makefile/CI and rebuild.

  1. The AASA Content-Type is not application/json (common when served through a CDN)
  2. The AASA has a BOM or extra bytes — check with file -i apple-app-site-association
  3. /auth/logi/callback is not in paths, plus a reinstall
  4. The iOS Simulator has poor AASA reliability → verify on a real device / TestFlight

invalid_grant: redirect_uri mismatch

The redirect_uri in the authorize and token calls must be a byte-exact match. Keep it in a single variable.

invalid_client: client credentials missing (public client)

client_type=public was not specified at registration → classified as confidential. Check /developer/applications/<id>; if it cannot be changed, register a new RP.

Custom scheme opens the browser on tap

The flutter_web_auth_2.CallbackActivity intent filter is missing from AndroidManifest. Add the block from the Android section above.

Works on the Simulator, fails on a real device

The iOS Simulator has limited UL interception. Use a real device / TestFlight. In CI, force the deep link with xcrun simctl openurl.

Review checklist

  • [ ] KeychainAccessibility.first_unlock_this_device in IOSOptions
  • [ ] FlutterSecureStorage.xml excluded in backup_rules.xml
  • [ ] android:fullBackupContent in AndroidManifest
  • [ ] PKCE S256 + state verification
  • [ ] callbackUrlScheme matches the redirect_uri scheme
  • [ ] biometric gated separately via local_auth
  • [ ] device_secret and refresh token stored separately
  • [ ] assert at app startup that the client_id starts with the logi_ prefix

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