keycloak_client 4.0.0 copy "keycloak_client: ^4.0.0" to clipboard
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.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:keycloak_client/keycloak_client.dart';

part 'home.dart';

// Supplied with --dart-define so this file can be run against a real server
// without editing it:
//
//   flutter run -d windows \
//     --dart-define=KC_BASE_URL=http://localhost:8080 \
//     --dart-define=KC_REALM=winche-demo \
//     --dart-define=KC_CLIENT_ID=flutter-app \
//     --dart-define=KC_DESKTOP_REDIRECT=http://localhost:8765/callback
//
// Optional:
//
//   --dart-define=KC_REQUIRED_REALM_ROLE=staff
//       Admit only principals holding this realm role. Others are turned away
//       at login (KeycloakAccessDeniedException) or, for a restored or
//       refreshed session, land on the access-denied screen.
//   --dart-define=KC_SERVICE_ACCOUNT=true
//   --dart-define=KC_CLIENT_SECRET=backend-sa-secret
//       Sign in as the client's own service account (client-credentials
//       grant, no browser). KC_CLIENT_ID must then name a confidential client
//       with service accounts enabled. KC_CLIENT_SECRET is also passed to a
//       confidential client in browser-login mode.
//
// See example/README.md for a local Keycloak with a realm set up for all of
// these.
const _baseUrl = String.fromEnvironment(
  'KC_BASE_URL',
  defaultValue: 'your-keycloak-server',
);
const _realm = String.fromEnvironment('KC_REALM', defaultValue: 'your-realm');
const _clientId = String.fromEnvironment(
  'KC_CLIENT_ID',
  defaultValue: 'your-client-id',
);
const _clientSecret = String.fromEnvironment('KC_CLIENT_SECRET');
const _serviceAccount = bool.fromEnvironment('KC_SERVICE_ACCOUNT');
const _requiredRealmRole = String.fromEnvironment('KC_REQUIRED_REALM_ROLE');

/// Where Keycloak sends the browser back to.
///
/// Desktop defaults to a hosted page that bounces to the loopback listener,
/// because Keycloak rejects some loopback redirect URIs. Point it straight at
/// the loopback when your realm allows it, as the local test realm does.
const _desktopRedirect = String.fromEnvironment(
  'KC_DESKTOP_REDIRECT',
  defaultValue: 'https://winchetechnologies.co.uk/tools/oauth_redirect',
);
const _webRedirect = String.fromEnvironment(
  'KC_WEB_REDIRECT',
  defaultValue: 'https://winchetechnologies.co.uk/tools/oauth_redirect',
);

/// The KeycloakClient constructor rejects a service account without a secret;
/// checked up front so the app can say so instead of failing to start.
const _missingSecret = _serviceAccount && _clientSecret == '';

final client = KeycloakClient(
  clientConfig: ClientConfig(
    baseUrl: _baseUrl,
    realm: _realm,
    clientId: _clientId,
    clientSecret: _clientSecret == '' ? null : _clientSecret,
    grantType: _serviceAccount
        ? GrantType.clientCredentials
        : GrantType.authorizationCode,
    requiredRealmRoles: {if (_requiredRealmRole != '') _requiredRealmRole},
    refreshTimeout: const Duration(seconds: 3),
  ),
  desktopConfig: const DesktopConfig(redirectUri: _desktopRedirect),
  webConfig: const WebConfig(redirectUri: _webRedirect),
);

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (_missingSecret) {
    runApp(const _ConfigErrorApp());
    return;
  }

  // Web only: resolve any in-progress OAuth callback before the app renders.
  // A service account has no redirect flow (handleWebCallback would throw).
  if (kIsWeb && !_serviceAccount) {
    try {
      final resumed = await client.handleWebCallback(Uri.base);
      if (resumed) {
        debugPrint('OAuth callback completed, session restored.');
      }
    } on Exception catch (e) {
      debugPrint('OAuth callback failed: $e');
    }
  }

  runApp(const _Application());
}

