Skip to content

fix: start a malformed link at the start page; harden sign-in failures and the re-sign-in return page - #597

Merged
91jaeminjo merged 8 commits into
mainfrom
fix/boot-route-and-sign-in-failures
Oct 2, 2026
Merged

91jaeminjo merged 8 commits into
mainfrom
fix/boot-route-and-sign-in-failures

Conversation

@91jaeminjo

Copy link
Copy Markdown
Collaborator

Summary

A web link whose address can't be decoded opens the app's start page
instead of hanging on the splash screen. A storage failure while starting a
sign-in shows an error instead of a stuck spinner. A re-sign-in's return
page applies only to the server it was issued for. The pre-auth storage is
injected, so its failure paths are tested.

What changed

  • Boot guard (75534599, refined in 77091307).
    • buildRouter computes the start location itself, with go_router's own
      rule plus a decode check, and passes overridePlatformDefaultLocation: true.
    • A start address whose path or query doesn't decode (#/room/%FF/x)
      starts at the config's initial route, with one warning that carries no
      part of the address. Before, go_router threw before any route ran and
      the splash never cleared, and a reload kept the bad hash.
    • After boot, a push with such an address (a same-tab hash change, an
      iOS deep link) is dropped by the framework's own observer, so it needs
      no guard. The buildRouter doc says why.
  • Sign-in failures (2143a420, f353c110).
    • Everything in _authenticate that can throw sits inside its try:
      the pre-auth save, the inactivity-flag read, serverIdFromUrl.
    • A final catch covers Error as well. An Error is logged through
      describeFailure; anything else keeps error:.
    • The pre-auth state is cleared through clearBestEffort(). A failing
      store can't throw out of a catch block, and on web it can't drop a
      sign-in the IdP has already completed.
    • The callback's catch-all logs through describeFailure: a huge
      expires_in makes a RangeError that carries a value derived from the
      link.
  • Injected PreAuthStateStorage (3851598a). It is an interface now,
    with LocalPreAuthStateStorage as the default through
    preAuthStateStorageProvider (not exported). ConnectFlow takes it, and
    AuthCallbackScreen reads it.
  • ReturnTarget (3897587a, 0d3c1247, 77091307).
    • ConnectFlow takes a saved server's id and a path, built by
      HomeScreen from the known server. It saves the path only for a
      sign-in to that server, and keeps it across resets and retries.
    • isSafeReturnTo is public, also rejects a path or query that doesn't
      decode, and rejects /auth/callback. An unsafe target is dropped with a
      warning that carries no value. A stored one that no longer passes makes
      load clear the state.
    • The callback screen's own copy of the check was unreachable and is
      gone.
  • Doc (0a5ab198). installUncaughtErrorLogging and
    runSoliplexShell say that PlatformDispatcher.onError records nothing
    on web (Implement PlatformDispatcher.onError for web flutter/flutter#100277).

Library consumers: buildRouter starts at the config's initial route
when the platform's start address can't be decoded.

Behaviour worth knowing

  • A // path after the host still crashes, as on main. Flutter's web
    engine builds its first history URL from pathname without the origin.
    Recorded as a follow-up, with an index.html workaround and an upstream
    report.
  • Going Back from the IdP can restore the "Finish signing in" spinner.
    That happens when Chrome uses its back/forward cache, so no page load
    runs. It doesn't loop, and Cancel works. main is the same.
  • The return page is used only by the web callback. A native sign-in
    lands on the lobby, as before.

Tests

  • Boot guard: a malformed path, a malformed query, and a route that
    Uri.parse rejects each start at the initial route, with a record that
    carries no part of the address. A valid deep link, a platform /, a
    callback boot and an unknown route behave as before.
  • Sign-in failures: a failing save, isMarked, or clear (alone, after
    a failed save, after an IdP rejection, after a sign-in) ends at an error,
    or at Connected when the server was already added. A reset while a
    check fails stays reset. The Error and Exception arms log as
    intended.
  • ReturnTarget: the target is saved only for its own server, kept
    across a reset and a retry, and dropped when unsafe, undecodable, or
    /auth/callback.
  • HomeScreen: a saved server, an unknown server, and a saved address
    written differently.
  • Storage: LocalPreAuthStateStorage.load returns null when the store
    can't be read or the state can't be cleared.
  • Every new test was run failing first, or was shown to fail with its
    behaviour removed.

Test Plan

  • The full suite passes (2878), with only the existing
    thread_view_state_test disposed-signal trace.
  • flutter analyze reports no issues, dart format is clean,
    markdownlint is clean, and flutter build web succeeds.
  • Every commit analyzes cleanly and passes test/core and
    test/modules/auth.
  • Reviewed by seven reviewers (code, silent failures, comments, tests,
    types, simplification, security): no Critical or High findings. The
    approved findings are fixed in 77091307.
  • Manual pass on web (Chrome), macOS and the iOS simulator against an
    afsoc-rag backend with Keycloak, with temporary logging and fault
    injection:
    • Web:
      • sign-in, with and without the index.html script;
      • the page-load clear;
      • a malformed start address on load, push and reload;
      • a malformed callback;
      • a re-sign-in returning to its room;
      • a returnTo=/auth/callback dropped;
      • an unknown server's ?url= ignored;
      • injected save, clear and load failures.
    • macOS: native sign-in, and an injected isMarked failure.
    • iOS: malformed deep links, warm and cold, are dropped; a saved
      server's link auto-connects; an unknown one is ignored.

🤖 Generated with Claude Code

91jaeminjo and others added 8 commits October 2, 2026 10:14
A web page load on an address whose path or query doesn't decode (such
as `#/room/%FF/x`) threw inside go_router before any route, redirect or
error page ran, so the splash never cleared, and a reload kept the bad
hash. `buildRouter` now computes the start location itself, with
go_router's own rule (an empty path is `/`; a platform route of exactly
`/` means the initial route) plus a decode check, and passes it with
`overridePlatformDefaultLocation: true`. An address that doesn't decode
starts at `/`, with one warning that carries only the failure's type.

