MaikuB / flutter_appauth

A Flutter wrapper for AppAuth iOS and Android SDKs
269 stars 238 forks source link

Android login redirect issue #503

Closed georgekal2798 closed 3 weeks ago

georgekal2798 commented 1 month ago

Hello! 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). 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!

VilleKaha commented 3 weeks ago

I'm facing similar issue with the default example code that is provided with flutter_appauth repo on an android device. error that can be observed from runnning the dev build with vscode is W/AppAuth (19428): No stored state - unable to handle response after logging in.

leutbounpaseuth commented 3 weeks ago

Same problem here.

W/AppAuth ( 4437): No stored state - unable to handle response
W/tions.scripting( 4437): Cleared Reference was only reachable from finalizer (only reported once)
I/flutter ( 4437): PlatformException(authorize_and_exchange_code_failed, Failed to authorize: [error: null, description: User cancelled flow], null, null)
voulgarakis commented 3 weeks ago

same problem for me too for Android. any updates?

leutbounpaseuth commented 3 weeks ago

My new solution finally works, I compared it to another old solution and found that the new AndroidManifest.xml contains an empty property : android:taskAffinity=""

I remove it and everything works fine.

georgekal2798 commented 3 weeks ago

@leutbounpaseuth thanks for sharing! It works! The question now is why does it work?

CameliaTriponHRS commented 1 week ago

@leutbounpaseuth thank you very much. After hours of searching and trying different solutions, this is the thing that finally worked !!