keycloak_client

v4.0.0

Cross-platform Keycloak authentication for Flutter: Authorization Code + PKCE for users, client credentials for service accounts, deep links, desktop loopback, and web callbacks.

Install

flutter pub add keycloak_client

Cross-platform Keycloak authentication for Flutter with:

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:

Platform config defaults:

For desktop, these two values have different jobs:

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.

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'}},
)

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:

  1. Register that public URL in Keycloak as a valid redirect URI.
  2. Use that public URL as DesktopConfig.redirectUri during desktop dev.
  3. Keep DesktopConfig.loopbackUri on a local address such as http://localhost:8765/callback.
  4. Keycloak redirects the browser to the public helper page after login.
  5. That page lets the you enter your loopback uri.
  6. 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:

For desktop dev with the helper endpoint:

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:

The app always listens on loopbackUri while Keycloak redirects to redirectUri. For local development:

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:

Main API

Auth States

Exceptions

The package throws typed exceptions:

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