Skip to content

fix: v0.20 client hardening (#198, #199, #204, #205, #206, #207) - #209

Merged
Bccorb merged 8 commits into
mainfrom
fix/v020-client-hardening
Oct 25, 2026
Merged

Bccorb merged 8 commits into
mainfrom
fix/v020-client-hardening

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Six v0.20 hardening fixes for the client core, the React and React Native bindings, and the release workflow. One commit per issue, each with tests and (where user-facing) a patch changeset.

Fixes

  • client: bearer redirect guard does not run on React Native #198 bearer redirect guard on React Native. The guard only ran when response.redirected was true, which whatwg-fetch never sets. A bearer request now rejects with UntrustedRedirectError whenever response.url is set, differs from the requested URL, and is not on apiHost or trustedOrigins. The client and React Native READMEs now say that on React Native the guard detects a followed redirect after the fact rather than preventing it. Test: a fetch stub with no redirected, url on another origin, status 200.
  • client: requestResult drops the cause of a transport failure #199 requestResult keeps the cause. A thrown request now yields a status 0 SeamlessAuthError whose cause is the thrown error. Test: a client method given a fetch that throws UntrustedRedirectError returns an error whose cause is that instance.
  • fix(client): bearer refresh follows redirects on React Native #204 bearer refresh refuses redirects. POST /refresh is sent with redirect: 'manual'. Any 3xx, an opaqueredirect, or a response whose final URL is on an untrusted origin rejects with UntrustedRedirectError and clears the held session (it cannot be renewed through that server, and on React Native the token may already have gone). Tests cover a cross-origin 307, a same-origin 302, a hidden redirect, and a platform-followed redirect.
  • fix(react): strip the token and code from the URL on the magic link and OAuth callback views #205 React callback views drop the query. VerifyMagicLink and OAuthCallback replace the URL query once read (setSearchParams(..., { replace: true })), as the Vue, Angular and Svelte screens do with dropQuery(). The magic link token is held in state so a sign-in held for another device can still be confirmed. Tests include a real MemoryRouter run showing the query is gone and confirmation still sends the token.
  • ci: release job runs install scripts with an OIDC token #206 release job. npm ci --ignore-scripts (no package needs an install script; the existing build step builds everything), workflow-level permissions: {} with the job granted only contents, pull-requests and id-token write (issues: write dropped), and actions/checkout, actions/setup-node and changesets/action pinned to the SHAs their current v6/v6/v1 refs resolve to, with version comments.
  • fix(client): only navigate to an http(s) OAuth authorization URL #207 OAuth authorization URL scheme. New assertNavigableAuthorizationUrl and UnsafeAuthorizationUrlError in @seamless-auth/client. The browser port and the React Native createWebBrowserOAuthRedirect port allow only https:, or http: on localhost, 127.0.0.1 or [::1], and throw otherwise without navigating. The scheme is judged the way a URL parser does (case, embedded tabs and newlines, leading control characters), by hand because React Native's built-in URL has not always implemented protocol/hostname. startOAuthSignIn reports a refused URL as a provider that could not start. Tests cover javascript:, data:, mixed case, tab/newline-split and percent-encoded variants, relative URLs, non-loopback http:, and userinfo/backslash host tricks.

Checks

Run on a fresh npm ci --ignore-scripts install, matching the new release job:

npm run lint              exit 0
npm run typecheck         exit 0 (all six packages; svelte-check 0 errors, 0 warnings)
npm run format:check      All matched files use Prettier code style!
npm test -- --runInBand   Jest: Test Suites 47 passed, Tests 662 passed
                          Vitest (svelte): Test Files 3 passed, Tests 41 passed
npm run build             exit 0 (all six packages)
npm run check-npm-build   exit 0

Closes #198
Closes #199
Closes #204
Closes #205
Closes #206
Closes #207

React Native's fetch follows redirects itself and never sets
response.redirected, so a response that ended on another origin passed the
guard. Refuse any response whose url differs from the one requested and is
not trusted, and document that on React Native the guard detects a followed
redirect rather than preventing it.

Closes #198
A request that threw reached the app as a status 0 error with no cause, so
an UntrustedOriginError or UntrustedRedirectError could not be told apart
from a network failure. Pass the thrown error through as the cause.

Closes #199
The refresh ran outside the origin-checked request loop with the default
redirect: 'follow', so a 3xx would resend the refresh token to the target.
Send it with redirect: 'manual', refuse any 3xx or hidden redirect, apply
the final-URL check for platforms that follow redirects themselves, and
clear the held session when the refresh is refused this way.

Closes #204
VerifyMagicLink and OAuthCallback left ?token= and ?code= in the address bar
and history. Replace the query once it is read, as the Vue, Angular and
Svelte screens do with dropQuery(). The magic link token is kept in state
so a held sign-in can still be confirmed after the query is gone.

Closes #205
The browser and React Native redirect ports opened whatever URL the server
returned. Allow https, and http only on a loopback host, judging the scheme
the way a URL parser does (case, tabs, newlines, leading control
characters), and throw UnsafeAuthorizationUrlError otherwise.
startOAuthSignIn reports that as a provider that would not start.

Closes #207
The release job ran npm ci with lifecycle scripts while holding an OIDC
token and the npm token, and granted issues: write it never used.

- install with npm ci --ignore-scripts; no package needs an install script,
  and the existing build step builds every package
- default the workflow to no permissions and grant the job only contents,
  pull-requests and id-token write
- pin actions/checkout, actions/setup-node and changesets/action to the
  commit SHAs their current major tags resolve to

Closes #206
@Bccorb
Bccorb merged commit e5e6680 into main Oct 25, 2026
5 checks passed
@Bccorb
Bccorb deleted the fix/v020-client-hardening branch October 25, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment