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 spuriousmissingCode(ainote, 2026-05-15). - iOS rejects underscore schemes:
my_app://❌ →myapp://✅ - Diagnostics: https://api.1pass.dev/diagnose
Choosing a pattern
| Scenario | client_type | Responsibility |
|---|---|---|
| Backend + Flutter (recommended) | confidential | BFF — passes the code; the backend handles secret/JWT |
| Flutter standalone | public | PKCE-only — exchanges tokens directly |
Details: Public vs Confidential.
Dependencies
# pubspec.yaml
dependencies:
flutter_secure_storage: ^10.0.0
local_auth: ^2.3.0
flutter_web_auth_2: ^4.0.0
crypto: ^3.0.5Requirements: 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"]
}
}An AASA change takes ~24h to propagate through Apple's CDN + device cache. A reinstall is required.
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 (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:
[
{
"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 only starts PKCE and receives the code. The token exchange happens on the backend.
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)
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.
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):
<!-- 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 gating (local_auth)
Instead of binding biometric directly to the token key, use a two-step "re-authenticate → use token" approach.
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)
- First call:
POST /api/v1/deviceswith{device_uuid, platform};device_secretis revealed once, and a PAK is issued. - Afterward: send
{device_uuid, platform, device_secret}to receive a new PAK. 401 on mismatch.
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):
static const String clientId = String.fromEnvironment(
'ONE_PASS_CLIENT_ID',
defaultValue: 'logi_<your_public_client_id>',
);Pattern B: a fail-fast guard:
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.
Universal Link not intercepted
- The AASA Content-Type is not
application/json(common when served through a CDN) - The AASA has a BOM or extra bytes — check with
file -i apple-app-site-association /auth/logi/callbackis not inpaths, plus a reinstall- 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_deviceinIOSOptions - [ ]
FlutterSecureStorage.xmlexcluded inbackup_rules.xml - [ ]
android:fullBackupContentin AndroidManifest - [ ] PKCE S256 + state verification
- [ ]
callbackUrlSchemematches theredirect_urischeme - [ ] biometric gated separately via
local_auth - [ ]
device_secretand refresh token stored separately - [ ] assert at app startup that the client_id starts with the
logi_prefix