Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c87fe7b
feat(auth): add ExpiredSession state preserving tokens across auth fa…
91jaeminjo May 20, 2026
7970d7b
feat(auth): bump refresh threshold to 5 minutes
91jaeminjo May 20, 2026
5c693af
feat(http): split 401/403 — add PermissionDeniedException
91jaeminjo May 20, 2026
1030294
feat(auth): per-server route guard via connectionRevision
91jaeminjo May 20, 2026
a747d48
feat(auth): funnel 401 surfaces through markSessionExpired
91jaeminjo May 20, 2026
f23d5eb
feat(auth): reactive cancellation on auth.session transitions
91jaeminjo May 20, 2026
a5b535b
feat(auth): extend PreAuthState with frontendReturnTo and 30-min TTL
91jaeminjo May 20, 2026
6d3c6bd
feat(auth): callback honors frontendReturnTo with relative-path guard
91jaeminjo May 20, 2026
bb23a56
feat(auth): ConnectFlow accepts returnTo and stashes it in PreAuthState
91jaeminjo May 20, 2026
5681d8c
feat(auth): route guard stashes return-to; HomeScreen forwards it
91jaeminjo May 20, 2026
e8f8043
feat(auth): persist and restore composer drafts across auth redirects
91jaeminjo May 20, 2026
14f6c29
fix(test): cover permissionDenied in FailureReason enum tests
91jaeminjo May 21, 2026
a647323
fix(auth): complete 401/403 split in transport docs and consumers
91jaeminjo May 21, 2026
6adee3c
fix(auth): log silent failures in composer drafts and token refresh
91jaeminjo May 21, 2026
36da087
test(auth): pin returnTo plumbing and expired-session persistence
91jaeminjo May 21, 2026
d5f2585
chore(auth): log rejected returnTo and document upload cancel path
91jaeminjo May 21, 2026
f4bdada
fix(lobby): log non-funnel rooms and profile fetch failures
91jaeminjo May 21, 2026
8eecfa9
test(lobby): pin PermissionDeniedException does not funnel to expired
91jaeminjo May 21, 2026
9021ccf
chore(auth): tune log severities and surface swallow behavior in docs
91jaeminjo May 21, 2026
c857b7c
fix(diagnostics): drop orphan response groups before sorting
91jaeminjo May 21, 2026
17976ab
feat(lobby): inline "sign in again" affordance for expired servers
91jaeminjo May 21, 2026
9a2ee61
refactor(lobby): sealed switches + RoomsExpired panel widget test
91jaeminjo May 21, 2026
3c9edaf
refactor(auth): tighten frontendReturnTo invariant, sharpen failure logs
91jaeminjo May 21, 2026
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: 5 additions & 1 deletion lib/src/core/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ class AppRoutes {
static const networkInspector = '/diagnostics/network';
static const authCallback = '/auth/callback';

static String homeWithUrl(String url) => '/?url=${Uri.encodeComponent(url)}';
static String homeWithUrl(String url, {String? returnTo}) {
final base = '/?url=${Uri.encodeComponent(url)}';
if (returnTo == null) return base;
return '$base&returnTo=${Uri.encodeComponent(returnTo)}';
}

static String versionsForServer(String serverAlias) =>
'/versions/server/$serverAlias';
Expand Down
27 changes: 24 additions & 3 deletions lib/src/modules/auth/auth_module.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class AuthAppModule extends AppModule {
_consentNotice = consentNotice,
_logo = logo,
_defaultBackendUrl = defaultBackendUrl,
_refreshListenable = SignalListenable(serverManager.authState);
_refreshListenable = SignalListenable(serverManager.connectionRevision);

final ServerManager _serverManager;
final SoliplexHttpClient _probeClient;
Expand Down Expand Up @@ -75,6 +75,7 @@ class AuthAppModule extends AppModule {
path: AppRoutes.home,
pageBuilder: (_, state) {
final autoConnectUrl = state.uri.queryParameters['url'];
final returnTo = state.uri.queryParameters['returnTo'];
return NoTransitionPage(
key: autoConnectUrl != null ? UniqueKey() : state.pageKey,
child: HomeScreen(
Expand All @@ -83,6 +84,7 @@ class AuthAppModule extends AppModule {
logo: _logo,
defaultBackendUrl: _defaultBackendUrl,
autoConnectUrl: autoConnectUrl,
autoConnectReturnTo: returnTo,
),
);
},
Expand All @@ -101,10 +103,29 @@ class AuthAppModule extends AppModule {
),
],
redirect: (_, state) {
final isPublic = _publicPaths.contains(state.matchedLocation);
if (isPublic) return null;

// Per-server guard: if the route names a specific server and
// that server isn't connected (signed out or expired),
// redirect to its sign-in entry. Carry the original location
// through so the callback can return the user back here.
final alias = state.pathParameters['serverAlias'];
if (alias != null) {
final entry = _serverManager.entryByAlias(alias);
if (entry != null && !entry.isConnected) {
return AppRoutes.homeWithUrl(
entry.serverUrl.toString(),
returnTo: state.matchedLocation,
);
}
}

// Global guard: if no server is connected at all, fall back
// to the home screen / server list.
final isAuthenticated =
_serverManager.authState.value is Authenticated;
final isPublic = _publicPaths.contains(state.matchedLocation);
if (!isAuthenticated && !isPublic) return AppRoutes.home;
if (!isAuthenticated) return AppRoutes.home;
return null;
},
);
Expand Down
79 changes: 67 additions & 12 deletions lib/src/modules/auth/auth_session.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:developer' as dev;

import 'package:soliplex_agent/soliplex_agent.dart';

import 'auth_tokens.dart';
Expand All @@ -17,8 +19,12 @@ class AuthSession implements TokenRefresher {
ReadonlySignal<SessionState> get session => _session;

/// Sync read for the HTTP client's getToken callback.
///
/// Returns null for [ExpiredSession] — the access token is known dead
/// and sending it as a header would just round-trip a guaranteed 401.
String? get accessToken => switch (_session.value) {
ActiveSession(:final tokens) => tokens.accessToken,
ExpiredSession() => null,
NoSession() => null,
};

Expand All @@ -32,6 +38,19 @@ class AuthSession implements TokenRefresher {
_session.value = const NoSession();
}

/// Flip an active session to [ExpiredSession], preserving the tokens
/// so a later refresh attempt can revive the session silently. No-op
/// when the session is already expired or has been signed out.
void markSessionExpired() {
switch (_session.value) {
case ActiveSession(:final provider, :final tokens):
_session.value = ExpiredSession(provider: provider, tokens: tokens);
case ExpiredSession():
case NoSession():
return;
}
}

// ── TokenRefresher interface ──

@override
Expand All @@ -56,41 +75,77 @@ class AuthSession implements TokenRefresher {
}

Future<bool> _doRefresh() async {
final current = _session.value;
if (current is! ActiveSession) return false;
final (provider, tokens) = switch (_session.value) {
ActiveSession(:final provider, :final tokens) => (provider, tokens),
ExpiredSession(:final provider, :final tokens) => (provider, tokens),
NoSession() => (null, null),
};
if (provider == null || tokens == null) return false;

final TokenRefreshResult result;
try {
result = await _refreshService.refresh(
discoveryUrl: current.provider.discoveryUrl,
refreshToken: current.tokens.refreshToken,
clientId: current.provider.clientId,
discoveryUrl: provider.discoveryUrl,
refreshToken: tokens.refreshToken,
clientId: provider.clientId,
);
} catch (e, st) {
dev.log(
'Token refresh threw before producing a result',
error: e,
stackTrace: st,
level: 1000,
);
} catch (_) {
return false;
}

// Guard: session may have changed (logout or re-login) during the await.
if (!identical(_session.value, current)) return false;
// User-initiated sign-out during the await wins; everything else
// (including a concurrent flip to ExpiredSession) accepts the new
// tokens.
if (_session.value is NoSession) return false;

switch (result) {
case TokenRefreshSuccess():
_session.value = ActiveSession(
provider: current.provider,
provider: provider,
tokens: AuthTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresAt: result.expiresAt,
idToken: result.idToken ?? current.tokens.idToken,
idToken: result.idToken ?? tokens.idToken,
),
);
return true;

case TokenRefreshFailure(reason: TokenRefreshFailureReason.invalidGrant):
logout();
dev.log(
'Token refresh rejected (invalid_grant) for ${provider.discoveryUrl}',
level: 900,
);
markSessionExpired();
return false;

case TokenRefreshFailure(
reason: TokenRefreshFailureReason.noRefreshToken
):
// A refresh attempt without a refresh token is a frontend
// invariant violation: the session should never have been
// marked refreshable in the first place.
dev.log(
'Token refresh requested without a refresh token '
'for ${provider.discoveryUrl}',
level: 1000,
);
markSessionExpired();
return false;

case TokenRefreshFailure():
case TokenRefreshFailure(:final reason):
// networkError is recoverable on retry; unknownError is the
// anomaly worth a SEVERE entry.
dev.log(
'Token refresh failed (${reason.name}) for ${provider.discoveryUrl}',
level: reason == TokenRefreshFailureReason.networkError ? 900 : 1000,
);
return false;
}
}
Expand Down
13 changes: 13 additions & 0 deletions lib/src/modules/auth/auth_tokens.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,16 @@ final class ActiveSession extends SessionState {
final OidcProvider provider;
final AuthTokens tokens;
}

/// In-memory transient state after an auth attempt has failed and a
/// silent refresh could not recover. Tokens are preserved so the next
/// user interaction can attempt another refresh.
final class ExpiredSession extends SessionState {
const ExpiredSession({
required this.provider,
required this.tokens,
});

final OidcProvider provider;
final AuthTokens tokens;
}
11 changes: 10 additions & 1 deletion lib/src/modules/auth/connect_flow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,18 @@ class ConnectFlow {
bool _disposed = false;
int _generation = 0;

/// Held between the start of [connect] and the eventual save of
/// `PreAuthState` so the user lands back where they came from after
/// a successful re-auth. Carried across consent / provider-selection
/// pauses since those don't reset the flow.
String? _pendingReturnTo;

bool _isCancelled(int gen) => _disposed || gen != _generation;

Future<void> connect(String url) async {
Future<void> connect(String url, {String? returnTo}) async {
if (state.value is! UrlInput) return;
final gen = ++_generation;
_pendingReturnTo = returnTo;
state.value = const Probing();

try {
Expand Down Expand Up @@ -146,6 +153,7 @@ class ConnectFlow {

void reset() {
_generation++;
_pendingReturnTo = null;
state.value = const UrlInput();
}

Expand Down Expand Up @@ -211,6 +219,7 @@ class ConnectFlow {
discoveryUrl: discoveryUrl,
clientId: provider.clientId,
createdAt: DateTime.timestamp(),
frontendReturnTo: _pendingReturnTo,
));

if (_isCancelled(gen)) return;
Expand Down
47 changes: 41 additions & 6 deletions lib/src/modules/auth/pre_auth_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@ import 'package:shared_preferences/shared_preferences.dart';
/// Includes [createdAt] for expiry — states older than [maxAge] are rejected.
@immutable
class PreAuthState {
const PreAuthState({
PreAuthState({
required this.serverUrl,
required this.providerId,
required this.discoveryUrl,
required this.clientId,
required this.createdAt,
});
this.frontendReturnTo,
}) {
if (frontendReturnTo != null && !_isSafeReturnTo(frontendReturnTo!)) {
throw ArgumentError.value(
frontendReturnTo,
'frontendReturnTo',
'must be an in-app path starting with "/" and not "//"',
);
}
}

factory PreAuthState.fromJson(Map<String, dynamic> json) {
return PreAuthState(
Expand All @@ -31,16 +40,34 @@ class PreAuthState {
discoveryUrl: json['discoveryUrl'] as String,
clientId: json['clientId'] as String,
createdAt: DateTime.parse(json['createdAt'] as String).toUtc(),
frontendReturnTo: json['frontendReturnTo'] as String?,
);
}

static bool _isSafeReturnTo(String value) {
if (value.isEmpty) return false;
if (value.startsWith('//')) return false;
return value.startsWith('/');
}

final Uri serverUrl;
final String providerId;
final String discoveryUrl;
final String clientId;
final DateTime createdAt;

static const maxAge = Duration(minutes: 5);
/// In-app route the user should be returned to after a successful
/// re-auth (e.g. `/room/<alias>/<roomId>`). Null when there's no
/// specific return target; the callback falls back to the lobby.
///
/// The constructor rejects anything that isn't a relative in-app
/// path (open-redirect defense): absolute URLs and `//host/...`
/// values throw [ArgumentError] before they can be persisted.
final String? frontendReturnTo;

/// Covers typical OIDC roundtrips: password reset, MFA prompts,
/// email magic links.
static const maxAge = Duration(minutes: 30);

bool isExpired({DateTime? now}) {
final currentTime = now ?? DateTime.timestamp();
Expand All @@ -53,6 +80,7 @@ class PreAuthState {
'discoveryUrl': discoveryUrl,
'clientId': clientId,
'createdAt': createdAt.toUtc().toIso8601String(),
if (frontendReturnTo != null) 'frontendReturnTo': frontendReturnTo,
};

@override
Expand All @@ -62,11 +90,18 @@ class PreAuthState {
other.providerId == providerId &&
other.discoveryUrl == discoveryUrl &&
other.clientId == clientId &&
other.createdAt == createdAt;
other.createdAt == createdAt &&
other.frontendReturnTo == frontendReturnTo;

@override
int get hashCode =>
Object.hash(serverUrl, providerId, discoveryUrl, clientId, createdAt);
int get hashCode => Object.hash(
serverUrl,
providerId,
discoveryUrl,
clientId,
createdAt,
frontendReturnTo,
);

@override
String toString() =>
Expand Down
Loading
Loading