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