You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter AppAuth在Android端登录后重定向异常求助

Flutter AppAuth + Keycloak Android端登录后重复打开浏览器问题排查

问题现象

自定义封装了AuthWrapper基于flutter_appauth实现认证逻辑,通过布尔流控制应用界面切换,iOS端运行正常,但Android端存在以下问题:

  • 点击登录按钮后,Chrome正常加载Keycloak登录页,输入有效凭证完成登录
  • 页面瞬间重定向回应用后,随即再次打开浏览器窗口
  • 已测试模拟器、物理设备及多版本Android系统,修改Keycloak重定向URI为单字词等方案均无效

相关代码

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>();

  TokenResponse? tokenResponse;

  bool get isTokenResponseValid =>
      tokenResponse != null &&
      tokenResponse?.accessToken != null &&
      tokenResponse?.idToken != null;

  Stream<bool> get authenticationStream => _streamController.stream;

  bool get isInitialized => _isInitialized;

  String? get idToken => tokenResponse?.idToken;

  String? get refreshToken => tokenResponse?.refreshToken;

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

    assert(_isInitialized, message);
  }

  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,
      );
    }
  }

  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,
      );
    }
  }

  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,
      );
    }
  }
}

Android build.gradle配置

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

排查与解决方向

1. 统一重定向Scheme配置

  • 确认Keycloak客户端的Valid Redirect URIs和Valid Post Logout Redirect URIs完全匹配com.io.domx://auth
  • 检查Android端appAuthRedirectScheme与redirectUri的Scheme部分(com.io.domx)完全一致,当前配置已符合,但需确保Keycloak端无拼写错误

2. 移除Ephemeral Session配置

Android端preferEphemeralSession: true可能与系统浏览器重定向逻辑冲突,尝试在login()和logout()的请求中移除该参数:

// 登录请求修改
AuthorizationTokenRequest(
  authConfig['clientId']!,
  authConfig['redirectUri']!,
  discoveryUrl: authConfig['discoveryUrl']!,
  scopes: ['openid', 'profile'],
  promptValues: ['login'],
  // 注释掉该行
  // preferEphemeralSession: true,
)

3. 修复初始化流程的流事件发送

当前initialize()方法的catch块未发送认证状态流事件,可能导致状态异常触发重复登录:

catch (error) {
  log(
    'Initialization error',
    name: 'auth_wrapper',
    error: error,
  );
  // 添加该行,确保未登录状态被正确发送
  _streamController.add(false);
  _isInitialized = true;
}

4. 手动确认AndroidManifest Intent Filter

flutter_appauth通常会自动生成Intent Filter,但可手动验证AndroidManifest.xml的MainActivity下是否存在:

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="com.io.domx" />
</intent-filter>

5. 升级flutter_appauth版本

旧版本可能存在Android端重定向bug,升级至最新稳定版尝试解决问题

内容的提问来源于stack exchange,提问作者George Kalogeropoulos

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 21:35:02