final class _ConfigErrorApp extends StatelessWidget {
  const _ConfigErrorApp();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Keycloak Example',
      theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
      home: const Scaffold(
        body: Center(
          child: _ErrorTile(
            message:
                'KC_SERVICE_ACCOUNT=true needs the client secret: pass '
                '--dart-define=KC_CLIENT_SECRET=...',
          ),
        ),
      ),
    );
  }
}

final class _Application extends StatefulWidget {
  const _Application();

  @override
  State<_Application> createState() => _ApplicationState();
}

final class _ApplicationState extends State<_Application> {
  @override
  void initState() {
    super.initState();
    client.initialize();
  }

  @override
  void dispose() {
    client.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Keycloak Example',
      theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
      home: _AuthGate(client: client),
    );
  }
}

final class _AuthGate extends StatelessWidget {
  final KeycloakClient client;
  const _AuthGate({required this.client});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<AuthState>(
      stream: client.onAuthChange,
      builder: (context, snapshot) {
        final state = snapshot.data ?? AuthState.unknown;

        return switch (state) {
          AuthState.unknown => const _LoadingScreen(),
          AuthState.signedIn => _HomeScreen(client: client),
          AuthState.signedOut => _LoginScreen(client: client),
          AuthState.sessionExpired => _SessionExpiredScreen(client: client),
          AuthState.accessDenied => _AccessDeniedScreen(client: client),
        };
      },
    );
  }
}

final class _LoadingScreen extends StatelessWidget {
  const _LoadingScreen();

  @override
  Widget build(BuildContext context) {
    return const Scaffold(body: Center(child: CircularProgressIndicator()));
  }
}

/// Shows which way this build signs in, so a screenshot or a tester can tell
/// the two modes apart at a glance.
final class _ModeChip extends StatelessWidget {
  const _ModeChip();

  @override
  Widget build(BuildContext context) {
    return Chip(
      visualDensity: VisualDensity.compact,
      avatar: Icon(
        _serviceAccount ? Icons.smart_toy_outlined : Icons.open_in_browser,
        size: 18,
      ),
      label: Text(_serviceAccount ? 'Service account' : 'Browser login'),
    );
  }
}

/// "realm: staff; my-client: editor" — for the roles a
/// [KeycloakAccessDeniedException] reports as missing.
String _describeRoles(KeycloakRoles roles) {
  return [
    if (roles.realm.isNotEmpty) 'realm: ${roles.realm.join(', ')}',
    for (final MapEntry(:key, :value) in roles.client.entries)
      if (value.isNotEmpty) '$key: ${value.join(', ')}',
  ].join('; ');
}

/// Shows [message] as an error snackbar on [messenger].
///
/// Takes the messenger rather than a context because the caller has often
/// been unmounted by the time it has something to report: a denied login
/// or refresh changes the auth state, which swaps the screen it lived on.
void _showError(ScaffoldMessengerState messenger, Color color, String message) {
  messenger.showSnackBar(
    SnackBar(content: Text(message), backgroundColor: color),
  );
}

/// A sign-in button that reports what it is doing.
///
/// Login takes the user out to a browser and back, which can take a while and
/// gives the app no progress to show. Without feedback the window just sits
/// there looking idle, so people tap again — and a failure surfaces as an
/// unhandled exception rather than something readable.
final class _SignInButton extends StatefulWidget {
  final KeycloakClient client;
  final String label;
  const _SignInButton({required this.client, required this.label});

  @override
  State<_SignInButton> createState() => _SignInButtonState();
}

final class _SignInButtonState extends State<_SignInButton> {
  bool _busy = false;

