Flutter / Dart
The base_idp Dart package — mobile PKCE login for iOS and Android, with a typed session and a server-payload helper.
The Dart SDK is the mobile half of Base IdP. It opens the system browser, runs PKCE, exchanges the code for tokens, and hands you back a typed session with the verified principal. It is a public client, which means no secret is ever on device.
If you have a Flutter app talking to your own backend, you will probably want
to send the session payload to your server's exchange endpoint after login.
The SDK has a helper for that: session.toServerPayload().
Install
flutter pub add base_idpThe package supports iOS and Android. It uses
flutter_web_auth_2 under the hood to handle the in-app browser session.
Quick start
import 'package:base_idp/base_idp.dart';
class AppAuth {
AppAuth()
: _auth = BaseIdpFlutterAuth(
config: const BaseIdpConfig(
clientId: String.fromEnvironment('BASE_IDP_CLIENT_ID'),
),
redirectUri: 'myapp://auth/callback',
);
final BaseIdpFlutterAuth _auth;
Future<BaseIdpMobileSession> signIn() => _auth.login();
}Run with the client id passed at build time:
flutter run --dart-define=BASE_IDP_CLIENT_ID=sq_live_yourappThe client id is public, so --dart-define is the right tool — the value
ends up in the binary at build time, never in your source.
Configuration
BaseIdpConfig
const config = BaseIdpConfig(
clientId: 'sq_live_yourapp',
scopes: ['openid', 'profile'],
);Fields:
| Field | Type | Notes |
|---|---|---|
clientId | String | Required. Your app's client id. |
clientSecret | String? | Server-side only. Never set on device. |
redirectUri | String? | Optional override; usually passed to the auth helper. |
scopes | List<String> | Optional. The SDK resolves allowed scopes from the registration. |
BaseIdpConfig.fromEnvironment
For build-time injection:
const config = BaseIdpConfig.fromEnvironment();This reads BASE_IDP_CLIENT_ID from --dart-define at build time. Pass the
value with the same flag.
The login flow
final session = await auth.login();
print(session.principal.email);
print(session.tokens.accessToken);login():
- Generates a PKCE verifier and challenge.
- Builds the authorize URL.
- Opens the system browser session.
- Awaits the redirect to your
redirect_uri. - Exchanges the authorization code for tokens.
- Returns a
BaseIdpMobileSession.
The BaseIdpMobileSession
class BaseIdpMobileSession {
final BaseIdpTokens tokens;
final BaseIdpPrincipal principal;
Map<String, dynamic> toServerPayload();
}The tokens field is the access and refresh pair. The principal is what
the SDK extracted from the verified token. The toServerPayload() helper
returns the canonical shape your backend's exchange endpoint expects:
final payload = session.toServerPayload();
// {
// "access_token": "v4.public.eyJ...",
// "refresh_token": "rt_01HRZ...",
// "token_type": "PASETO",
// "expires_in": 3600,
// "principal": { "sub": "...", "email": "...", "name": "..." }
// }POST that to your server. The server verifies the token, upserts a user, and issues its own session.
Registering the redirect scheme
The redirect URI uses a custom scheme that the OS needs to know about. This is where most "browser opens, never returns" bugs come from — the URI in your code must match what is registered on the platform and in your Base IdP app registration.
iOS
In ios/Runner/Info.plist, add a CFBundleURLTypes entry for your scheme.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>Android
In android/app/src/main/AndroidManifest.xml, add an intent filter on your
launcher activity.
<activity
android:name=".MainActivity"
android:exported="true"
... >
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
</activity>If you do not see your scheme listed in the app's registered URL handlers when you long-press a link with it, the OS does not know about it and the callback will never reach your app.
Sending the session to your backend
The common shape:
import 'package:base_idp/base_idp.dart';
import 'package:dio/dio.dart';
class AuthController {
AuthController(this._auth, this._dio);
final BaseIdpFlutterAuth _auth;
final Dio _dio;
Future<UserProfile> signIn() async {
final session = await _auth.login();
final response = await _dio.post(
'/auth/idp-exchange',
data: session.toServerPayload(),
);
return UserProfile.fromJson(response.data['user']);
}
}Your backend can be Go, Rust, NestJS, or any verify-only service — see Frameworks for the receiving side.
Refresh
The SDK refreshes silently before the access token expires. You can also trigger a refresh manually if you need to.
final tokens = await auth.refresh(session.tokens.refreshToken);Logout
await auth.logout(session.tokens.refreshToken);This revokes the session on Base IdP. Clear any local copy of the tokens afterward.
Errors
The SDK throws BaseIdpException with a code and a message.
try {
final session = await auth.login();
} on BaseIdpException catch (e) {
switch (e.code) {
case 'invalid_config':
// Misconfigured client id or redirect URI.
break;
case 'invalid_redirect_uri':
// Redirect URI is not registered on the app.
break;
case 'browser_launch_failed':
// The OS could not open the in-app browser.
break;
case 'callback_timeout':
// The browser opened but never redirected back.
break;
default:
// Something else.
break;
}
}The codes match the OAuth2 spec where applicable. Show a useful message to the user — "Sign-in cancelled" beats "browser_launch_failed."
A complete mobile login
import 'package:base_idp/base_idp.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final authProvider = StateNotifierProvider<AuthController, AuthState>(
(ref) => AuthController(),
);
class AuthState {
AuthState({this.session, this.error});
final BaseIdpMobileSession? session;
final String? error;
}
class AuthController extends StateNotifier<AuthState> {
AuthController() : super(AuthState());
final _auth = BaseIdpFlutterAuth(
config: const BaseIdpConfig(
clientId: String.fromEnvironment('BASE_IDP_CLIENT_ID'),
),
redirectUri: 'myapp://auth/callback',
);
Future<void> signIn() async {
try {
final session = await _auth.login();
debugPrint('Signed in as ${session.principal.email}');
state = AuthState(session: session);
} on BaseIdpException catch (e) {
state = AuthState(error: e.message);
}
}
Future<void> signOut() async {
final refresh = state.session?.tokens.refreshToken;
if (refresh != null) {
await _auth.logout(refresh);
}
state = AuthState();
}
}A real auth controller, wired to Riverpod, with sign-in and sign-out. That is all the integration code most apps need.