Skip to content

fix(wave): stop a skewed device clock from logging users out in the browser - #1969

Merged
efstajas merged 3 commits into
mainfrom
fix/wave-client-clock-skew
Sep 23, 2026
Merged

efstajas merged 3 commits into
mainfrom
fix/wave-client-clock-skew

Conversation

@efstajas

Copy link
Copy Markdown
Contributor

What

  • src/routes/(pages)/wave/+layout.server.ts now returns serverTime: Date.now() next to waveAccessToken.
  • getUserData in src/lib/utils/wave/auth.ts takes an optional nowMs (default Date.now()) and runs the existing EXPIRY_BUFFER_SECONDS check against it. The server hook calls it unchanged.
  • src/routes/(pages)/wave/+layout.ts, when running in the browser, measures the offset between data.serverTime and the device clock and passes Date.now() + offset into getUserData. The offset is memoized at module level and recomputed only when a new serverTime arrives, because the universal load re-runs on client-side navigations while the server-load data may not. If serverTime is missing it falls back to Date.now(), i.e. today's behaviour.

Why

Wave login state is decided in two places. Server side, hooks.server.ts reads the wave_access_token cookie and checks its exp with the server clock. Client side, the wave layout's universal load reads the same cookie from document.cookie and ran the same check with Date.now(), which is the device clock.

Access tokens live 15 minutes. When a device clock is 15 or more minutes ahead of real time, a token issued seconds ago already looks expired to the browser, so the client renders the user as logged out while the server, using its own clock, renders them as logged in. Guarded pages redirect to /wave/login, whose load sees the server-side user and redirects back, and the user is stuck in a login loop.

The cookie itself survives the skew because Chrome corrects cookie expiry using the server Date header; only this JavaScript expiry check breaks. Two users were stuck in this loop today.

The server keeps its own clock throughout, so this only changes what the browser compares against.

Verification

  • npx prettier --check on the three touched files: clean
  • npx eslint on the three touched files: clean
  • npx svelte-check --threshold error --output human: 0 errors (178 pre-existing warnings)
  • No unit test file exists for src/lib/utils/wave/auth.ts, so none was added.

…rowser

Wave login state is derived from the access-token cookie in two places: the
server hook (server clock) and the universal wave layout load in the browser
(document.cookie + Date.now()). When a device clock runs 15+ minutes fast, a
token issued seconds ago already fails the client-side expiry check, so the
client renders the user as logged out while the server still sees them as
logged in. Guarded pages then bounce between themselves and /wave/login.

The server layout now returns its own clock, and the browser measures the
skew against it once per fresh server time, passing a corrected clock into
getUserData. The server keeps calling getUserData with the default clock, so
server behaviour is unchanged.
@efstajas

Copy link
Copy Markdown
Contributor Author

/ryph reviewer review this thoroughly. safe to merge?

@pga-ryph

pga-ryph Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

✅ Reviewer finished — ryph run r-7ca2df170e is succeeded.

Reviewer posted what it did in this thread.

No code changes were needed.

Provenance: requested by @efstajas via a /ryph command.


Proposed by Reviewer (ryph run r-7ca2df170e). This is unreviewed agent output — read the diff before merging.

View the run

A +layout.ts module is evaluated on the server as well, so module-level
mutable state there is shared across every request in the Node process.
It was only safe because the call sat behind a browser check. Move the
offset into a small utility that returns the plain wall clock on the
server before touching any state, so the layout stays stateless.
@efstajas

Copy link
Copy Markdown
Contributor Author

Follow-up commit: the clock-skew offset no longer lives as module-level state in +layout.ts. That module is evaluated on the server too, so top-level mutables there are process-wide; it was only safe because the call was behind browser. The state now lives in src/lib/utils/wave/server-clock.ts, whose correctedNow returns the plain wall clock on the server before touching anything, so the layout is stateless again. Prettier, ESLint and svelte-check (0 errors) re-run on the touched files.

Exercises the real modules under a device clock 20 minutes fast: the
offset is measured against server time, advances with the local clock
between server loads, is re-measured when a new server time arrives,
falls back to the device clock without one, and is never touched on the
server. Also checks that a fresh token is rejected on the raw device
clock but accepted on the corrected one, and that a genuinely expired
token is still rejected.
@efstajas

Copy link
Copy Markdown
Contributor Author

Verification, since this module had no tests:

  • Added src/lib/utils/wave/server-clock.unit.test.ts (5 tests, all green under npm run test:unit). It runs the real auth.ts and server-clock.ts with the device clock set 20 minutes fast: a fresh token is rejected on the raw clock and accepted on the corrected one, a genuinely expired token stays rejected, the offset advances with the local clock between server loads, is re-measured on a new serverTime, falls back to the device clock without one, and is untouched on the server.
  • Ran the dev server on this branch and fetched /wave: the serialized layout data carries serverTime (e.g. serverTime:1790165459490), so the field reaches the client. The page itself 500s locally only because no Wave API runs on this machine (fetch failed against localhost:8000), unrelated to this change.
  • Prettier, ESLint and svelte-check (0 errors) on all touched files.

@pga-ryph pga-ryph Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: ✅ LGTM

Verified independently (not just trusting the PR description):

  • npx prettier --check and npx eslint on the four touched files: clean.
  • npx svelte-check --threshold error: 0 errors attributable to these files (the 440 pre-existing errors are all in unrelated $lib/graphql/__generated__ consumers, a known baseline in this checkout).

Logic review:

  • The actual authorization boundary is untouched — the backend/cookie exp and hooks.server.ts's server-side check still use the real server clock. This change only affects what the browser uses to decide whether to render the user as logged in in the universal load, so there's no security regression from letting a client claim any nowMs it wants.
  • correctedNow is careful about where its module-level mutable state can be touched: the !browser short-circuit runs first, so the SSR codepath (where the module is a process-wide singleton across requests) never writes lastServerTime/clockOffsetMs. Good catch already made in the follow-up commit — moving this out of +layout.ts (which is genuinely shared server-side) and into a dedicated module is the right shape.
  • Memoization behavior matches the stated intent: the offset is recomputed only when a new serverTime arrives (i.e. after the server load reruns), and holds steady across client-side navigations where the parent server load doesn't rerun — avoiding the offset drifting away from a legitimate elapsed-time delta into noise.
  • getUserData's new nowMs parameter defaults to Date.now(), so the one other call site (hooks.server.ts, and the server branch in +layout.ts) is unaffected and needed no changes.
  • serverTime missing (undefined) falls back to Date.now(), preserving today's behavior as a safe default — can't in practice happen given +layout.server.ts always sets it, but it's a sensible defensive default anyway.

No changes requested. Nit-level observations only: no unit test was added for auth.ts/server-clock.ts, but that's consistent with there being no existing test file for auth.ts at all — not a regression this PR introduces.

Safe to merge.


— Reviewer, a ryph agent · run · asked by @efstajas

@efstajas
efstajas merged commit 995a19c into main Sep 23, 2026
6 of 8 checks passed
@efstajas
efstajas deleted the fix/wave-client-clock-skew branch September 23, 2026 12:17
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