A push after boot (a same-tab hash change, an iOS deep link) is already
dropped by the framework's own observer, so it needs no guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`_authenticate` saved the pre-auth state and read the inactivity flag
before its `try`, and its callers don't await it, so a storage failure
or the `ArgumentError` the `PreAuthState` constructor throws for an
unsafe `returnTo` (from a link) left the spinner up with no message.
Everything that can throw now sits inside the `try`, and a final catch
covers errors as well as exceptions: an `Exception` is logged with
`error:`, anything else through `describeFailure`, so a link-supplied
`returnTo` never reaches the log. The pre-auth state is cleared through
a guarded helper, so a failing store can't throw out of a catch block.

The storage-failure tests for the guarded clear need a failing
`SharedPreferences` platform store; they wait for an injected
pre-auth store (follow-up).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web engine stores `PlatformDispatcher.onError` and never calls it
(flutter/flutter#100277), so on web an unhandled async error reaches only
the browser console. `installUncaughtErrorLogging`'s doc and
`runSoliplexShell`'s say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PreAuthStateStorage` was a static class over SharedPreferences, so
`ConnectFlow` couldn't be given one that fails, and the tests for its
guarded pre-auth clear had no seam short of the plugin's platform store.
It is an interface now, with `LocalPreAuthStateStorage` keeping the same
bodies and key, provided by `preAuthStateStorageProvider` with that as
the default (not exported, so forks see no change). `ConnectFlow` takes
it in its constructor and `AuthCallbackScreen` reads it from the
provider.

Restores the storage-failure tests: a failed save, and the clear failing
after a failed save, after an IdP rejection, and after a sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A re-sign-in link carries `?url=` and `returnTo`. `HomeScreen` forwarded
`returnTo` on every connect, so after the address was edited, or a retry,
a sign-in to another server still returned to the first server's page.
`ConnectFlow` takes a `ReturnTarget` (a saved server's id and a path) in
its constructor, built from the known server, and saves the path only for
a sign-in to that server; it lasts across resets and retries.

`isSafeReturnTo` is public and also rejects a path or query that doesn't
decode, which would throw when the callback navigates to it. An unsafe
target is dropped with a warning that carries no value, and a stored one
that no longer passes makes `load` clear the state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check

`isDecodable` holds the path-and-query decode check the router's start
guard and `isSafeReturnTo` both run. The callback screen's return check
goes through `isSafeReturnTo` instead of its own weaker copy, and logs a
rejected value without the value, per the logging rule; the diagnostics
risks doc no longer lists `returnTo` among logged attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The callback screen cleared the saved pre-auth state before adding the
server, unguarded, so a failing store dropped a sign-in the IdP had
already completed ("Something went wrong"). `ConnectFlow` already treated
the same clear as best-effort; both now use one `clearBestEffort()` on
`PreAuthStateStorage`, which logs a failure and never throws.

The callback's catch-all logs through `describeFailure`: a huge
`expires_in` in the link makes the expiry overflow with a `RangeError`
that carries a value derived from it. No call in that `try` does network
I/O, so no host is lost.

`buildRouter`'s doc says why an undecodable push after boot leaves no
record: the framework's first binding observer drops it before the router
runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the whole-branch review:

- An undecodable start address starts at the config's initial route,
  as a platform route of `/` does, instead of a fixed `/`: a signed-in
  user lands on the lobby, a landing path is honoured, and a fork with
  no `/` route has no dead end. `platformStartLocation` returns `null`
  for a rejected route instead of throwing.
- `isSafeReturnTo` also rejects `/auth/callback` (with or without a
  trailing slash or query), so a link can't make a sign-in return into
  the callback, and parses with `Uri.tryParse`.
- `AuthCallbackScreen` navigates to the stored return page directly: the
  `PreAuthState` constructor already enforces `isSafeReturnTo`, so its
  re-check could never reject anything.
- `LocalPreAuthStateStorage.load` returns `null` when the store can't be
  read or the state can't be cleared, as its interface says; `now` is an
  implementation detail, off the interface.
- `_authenticate`'s final catch sends an `Error` through
  `describeFailure` and keeps `error:` for anything else, which also
  covers a web storage `DOMException`.

Tests pin the callback's rejection of tampered state, HomeScreen's
target for unknown and differently written saved addresses, and the
storage failures of `load`; comments name reasons, not pointers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@91jaeminjo
91jaeminjo merged commit bbcdf8e into main Oct 2, 2026
6 checks passed
@91jaeminjo
91jaeminjo deleted the fix/boot-route-and-sign-in-failures branch October 2, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant