Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/core/auth/auth_flow_native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ class NativeAuthFlow implements AuthFlow {
required FlutterAppAuth appAuth,
required String redirectScheme,
}) : _appAuth = appAuth,
_redirectUri = '$redirectScheme://callback';
_redirectUri = '$redirectScheme://callback',
_logoutRedirectUri = '$redirectScheme://signedout';

final FlutterAppAuth _appAuth;
final String _redirectUri;
final String _logoutRedirectUri;

@override
bool get isWeb => false;
Expand Down Expand Up @@ -85,7 +87,7 @@ class NativeAuthFlow implements AuthFlow {
EndSessionRequest(
idTokenHint: idToken,
discoveryUrl: discoveryUrl,
postLogoutRedirectUrl: _redirectUri,
postLogoutRedirectUrl: _logoutRedirectUri,
),
);
} on Exception catch (e, s) {
Expand Down
2 changes: 1 addition & 1 deletion lib/core/auth/auth_flow_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class WebAuthFlow implements AuthFlow {
final frontendOrigin = _navigator.origin;
final logoutUri = Uri.parse(endSessionEndpoint).replace(
queryParameters: {
'post_logout_redirect_uri': frontendOrigin,
'post_logout_redirect_uri': '$frontendOrigin/#/signedout',
'client_id': clientId,
if (idToken.isNotEmpty) 'id_token_hint': idToken,
},
Expand Down
7 changes: 7 additions & 0 deletions lib/core/auth/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ final authFlowProvider = Provider<AuthFlow>((ref) {
);
});

/// Provider for initial URL hash path captured at startup.
///
/// On web, captures the hash path before GoRouter overwrites it.
/// Used to detect post-logout redirect from IdP (e.g., '/signedout').
/// On native, always null.
final capturedInitialPathProvider = Provider<String?>((ref) => null);

/// Provider for callback params captured at startup.
///
/// Override this in [ProviderScope.overrides] with the value from
Expand Down
6 changes: 6 additions & 0 deletions lib/core/auth/web_auth_callback.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ abstract final class CallbackParamsCapture {
/// On web, extracts tokens from URL query params.
/// On native, returns [NoCallbackParams] (native uses flutter_appauth).
static CallbackParams captureNow() => impl.captureCallbackParamsNow();

/// Capture the initial URL hash path before GoRouter overwrites it.
///
/// On web, returns the path from the hash (e.g., '/signedout').
/// On native, returns null (not applicable).
static String? captureInitialHashPath() => impl.captureInitialHashPath();
}

/// Service for handling OAuth callback URL operations.
Expand Down
5 changes: 5 additions & 0 deletions lib/core/auth/web_auth_callback_native.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import 'package:soliplex_frontend/core/auth/web_auth_callback.dart';

/// Capture initial hash path - no-op on native.
///
/// Only relevant on web where the IdP redirects back with a hash path.
String? captureInitialHashPath() => null;

/// Capture callback params - no-op on native.
///
/// Native platforms use flutter_appauth which handles OAuth callbacks
Expand Down
13 changes: 13 additions & 0 deletions lib/core/auth/web_auth_callback_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ import 'package:soliplex_frontend/core/auth/web_auth_callback.dart';
import 'package:soliplex_frontend/core/logging/loggers.dart';
import 'package:web/web.dart' as web;

/// Capture the initial URL hash path on web (e.g., '/signedout' from '/#/signedout').
///
/// Returns the path portion of the hash, or null if no hash or empty.
/// Used to detect post-logout redirect from IdP before GoRouter overrides it.
String? captureInitialHashPath() {
final hash = web.window.location.hash;
if (hash.isEmpty || hash == '#/') return null;
// Strip leading '#' to get path (e.g., '#/signedout' → '/signedout')
final path = hash.startsWith('#') ? hash.substring(1) : hash;
Loggers.auth.debug('Web auth: Captured initial hash path: $path');
return path;
}

/// Capture callback params from current URL.
///
/// Used by [CallbackParamsCapture.captureNow] in main() before ProviderScope.
Expand Down
26 changes: 21 additions & 5 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:soliplex_frontend/core/models/features.dart';
import 'package:soliplex_frontend/core/models/route_config.dart';
import 'package:soliplex_frontend/core/providers/shell_config_provider.dart';
import 'package:soliplex_frontend/features/auth/auth_callback_screen.dart';
import 'package:soliplex_frontend/features/auth/signed_out_screen.dart';
import 'package:soliplex_frontend/features/home/home_screen.dart';
import 'package:soliplex_frontend/features/inspector/network_inspector_screen.dart';
import 'package:soliplex_frontend/features/log_viewer/log_viewer_screen.dart';
Expand Down Expand Up @@ -144,7 +145,7 @@ String _normalizePath(String path) {
/// Home ('/') is public so users can configure the backend URL before auth.
/// When [RouteConfig.showHomeRoute] is false, the '/' route doesn't exist
/// (no fallback) - requests to '/' will hit the error page.
const _publicRoutes = {'/', '/login', '/auth/callback'};
const _publicRoutes = {'/', '/login', '/auth/callback', '/signedout'};

/// Application router provider.
///
Expand Down Expand Up @@ -182,14 +183,23 @@ final routerProvider = Provider<GoRouter>((ref) {
final isOAuthCallback = capturedParams is WebCallbackParams;
Loggers.router.debug('isOAuthCallback = $isOAuthCallback');

// Check if browser URL has a post-logout redirect path (e.g., '/signedout').
// GoRouter's initialLocation overrides the browser hash, so we must
// capture the path before GoRouter takes over.
final capturedPath = ref.read(capturedInitialPathProvider);

// Use configured initial route or default to /
final configuredInitial = routeConfig.initialRoute;
final validatedInitial =
isRouteVisible(configuredInitial, features, routeConfig)
? configuredInitial
: getDefaultAuthenticatedRoute(features, routeConfig);
// Route to callback screen if we have OAuth tokens to process
final initialPath = isOAuthCallback ? '/auth/callback' : validatedInitial;
// Priority: OAuth callback > captured hash path > configured initial route
final initialPath = isOAuthCallback
? '/auth/callback'
: capturedPath == '/signedout'
? '/signedout'
: validatedInitial;
Loggers.router.debug('Initial location: $initialPath');

return GoRouter(
Expand Down Expand Up @@ -219,8 +229,7 @@ final routerProvider = Provider<GoRouter>((ref) {
if (isExplicitSignOut) {
Loggers.router.info('Explicit sign-out detected');
}
final target =
isExplicitSignOut && routeConfig.showHomeRoute ? '/' : '/login';
final target = isExplicitSignOut ? '/signedout' : '/login';
Loggers.router.debug('redirecting to $target');
return target;
}
Expand Down Expand Up @@ -253,6 +262,13 @@ final routerProvider = Provider<GoRouter>((ref) {
pageBuilder: (context, state) =>
const NoTransitionPage(child: AuthCallbackScreen()),
),
// Post-logout landing page - chrome-less like other auth screens
GoRoute(
path: '/signedout',
name: 'signed-out',
pageBuilder: (context, state) =>
const NoTransitionPage(child: SignedOutScreen()),
),
if (routeConfig.showHomeRoute)
GoRoute(
path: '/',
Expand Down
34 changes: 34 additions & 0 deletions lib/features/auth/signed_out_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

/// Screen shown after the user has been signed out.
///
/// Chrome-less (no AppShell) like other auth screens.
/// Provides a button to navigate back to the login screen.
class SignedOutScreen extends StatelessWidget {
const SignedOutScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ExcludeSemantics(child: Icon(Icons.logout, size: 48)),
const SizedBox(height: 16),
Text(
'You have been signed out',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/login'),
child: const Text('Sign in again'),
),
],
),
),
);
}
}
9 changes: 7 additions & 2 deletions lib/run_soliplex_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,12 @@ Future<void> runSoliplexApp({
return true; // Handled — prevent default error reporting.
};

// Capture OAuth callback params BEFORE GoRouter initializes.
// GoRouter may modify the URL, losing the callback tokens.
// Capture URL state BEFORE GoRouter initializes (it overwrites the URL).
// GoRouter's initialLocation overrides the browser hash, so we must
// capture both OAuth callback params and the hash path (for post-logout
// redirect from IdP) before GoRouter takes over.
final callbackParams = CallbackParamsCapture.captureNow();
final initialHashPath = CallbackParamsCapture.captureInitialHashPath();

// Clear URL params immediately after capture (security: remove tokens).
// Must happen before GoRouter initializes to avoid URL state conflicts.
Expand Down Expand Up @@ -108,6 +111,8 @@ Future<void> runSoliplexApp({
// Inject shell configuration via ProviderScope (no global state)
shellConfigProvider.overrideWithValue(config),
capturedCallbackParamsProvider.overrideWithValue(callbackParams),
if (initialHashPath != null)
capturedInitialPathProvider.overrideWithValue(initialHashPath),
// Fulfills the contract of preloadedPrefsProvider, enabling
// synchronous log config initialization (no race condition).
preloadedPrefsProvider.overrideWithValue(prefs),
Expand Down
37 changes: 28 additions & 9 deletions test/core/router/app_router_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'package:soliplex_frontend/core/providers/shell_config_provider.dart';
import 'package:soliplex_frontend/core/providers/threads_provider.dart';
import 'package:soliplex_frontend/core/router/app_router.dart';
import 'package:soliplex_frontend/features/auth/auth_callback_screen.dart';
import 'package:soliplex_frontend/features/auth/signed_out_screen.dart';
import 'package:soliplex_frontend/features/home/home_screen.dart';
import 'package:soliplex_frontend/features/login/login_screen.dart';
import 'package:soliplex_frontend/features/quiz/quiz_screen.dart';
Expand Down Expand Up @@ -116,14 +117,19 @@ Widget createRouterAppAt(
return GoRouter(
initialLocation: initialLocation,
redirect: (context, state) {
const publicRoutes = {'/', '/login', '/auth/callback'};
const publicRoutes = {
'/',
'/login',
'/auth/callback',
'/signedout',
};
final isPublicRoute = publicRoutes.contains(state.matchedLocation);
if (!hasAccess && !isPublicRoute) {
final target = switch (currentAuthState) {
Unauthenticated(
reason: UnauthenticatedReason.explicitSignOut,
) =>
'/',
'/signedout',
_ => '/login',
};
return target;
Expand All @@ -145,6 +151,11 @@ Widget createRouterAppAt(
name: 'home',
builder: (_, __) => const Scaffold(body: HomeScreen()),
),
GoRoute(
path: '/signedout',
name: 'signed-out',
builder: (_, __) => const SignedOutScreen(),
),
GoRoute(
path: '/rooms',
name: 'rooms',
Expand Down Expand Up @@ -405,7 +416,7 @@ void main() {
expect(find.byType(LoginScreen), findsOneWidget);
});

testWidgets('explicit sign-out redirects to home', (tester) async {
testWidgets('explicit sign-out redirects to /signedout', (tester) async {
final container = ProviderContainer(
overrides: [
shellConfigProvider.overrideWithValue(testSoliplexConfig),
Expand Down Expand Up @@ -437,12 +448,12 @@ void main() {

expect(find.byType(RoomsScreen), findsOneWidget);

// Explicit sign-out → home (to choose different backend)
// Explicit sign-out → /signedout
await (container.read(authProvider.notifier) as _ControllableAuthNotifier)
.signOut();
await tester.pumpAndSettle();

expect(find.byType(HomeScreen), findsOneWidget);
expect(find.byType(SignedOutScreen), findsOneWidget);
});

testWidgets('token refresh preserves navigation location', (tester) async {
Expand Down Expand Up @@ -536,6 +547,14 @@ void main() {
expect(find.byType(LoginScreen), findsOneWidget);
});

testWidgets('authenticated user at /signedout redirects to /rooms', (
tester,
) async {
await tester.pumpWidget(createRouterAppAt('/signedout'));
await tester.pumpAndSettle();
expect(find.byType(RoomsScreen), findsOneWidget);
});

testWidgets('NoAuthRequired user at /login redirects to /rooms', (
tester,
) async {
Expand Down Expand Up @@ -946,10 +965,10 @@ void main() {
expect(find.byType(RoomsScreen), findsOneWidget);
});

testWidgets('sign-out redirects to /login regardless of config', (
testWidgets('sign-out redirects to /signedout regardless of config', (
tester,
) async {
// Setup: showHomeRoute: false - verify sign-out goes to /login not /
// Setup: showHomeRoute: false - verify sign-out goes to /signedout
final container = ProviderContainer(
overrides: [
shellConfigProvider.overrideWithValue(
Expand Down Expand Up @@ -991,8 +1010,8 @@ void main() {
.signOut();
await tester.pumpAndSettle();

// Expect: Redirects to /login (not / which would crash)
expect(find.byType(LoginScreen), findsOneWidget);
// Expect: Redirects to /signedout (dedicated post-logout page)
expect(find.byType(SignedOutScreen), findsOneWidget);
});

testWidgets('no redirect loop when already on target route', (
Expand Down
57 changes: 57 additions & 0 deletions test/features/auth/signed_out_screen_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:soliplex_frontend/features/auth/signed_out_screen.dart';

import '../../helpers/test_helpers.dart';

Widget _createApp() {
final router = GoRouter(
initialLocation: '/signedout',
routes: [
GoRoute(
path: '/signedout',
builder: (_, __) => const SignedOutScreen(),
),
GoRoute(path: '/login', builder: (_, __) => const Text('Login')),
],
);

return MaterialApp.router(theme: testThemeData, routerConfig: router);
}

void main() {
group('SignedOutScreen', () {
testWidgets('renders heading text', (tester) async {
await tester.pumpWidget(_createApp());
await tester.pumpAndSettle();

expect(find.text('You have been signed out'), findsOneWidget);
});

testWidgets('renders sign in button', (tester) async {
await tester.pumpWidget(_createApp());
await tester.pumpAndSettle();

expect(find.text('Sign in again'), findsOneWidget);
});

testWidgets('renders logout icon', (tester) async {
await tester.pumpWidget(_createApp());
await tester.pumpAndSettle();

expect(find.byIcon(Icons.logout), findsOneWidget);
});

testWidgets('tapping button navigates to /login', (tester) async {
await tester.pumpWidget(_createApp());
await tester.pumpAndSettle();

await tester.tap(find.text('Sign in again'));
await tester.pumpAndSettle();

expect(find.text('Login'), findsOneWidget);
expect(find.text('You have been signed out'), findsNothing);
});
});
}
Loading