feat: OIDC Login on same page

This commit is contained in:
Christian Kußowski 2026-02-22 10:47:58 +01:00
parent 8998d5600a
commit df847abbeb
No known key found for this signature in database
GPG key ID: E067ECD60F1A0652
5 changed files with 252 additions and 78 deletions

View file

@ -17,7 +17,7 @@ import 'package:fluffychat/pages/chat_members/chat_members.dart';
import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart';
import 'package:fluffychat/pages/chat_search/chat_search_page.dart';
import 'package:fluffychat/pages/device_settings/device_settings.dart';
import 'package:fluffychat/pages/intro/intro_page.dart';
import 'package:fluffychat/pages/intro/intro_page_presenter.dart';
import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart';
import 'package:fluffychat/pages/login/login.dart';
import 'package:fluffychat/pages/new_group/new_group.dart';
@ -68,7 +68,7 @@ abstract class AppRoutes {
GoRoute(
path: '/home',
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, const IntroPage()),
defaultPageBuilder(context, state, const IntroPagePresenter()),
redirect: loggedInRedirect,
routes: [
GoRoute(
@ -263,8 +263,11 @@ abstract class AppRoutes {
GoRoute(
path: 'addaccount',
redirect: loggedOutRedirect,
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, const IntroPage()),
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const IntroPagePresenter(),
),
routes: [
GoRoute(
path: 'sign_in',

View file

@ -13,7 +13,14 @@ import 'package:fluffychat/widgets/layouts/login_scaffold.dart';
import 'package:fluffychat/widgets/matrix.dart';
class IntroPage extends StatelessWidget {
const IntroPage({super.key});
final bool isLoading;
final String? loggingInToHomeserver;
const IntroPage({
required this.isLoading,
required this.loggingInToHomeserver,
super.key,
});
@override
Widget build(BuildContext context) {
@ -21,6 +28,7 @@ class IntroPage extends StatelessWidget {
final addMultiAccount = Matrix.of(
context,
).widget.clients.any((client) => client.isLogged());
final loggingInToHomeserver = this.loggingInToHomeserver;
return LoginScaffold(
appBar: AppBar(
@ -35,7 +43,7 @@ class IntroPage extends StatelessWidget {
useRootNavigator: true,
itemBuilder: (_) => [
PopupMenuItem(
onTap: () => restoreBackupFlow(context),
onTap: isLoading ? null : () => restoreBackupFlow(context),
child: Row(
mainAxisSize: .min,
children: [
@ -71,17 +79,32 @@ class IntroPage extends StatelessWidget {
),
],
),
body: LayoutBuilder(
body: isLoading
? Center(
child: Column(
mainAxisAlignment: .center,
children: [
CircularProgressIndicator.adaptive(),
if (loggingInToHomeserver != null)
Text(L10n.of(context).logInTo(loggingInToHomeserver)),
],
),
)
: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
children: [
Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
),
child: Hero(
tag: 'info-logo',
child: Image.asset(
@ -92,7 +115,9 @@ class IntroPage extends StatelessWidget {
),
const SizedBox(height: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
padding: const EdgeInsets.symmetric(
horizontal: 32.0,
),
child: SelectableLinkify(
text: L10n.of(context).appIntro,
textScaleFactor: MediaQuery.textScalerOf(
@ -115,13 +140,17 @@ class IntroPage extends StatelessWidget {
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.secondary,
foregroundColor: theme.colorScheme.onSecondary,
backgroundColor:
theme.colorScheme.secondary,
foregroundColor:
theme.colorScheme.onSecondary,
),
onPressed: () => context.go(
'${GoRouterState.of(context).uri.path}/sign_up',
),
child: Text(L10n.of(context).createNewAccount),
child: Text(
L10n.of(context).createNewAccount,
),
),
SizedBox(height: 16),
ElevatedButton(
@ -140,7 +169,9 @@ class IntroPage extends StatelessWidget {
extra: client,
);
},
child: Text(L10n.of(context).loginWithMatrixId),
child: Text(
L10n.of(context).loginWithMatrixId,
),
),
],
),

View file

@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix_api_lite/utils/logs.dart';
import 'package:matrix/msc_extensions/msc_2964_oidc_login_flow/msc_2964_oidc_login_flow.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_html/universal_html.dart' as web;
import 'package:fluffychat/pages/intro/intro_page.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/oidc_session_json_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
class IntroPagePresenter extends StatefulWidget {
const IntroPagePresenter({super.key});
@override
State<IntroPagePresenter> createState() => _IntroPagePresenterState();
}
class _IntroPagePresenterState extends State<IntroPagePresenter> {
bool isLoading = kIsWeb;
String? loggingInToHomeserver;
@override
void initState() {
super.initState();
if (kIsWeb) _finishOidcLogin();
}
void _finishOidcLogin() async {
final store = await SharedPreferences.getInstance();
final storedHomeserverString = store.getString(
OidcSessionJsonExtension.homeserverStoreKey,
);
final homeserverUrl = storedHomeserverString == null
? null
: Uri.tryParse(storedHomeserverString);
final oidcSessionString = store.getString(
OidcSessionJsonExtension.storeKey,
);
final session = oidcSessionString == null
? null
: OidcSessionJsonExtension.fromJson(jsonDecode(oidcSessionString));
await store.remove(OidcSessionJsonExtension.storeKey);
await store.remove(OidcSessionJsonExtension.homeserverStoreKey);
if (homeserverUrl == null || session == null) {
setState(() {
isLoading = false;
});
return;
}
setState(() {
loggingInToHomeserver = homeserverUrl.origin;
});
try {
final returnUrl = Uri.parse(web.window.location.href);
final queryParameters = returnUrl.hasFragment
? Uri.parse(returnUrl.fragment).queryParameters
: returnUrl.queryParameters;
final code = queryParameters['code'] as String;
final state = queryParameters['state'] as String;
final client = await Matrix.of(context).getLoginClient();
await client.checkHomeserver(homeserverUrl);
await client.oidcLogin(session: session, code: code, state: state);
} catch (e, s) {
Logs().w('Unable to login via OIDC', e, s);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
}
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return IntroPage(
isLoading: isLoading,
loggingInToHomeserver: loggingInToHomeserver,
);
}
}

View file

@ -1,12 +1,16 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/oidc_session_json_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart';
Future<void> oidcLoginFlow(
@ -16,9 +20,7 @@ Future<void> oidcLoginFlow(
) async {
Logs().i('Starting Matrix Native OIDC Flow...');
final redirectUrl = kIsWeb
? Uri.parse(
html.window.location.href,
).resolveUri(Uri(pathSegments: ['auth.html']))
? Uri.parse(html.window.location.href).resolveUri(Uri(query: ''))
: (PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS)
? Uri.parse('${AppConfig.appOpenUrlScheme.toLowerCase()}:/login')
: Uri.parse('http://localhost:3001/login');
@ -62,11 +64,28 @@ Future<void> oidcLoginFlow(
if (!context.mounted) return;
if (kIsWeb) {
final store = await SharedPreferences.getInstance();
store.setString(
OidcSessionJsonExtension.homeserverStoreKey,
client.homeserver!.toString(),
);
store.setString(
OidcSessionJsonExtension.storeKey,
jsonEncode(session.toJson()),
);
}
final returnUrlString = await FlutterWebAuth2.authenticate(
url: session.authenticationUri.toString(),
callbackUrlScheme: urlScheme,
options: FlutterWebAuth2Options(useWebview: PlatformInfos.isMobile),
options: FlutterWebAuth2Options(
useWebview: PlatformInfos.isMobile,
windowName: '_self',
),
);
if (kIsWeb) return; // On Web we return at intro page when app starts again!
final returnUrl = Uri.parse(returnUrlString);
final queryParameters = returnUrl.hasFragment
? Uri.parse(returnUrl.fragment).queryParameters

View file

@ -0,0 +1,24 @@
import 'package:matrix/matrix.dart';
extension OidcSessionJsonExtension on OidcLoginSession {
static const String storeKey = 'oidc_session';
static const String homeserverStoreKey = 'oidc_stored_homeserver';
Map<String, Object?> toJson() => {
'oidc_client_data': oidcClientData.toJson(),
'authentication_uri': authenticationUri.toString(),
'redirect_uri': redirectUri.toString(),
'code_verifier': codeVerifier,
'state': state,
};
static OidcLoginSession fromJson(Map<String, Object?> json) =>
OidcLoginSession(
oidcClientData: OidcClientData.fromJson(
json['oidc_client_data'] as Map<String, Object?>,
),
authenticationUri: Uri.parse(json['authentication_uri'] as String),
redirectUri: Uri.parse(json['redirect_uri'] as String),
codeVerifier: json['code_verifier'] as String,
state: json['state'] as String,
);
}