Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/refresh-token-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,101 @@ and does not require any backend changes.
| `signed-in` | After a successful `/v1/auth/siwe/verify` | Write session to sessionStorage and authenticate |
| `refreshed` | After a successful `/v1/auth/siwe/refresh` | Update access token in sessionStorage |
| `signed-out` | After logout or 401-triggered expiry | Clear session and show re-auth prompt |
| `request-current-session` | Sent by a tab that detects (via the localStorage marker below) that a peer refreshed but never received that peer's `refreshed` message | Any tab holding a valid session for that address re-broadcasts `refreshed` |

Because each tab runs its own `SiweAuthProvider` instance, two tabs can
independently enter the proactive renewal window (60 s before access-token
expiry) and both attempt to redeem the same **one-time-use** refresh token
before either observes the other's `BroadcastChannel` message. The refresh
path (`lib/wallet/refresh-coordination.ts`, driven from
`lib/wallet/providers.tsx`) adds three layers on top of `BroadcastChannel` to
prevent that race:

### Web Locks coordination

The network call to `POST /v1/auth/siwe/refresh` is wrapped in
`navigator.locks.request()`, scoped per wallet address
(`guildpass:siwe-refresh:<address>`). At most one same-origin tab holds the
lock for a given address at a time, so at most one tab is ever mid-flight on
the refresh call for that address; any other tab that reaches its renewal
window for the same address queues behind the lock instead of firing a
concurrent request.

### Adopting a peer's refreshed session

A tab that was queued behind the lock does **not** blindly replay its refresh
token once the lock is granted — that token may already have been rotated
away by the peer that held the lock first. Before calling the API it:

1. Re-reads the stored session and checks whether it is now newer than the
session snapshot this attempt started with (different `expiresAt` /
`refreshToken`, and not itself expired). If so, it adopts that session
directly — no network call.
2. If storage hasn't caught up yet — a peer's `refreshed` message can be
**missed entirely**, not just delayed, if it was sent before this tab's
`BroadcastChannel` listener existed (e.g. the winner finishes in a
handful of milliseconds while this tab is still mid-navigation) — the tab
sends `request-current-session` and briefly polls storage for any peer's
response, rather than assuming it must perform its own call.
3. Only if neither step yields a fresh session does the tab call
`siweRefresh()` itself, store the result, and broadcast `refreshed`.

This adoption path also runs when Web Locks is unavailable (see below), so a
tab that loses an unlocked race can still recover a peer's result instead of
presenting an already-invalidated token.

A small `localStorage` marker (timestamp of the last successful refresh per
address — never the token itself) backs step 1/2 above: unlike
`BroadcastChannel` messages, `localStorage` writes are synchronously visible
to other same-origin tabs regardless of listener timing, so it reliably tells
a queued tab *that* a peer refreshed even when it missed the message saying
so.

### Bounded fallback when the leader disappears

The wait for a peer's response (`request-current-session` → `refreshed`) is
bounded — by default a 2 second timeout, polling storage every 25 ms. If the
tab that completed the refresh (or held the lock) closes or navigates away
before a waiting tab observes the result — e.g. every peer holding the fresh
session closed its tab — the wait times out and the queued tab falls back to
performing its own `siweRefresh()` call. A stuck or vanished leader tab can
therefore never wedge session renewal in the tabs that survive it.

### Graceful degradation without Web Locks or BroadcastChannel

Both coordination primitives are optional; their absence degrades safety
without breaking functionality:

- **No Web Locks** (`navigator.locks` undefined): `withRefreshLock` runs the
refresh operation directly instead of acquiring a lock. Same-tab exclusion
(the existing `isRefreshing` ref) still prevents duplicate calls within one
tab, and the peer-adoption check above still lets a tab that loses an
unlocked race adopt the winner's session — but multiple tabs can now
concurrently *attempt* the network call, relying on the backend's one-time-use
enforcement (see below) to make any redundant attempt fail safely as a 401
rather than a security issue.
- **No `BroadcastChannel`** (checked via `"BroadcastChannel" in window`): the
provider skips wiring up the channel entirely. Cross-tab propagation of
`signed-in` / `refreshed` / `signed-out` and the `request-current-session`
handshake do not happen, so each tab manages its own session independently
and only learns of a peer's rotation the next time it reads
`sessionStorage` on its own schedule (e.g. its own renewal timer). Within a
single tab, refresh behaviour is unaffected.

