From 2163bd0fb512a3621b20d3784dd8e607304323fa Mon Sep 17 00:00:00 2001
From: Jaemin Jo <44039707+91jaeminjo@users.noreply.github.com>
Date: Thu, 1 Oct 2026 14:20:25 -0500
Subject: [PATCH 1/5] feat: clear the web sign-in tokens before the app loads
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An inline script at the top of `web/index.html` moves the
`#/auth/callback?…` query into `window.soliplexCallbackQuery` and
rewrites the URL before any other resource loads. `captureNow` reads
that stash, and falls back to the URL with a value-free error log when
the script is missing, so a fork without it still signs in.
The script follows `
`, so the history entry Chrome records for
the callback URL shows the title rather than the tokens; the entry still
holds them, and only a backend change keeps them out of the URL.
`clearCallbackUrl` keeps main's page-load clear: it drops
`location.search` and every hash query and keeps the route, so no
outside link can hand the app a query. It and the script build the URL
as origin + pathname + route, so a pathname such as `//evil.example/`
can't make `replaceState` cross-origin. Query-string callbacks and the
`access_token` key are no longer read; only fragment-form backends are
supported. `docs/authoring-a-flavor.md` documents the script for forks.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 6 +
docs/authoring-a-flavor.md | 34 +++++
.../auth/platform/callback_params.dart | 5 +-
.../auth/platform/callback_params_parser.dart | 69 ++++++---
.../auth/platform/callback_service.dart | 6 +-
.../auth/platform/callback_service_web.dart | 50 ++++---
.../platform/callback_params_parser_test.dart | 131 ++++++++++++------
web/index.html | 21 ++-
8 files changed, 239 insertions(+), 83 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c02fbc92..ae53a108c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,10 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
- The account name and email come from the sign-in tokens instead of a
`/api/user_info` request, so they show as soon as the app opens. The lobby
and the room rail show the email once when it is also the name or username.
+- After a web sign-in, the tokens leave the address bar before the app starts
+ loading rather than once it has booted. Forks with their own
+ `web/index.html` need the script in `docs/authoring-a-flavor.md`; without it
+ sign-in still works and the app logs an error.
- **Library consumers:** `AuthProviderConfig.scope` is `String?`.
### Removed
@@ -39,6 +43,8 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
they now pass through unprocessed. Reasoning an older backend stored as
`THINKING_*` events no longer shows when such a thread is reopened, and a
`MESSAGES_SNAPSHOT` no longer logs a warning.
+- Web sign-in against backends that return the tokens in the query string
+ (before `v0.82.2` / `v0.83.2`); those put the tokens in server logs.
- **Library consumers (breaking):** `StepProgress` removed from
`ExecutionEvent`; `bridgeBaseEvent` maps `STEP_STARTED` and every
`THINKING_*` event to `null`, and `processEvent` leaves the conversation and
diff --git a/docs/authoring-a-flavor.md b/docs/authoring-a-flavor.md
index 7f9cf130b..28484af91 100644
--- a/docs/authoring-a-flavor.md
+++ b/docs/authoring-a-flavor.md
@@ -125,6 +125,40 @@ exported for exactly this, and it is what the shell runs, so your tests cannot
drift from production by reproducing the composition slightly differently.
Prefer `standardFlavor` unless you genuinely need a different module graph.
+## Web entry page
+
+A fork with its own `web/index.html` must carry the sign-in callback script,
+right after ``, with no tag that loads a resource (``,
+`
+```
+
+After a web sign-in the backend sends the browser to
+`#/auth/callback?token=…`. The script takes the tokens out of the address bar
+before the app starts downloading, instead of leaving them there until Flutter
+boots. Without it, sign-in still works, but the tokens stay visible for that
+time and the app logs an error naming this page.
+
+The browser still records the callback URL, tokens included, in its history.
+In Chrome, placing the script after `` makes that entry show the page
+title rather than the tokens; only a backend change keeps them out of the URL.
+
+Keep `CallbackParamsCapture.captureNow()` then `clearCallbackUrl()` at the top
+of `main()`, after `installLogSinks()`.
+
## Rules
- Build the theme with `buildSoliplexThemeData` (never a bare `ThemeData`) — the
diff --git a/lib/src/modules/auth/platform/callback_params.dart b/lib/src/modules/auth/platform/callback_params.dart
index 41351546e..9ffa2634c 100644
--- a/lib/src/modules/auth/platform/callback_params.dart
+++ b/lib/src/modules/auth/platform/callback_params.dart
@@ -17,8 +17,9 @@ class WebCallbackSuccess extends CallbackParams {
final int? expiresIn;
/// OIDC ID Token. Required as `id_token_hint` for RP-Initiated
- /// Logout to deterministically end the IdP SSO session. Null until
- /// the BFF includes `id_token` in the callback redirect.
+ /// Logout to deterministically end the IdP SSO session. Null when the
+ /// backend's scope lacks `openid`, or when its callback carries no ID token
+ /// (before v0.82.3 / v0.83.3).
final String? idToken;
@override
diff --git a/lib/src/modules/auth/platform/callback_params_parser.dart b/lib/src/modules/auth/platform/callback_params_parser.dart
index 0b41cdeb9..511968046 100644
--- a/lib/src/modules/auth/platform/callback_params_parser.dart
+++ b/lib/src/modules/auth/platform/callback_params_parser.dart
@@ -3,8 +3,8 @@ import 'callback_params.dart';
/// Parses OAuth callback parameters from URL query params.
///
/// Returns [WebCallbackError] if an `error` key is present,
-/// [WebCallbackSuccess] if a token is found (checks both `token`
-/// and `access_token` keys), or [NoCallbackParams] otherwise.
+/// [WebCallbackSuccess] if a token is found (checks the `token` key), or
+/// [NoCallbackParams] otherwise.
CallbackParams parseCallbackParams(Map params) {
if (params.isEmpty) return const NoCallbackParams();
@@ -16,7 +16,7 @@ CallbackParams parseCallbackParams(Map params) {
);
}
- final accessToken = params['token'] ?? params['access_token'];
+ final accessToken = params['token'];
if (accessToken != null) {
return WebCallbackSuccess(
accessToken: accessToken,
@@ -29,26 +29,59 @@ CallbackParams parseCallbackParams(Map params) {
return const NoCallbackParams();
}
-/// Extracts query parameters from URL search string and hash fragment.
-///
-/// Checks [search] first (standard `?key=val`), then falls back to
-/// hash-based query params (`#/path?key=val`).
-Map extractQueryParams({
+/// The sign-in callback route, where the backend puts the tokens after a `?`.
+const authCallbackHash = '#/auth/callback';
+
+/// The query of a sign-in callback [hash], or `null` when [hash] is not
+/// `#/auth/callback?…`.
+String? callbackQueryFromHash(String hash) {
+ const prefix = '$authCallbackHash?';
+ return hash.startsWith(prefix) ? hash.substring(prefix.length) : null;
+}
+
+/// The URL to replace the page's with on a page load, so it starts with no
+/// query, not in the address bar ([search]) and not in the hash route, or
+/// `null` when it carries none. The callback query holds tokens; any other
+/// query is set by in-app navigation, so a page load starts with no query: an
+/// outside link can't supply one that way. It starts with [origin], so a
+/// [pathname] that reads as another host (`//evil.example/`) cannot make it
+/// cross-origin.
+String? urlWithoutQueries({
+ required String origin,
+ required String pathname,
required String search,
required String hash,
}) {
- if (search.isNotEmpty) {
- return Uri.splitQueryString(search.substring(1));
- }
+ final queryStart = hash.indexOf('?');
+ if (search.isEmpty && queryStart == -1) return null;
+ final route = queryStart == -1 ? hash : hash.substring(0, queryStart);
+ return '$origin$pathname$route';
+}
- if (hash.isNotEmpty) {
- final queryIndex = hash.indexOf('?');
- if (queryIndex != -1) {
- return Uri.splitQueryString(hash.substring(queryIndex + 1));
- }
- }
+/// A captured sign-in callback, and whether it was still in the URL because
+/// `web/index.html` lacks the script that moves it out before the app loads.
+typedef CapturedCallback = ({CallbackParams params, bool scriptMissing});
- return {};
+/// Captures the sign-in callback from the query `web/index.html` stashed
+/// ([stashedQuery]), falling back to the current URL's [hash].
+CapturedCallback captureCallback({
+ required String? stashedQuery,
+ required String hash,
+}) {
+ if (stashedQuery != null) {
+ return (
+ params: parseCallbackParams(Uri.splitQueryString(stashedQuery)),
+ scriptMissing: false,
+ );
+ }
+ final query = callbackQueryFromHash(hash);
+ if (query == null) {
+ return (params: const NoCallbackParams(), scriptMissing: false);
+ }
+ return (
+ params: parseCallbackParams(Uri.splitQueryString(query)),
+ scriptMissing: true,
+ );
}
int? _parseIntOrNull(String? value) {
diff --git a/lib/src/modules/auth/platform/callback_service.dart b/lib/src/modules/auth/platform/callback_service.dart
index e767cc4af..99b249b5b 100644
--- a/lib/src/modules/auth/platform/callback_service.dart
+++ b/lib/src/modules/auth/platform/callback_service.dart
@@ -11,13 +11,15 @@ export 'callback_params.dart';
abstract final class CallbackParamsCapture {
/// Capture callback params from current URL.
///
- /// On web, extracts tokens from URL query params.
+ /// On web, reads the tokens `web/index.html` moved out of the URL, or the
+ /// URL itself when that script is missing.
/// On native, returns [NoCallbackParams].
static CallbackParams captureNow() => impl.captureCallbackParamsNow();
}
/// Clears OAuth callback parameters from the browser URL.
///
-/// On web, removes tokens from the URL and browser history.
+/// On web, removes every query from the page URL on load, in the address bar
+/// and in the hash route, including the sign-in callback's tokens.
/// On native, this is a no-op.
void clearCallbackUrl() => impl.clearCallbackUrl();
diff --git a/lib/src/modules/auth/platform/callback_service_web.dart b/lib/src/modules/auth/platform/callback_service_web.dart
index d480e4ce7..4db17de4a 100644
--- a/lib/src/modules/auth/platform/callback_service_web.dart
+++ b/lib/src/modules/auth/platform/callback_service_web.dart
@@ -1,32 +1,46 @@
import 'dart:js_interop';
+import 'dart:js_interop_unsafe';
+import 'package:soliplex_logging/soliplex_logging.dart';
import 'package:web/web.dart' as web;
import 'callback_params.dart';
import 'callback_params_parser.dart';
-/// Captures callback params from current URL.
+final Logger _logger = LogManager.instance.getLogger('soliplex.auth_callback');
+
+/// Where `web/index.html` leaves the callback query it moved out of the URL.
+const _stashKey = 'soliplexCallbackQuery';
+
+/// Captures the sign-in callback, from the `web/index.html` stash or, when the
+/// script is missing, from the URL.
CallbackParams captureCallbackParamsNow() {
- final params = extractQueryParams(
- search: web.window.location.search,
+ final key = _stashKey.toJS;
+ final stashed = globalContext.getProperty(key)?.toDart;
+ globalContext.delete(key);
+ final captured = captureCallback(
+ stashedQuery: stashed,
hash: web.window.location.hash,
);
- return parseCallbackParams(params);
+ if (captured.scriptMissing) {
+ _logger.error(
+ 'Sign-in callback reached the app still in the URL: web/index.html '
+ 'lacks the callback script (see docs/authoring-a-flavor.md)',
+ );
+ }
+ return captured.params;
}
-/// Clears OAuth callback parameters from the browser URL.
+/// Removes every query from the page URL on load, in the address bar and in
+/// the hash route, including the sign-in callback's tokens.
void clearCallbackUrl() {
- final origin = web.window.location.origin;
- final pathname = web.window.location.pathname;
- var hash = web.window.location.hash;
-
- if (hash.isNotEmpty) {
- final queryIndex = hash.indexOf('?');
- if (queryIndex != -1) {
- hash = hash.substring(0, queryIndex);
- }
- }
-
- final cleanUrl = '$origin$pathname$hash';
- web.window.history.replaceState(JSObject(), '', cleanUrl);
+ final location = web.window.location;
+ final url = urlWithoutQueries(
+ origin: location.origin,
+ pathname: location.pathname,
+ search: location.search,
+ hash: location.hash,
+ );
+ if (url == null) return;
+ web.window.history.replaceState(JSObject(), '', url);
}
diff --git a/test/modules/auth/platform/callback_params_parser_test.dart b/test/modules/auth/platform/callback_params_parser_test.dart
index 03cdf05f7..b83ea9e3c 100644
--- a/test/modules/auth/platform/callback_params_parser_test.dart
+++ b/test/modules/auth/platform/callback_params_parser_test.dart
@@ -35,22 +35,6 @@ void main() {
expect((result as WebCallbackSuccess).accessToken, 'abc123');
});
- test('access_token param used as fallback', () {
- final result = parseCallbackParams({'access_token': 'xyz789'});
-
- expect(result, isA());
- expect((result as WebCallbackSuccess).accessToken, 'xyz789');
- });
-
- test('token takes precedence over access_token', () {
- final result = parseCallbackParams({
- 'token': 'primary',
- 'access_token': 'fallback',
- });
-
- expect((result as WebCallbackSuccess).accessToken, 'primary');
- });
-
test('refresh_token and expires_in forwarded when present', () {
final result = parseCallbackParams({
'token': 'abc',
@@ -98,46 +82,109 @@ void main() {
});
});
- group('extractQueryParams', () {
- test('parses from search string', () {
- final result = extractQueryParams(
- search: '?code=abc&state=xyz',
- hash: '',
- );
+ group('callbackQueryFromHash', () {
+ test('returns the query of the sign-in callback route', () {
+ expect(callbackQueryFromHash('#/auth/callback?token=abc&expires_in=60'),
+ 'token=abc&expires_in=60');
+ });
- expect(result, {'code': 'abc', 'state': 'xyz'});
+ test('ignores other routes, even with a query', () {
+ expect(callbackQueryFromHash('#/lobby?server=x'), isNull);
+ expect(callbackQueryFromHash('#/?url=https%3A%2F%2Fa&returnTo=%2Fr'),
+ isNull);
+ expect(callbackQueryFromHash('#/auth/callback'), isNull);
+ expect(callbackQueryFromHash(''), isNull);
});
+ });
- test('empty search falls back to hash query', () {
- final result = extractQueryParams(
- search: '',
- hash: '#/callback?token=abc&expires_in=3600',
- );
+ group('urlWithoutQueries', () {
+ const page = 'https://soliplex.example/app/';
+
+ String? clear(String hash,
+ {String search = '', String pathname = '/app/'}) =>
+ urlWithoutQueries(
+ origin: 'https://soliplex.example',
+ pathname: pathname,
+ search: search,
+ hash: hash,
+ );
+
+ test("drops a sign-in callback's tokens", () {
+ expect(clear('#/auth/callback?token=x'), '$page#/auth/callback');
+ });
+
+ test("drops a lobby route's query", () {
+ expect(clear('#/lobby?server=x'), '$page#/lobby');
+ });
- expect(result, {'token': 'abc', 'expires_in': '3600'});
+ test("drops the home route's query", () {
+ expect(clear('#/?url=x'), '$page#/');
});
- test('both empty returns empty map', () {
- final result = extractQueryParams(search: '', hash: '');
- expect(result, isEmpty);
+ test('drops the query of a hash with no path', () {
+ expect(clear('#?url=x'), '$page#');
});
- test('hash without query portion returns empty map', () {
- final result = extractQueryParams(
- search: '',
- hash: '#/callback',
+ test('drops the query of another spelling of the home route', () {
+ expect(clear('#//?url=x'), '$page#//');
+ });
+
+ test('drops tokens in the address bar query', () {
+ expect(clear('#/', search: '?token=a&refresh_token=b'), '$page#/');
+ });
+
+ test('drops the address bar query when there is no hash', () {
+ expect(clear('', search: '?token=a'), page);
+ });
+
+ test('leaves a URL with no query unchanged', () {
+ expect(clear('#/lobby'), isNull);
+ });
+
+ test('keeps the origin when the path reads as another host', () {
+ expect(clear('#/auth/callback?token=abc', pathname: '//evil.example/'),
+ startsWith('https://soliplex.example/'));
+ });
+ });
+
+ group('captureCallback', () {
+ test('reads the query index.html stashed', () {
+ final captured = captureCallback(
+ stashedQuery: 'token=abc&id_token=id',
+ hash: '#/auth/callback',
);
- expect(result, isEmpty);
+ expect(captured.scriptMissing, isFalse);
+ final success = captured.params as WebCallbackSuccess;
+ expect(success.accessToken, 'abc');
+ expect(success.idToken, 'id');
+ });
+
+ test('falls back to the URL and flags the missing script', () {
+ final captured = captureCallback(
+ stashedQuery: null,
+ hash: '#/auth/callback?token=abc',
+ );
+
+ expect(captured.scriptMissing, isTrue);
+ expect((captured.params as WebCallbackSuccess).accessToken, 'abc');
+ });
+
+ test('an ordinary page load is no callback and no missing script', () {
+ final captured =
+ captureCallback(stashedQuery: null, hash: '#/lobby?server=x');
+
+ expect(captured.params, isA());
+ expect(captured.scriptMissing, isFalse);
});
- test('search takes precedence over hash', () {
- final result = extractQueryParams(
- search: '?from=search',
- hash: '#/path?from=hash',
+ test('ignores an access_token key', () {
+ final captured = captureCallback(
+ stashedQuery: 'access_token=abc',
+ hash: '#/auth/callback',
);
- expect(result['from'], 'search');
+ expect(captured.params, isA());
});
});
}
diff --git a/web/index.html b/web/index.html
index 7bab97f9b..1da452fbd 100644
--- a/web/index.html
+++ b/web/index.html
@@ -15,6 +15,26 @@
+ Soliplex
+
+
@@ -27,7 +47,6 @@
- Soliplex