keycloak_client
Cross-platform Keycloak authentication for Flutter with:
- Authorization Code + PKCE
- mobile deep links
- desktop loopback callbacks
- web redirect callbacks
- secure credential persistence
- automatic token refresh
- auth and user streams
Install
dependencies:
keycloak_client: ^1.0.0
Quick Start
import 'package:keycloak_client/keycloak_client.dart';
final client = KeycloakClient(
config: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-client',
),
);
Initialize early:
@override
void initState() {
super.initState();
client.initialize();
}
@override
void dispose() {
client.dispose();
super.dispose();
}
Login:
await client.login();
Logout:
await client.logout();
Get a valid access token:
final token = await client.getAuthToken();
Configuration
Everything is configured through ClientConfig.
final client = KeycloakClient(
config: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-client',
scopes: const ['openid', 'profile', 'email'],
logLevel: LogLevel.info,
desktop: const DesktopConfig(),
mobile: const MobileConfig(),
web: const WebConfig(),
),
);
Main fields:
baseUrl: Keycloak server rootrealm: Keycloak realm nameclientId: OAuth client IDclientSecret: for confidential clients onlyscopes: defaults toopenid,email,profilelogLevel: package logging verbosity
Platform config defaults:
MobileConfig.redirectUri:myapp://authDesktopConfig.redirectUri:https://winchetechnologies.co.uk/tools/oauth_redirectDesktopConfig.loopbackUri:http://localhost:8765/callbackWebConfig.redirectUri:https://winchetechnologies.co.uk/tools/oauth_redirect
For desktop, these two values have different jobs:
DesktopConfig.redirectUri: the URI sent to KeycloakDesktopConfig.loopbackUri: the local URI the desktop app listens on
If loopbackUri is null, the local desktop listener binds directly to
redirectUri.
Dev Redirect Helper
Recent Keycloak versions can be awkward about using localhost as an allowed redirect URI. To make local development easier, this package ships with a public redirect endpoint by default:
https://winchetechnologies.co.uk/tools/oauth_redirect
The idea is:
- Register that public URL in Keycloak as a valid redirect URI.
- Use that public URL as
DesktopConfig.redirectUriduring desktop dev. - Keep
DesktopConfig.loopbackUrion a local address such ashttp://localhost:8765/callback. - Keycloak redirects the browser to the public helper page after login.
- That page lets the user enter their local port.
- The page forwards the full callback, including Keycloak query parameters, to the local loopback server.
This is especially convenient for:
- desktop development, where your app is listening on a local loopback URL
- web development, where you want a simple public callback during local work
Desktop default dev setup:
final client = KeycloakClient(
config: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-client',
desktop: const DesktopConfig(
redirectUri: 'https://winchetechnologies.co.uk/tools/oauth_redirect',
loopbackUri: 'http://localhost:8765/callback',
),
),
);
This desktop split is intentional:
redirectUribypasses Keycloak's localhost restrictionloopbackUriis where the desktop app actually receives the callback
Web default dev setup:
final client = KeycloakClient(
config: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-client',
web: const WebConfig(
redirectUri: 'https://winchetechnologies.co.uk/tools/oauth_redirect',
),
),
);
If you already have your own public callback page, you can replace these defaults with your own URLs.
If you are using a real endpoint and do not need the localhost workaround,
prefer setting loopbackUri to null. In that case, the desktop strategy
binds the local loopback server to redirectUri itself.
Example:
final client = KeycloakClient(
config: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-client',
desktop: const DesktopConfig(
redirectUri: 'https://your-domain.example.com/auth/callback',
loopbackUri: null,
),
),
);
Web Startup
Web login is redirect-based. Call handleWebCallback(Uri.base) on startup before rendering the app:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (kIsWeb) {
await client.handleWebCallback(Uri.base);
}
runApp(const MyApp());
}
On web, login() redirects the current tab and does not complete before navigation.
Streams
Auth state:
StreamBuilder<AuthState>(
stream: client.onAuthChange,
builder: (context, snapshot) {
final state = snapshot.data ?? AuthState.unknown;
return Text('$state');
},
);
User info:
StreamBuilder<UserInfo?>(
stream: client.onUserChange,
builder: (context, snapshot) {
final user = snapshot.data;
return Text(user?.username ?? 'No user');
},
);
Platform Setup
Keycloak
Register the correct redirect URIs in your Keycloak client.
Typical values:
- Android/iOS:
myapp://auth - Desktop dev:
https://winchetechnologies.co.uk/tools/oauth_redirect - Desktop local listener:
http://localhost:8765/callback - Web dev:
https://winchetechnologies.co.uk/tools/oauth_redirect - Web prod: your real public web callback URL
For desktop dev with the helper endpoint:
redirectUriis the public URL you register in KeycloakloopbackUriis the local listener inside your desktop app- the helper page bridges the public redirect back to the local loopback server
Android
Add the deep-link intent filter to your MainActivity in android/app/src/main/AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<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" android:host="auth"/>
</intent-filter>
</activity>
Do not register net.openid.appauth.RedirectUriReceiverActivity unless your app is actually using AppAuth. This package uses app_links for mobile deep-link callbacks, so the redirect should reopen your app activity directly.
Also ensure internet permission exists:
<uses-permission android:name="android.permission.INTERNET"/>
Your MobileConfig.redirectUri must match the scheme/host you register here.
iOS
Add your custom scheme to ios/Runner/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
Only the scheme goes in CFBundleURLSchemes. For the default
MobileConfig.redirectUri of myapp://auth, iOS registers myapp here and
your Dart config keeps the full redirect URI.
macOS / Windows / Linux
Desktop login uses a system browser plus a local HTTP listener.
Important fields:
DesktopConfig.redirectUri: the redirect URI sent to KeycloakDesktopConfig.loopbackUri: optional separate local listener URIDesktopConfig.loopbackTimeout: how long to wait for the callback
Behavior:
- if
loopbackUriis set, the app listens onloopbackUriwhile Keycloak usesredirectUri - if
loopbackUriisnull, the app listens directly onredirectUri
For local development, the easiest setup is:
- register
https://winchetechnologies.co.uk/tools/oauth_redirectin Keycloak - keep
loopbackUrion a local port likehttp://localhost:8765/callback - when the helper page opens, enter that local port so it forwards the callback back to your app
For real endpoint setups, prefer:
redirectUri: your real callback URLloopbackUri: null
That avoids splitting the two values when you do not need the localhost workaround.
Web
For local development, you can use the same public helper endpoint:
web: const WebConfig(
redirectUri: 'https://winchetechnologies.co.uk/tools/oauth_redirect',
),
For production, use your own public callback URL instead.
Always:
- register the web redirect URI in Keycloak
- call
handleWebCallback(Uri.base)on app startup
Main API
initialize(): restore any existing sessionlogin(): start authenticationhandleWebCallback(uri): resume a web redirect flowlogout(): clear session and notify Keycloak when possiblegetAuthToken(): return a valid access token ornullreloadUser(): reload profile data from/userinfoonAuthChange: stream ofAuthStateonUserChange: stream ofUserInfo?
Auth States
AuthState.unknownAuthState.signedOutAuthState.signedInAuthState.sessionExpired
Exceptions
The package throws typed exceptions:
KeycloakNetworkExceptionKeycloakServerExceptionKeycloakSessionExpiredExceptionKeycloakTimeoutException
Notes
- Call
initialize()beforelogin(),logout(), orreloadUser() - Credentials are stored with
flutter_secure_storage - User profile data comes from Keycloak's
/userinfoendpoint