In both degraded cases, no tab presents a refresh token it already knows to
be stale — the remaining risk is redundant network calls, not incorrect
token reuse, and that risk is bounded by the backend contract below.

### Backend contract is unchanged

None of this cross-tab coordination changes the backend contract described
above. The refresh token is still **one-time-use**
(see [Token rotation and invalidation semantics](#token-rotation-and-invalidation-semantics)):
if degraded conditions ever let two tabs present the same refresh token, the
backend's existing 401 (`unauthorized`) response for an already-used token is
what makes the loser's attempt fail safely — the frontend coordination above
exists purely to make that case rare, not to replace the backend's
enforcement of it.

---

Expand Down
7 changes: 7 additions & 0 deletions lib/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,13 @@ export class MockAccessApi implements AccessApi {

async siweRefresh(refreshToken: string): Promise<SiweAuthSession> {
await initPromise
// e2e instrumentation only: mock mode makes no real network request for
// siweRefresh, so cross-tab race tests need some observable signal for
// "how many refresh attempts actually happened" per tab.
if (typeof window !== 'undefined') {
(window as any).__mockSiweRefreshCalls__ =
((window as any).__mockSiweRefreshCalls__ ?? 0) + 1
}
if (MOCK_SESSION_STATE === 'expired' || MOCK_SESSION_STATE === 'unauthenticated') {
throw new ApiError({
status: 401,
Expand Down
92 changes: 81 additions & 11 deletions lib/wallet/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* Multi-tab synchronisation — BroadcastChannel
* ─────────────────────────────────────────────
* A single named channel (`guildpass:auth`) broadcasts auth-state transitions
* to every other same-origin tab. Three message types are emitted:
* to every other same-origin tab. Message types:
*
* { type: 'signed-in', session: SiweAuthSession }
* — Sent after a successful wallet signature. Peer tabs write the session
Expand All @@ -37,6 +37,14 @@
* — Sent after an explicit logout or a detected expiry. Peer tabs clear
* their session and transition to the appropriate unauthenticated state.
*
* { type: 'request-current-session', address: string }
* — Sent by a tab that, per lib/wallet/refresh-coordination.ts's
* localStorage marker, knows a peer just refreshed but never received
* that peer's 'refreshed' message (e.g. sent before this tab's
* listener existed — BroadcastChannel does not queue/replay missed
* messages). Any tab currently holding a valid session for that
* address responds by re-broadcasting 'refreshed'.
*
* The tab that sends a message does NOT receive it via its own listener
* (BroadcastChannel's same-tab exclusion), so there is no risk of loops.
*
Expand Down Expand Up @@ -72,6 +80,7 @@ import { SiweAuthSession, AdminSessionStatus } from "@/lib/api/types";
import {
clearAuthSession,
getStoredToken,
isAccessTokenExpired,
isRefreshTokenExpired,
loadAuthSession,
loadAuthSessionIncludingExpired,
Expand All @@ -81,6 +90,12 @@ import {
subscribeToAuthSessionStorage,
} from "@/lib/session";
import { isApiError } from "@/lib/api/errors";
import {
isSessionAlreadyRefreshed,
markRefreshCompleted,
waitForPeerRefresh,
withRefreshLock,
} from "@/lib/wallet/refresh-coordination";
import {
buildSiweMessage,
deriveSessionStatus,
Expand All @@ -99,7 +114,8 @@ const wagmiConfig = createConfig(walletConfig);
type AuthBroadcastMessage =
| { type: "signed-in"; session: SiweAuthSession }
| { type: "refreshed"; session: SiweAuthSession }
| { type: "signed-out" };
| { type: "signed-out" }
| { type: "request-current-session"; address: string };

const AUTH_CHANNEL_NAME = "guildpass:auth";

Expand Down Expand Up @@ -225,7 +241,22 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {

/**
* Attempt a silent token renewal using the stored refresh token.
* On success: updates reducer state, persists session, broadcasts.
*
* The network call is wrapped in withRefreshLock() so at most one
* same-origin tab performs it per address at a time. A tab that was queued
* behind the lock re-checks storage first (isSessionAlreadyRefreshed) and
* adopts a peer's already-rotated session instead of replaying the (now
* invalidated) refresh token. If sessionStorage hasn't caught up yet — the
* peer's BroadcastChannel message can be missed entirely if it was sent
* before this tab's listener existed, not just delayed — it asks any
* listening peer to resend the current session via a
* 'request-current-session' message before falling back to its own call.
* See lib/wallet/refresh-coordination.ts.
*
* On success: updates reducer state, persists + broadcasts session (only
* the tab that actually called the API does this), and drains any pending
* retry callbacks — whether this tab performed the refresh or adopted a
* peer's, the session is fresh either way.
* On failure: transitions to 'expired', broadcasts sign-out.
*/
const performSilentRefresh = useCallback(
Expand All @@ -240,20 +271,46 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {

isRefreshing.current = true;
try {
const api = getApi(session.address);
const refreshed = await api.siweRefresh(session.refreshToken);
storeAuthSession(refreshed);
dispatch({ type: "refresh-success", session: refreshed });
broadcast({ type: "refreshed", session: refreshed });
scheduleRenewal(refreshed);
const settled = await withRefreshLock(session.address, async () => {
// Re-check whether another tab already refreshed while this tab
// waited (or, without Web Locks, raced ahead of it).
const current = loadAuthSessionIncludingExpired();
if (current && isSessionAlreadyRefreshed(current, session)) {
return current;
}

// sessionStorage may not have caught up to a peer's refresh yet.
// Ask any listening peer to resend the current session before
// assuming this tab needs to perform its own (redundant,
// already-invalidated) call.
const fromPeer = await waitForPeerRefresh(
session.address,
session,
() => loadAuthSessionIncludingExpired(),
() => broadcast({ type: "request-current-session", address: session.address }),
);
if (fromPeer) {
return fromPeer;
}

const api = getApi(session.address);
const refreshed = await api.siweRefresh(session.refreshToken!);
storeAuthSession(refreshed);
markRefreshCompleted(session.address, refreshed.expiresAt);
broadcast({ type: "refreshed", session: refreshed });
return refreshed;
});

dispatch({ type: "refresh-success", session: settled });
scheduleRenewal(settled);

// A silent refresh also counts as session recovery — drain any pending
// retry callbacks that were registered before the 401 was surfaced.
const retries = pendingRetriesRef.current;
pendingRetriesRef.current = [];
for (const entry of retries) {
try {
await entry.callback(refreshed);
await entry.callback(settled);
} catch (retryErr) {
entry.onRetryFailure?.(retryErr);
}
Expand Down Expand Up @@ -373,6 +430,19 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
applyIncomingSession(msg.session);
} else if (msg.type === "signed-out") {
applyIncomingSession(null);
} else if (msg.type === "request-current-session") {
// A peer missed our (or another tab's) 'refreshed' broadcast —
// BroadcastChannel doesn't queue/replay messages sent before a
// listener existed. If we currently hold a valid session for the
// requested address, resend it.
const current = loadAuthSessionIncludingExpired();
if (
current &&
current.address.toLowerCase() === msg.address.toLowerCase() &&
!isAccessTokenExpired(current)
) {
broadcast({ type: "refreshed", session: current });
}
}
};

Expand All @@ -386,7 +456,7 @@ export function SiweAuthProvider({ children }: { children: React.ReactNode }) {
return () => {
unsubscribeStorage();
};
}, [address, cancelRenewal, scheduleRenewal]);
}, [address, broadcast, cancelRenewal, scheduleRenewal]);

// ── Invalidation event from same tab (lib/session.ts fires this) ───────────

Expand Down
Loading