androidflutterdartappauth

flutter_appauth redirect after login does not work properly on Android


I'm facing an issue with flutter_appauth. I have created a custom wrapper in a fresh demo app, similar to the one that keycloak_wrapper offers. This simplified version of the wrapper provides the MainApp class with a bool stream to conditionally render the screen's content. The auth provider I am trying to use is Keycloak.

When I tap the Login button, the chrome browser loads the login page correctly. The credentials I provide are valid. However, when I try to login, the page redirects me back to the app for a split second and opens up the browser window again, as you can see in the video: auth_bug.webm

I have tried the alternative in the guides and changing the Keycloak provider's settings to different values (i.e. single word for redirect uri). I have also tried running on emulator and physical device, running different versions of Android. They all have the same result. I cannot figure out how to make this work. Considering I have followed the guide and this is a fresh app and it works normally on iOS, it seems like a bug. Please let me know if there is something I have missed.

// lib/main.dart
import 'package:auth/auth/wrapper.dart';
import 'package:flutter/material.dart';

final authWrapper = AuthWrapper();

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  authWrapper.initialize();

  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: StreamBuilder<bool>(
            stream: authWrapper.authenticationStream,
            builder: (context, snapshot) {
              final isLoggedIn = snapshot.data ?? false;

              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(isLoggedIn ? 'Hello World!' : 'Welcome!'),
                  ElevatedButton(
                    onPressed:
                        isLoggedIn ? authWrapper.logout : authWrapper.login,
                    child: Text(isLoggedIn ? 'Logout' : 'Login'),
                  )
                ],
              );
            },
          ),
        ),
      ),
    );
  }
}
// lib/auth/wrapper.dart
import 'dart:async';
import 'dart:developer';

import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const _appAuth = FlutterAppAuth();
const _secureStorage = FlutterSecureStorage();

const Map<String, String> authConfig = {
  'clientId': 'domx_mobile_app',
  'redirectUri': 'com.io.domx://auth',
  'discoveryUrl': 'https://sso.domx-dev.com/auth/realms/domx'
      '/.well-known/openid-configuration',
};

class AuthWrapper {
  factory AuthWrapper() => _instance ??= AuthWrapper._();

  AuthWrapper._();

  static AuthWrapper? _instance = AuthWrapper._();

  bool _isInitialized = false;

  late final _streamController = StreamController<bool>();

  /// The details from making a successful token exchange.
  TokenResponse? tokenResponse;

  /// Checks the validity of the token response.
  bool get isTokenResponseValid =>
      tokenResponse != null &&
      tokenResponse?.accessToken != null &&
      tokenResponse?.idToken != null;

  /// The stream of the user authentication state.
  ///
  /// Returns true if the user is currently logged in.
  Stream<bool> get authenticationStream => _streamController.stream;

  /// Whether this package has been initialized.
  bool get isInitialized => _isInitialized;

  /// Returns the id token string.
  ///
  /// To get the payload, do `jwtDecode(KeycloakWrapper().idToken)`.
  String? get idToken => tokenResponse?.idToken;

  /// Returns the refresh token string.
  ///
  /// To get the payload, do `jwtDecode(KeycloakWrapper().refreshToken)`.
  String? get refreshToken => tokenResponse?.refreshToken;

  void _assert() {
    const message =
        'Make sure the package has been initialized prior to calling this method.';

    assert(_isInitialized, message);
  }

  /// Initializes the user authentication state and refresh token.
  Future<void> initialize() async {
    try {
      final securedRefreshToken = await _secureStorage.read(
        key: 'refreshToken',
      );

      if (securedRefreshToken == null) {
        log('No refresh token is stored');
        _streamController.add(false);
      } else {
        tokenResponse = await _appAuth.token(
          TokenRequest(
            authConfig['clientId']!,
            authConfig['redirectUri']!,
            discoveryUrl: authConfig['discoveryUrl']!,
            refreshToken: securedRefreshToken,
          ),
        );

        await _secureStorage.write(
          key: 'refreshToken',
          value: refreshToken,
        );

        _streamController.add(isTokenResponseValid);
      }

      _isInitialized = true;
    } catch (error) {
      log(
        'Initialization error',
        name: 'auth_wrapper',
        error: error,
      );
    }
  }

  /// Logs the user in.
  Future<void> login() async {
    _assert();

    try {
      tokenResponse = await _appAuth.authorizeAndExchangeCode(
        AuthorizationTokenRequest(
          authConfig['clientId']!,
          authConfig['redirectUri']!,
          discoveryUrl: authConfig['discoveryUrl']!,
          scopes: ['openid', 'profile'],
          promptValues: ['login'],
          preferEphemeralSession: true,
        ),
      );

      if (isTokenResponseValid && refreshToken != null) {
        await _secureStorage.write(
          key: 'refreshToken',
          value: tokenResponse!.refreshToken,
        );
      } else {
        log('Invalid token response.');
      }

      _streamController.add(isTokenResponseValid);
    } catch (error) {
      log(
        'Login error',
        name: 'auth_wrapper',
        error: error,
      );
    }
  }

  /// Logs the user out.
  Future<void> logout() async {
    _assert();

    try {
      await _appAuth.endSession(EndSessionRequest(
        idTokenHint: idToken,
        discoveryUrl: authConfig['discoveryUrl']!,
        postLogoutRedirectUrl: authConfig['redirectUri']!,
        preferEphemeralSession: true,
      ));

      await _secureStorage.delete(key: 'refreshToken');
      _streamController.add(false);
    } catch (error) {
      log(
        'Login error',
        name: 'auth_wrapper',
        error: error,
      );
    }
  }
}

My android app's build.gradle is configured according to the instructions,

//...
android {
    //...
    defaultConfig {
        applicationId = "com.example.auth"
        minSdk = flutter.minSdkVersion
        targetSdk = flutter.targetSdkVersion
        versionCode = flutterVersionCode.toInteger()
        versionName = flutterVersionName
        manifestPlaceholders += ['appAuthRedirectScheme': 'com.io.domx'] // <- Added this line
    }
    //...
}
//...

Thank you in advance!


Solution

  • For anyone that might have this problem in the future, leutbounpaseuth from a GitHub issue I posted in flutter_appauth repo, responded with a solution.

    It looks like there is a property in AndroidManifest.xml that was there by default: android:taskAffinity="". Removing this line, "magically" solves the issue.