refact(oidc): manually open the browser (#15706)

* refact(oidc): manually open the browser

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): allow copying OIDC authentication links

Signed-off-by: fufesou <linlong1266@gmail.com>

* Remove unused translation in ko.rs

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): better hint on browser didn't open

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): login handle exception

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): remove unused translations

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): login handle error

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): login in flight

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(translation): move "Continue" to the end of template.rs

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): var rename

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): remove useless "open sign-in page"

Signed-off-by: fufesou <linlong1266@gmail.com>

* Remove unecessary translation contents

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): better way to show&expand the url

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(oidc): better login ui

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(oidc): discard stale auth results after cancellation

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(oidc): handle auth status query failures safely

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(oidc): prevent concurrent login operations

- reuse the active login dialog and block duplicate password submissions
- cancel only active OIDC operations when closing the dialog
- preserve authentication state until failure cancellation succeeds

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(oidc): refine login options error feedback

Preserve typed errors to hide the network tip for
HTTP failures and clarify the login-options API contract.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-08-04 12:35:04 +08:00
committed by GitHub
parent e6dd925ab0
commit a84bad4639
54 changed files with 703 additions and 186 deletions

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/user_model.dart';
@@ -11,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../common.dart';
import './dialog.dart';
import './oidc_auth_status.dart';
const kOpSvgList = [
'github',
@@ -23,6 +25,8 @@ const kOpSvgList = [
'auth0',
'microsoft'
];
const _requestingAccountAuth = 'Requesting account auth';
const _waitingAccountAuth = 'Waiting account auth';
class _OidcProviderBranding {
final String label;
@@ -90,6 +94,7 @@ class ButtonOP extends StatelessWidget {
final Color primaryColor;
final double height;
final Function() onTap;
final bool Function() canStartAuth;
const ButtonOP({
Key? key,
@@ -99,6 +104,7 @@ class ButtonOP extends StatelessWidget {
required this.primaryColor,
required this.height,
required this.onTap,
required this.canStartAuth,
}) : super(key: key);
@override
@@ -111,11 +117,10 @@ class ButtonOP extends StatelessWidget {
width: 200,
child: Obx(() => ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: curOP.value.isEmpty || curOP.value == op
? primaryColor
: Colors.grey,
backgroundColor: primaryColor,
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
onPressed:
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
child: Row(
children: [
SizedBox(
@@ -145,15 +150,120 @@ class ConfigOP {
ConfigOP({required this.op, required this.icon});
}
class _OidcAuthController {
final RxString curOP = ''.obs;
Future<void> _pendingOperation = Future<void>.value();
int _authAttempt = 0;
bool _closed = false;
final _cancelInProgress = false.obs;
bool _isCurrent(int authAttempt, String op) {
return !_closed && authAttempt == _authAttempt && curOP.value == op;
}
Future<bool> start(String op) {
if (!canStart()) {
return Future<bool>.value(false);
}
final authAttempt = ++_authAttempt;
curOP.value = op;
// Web auth must start during the original user gesture so popups are allowed.
if (isWeb) {
return _startWeb(authAttempt, op);
}
final completer = Completer<bool>();
_pendingOperation = _pendingOperation.then((_) async {
if (!_isCurrent(authAttempt, op)) {
completer.complete(false);
return;
}
try {
await bind.mainAccountAuthCancel();
if (!_isCurrent(authAttempt, op)) {
completer.complete(false);
return;
}
await bind.mainAccountAuth(op: op, rememberMe: true);
completer.complete(_isCurrent(authAttempt, op));
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
});
return completer.future;
}
Future<bool> _startWeb(int authAttempt, String op) async {
await bind.mainAccountAuth(op: op, rememberMe: true);
return _isCurrent(authAttempt, op);
}
bool canStart() {
return !_closed && !_cancelInProgress.value;
}
Future<bool> cancelCurrent(String op) {
if (!canStart() || curOP.value != op) {
return Future<bool>.value(false);
}
final authAttempt = ++_authAttempt;
final completer = Completer<bool>();
_cancelInProgress.value = true;
_pendingOperation = _pendingOperation.then((_) async {
try {
await bind.mainAccountAuthCancel();
completer.complete(_isCurrent(authAttempt, op));
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} finally {
_cancelInProgress.value = false;
}
});
return completer.future;
}
Future<void> _cancelBackend() async {
try {
await bind.mainAccountAuthCancel();
} catch (error, stackTrace) {
debugPrint('Failed to cancel account authentication $error');
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> close() async {
if (_closed) {
return;
}
final hasActiveOidcAuth =
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
_closed = true;
_authAttempt++;
curOP.value = '';
if (hasActiveOidcAuth) {
await _cancelBackend();
}
await _pendingOperation;
if (hasActiveOidcAuth) {
await _cancelBackend();
}
}
}
class WidgetOP extends StatefulWidget {
final ConfigOP config;
final RxString curOP;
final Function(Map<String, dynamic>) cbLogin;
final Future<bool> Function(String) startAuth;
final Future<bool> Function(String) cancelAuth;
final bool Function() canStartAuth;
const WidgetOP({
Key? key,
required this.config,
required this.curOP,
required this.cbLogin,
required this.startAuth,
required this.cancelAuth,
required this.canStartAuth,
}) : super(key: key);
@override
@@ -164,6 +274,8 @@ class WidgetOP extends StatefulWidget {
class _WidgetOPState extends State<WidgetOP> {
Timer? _updateTimer;
bool _isAuthStatusQueryInFlight = false;
int _authAttempt = 0;
String _stateMsg = '';
String _failedMsg = '';
String _url = '';
@@ -174,55 +286,180 @@ class _WidgetOPState extends State<WidgetOP> {
_updateTimer?.cancel();
}
_beginQueryState() {
_beginQueryState(int authAttempt) {
_updateTimer?.cancel();
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
_updateState();
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
});
}
_updateState() {
bind.mainAccountAuthResult().then((result) {
if (result.isEmpty) {
Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
if (_isAuthStatusQueryInFlight) {
return;
}
_isAuthStatusQueryInFlight = true;
try {
await query();
} finally {
_isAuthStatusQueryInFlight = false;
}
}
Future<void> _launchAuthUrl(String url) async {
try {
final launched = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!launched) {
debugPrint('Failed to open OIDC authentication URL');
}
} catch (error, stackTrace) {
debugPrint(
'Failed to open OIDC authentication URL (${error.runtimeType})');
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> _copyAuthUrl(String url) async {
try {
await Clipboard.setData(ClipboardData(text: url));
showToast(
translate('Copied'),
);
} catch (error, stackTrace) {
debugPrint(
'Failed to copy OIDC authentication URL (${error.runtimeType})');
debugPrintStack(stackTrace: stackTrace);
showToast(translate('Failed'));
}
}
void _runCurrentAuthUrlAction(
int authAttempt,
String authUrl,
Future<void> Function(String) action,
) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op ||
authUrl.isEmpty ||
_url != authUrl) {
return;
}
unawaited(action(authUrl));
}
void _invalidateAuthAttempt() {
_authAttempt++;
_url = '';
}
bool _isCurrentAuthAttempt(int authAttempt) {
return mounted &&
authAttempt == _authAttempt &&
widget.curOP.value == widget.config.op;
}
Future<void> _handleAuthFailure(
int authAttempt,
Object error,
String operation,
) async {
debugPrint('Failed to $operation $error');
if (!_isCurrentAuthAttempt(authAttempt)) {
return;
}
_updateTimer?.cancel();
setState(() => _failedMsg = 'Failed');
try {
final canceled = await widget.cancelAuth(widget.config.op);
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
return;
}
} catch (cancelError, stackTrace) {
debugPrint('Failed to cancel account authentication $cancelError');
debugPrintStack(stackTrace: stackTrace);
return;
}
setState(() {
_invalidateAuthAttempt();
widget.curOP.value = '';
});
}
Future<void> _updateState(int authAttempt) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op) {
_updateTimer?.cancel();
return Future<void>.value();
}
return bind.mainAccountAuthResult().then<void>((result) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op ||
result.isEmpty) {
return;
}
final resultMap = jsonDecode(result);
if (resultMap == null) {
return;
}
final String stateMsg = resultMap['state_msg'];
final String backendStateMsg = resultMap['state_msg'];
String failedMsg = resultMap['failed_msg'];
final String? url = resultMap['url'];
final stateMsg = backendStateMsg == _requestingAccountAuth &&
(url == null || url.isEmpty)
? _waitingAccountAuth
: backendStateMsg;
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
final authBody = resultMap['auth_body'];
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
if (_url.isEmpty && url != null && url.isNotEmpty) {
if (!urlLaunched) {
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
_url = url;
}
if (authBody != null) {
_updateTimer?.cancel();
widget.curOP.value = '';
widget.cbLogin(authBody as Map<String, dynamic>);
}
setState(() {
_stateMsg = stateMsg;
_failedMsg = failedMsg;
if (failedMsg.isNotEmpty) {
widget.curOP.value = '';
_updateTimer?.cancel();
}
});
if (authBody != null) {
_updateTimer?.cancel();
_invalidateAuthAttempt();
widget.curOP.value = '';
widget.cbLogin(authBody as Map<String, dynamic>);
return;
}
});
final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
if (!stateChanged && newUrl == null) {
return;
}
setState(() {
_stateMsg = stateMsg;
_failedMsg = failedMsg;
if (newUrl != null) {
_url = newUrl;
}
if (failedMsg.isNotEmpty) {
_invalidateAuthAttempt();
widget.curOP.value = '';
_updateTimer?.cancel();
}
});
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
unawaited(_launchAuthUrl(newUrl));
}
}).catchError(
(e) => _handleAuthFailure(
authAttempt,
e,
'query account authentication',
),
);
}
_resetState() {
_stateMsg = '';
_failedMsg = '';
_url = '';
int _resetState() {
_updateTimer?.cancel();
setState(() {
_invalidateAuthAttempt();
_stateMsg = _waitingAccountAuth;
_failedMsg = '';
});
return _authAttempt;
}
@override
@@ -235,11 +472,31 @@ class _WidgetOPState extends State<WidgetOP> {
icon: widget.config.icon,
primaryColor: str2color(widget.config.op, 0x7f),
height: 36,
canStartAuth: widget.canStartAuth,
onTap: () async {
_resetState();
widget.curOP.value = widget.config.op;
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
_beginQueryState();
if (!widget.canStartAuth()) {
return;
}
final authAttempt = _resetState();
try {
final started = await widget.startAuth(widget.config.op);
if (!started) {
return;
}
} catch (e) {
await _handleAuthFailure(
authAttempt,
e,
'start account authentication',
);
return;
}
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op) {
return;
}
_beginQueryState(authAttempt);
},
),
Obx(() {
@@ -247,6 +504,8 @@ class _WidgetOPState extends State<WidgetOP> {
widget.curOP.value != widget.config.op) {
_failedMsg = '';
}
final authAttempt = _authAttempt;
final authUrl = _url;
return Offstage(
offstage:
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
@@ -256,11 +515,20 @@ class _WidgetOPState extends State<WidgetOP> {
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SelectableText(
translate(_stateMsg),
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: 12),
child: OidcAuthStatus(
message: translate(_stateMsg),
browserFallbackPrompt: translate(
"Browser didn't open? Use the url below to sign in.",
),
authUrl: authUrl,
copyLabel: translate('Copy to clipboard'),
onCopy: authUrl.isEmpty
? null
: () => _runCurrentAuthUrlAction(
authAttempt,
authUrl,
_copyAuthUrl,
),
),
),
if (_failedMsg.isNotEmpty)
@@ -304,34 +572,6 @@ class _WidgetOPState extends State<WidgetOP> {
),
);
}),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: const SizedBox(
height: 5.0,
),
),
),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 20),
child: ElevatedButton(
onPressed: () {
widget.curOP.value = '';
_updateTimer?.cancel();
_resetState();
bind.mainAccountAuthCancel();
},
child: Text(
translate('Cancel'),
style: TextStyle(fontSize: 15),
),
),
),
),
),
],
);
}
@@ -341,12 +581,18 @@ class LoginWidgetOP extends StatelessWidget {
final List<ConfigOP> ops;
final RxString curOP;
final Function(Map<String, dynamic>) cbLogin;
final Future<bool> Function(String) startAuth;
final Future<bool> Function(String) cancelAuth;
final bool Function() canStartAuth;
LoginWidgetOP({
Key? key,
required this.ops,
required this.curOP,
required this.cbLogin,
required this.startAuth,
required this.cancelAuth,
required this.canStartAuth,
}) : super(key: key);
@override
@@ -357,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget {
config: op,
curOP: curOP,
cbLogin: cbLogin,
startAuth: startAuth,
cancelAuth: cancelAuth,
canStartAuth: canStartAuth,
),
const Divider(
indent: 5,
@@ -434,12 +683,11 @@ class LoginWidgetUserPass extends StatelessWidget {
translate('Login'),
style: TextStyle(fontSize: 16),
),
onPressed:
curOP.value.isEmpty || curOP.value == 'rustdesk'
? () {
onLogin();
}
: null,
onPressed: curOP.value.isEmpty && !isInProgress
? () {
onLogin();
}
: null,
)),
),
])),
@@ -450,8 +698,28 @@ class LoginWidgetUserPass extends StatelessWidget {
const kAuthReqTypeOidc = 'oidc/';
Future<bool?>? _activeLoginDialog;
// call this directly
Future<bool?> loginDialog() async {
Future<bool?> loginDialog() {
final activeDialog = _activeLoginDialog;
if (activeDialog != null) {
return activeDialog;
}
final dialog = _openLoginDialogOnce();
_activeLoginDialog = dialog;
return dialog;
}
Future<bool?> _openLoginDialogOnce() async {
try {
return await _openLoginDialog();
} finally {
_activeLoginDialog = null;
}
}
Future<bool?> _openLoginDialog() async {
var username =
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
var password = TextEditingController();
@@ -461,12 +729,13 @@ Future<bool?> loginDialog() async {
String? usernameMsg;
String? passwordMsg;
var isInProgress = false;
final RxString curOP = ''.obs;
final oidcAuth = _OidcAuthController();
final curOP = oidcAuth.curOP;
// Track hover state for the close icon
bool isCloseHovered = false;
final loginOptions = [].obs;
final loginOptionsError = Rxn<String>();
final loginOptionsError = Rxn<Object>();
final loginOptionsInProgress = false.obs;
fetchLoginOptions() async {
loginOptionsInProgress.value = true;
@@ -475,7 +744,7 @@ Future<bool?> loginDialog() async {
loginOptionsError.value = null;
} catch (e) {
debugPrint("queryOidcLoginOptions failed: $e");
loginOptionsError.value = e.toString();
loginOptionsError.value = e;
} finally {
loginOptionsInProgress.value = false;
}
@@ -555,6 +824,9 @@ Future<bool?> loginDialog() async {
}
onLogin() async {
if (curOP.value.isNotEmpty || isInProgress) {
return;
}
// validate
if (username.text.isEmpty) {
setState(() => usernameMsg = translate('Username missed'));
@@ -593,7 +865,7 @@ Future<bool?> loginDialog() async {
const SizedBox(height: 8.0),
// NOT use Offstage to wrap LinearProgressIndicator
if (inProgress) const LinearProgressIndicator(),
if (!inProgress)
if (!inProgress && error is! RequestException)
Text(
translate('network_error_tip'),
style: const TextStyle(fontSize: 12),
@@ -608,7 +880,7 @@ Future<bool?> loginDialog() async {
),
if (!inProgress)
SelectableText(
error,
error.toString(),
style: const TextStyle(fontSize: 11, color: Colors.red),
textAlign: TextAlign.center,
),
@@ -635,6 +907,9 @@ Future<bool?> loginDialog() async {
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
.toList(),
curOP: curOP,
startAuth: oidcAuth.start,
cancelAuth: oidcAuth.cancelCurrent,
canStartAuth: oidcAuth.canStart,
cbLogin: (Map<String, dynamic> authBody) async {
LoginResponse? resp;
try {
@@ -716,7 +991,7 @@ Future<bool?> loginDialog() async {
onCancel: onDialogCancel,
onSubmit: onLogin,
);
});
}).whenComplete(oidcAuth.close);
if (res != null) {
await UserModel.updateOtherModels();

View File

@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
const _statusFontSize = 12.0;
const _statusSpacing = 4.0;
const _messageActionSpacing = 8.0;
const _desktopActionSize = 28.0;
const _touchPlatforms = <TargetPlatform>{
TargetPlatform.android,
TargetPlatform.iOS,
TargetPlatform.fuchsia,
};
class OidcAuthStatus extends StatelessWidget {
final String message;
final String browserFallbackPrompt;
final String authUrl;
final String copyLabel;
final VoidCallback? onCopy;
const OidcAuthStatus({
super.key,
required this.message,
required this.browserFallbackPrompt,
required this.authUrl,
required this.copyLabel,
this.onCopy,
});
@override
Widget build(BuildContext context) {
final messageStyle =
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SelectableText(message, style: messageStyle),
if (authUrl.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _messageActionSpacing),
child: _OidcAuthFallback(
browserFallbackPrompt: browserFallbackPrompt,
authUrl: authUrl,
copyLabel: copyLabel,
onCopy: onCopy,
),
),
],
);
}
}
class _OidcAuthFallback extends StatefulWidget {
final String browserFallbackPrompt;
final String authUrl;
final String copyLabel;
final VoidCallback? onCopy;
const _OidcAuthFallback({
required this.browserFallbackPrompt,
required this.authUrl,
required this.copyLabel,
required this.onCopy,
});
@override
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
}
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
bool _expanded = false;
@override
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.authUrl != widget.authUrl) {
_expanded = false;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
fontSize: _statusFontSize,
color: theme.colorScheme.onSurfaceVariant,
);
final linkColor = theme.brightness == Brightness.dark
? Colors.blue.shade300
: Colors.blue.shade800;
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
final actionSize =
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
final urlStyle =
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.browserFallbackPrompt,
style: helperStyle,
textAlign: TextAlign.center,
),
Padding(
padding: const EdgeInsets.only(top: _statusSpacing),
child: _buildUrl(urlStyle, linkColor, actionSize),
),
],
);
}
void _copyAndExpand() {
setState(() => _expanded = true);
widget.onCopy?.call();
}
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
final collapsedUrl = SizedBox(
width: double.infinity,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: linkColor,
minimumSize: Size(0, actionSize),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
),
onPressed: _copyAndExpand,
child: Text(
widget.authUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: urlStyle.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
),
),
),
);
final collapsedChild = widget.onCopy == null
? collapsedUrl
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
return Container(
width: double.infinity,
constraints: BoxConstraints(minHeight: actionSize),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(_statusSpacing),
),
child: _expanded
? SelectableText(widget.authUrl, style: urlStyle)
: collapsedChild,
);
}
}