keycloak_client 4.0.0
keycloak_client: ^4.0.0 copied to clipboard
A Flutter package for Keycloak authentication, with Authorization Code + PKCE login for users and client-credentials login for service accounts.
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