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. - iOS rejects underscore schemes:
my_app://❌ →myapp://✅ - Diagnostics: https://api.1pass.dev/diagnose
Do not launch login in the external browser (LaunchMode.externalApplication)
Opening the authorize URL with url_launcher's LaunchMode.externalApplication makes iOS fall through to the system default browser (Safari) when the IdP app is not installed — that is an external-browser login rather than an in-app auth session, which gets rejected in iOS App Store review. externalApplication also returns true in that case, so your flutter_web_auth_2 fallback never fires (dead code).
The terminal launch surface for login must always be flutter_web_auth_2 (iOS ASWebAuthenticationSession / Android Custom Tabs). If you want a first-try app-to-app handoff into the IdP app, use LaunchMode.externalNonBrowserApplication (maps to iOS .universalLinksOnly:true); when no app handles it, a PlatformException is thrown — catch it and fall back to flutter_web_auth_2.
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.
SDK quick start (recommended, Public client)
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.
# pubspec.yaml
dependencies:
logi_auth: ^1.0.1
flutter_web_auth_2: ^4.0.0 # browser handoff adapterimport '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.
id_token verification order (built into the SDK — identical across all SDKs)
alg==RS256 → kid→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) → iat → nonce (when requested) → sub → at_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).
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)
# 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) — 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.
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 - [ ] login launch never falls through to the external system browser (no
LaunchMode.externalApplication; terminal surface isflutter_web_auth_2) - [ ] 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