From df847abbebc3865a1979cd28c9ca53abe37c0091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 22 Feb 2026 10:47:58 +0100 Subject: [PATCH] feat: OIDC Login on same page --- lib/config/routes.dart | 11 +- lib/pages/intro/intro_page.dart | 171 +++++++++++------- lib/pages/intro/intro_page_presenter.dart | 97 ++++++++++ .../sign_in/view_model/flows/oidc_login.dart | 27 ++- .../oidc_session_json_extension.dart | 24 +++ 5 files changed, 252 insertions(+), 78 deletions(-) create mode 100644 lib/pages/intro/intro_page_presenter.dart create mode 100644 lib/utils/matrix_sdk_extensions/oidc_session_json_extension.dart diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 60e788786..451b1b665 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -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', diff --git a/lib/pages/intro/intro_page.dart b/lib/pages/intro/intro_page.dart index d973ae14d..3b321add3 100644 --- a/lib/pages/intro/intro_page.dart +++ b/lib/pages/intro/intro_page.dart @@ -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,87 +79,110 @@ class IntroPage extends StatelessWidget { ), ], ), - body: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - children: [ - Container( - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Hero( - tag: 'info-logo', - child: Image.asset( - './assets/banner_transparent.png', - fit: BoxFit.fitWidth, - ), - ), + 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, ), - const SizedBox(height: 32), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: SelectableLinkify( - text: L10n.of(context).appIntro, - textScaleFactor: MediaQuery.textScalerOf( - context, - ).scale(1), - textAlign: TextAlign.center, - linkStyle: TextStyle( - color: theme.colorScheme.secondary, - decorationColor: theme.colorScheme.secondary, - ), - onOpen: (link) => launchUrlString(link.url), - ), - ), - const Spacer(), - Padding( - padding: const EdgeInsets.all(32.0), + child: IntrinsicHeight( child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, children: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.secondary, - foregroundColor: theme.colorScheme.onSecondary, + Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: 8.0, ), - onPressed: () => context.go( - '${GoRouterState.of(context).uri.path}/sign_up', + child: Hero( + tag: 'info-logo', + child: Image.asset( + './assets/banner_transparent.png', + fit: BoxFit.fitWidth, + ), ), - child: Text(L10n.of(context).createNewAccount), ), - SizedBox(height: 16), - ElevatedButton( - onPressed: () => context.go( - '${GoRouterState.of(context).uri.path}/sign_in', + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32.0, ), - child: Text(L10n.of(context).signIn), - ), - TextButton( - onPressed: () async { - final client = await Matrix.of( + child: SelectableLinkify( + text: L10n.of(context).appIntro, + textScaleFactor: MediaQuery.textScalerOf( context, - ).getLoginClient(); - context.go( - '${GoRouterState.of(context).uri.path}/login', - extra: client, - ); - }, - child: Text(L10n.of(context).loginWithMatrixId), + ).scale(1), + textAlign: TextAlign.center, + linkStyle: TextStyle( + color: theme.colorScheme.secondary, + decorationColor: theme.colorScheme.secondary, + ), + onOpen: (link) => launchUrlString(link.url), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.secondary, + foregroundColor: + theme.colorScheme.onSecondary, + ), + onPressed: () => context.go( + '${GoRouterState.of(context).uri.path}/sign_up', + ), + child: Text( + L10n.of(context).createNewAccount, + ), + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.go( + '${GoRouterState.of(context).uri.path}/sign_in', + ), + child: Text(L10n.of(context).signIn), + ), + TextButton( + onPressed: () async { + final client = await Matrix.of( + context, + ).getLoginClient(); + context.go( + '${GoRouterState.of(context).uri.path}/login', + extra: client, + ); + }, + child: Text( + L10n.of(context).loginWithMatrixId, + ), + ), + ], + ), ), ], ), ), - ], - ), - ), + ), + ); + }, ), - ); - }, - ), ); } } diff --git a/lib/pages/intro/intro_page_presenter.dart b/lib/pages/intro/intro_page_presenter.dart new file mode 100644 index 000000000..1f7bdebe4 --- /dev/null +++ b/lib/pages/intro/intro_page_presenter.dart @@ -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 createState() => _IntroPagePresenterState(); +} + +class _IntroPagePresenterState extends State { + 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, + ); + } +} diff --git a/lib/pages/sign_in/view_model/flows/oidc_login.dart b/lib/pages/sign_in/view_model/flows/oidc_login.dart index be496af2b..02e16eb00 100644 --- a/lib/pages/sign_in/view_model/flows/oidc_login.dart +++ b/lib/pages/sign_in/view_model/flows/oidc_login.dart @@ -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 oidcLoginFlow( @@ -16,9 +20,7 @@ Future 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 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 diff --git a/lib/utils/matrix_sdk_extensions/oidc_session_json_extension.dart b/lib/utils/matrix_sdk_extensions/oidc_session_json_extension.dart new file mode 100644 index 000000000..90fb99112 --- /dev/null +++ b/lib/utils/matrix_sdk_extensions/oidc_session_json_extension.dart @@ -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 toJson() => { + 'oidc_client_data': oidcClientData.toJson(), + 'authentication_uri': authenticationUri.toString(), + 'redirect_uri': redirectUri.toString(), + 'code_verifier': codeVerifier, + 'state': state, + }; + + static OidcLoginSession fromJson(Map json) => + OidcLoginSession( + oidcClientData: OidcClientData.fromJson( + json['oidc_client_data'] as Map, + ), + 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, + ); +}