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
- typed read of the user's configured authentication methods (password, OTP, WebAuthn)
Install
dependencies:
keycloak_client: ^2.1.0
Quick Start
import 'package:keycloak_client/keycloak_client.dart';
final client = KeycloakClient(
clientConfig: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-client',
),
// optional — only needed to override defaults
mobileConfig: MobileConfig(redirectUri: 'myapp://auth'),
desktopConfig: DesktopConfig(loopbackUri: Uri.parse('http://localhost:8765/callback')),
webConfig: WebConfig(redirectUri: 'https://example.com/auth/callback'),
);
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:
try {
final token = await client.getAuthToken();
if (token == null) {
// No active session — show login screen
} else {
// Use token in Authorization header
}
} on KeycloakNetworkException {
// Session is valid but device is offline — show offline banner
}
Configuration
Everything is configured through ClientConfig and optional per-platform config objects.
final client = KeycloakClient(
clientConfig: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-client',
),
// optional platform overrides
mobileConfig: MobileConfig(redirectUri: 'myapp://auth'),
desktopConfig: DesktopConfig(loopbackUri: Uri.parse('http://localhost:8765/callback')),
webConfig: WebConfig(redirectUri: 'https://example.com/auth/callback'),
);
ClientConfig fields:
baseUrl: Keycloak server rootrealm: Keycloak realm nameclientId: OAuth client IDclientSecret: for confidential clients; required withGrantType.clientCredentialsscopes: defaults toopenid,email,profile. Includeoffline_accessfor a long-lived session — the client detects it here and stores the refresh token with no local expiryrefreshTokenLifetime: how long a refresh token is assumed to last — defaults to 30 days.package:oauth2drops the server'srefresh_expires_in, so this is assumed rather than read; set it to match your realm's SSO Session Max. Ignored foroffline_accesssessionsrefreshTimeout: HTTP timeout for each token refresh attempt — defaults toDuration(seconds: 15). Lower for faster offline detection; raise for high-latency deployments.grantType:GrantType.authorizationCode(default, browser login) orGrantType.clientCredentials(service account, see below)requiredRealmRoles/requiredClientRoles: roles the principal must hold, see Roles
Platform config defaults:
MobileConfig.redirectUri:myapp://authMobileConfig.preferEphemeral:true— a private auth session: no iOS "wants to use … to Sign In" prompt and no cookies shared with the browser. Setfalsefor single sign-on with the browser's Keycloak sessionDesktopConfig.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
Service Accounts
To authenticate as the client's own Keycloak service account instead of a
user, set grantType:
final client = KeycloakClient(
clientConfig: ClientConfig(
baseUrl: 'https://auth.example.com',
realm: 'my-realm',
clientId: 'my-backend',
clientSecret: '...',
grantType: GrantType.clientCredentials,
),
);
await client.login(); // no browser
final token = await client.getAuthToken(); // renewed automatically
In Keycloak, turn on Client authentication and Service accounts roles for the client.
login()fetches a token with the client ID and secret. When the token expires, a new one is fetched the same way; there is no refresh token.currentUseris Keycloak'sservice-account-<clientId>user.logout()only clears the local session.manageAccount(),getAccountCredentials()andhandleWebCallback()throwUnsupportedError.- If Keycloak rejects the secret during renewal, the session ends as
AuthState.sessionExpired.
Never ship a client secret in a public mobile, web or desktop build. Anyone can extract it. Use this mode only on machines you control.
Roles
client.roles exposes the principal's Keycloak roles, read from the access
token (realm_access, resource_access). It is null while signed out and
updates on every refresh.
client.roles?.hasRealmRole('staff');
client.roles?.hasClientRole('my-client', 'editor');
To admit only principals with certain roles:
ClientConfig(
...,
requiredRealmRoles: {'staff'},
requiredClientRoles: {'my-client': {'editor'}},
)
login()without them ends the Keycloak session and throwsKeycloakAccessDeniedException(e.missinglists what was lacking).- A restored session without them, or one whose role is revoked while signed
in, ends as
AuthState.accessDenied. refreshToken()throwsKeycloakAccessDeniedExceptioninstead if a required role is lost on a refresh it triggered directly.
This is a UX guard. The token's signature isn't checked, and anyone can modify an app, so your API must enforce roles itself. To block the login in Keycloak itself, add a Condition – user role + Deny access step to the browser flow.
Custom Login and Storage
Every dependency can be replaced through the public constructor:
final client = KeycloakClient(
clientConfig: config,
credentialsStorage: MyStore(), // default: SecureStorageAuthCredentialsStore
mobileLoginStrategy: MyMobileStrategy(), // implements IMobileLoginStrategy
);
Only the strategy for the platform the app runs on is used. A custom strategy
can reuse generateCodeVerifier() and generateState() for PKCE, and
SecureStorageAuthCredentialsStore is exported so it can be wrapped or
reused.
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 you enter your loopback uri.
- The page forwards the full callback, including Keycloak query parameters, to the local loopback server.
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.
Account Credentials
Read the authentication methods the current user has configured (password, OTP, WebAuthn, …) from Keycloak's account REST API:
final credentials = await client.getAccountCredentials();
Results are returned as a sealed AccountCredential family. Pattern match to access type-specific fields:
for (final credential in credentials) {
switch (credential) {
case PasswordCredential():
print('Password: ${credential.isConfigured ? 'set' : 'not set'}');
case OtpCredential(:final instances):
for (final otp in instances) {
print('OTP: ${otp.userLabel} (${otp.subType.name}, ${otp.digits} digits)');
}
case WebAuthnCredential(:final instances):
for (final key in instances) {
print('WebAuthn: ${key.userLabel} (aaguid=${key.aaguid})');
}
case UnknownCredential():
// Realm-specific or future credential provider — inspect raw `credentialData`.
print('${credential.type}: ${credential.instanceCount} configured');
}
}
Each AccountCredential exposes type, category, displayName, instanceCount, and isConfigured. Per-type instance models carry the common id / userLabel / createdDate plus the fields shown above (OTP subType/digits/period/algorithm, WebAuthn aaguid). UnknownCredential carries the raw credentialData map so realm-specific or future providers don't break parsing.
Account credentials are queried on demand and not cached — the source of truth is Keycloak. If your UI needs offline-first reads, memoize the result in your app.
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');
},
);
Token rotation — for connections authenticated once at dial time:
client.onTokenRefreshed.listen((_) async {
final token = await client.getAuthToken();
socket.redial(token); // the old token is about to expire
});
Unlike the two streams above, this one does not replay on listen: a rotation is
an event, not a state. It stays silent while signed out, including during the
refresh initialize() performs on a cold start with an expired access token.
Logging
The client logs through package:logging under the logger name
KeycloakClient. Nothing is printed unless your app installs a listener, so
verbosity is yours to set — there is no package-level log option:
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((r) => debugPrint('${r.level.name}: ${r.message}'));
To keep the rest of your app quiet, listen on the package's logger alone and
set hierarchicalLoggingEnabled = true first:
hierarchicalLoggingEnabled = true;
Logger('KeycloakClient')
..level = Level.INFO
..onRecord.listen((r) => debugPrint('${r.level.name}: ${r.message}'));
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
Mobile login runs in an Auth Tab / Custom Tab via
flutter_web_auth_2. Register
its callback activity for your redirect scheme in
android/app/src/main/AndroidManifest.xml, inside <application>:
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true"
android:taskAffinity="">
<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="myapp"/>
</intent-filter>
</activity>
Remove any VIEW intent filter for this scheme from MainActivity; two
activities claiming it makes Android ask the user which one to open. Also set
android:taskAffinity="" on MainActivity, as flutter_web_auth_2
recommends. flutter_web_auth_2 compiles against Android SDK 36, so your app
may need compileSdk = 36.
Also ensure internet permission exists:
<uses-permission android:name="android.permission.INTERNET"/>
For an https redirect (App Links), use https as the scheme in the intent
filter with your host, and set MobileConfig.redirectUri to the full URL.
Add android:autoVerify="true" to the intent filter so Android verifies the
app as the handler for that host, and give the data element the host and
path instead of a custom scheme:
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true"
android:taskAffinity="">
<intent-filter android:autoVerify="true" 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="https" android:host="app.example.com" android:path="/auth/callback"/>
</intent-filter>
</activity>
The host (app.example.com above) must serve
/.well-known/assetlinks.json declaring this app, or Android can't verify
the link: instead of the Auth Tab matching it, the user sees a disambiguation
chooser, or the redirect never comes back to the app at all.
iOS
Mobile login runs in an ASWebAuthenticationSession sheet over your app, and
it matches the redirect scheme itself. A custom scheme such as
myapp://auth needs no Info.plist entry. An https redirect (universal
link) requires iOS 17.4 or later and an associated domain.
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: the local URI the desktop app listens on (default:http://localhost:8765/callback)DesktopConfig.loopbackTimeout: how long to wait for the callback
The app always listens on loopbackUri while Keycloak redirects to redirectUri. For local development:
- 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
Linux build requirement: flutter_web_auth_2 is a single all-platform
plugin; its desktop side depends on desktop_webview_window, whose CMake
requires the WebKitGTK and libsoup development packages to be installed even
though desktop login here doesn't use flutter_web_auth_2. On Debian/Ubuntu:
sudo apt install libwebkit2gtk-4.1-dev libsoup-3.0-dev
(Older distros may need libwebkit2gtk-4.0-dev / libsoup2.4-dev instead.)
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,nullif no session, or throwKeycloakNetworkExceptionif offline with a valid sessionrefreshToken(): force an immediate token refresh and user profile reload regardless of token expiry — useful after account management or role changes; throwsKeycloakNetworkExceptionif offline, orKeycloakSessionExpiredExceptionif the session is deadreloadUser(): reload profile data from/userinfogetAccountCredentials(): list the user's configured authentication methods as a sealedAccountCredentialfamilymanageAccount(): open Keycloak account console in external browserroles: the principal's realm and client roles,nullwhile signed outonAuthChange: stream ofAuthStateonUserChange: stream ofUserInfo?onTokenRefreshed: fires after every successful refresh while signed in; does not replay on listen
Auth States
AuthState.unknownAuthState.signedOutAuthState.signedInAuthState.sessionExpiredAuthState.accessDenied
Exceptions
The package throws typed exceptions:
KeycloakNetworkException— the server could not be reached, or a browser/listener could not be started. RetryableKeycloakServerException— a non-2xx response, or an IdPerrorin the login callback other thanaccess_deniedKeycloakSessionExpiredException— thrown byrefreshToken()when the session is permanently dead and the user must sign in againKeycloakTimeoutException— desktop only: the user never came back from the browser, or a web grant aged pastpendingGrantTTLKeycloakAccessDeniedException— thrown bylogin(),handleWebCallback()andrefreshToken()when the principal lacks a required role; the session was already ended
A cancelled login is not an exception: login() and handleWebCallback()
return normally when the IdP reports access_denied, and login() also
returns normally when a mobile auth session is dismissed.
Notes
- Call
initialize()beforelogin(),logout(), orreloadUser() - Credentials are stored with
flutter_secure_storageby default (SecureStorageAuthCredentialsStore); passcredentialsStorageto change it - User profile data comes from Keycloak's
/userinfoendpoint