Flutter

flutter_appauth wraps Google's AppAuth SDKs — the canonical OAuth client for native iOS and Android. PKCE, browser tabs, deep-link handling, and silent refresh are all handled by the underlying native code.

Use the Native application type in the developer portal — public client, no secret. Mobile apps cannot keep secrets safely.

Install

# pubspec.yaml
dependencies:
  flutter_appauth: ^12.0.0
  flutter_secure_storage: ^10.1.0

These are the current major versions on pub.dev as of writing — flutter_appauth 12 needs Flutter 3.38+ / Dart 3.10+, iOS 13 minimum, and Android minSdkVersion 24. The platform bumps are non-optional; see below.

Platform configuration

iOS

flutter_appauth 12 and flutter_secure_storage 10 both require iOS 13. The Podfile shipped by flutter create targets iOS 12, which builds but fails at link time with cryptic "Unsupported deployment target" errors.

# ios/Podfile
platform :ios, '13.0'

Android

// android/app/build.gradle
android {
  defaultConfig {
    minSdkVersion 24
    manifestPlaceholders = [appAuthRedirectScheme: 'is.kenni.example']
  }
}

The AppAuth Android plugin reads manifestPlaceholders[appAuthRedirectScheme] at manifest-merge time — it's how Android registers your custom URL scheme. Use a reverse-DNS form (is.kenni.example) — myapp collides with every other tutorial app on the device.

iOS URL scheme

<!-- ios/Runner/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>is.kenni.example</string>
    </array>
  </dict>
</array>

Register two URIs in the developer portal — is.kenni.example://callback as a Redirect URI and is.kenni.example://post-logout as a Post logout redirect URI. They're separate lists with separate lifecycles.

Configuration

The idiomatic Flutter pattern is --dart-define-from-file (stable since Flutter 3.7), with values surfaced via String.fromEnvironment. No runtime .env parser, no extra dependency.

// dart_defines.json (gitignored)
{
  "KENNI_ISSUER": "https://idp.kenni.is/<team-domain>",
  "KENNI_CLIENT_ID": "@<team-domain>/native",
  "KENNI_REDIRECT_URI": "is.kenni.example://callback",
  "KENNI_POST_LOGOUT_REDIRECT_URI": "is.kenni.example://post-logout"
}
// lib/src/config.dart
class KenniConfig {
  static const issuer = String.fromEnvironment('KENNI_ISSUER');
  static const clientId = String.fromEnvironment('KENNI_CLIENT_ID');
  static const redirectUrl = String.fromEnvironment('KENNI_REDIRECT_URI');
  static const postLogoutRedirectUrl =
      String.fromEnvironment('KENNI_POST_LOGOUT_REDIRECT_URI');

  static String get discoveryUrl => '$issuer/.well-known/openid-configuration';

  static void assertConfigured() {
    if (issuer.isEmpty || clientId.isEmpty || redirectUrl.isEmpty || postLogoutRedirectUrl.isEmpty) {
      throw StateError(
        'Missing KENNI_* dart-defines. Run with --dart-define-from-file=dart_defines.json',
      );
    }
  }
}

Run with:

flutter run --dart-define-from-file=dart_defines.json

Sign-in flow

import 'dart:convert';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'src/config.dart';

final FlutterAppAuth _appAuth = const FlutterAppAuth();
final FlutterSecureStorage _storage = const FlutterSecureStorage();

Future<AuthorizationTokenResponse?> signIn({bool forceReauth = false}) async {
  try {
    return await _appAuth.authorizeAndExchangeCode(
      AuthorizationTokenRequest(
        KenniConfig.clientId,
        KenniConfig.redirectUrl,
        discoveryUrl: KenniConfig.discoveryUrl,
        scopes: const ['openid', 'profile', 'national_id', 'offline_access'],
        promptValues: forceReauth ? const ['login'] : null,
      ),
    );
  } on FlutterAppAuthUserCancelledException {
    // User dismissed the in-app browser — not an error to surface.
    return null;
  }
}

const FlutterAppAuth() is the right constructor — current versions are const-constructable, and the prefer_const_constructors lint (default-on with flutter_lints) flags the non-const form.

