Skip to content

Flutter

Source: en/integrations/flutter.md · Live: https://docs.1pass.dev/en/integrations/flutter LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).

📍 Jump-to Index

  • L38-L46: ## Choosing a pattern
  • L47-L101: ## SDK quick start (recommended, Public client)
  • L102-L114: ## Dependencies (for manual integration)
  • L115-L147: ## iOS Associated Domains
  • L148-L193: ## Android App Links + custom scheme
  • L194-L244: ## Flow A: BFF (Confidential)
  • L245-L292: ## Flow B: Public client (PKCE-only) — manual (without the SDK)
  • L293-L337: ## Token Storage (flutter_secure_storage)
  • L338-L361: ## Biometric gating (local_auth)
  • L362-L386: ## Device Bootstrap (device_secret)
  • L387-L442: ## Troubleshooting
    • L389-L424: ### invalid_client (release build)
    • L425-L430: ### Universal Link not intercepted
    • L431-L433: ### invalid_grant: redirect_uri mismatch
    • L434-L436: ### invalid_client: client credentials missing (public client)
    • L437-L439: ### Custom scheme opens the browser on tap
    • L440-L442: ### Works on the Simulator, fails on a real device
  • L443-L453: ## Review checklist

Flutter

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

⚠️ Warning: 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.

The official Flutter SDK logi_auth bundles PKCE, nonce, and id_token signature verification (RS256, pointycastle) — giving public clients a defense layer without a backend; with a backend/BFF, the server re-verifying is the SoT for audience checks (see the BFF section below). You only inject the browser handoff via LogiAuthBrowser (a flutter_web_auth_2 adapter below). The SDK path is the recommended route; the Flow B: Public client manual code below is a fallback.

yaml
# pubspec.yaml
dependencies:
  logi_auth: ^1.0.1
  flutter_web_auth_2: ^4.0.0   # browser handoff adapter
dart
import 'package:logi_auth/logi_auth.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';

// LogiAuthBrowser implementation — opens the system browser, captures the redirect
class WebAuthBrowser extends LogiAuthBrowser {
  @override
  Future<Uri> authorize(Uri authorizeUrl, {required String callbackScheme}) async {
    final result = await FlutterWebAuth2.authenticate(
      url: authorizeUrl.toString(),
      callbackUrlScheme: callbackScheme,
    );
    return Uri.parse(result);
  }
}

final auth = LogiAuth(
  config: const LogiAuthConfig(
    clientId: 'logi_<your_public_client_id>',
    redirectUri: 'com.example.app://oauth/1pass/callback',
    // issuer defaults to https://api.1pass.dev · tokenIssuer defaults to "https://api.1pass.dev" (the id_token iss)
  ),
  browser: WebAuthBrowser(),
);

Future<void> signIn() async {
  try {
    final session = await auth.signIn();   // -> LogiSession
    // session.sub  — verified subject (after RS256 signature + iss/aud/azp/exp/nonce)
    // session.idToken / session.accessToken / session.refreshToken / session.email
  } on LogiAuthException catch (e) {
    // e.code: LogiAuthErrorCode (userCancelled, idTokenInvalid, jwksFetchFailed, ...)
  }
}

Since v1.0.1, signIn() wires the token response's access_token into id_token verification to confirm the at_hash binding (access_token substitution defense) before returning the session. Standalone id_token verification is verifyIdToken(idToken, jwks, expected, accessToken: ...) — pass accessToken to also verify at_hash when the id_token carries it (present-only, contract base64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes])). Token storage (flutter_secure_storage), biometric, and device bootstrap sections below still apply.

💡 Tip: id_token verification order (built into the SDK — identical across all SDKs) alg==RS256kid→JWKS → signature (RS256) → iss (=tokenIssuer "https://api.1pass.dev") → aud (must contain your client_id) → azp (required only when aud is multi-valued; value=client_id)exp (60s skew) → iatnonce (when requested) → subat_hash (present-only, v1.0.1). azp is conditional on a multi-audience token, not unconditional. at_hash (v1.0.1): when the id_token carries at_hash, it is verified as base64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes]) (OIDC §3.1.3.6) to bind access_token↔id_token (rejected with atHashMismatch). signIn() wires the access_token automatically; standalone is verifyIdToken(idToken, jwks, expected, accessToken:). v1.0.1 selects the signing key with a kty=="RSA" filter, robust to EC-key mix-in (JWKS).

⚠️ Warning: BFF (Confidential) is verified server-side The SDK above bundles id_token verification for a public client (Flutter standalone). With the BFF pattern, the backend performs the token exchange and verification — use Flow A: BFF below and verify on the server with @logi-auth/server · logi_auth gem · logi-auth pip. That server-side check is the SoT for audience verification.

Dependencies (for manual integration)

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>

<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) — manual (without the SDK)

A fallback for when you can't use the SDK quick start above. If you roll it by hand, you must also verify the id_token yourself or verify the access token via JWKS.

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

<full-backup-content>
  <exclude domain="sharedpref" path="FlutterSecureStorage.xml" />
</full-backup-content>
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)

🚨 Danger: 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가 제품의 신뢰를 만듭니다.