  Future<void> _signIn() async {
    // Captured up front: a denied login moves the app to signedOut, which may
    // replace the screen this button is on before the error arrives.
    final messenger = ScaffoldMessenger.of(context);
    final errorColor = Theme.of(context).colorScheme.error;
    setState(() => _busy = true);
    try {
      await widget.client.login();
    } on KeycloakAccessDeniedException catch (e) {
      _showError(
        messenger,
        errorColor,
        'Access denied — missing roles: ${_describeRoles(e.missing)}',
      );
    } on Exception catch (e) {
      _showError(messenger, errorColor, 'Sign-in failed: $e');
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: 12,
      children: [
        FilledButton.icon(
          onPressed: _busy ? null : _signIn,
          icon: _busy
              ? const SizedBox(
                  width: 16,
                  height: 16,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              : Icon(_serviceAccount ? Icons.smart_toy_outlined : Icons.login),
          label: Text(widget.label),
        ),
        if (_busy)
          Text(
            _serviceAccount
                ? 'Requesting a token…'
                : 'Waiting for you to finish in the browser…',
            style: Theme.of(context).textTheme.bodySmall,
          ),
      ],
    );
  }
}

/// Mentions the required role, when one is configured, under a sign-in
/// prompt.
final class _RequiredRoleHint extends StatelessWidget {
  const _RequiredRoleHint();

  @override
  Widget build(BuildContext context) {
    if (_requiredRealmRole == '') return const SizedBox.shrink();
    return Text(
      'Requires realm role "$_requiredRealmRole"',
      style: Theme.of(context).textTheme.bodySmall,
    );
  }
}

final class _LoginScreen extends StatelessWidget {
  final KeycloakClient client;
  const _LoginScreen({required this.client});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          spacing: 16,
          children: [
            const Icon(Icons.lock_outline, size: 64, color: Colors.teal),
            Text(
              'Sign in to continue',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const _ModeChip(),
            _SignInButton(
              client: client,
              label: _serviceAccount
                  ? 'Sign in as service account'
                  : 'Sign in with Keycloak',
            ),
            const _RequiredRoleHint(),
          ],
        ),
      ),
    );
  }
}

final class _SessionExpiredScreen extends StatelessWidget {
  final KeycloakClient client;
  const _SessionExpiredScreen({required this.client});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          spacing: 16,
          children: [
            const Icon(
              Icons.timer_off_outlined,
              size: 64,
              color: Colors.orange,
            ),
            Text(
              'Your session has expired',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const Text('Please sign in again to continue.'),
            _SignInButton(client: client, label: 'Sign in again'),
          ],
        ),
      ),
    );
  }
}

/// [AuthState.accessDenied]: a restored or refreshed session turned out to
/// lack a required role, so the client ended it.
///
/// A login without the role never gets here — [KeycloakClient.login] throws
/// instead and the app stays signed out; [_SignInButton] reports that.
final class _AccessDeniedScreen extends StatelessWidget {
  final KeycloakClient client;
  const _AccessDeniedScreen({required this.client});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 420),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            spacing: 16,
            children: [
              Icon(
                Icons.gpp_bad_outlined,
                size: 64,
                color: Theme.of(context).colorScheme.error,
              ),
              Text(
                'Access denied',
                style: Theme.of(context).textTheme.titleLarge,
              ),
              Text(
                _requiredRealmRole == ''
                    ? 'This account no longer holds a role this app requires, '
                          'so you have been signed out.'
                    : 'This account does not hold the realm role '
                          '"$_requiredRealmRole" this app requires, so you '
                          'have been signed out.',
                textAlign: TextAlign.center,
              ),
              const Text(
                'Sign in with an account that has it, or ask an administrator '
                'to grant it.',
                textAlign: TextAlign.center,
              ),
              _SignInButton(
                client: client,
                label: _serviceAccount
                    ? 'Sign in as service account again'
                    : 'Sign in again',
              ),
            ],
          ),
        ),
      ),
    );
  }
}
5
likes
140
points
132
downloads

Documentation

API reference

Publisher

verified publisherwinchetechnologies.co.uk

Weekly Downloads

A Flutter package for Keycloak authentication, with Authorization Code + PKCE login for users and client-credentials login for service accounts.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

crypto, dio, flutter, flutter_secure_storage, flutter_web_auth_2, http, logging, oauth2, url_launcher, web

More

Packages that depend on keycloak_client