authorizeAndExchangeCode performs the authorization request, code exchange, and PKCE in a single call. The returned response contains accessToken, idToken, refreshToken, and accessTokenExpirationDateTime. FlutterAppAuthUserCancelledException fires when the user dismisses the in-app browser — catch it, don't let it crash to Crashlytics/Sentry as if it were a bug.

Storing tokens

Persist tokens on sign-in and round-trip them on launch — without this, every relaunch bounces the user back through Kenni even when their Kenni session is still valid.

const _tokensKey = 'kenni.tokens.v1';

Future<void> persist(AuthorizationTokenResponse res) async {
  await _storage.write(
    key: _tokensKey,
    value: jsonEncode({
      'accessToken': res.accessToken,
      'idToken': res.idToken,
      'refreshToken': res.refreshToken,
      'expiresAt': res.accessTokenExpirationDateTime?.toIso8601String(),
    }),
  );
}

Future<Map<String, dynamic>?> loadTokens() async {
  final raw = await _storage.read(key: _tokensKey);
  return raw == null ? null : jsonDecode(raw) as Map<String, dynamic>;
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  KenniConfig.assertConfigured();
  final tokens = await loadTokens(); // pre-fill auth state before runApp
  runApp(MyApp(initialTokens: tokens));
}

flutter_secure_storage uses Keychain on iOS and EncryptedSharedPreferences on Android. Don't store tokens in SharedPreferences directly.

Showing the user's claims

Decode the id_token payload to display the user's name — the canonical "prove sign-in worked" UI.

Map<String, dynamic> decodeJwtPayload(String token) {
  final parts = token.split('.');
  final padded = base64Url.normalize(parts[1]);
  return jsonDecode(utf8.decode(base64Url.decode(padded))) as Map<String, dynamic>;
}

// In a widget:
final claims = decodeJwtPayload(idToken);
Text('Welcome ${claims['name'] ?? claims['sub']}');

Refresh

Future<TokenResponse?> refresh(String refreshToken) {
  return _appAuth.token(
    TokenRequest(
      KenniConfig.clientId,
      KenniConfig.redirectUrl,
      discoveryUrl: KenniConfig.discoveryUrl,
      refreshToken: refreshToken,
      grantType: 'refresh_token',
    ),
  );
}

Sign out

RP-initiated logout has rough UX on iOS that Apple doesn't let you fix. flutter_appauth.endSession() uses ASWebAuthenticationSession under the hood, which shows a hardcoded "<App> wants to use 'kenni.is' to sign in" consent prompt — even on a sign-out flow. The wording is not configurable. Opening Safari directly via url_launcher only trades it for the OS-level "Open this page in '<App>'?" confirmation when Kenni redirects back to the custom scheme. The only clean fix is Universal Links: a real HTTPS domain you control + an apple-app-site-association file + the applinks: entitlement.

What real iOS apps ship: skip RP-initiated logout entirely on iOS. Clear local tokens and force prompt=login on the next sign-in via AuthorizationTokenRequest.promptValues = ['login']. Android Chrome Custom Tabs have neither prompt and the full flow is clean.

import 'dart:io' show Platform;

Future<void> signOut({required String idToken, String? refreshToken}) async {
  // Clear local state first — a failed RP-initiated logout still leaves you signed out locally.
  await _storage.deleteAll();

  if (Platform.isIOS) {
    // Skip endSession on iOS. Set a flag so the next signIn passes promptValues: ['login'].
    return;
  }

  try {
    await _appAuth.endSession(
      EndSessionRequest(
        idTokenHint: idToken,
        postLogoutRedirectUrl: KenniConfig.postLogoutRedirectUrl,
        discoveryUrl: KenniConfig.discoveryUrl,
      ),
    );
  } on FlutterAppAuthUserCancelledException {
    // Already cleared locally, the user dismissing the browser is fine.
  }
}

postLogoutRedirectUrl must be a different URI from your sign-in redirectUrl so the OS deep-link layer can tell sign-in completion apart from sign-out completion — register both separately in the developer portal.

See the No framework (curl) guide for the protocol-level reasoning.

Next steps