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 <noreply@anthropic.com> --- 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 `<title>`, with no tag that loads a resource (`<link>`, +`<script src>`) above it: + +```html +<script> + (function () { + var route = '#/auth/callback'; + var hash = window.location.hash; + if (hash.indexOf(route + '?') !== 0) return; + window.soliplexCallbackQuery = hash.substring(route.length + 1); + history.replaceState( + null, '', + window.location.origin + window.location.pathname + + window.location.search + route); + })(); +</script> +``` + +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 `<title>` 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<String, String> params) { if (params.isEmpty) return const NoCallbackParams(); @@ -16,7 +16,7 @@ CallbackParams parseCallbackParams(Map<String, String> 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<String, String> 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<String, String> 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<JSString?>(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<WebCallbackSuccess>()); - 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<NoCallbackParams>()); + 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<NoCallbackParams>()); }); }); } 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 @@ <base href="$FLUTTER_BASE_HREF"> <meta charset="UTF-8"> + <title>Soliplex + + @@ -27,7 +47,6 @@ - Soliplex