Skip to content
Open
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
30 changes: 30 additions & 0 deletions docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,36 @@ The existing static serving needs no special-casing: `serveStatic` already
answers `application/manifest+json` for `.webmanifest` and `text/javascript` for
`sw.js`.

### An expired session drops to sign-in

Sessions live only in the Server's memory ([server.md](./server.md)), so they
end on their 12h expiry *and* on every Server restart, while the passkey and the
paired-host markers in `localStorage` outlive both. Recovery is therefore one
passkey prompt — but only if the user is offered it.

Pocket treats a dead session as actionable rather than reportable:
`PocketClient` clears its in-memory token and throws `SessionExpiredError`, and
the app tears down any live adapter and returns to the sign-in screen carrying
that message. Signing back in restores the Hosts list with pairing and push
registration intact. Source of truth: `SessionExpiredError` in
`lib/src/remote/client/pocket-client.ts`, handled in `run` in
`lib/src/remote/pocket-app/App.tsx`.

Two details this depends on:

- **The trigger is the session gate specifically**, matched on the shared
`UNAUTHORIZED_ERROR` from `server-lib-common/src/remote/wire.ts` — a 401 alone
is ambiguous, since a wrong setup password and a rejected device signature
answer 401 too, and signing the user out for those would be worse than the bug
this fixes.
- **A rejected relay upgrade carries no status.** The browser surfaces it as a
bare `error` event, so `openSocket` asks an authenticated route what happened:
a 401 there means expiry, anything else leaves it an ordinary socket failure.

Without this an installed Pocket is stuck — there is no address bar to reload
from, and the in-app Refresh re-sends the same dead token — leaving force-quitting
the app as the only way back in.

## Deployment: same-origin, always

WebAuthn binds passkeys to the serving origin, and Chrome's Private Network
Expand Down
57 changes: 52 additions & 5 deletions docs/specs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ UI lives in `lib`/`standalone`.
| `DORMOUSE_STATE_DIR` | Where the JSON state files live. Default `./data`. |
| `PORT` | Default 3000. |
| `DORMOUSE_VAPID_PUBLIC_KEY` / `DORMOUSE_VAPID_PRIVATE_KEY` | Web Push signing keypair. Set both or neither. At startup the Server decodes both, derives the P-256 public point from the private key, and exits on a missing, malformed, or mismatched pair. Unset, the server mints a pair on first boot and persists it to `vapid.json`. |
| `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292). Default `mailto:admin@localhost`. The Server parses and validates it at startup and exits on an invalid value. |
| `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292). Defaults to `DORMOUSE_ORIGIN` when that origin is https and not loopback; otherwise there is no default and push stays off. The Server parses and validates it at startup and exits on an invalid value — including a loopback contact, which Apple rejects. |

WebAuthn requires a secure context: `localhost` works for development; for a
real phone, put the server behind TLS (`tailscale serve` is the intended
Expand Down Expand Up @@ -157,6 +157,14 @@ so `node --test` can drive setup → pairing → connect end to end via
The setup password is compared in constant time with a small fixed delay on
failure; that is the extent of the hardening today.

Every session-gated route — including the `/ws/client` upgrade, which is
rejected before `injectWebSocket` ever sees it — answers an unknown or expired
token with 401 and the shared `UNAUTHORIZED_ERROR` from
`server-lib-common/src/remote/wire.ts`. That exact string is load-bearing:
Pocket keys its "sign in again" recovery on it, and a bare 401 is ambiguous,
since a wrong setup password and a rejected device signature answer 401 as well
([pocket-app.md](./pocket-app.md) -> An expired session drops to sign-in).

### Web Push

The relay routes between two live sockets; a push has to reach a phone whose app
Expand Down Expand Up @@ -214,10 +222,29 @@ Source of truth: `server/src/push.ts` and the routes in `server/src/app.ts`.
never silent: the refusal is logged (origin only — the endpoint is a bearer
capability) and counted in the response's `failed`, since the route answers
200 either way and the Host needs to tell an all-failed fan-out from
success. TTL is 300s — an alarm that arrives an hour late is noise, not
information.
- Push is disabled, not half-working, when no VAPID key is configured: the
config route reports `null` and subscribe/send answer 503.
success. The log carries the push service's own reason body alongside the
status — whitespace-collapsed and capped at 200 characters so an HTML error
page cannot flood it — because a status alone does not separate a bad subject
from a bad key from a bad payload, and this is the only place that
explanation is ever visible. TTL is 300s — an alarm that arrives an hour late
is noise, not information.
- Push is disabled, not half-working, when no VAPID key **or no VAPID subject**
is configured: the config route reports `null` and subscribe/send answer 503.
The key and the subject are advertised together or not at all — a phone that
registered against a key the Server has no contact to sign with would be
subscribed to a push it can never receive.
- **A VAPID subject naming a loopback host is a startup error, not a default.**
Apple answers `403 {"reason":"BadJwtToken"}` for one — verified against
`web.push.apple.com` for `mailto:admin@localhost` and `https://localhost:3000`,
while `mailto:admin@example.com` and an ordinary https origin were accepted, so
the rule is loopback specifically and not reachability of the contact.
`web-push` only warns about the https form, at send time, and says nothing
about `mailto:` at `localhost`. This mattered: the previous default
(`mailto:admin@localhost`) let a Server boot clean, answer 200 on send, and
deliver nothing to any iPhone — the one platform the feature targets. Hence
the origin-derived default, and hence a loopback dev server turning push off
instead of guessing a placeholder contact. Source of truth:
`defaultVapidSubject` / `assertVapidSubject` in `server/src/push.ts`.

## Relay

Expand Down Expand Up @@ -373,6 +400,18 @@ Builds the Pocket app (`lib/dist-pocket`) and the server, then serves both on
`DORMOUSE_ORIGIN` to your TLS origin (e.g. via `tailscale serve`) — WebAuthn
needs a secure context, and only `localhost` is exempt.

On the default localhost origin **push is off** and the server says so at
startup: there is no routable operator contact to sign a VAPID JWT with, and a
phone could not route to localhost anyway. Setting `DORMOUSE_ORIGIN` to an https
origin enables it with no further configuration, since that origin becomes the
subject. To exercise push against a desktop browser on localhost, supply a
contact explicitly:

```sh
DORMOUSE_SETUP_PASSWORD=hunter2 DORMOUSE_VAPID_SUBJECT=mailto:you@example.com \
pnpm dev:pocket-server
```

**2. Host** (the laptop being controlled): `pnpm dev:standalone`, then enroll
once from the devtools console of the standalone webview:

Expand All @@ -394,6 +433,14 @@ First-time setup (password + label) creates the passkey and signs you in →
Hosts → **Pair** → approve in the modal on the laptop → **Connect** (one
biometric prompt) → pick a pane → type.

To test push, **add Pocket to the Home Screen before signing in** and do all of
the above inside the installed app: iOS delivers Web Push only there, and the
install is a separate storage partition needing its own pairing, so setting up
in the tab first means doing it twice ([pocket-app.md](./pocket-app.md) ->
Installable web app). Alerts are then a per-Host opt-in — **Enable alerts** on the Host's
row, which is the user gesture iOS requires before it will prompt for
permission. Connecting alone does not subscribe.

Limitations to know about: each browser storage partition has its own device key
and therefore needs its own Host pairing, even when a synced passkey signs it
in; clearing site data destroys that device key → re-pair, per the security
Expand Down
66 changes: 66 additions & 0 deletions lib/src/remote/client/pocket-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
hasRecoverablePairingFailure,
PASSKEY_UNAVAILABLE_MESSAGE,
PocketClient,
SessionExpiredError,
type PocketSocket,
type PocketStorage,
type PocketClientDeps,
Expand Down Expand Up @@ -160,6 +161,12 @@ class FakeSocket implements PocketSocket {
this.#emit('open', {});
}

/** A rejected upgrade: the browser fires `error` with no status, never `open`. */
fireError(): void {
this.readyState = 3;
this.#emit('error', {});
}

/** Simulate the server sending a frame to this client. */
server(frame: unknown): void {
this.#emit('message', { data: JSON.stringify(frame) });
Expand Down Expand Up @@ -541,6 +548,65 @@ async function connectEstablished(harness: Harness): Promise<void> {
await connecting;
}

describe('session expiry', () => {
/** Signed in, with `/api/hosts` switchable between healthy and session-gate 401. */
async function withHostsRoute(
response: () => { status?: number; json: unknown },
): Promise<Harness> {
const harness = makeClient({ ...AUTH_ROUTES, '/api/hosts': response });
await harness.client.setup('pw', 'My Phone');
await harness.client.signin();
return harness;
}

it('discards the token and reports expiry on the session gate 401', async () => {
let live = true;
const harness = await withHostsRoute(() =>
live ? { json: { hosts: [] } } : { status: 401, json: { error: 'unauthorized' } },
);
expect(harness.client.sessionToken).toBe('tok-abc');

live = false;
await expect(harness.client.listHosts()).rejects.toBeInstanceOf(SessionExpiredError);
// Keeping it would leave the UI believing it is still signed in.
expect(harness.client.sessionToken).toBeNull();
});

// A wrong setup password and a rejected device signature also answer 401;
// treating those as expiry would sign the user out mid-action.
it('leaves a 401 that is not the session gate as an ordinary failure', async () => {
const harness = await withHostsRoute(() => ({
status: 401,
json: { error: 'device signature rejected' },
}));

await expect(harness.client.listHosts()).rejects.toThrow('device signature rejected');
expect(harness.client.sessionToken).toBe('tok-abc');
});

it('turns a rejected relay upgrade into expiry when the session is the reason', async () => {
let live = true;
const harness = await withHostsRoute(() =>
live ? { json: { hosts: [] } } : { status: 401, json: { error: 'unauthorized' } },
);

live = false;
const opening = harness.client.openSocket();
harness.socket.fireError();
await expect(opening).rejects.toBeInstanceOf(SessionExpiredError);
expect(harness.client.sessionToken).toBeNull();
});

it('keeps a socket failure a socket failure while the session is alive', async () => {
const harness = await withHostsRoute(() => ({ json: { hosts: [] } }));

const opening = harness.client.openSocket();
harness.socket.fireError();
await expect(opening).rejects.toThrow('relay socket error');
expect(harness.client.sessionToken).toBe('tok-abc');
});
});

describe('socket lifecycle', () => {
it('an unexpected close fires host-gone for an established session and resets the socket', async () => {
const harness = await signedIn();
Expand Down
66 changes: 65 additions & 1 deletion lib/src/remote/client/pocket-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
REMOTE_EVENTS,
REMOTE_METHODS,
SELFHOST_ACCOUNT_ID,
UNAUTHORIZED_ERROR,
WS_ROUTES,
WS_TOKEN_PARAM,
hashPasskeyPublicKey,
Expand Down Expand Up @@ -108,6 +109,27 @@ export interface PairResult {
readonly error?: string;
}

/** Shown when the Server no longer accepts our session token. */
export const SESSION_EXPIRED_MESSAGE = 'Your session expired. Sign in again to continue.';

/**
* The Server rejected our session token, so nothing works until the user signs
* in again. Distinct from an ordinary failure because the UI must react rather
* than report: sessions live only in the Server's memory (docs/specs/server.md),
* so they die on a 12h expiry *and* on every Server restart, and an installed
* Pocket has no address bar to reload from. Left as a message, the user is
* stuck holding a dead token with force-quitting the app as the only way out.
*
* {@link PocketClient} clears the token before throwing this, so recovery is
* exactly "sign in again" with the passkey and paired-host markers intact.
*/
export class SessionExpiredError extends Error {
constructor() {
super(SESSION_EXPIRED_MESSAGE);
this.name = 'SessionExpiredError';
}
}

interface Waiter {
resolve(frame: ServerToClientFrame): void;
reject(error: Error): void;
Expand Down Expand Up @@ -305,7 +327,17 @@ export class PocketClient {
}

/** Open the `/ws/client` relay socket; resolves once it is open. */
openSocket(): Promise<void> {
async openSocket(): Promise<void> {
try {
await this.#openSocket();
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
if (error instanceof SessionExpiredError) throw error;
await this.#diagnoseSocketFailure(error);
}
}

#openSocket(): Promise<void> {
const token = this.#requireToken();
const url = `${this.#wsBase}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${encodeURIComponent(token)}`;
const ws = this.#createWebSocket(url);
Expand Down Expand Up @@ -643,10 +675,42 @@ export class PocketClient {
...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }),
});
const parsed = (await response.json().catch(() => ({}))) as T & { error?: string };
// Only the session gate's 401 means "sign in again" — a wrong setup
// password and a rejected device signature answer 401 too, and bouncing the
// user to sign-in for those would be a worse bug than the one this fixes.
if (response.status === 401 && parsed.error === UNAUTHORIZED_ERROR) {
// Drop the token here rather than at the call site: every later request
// and every relay upgrade would fail the same way, and keeping it would
// let the UI believe it is still signed in.
this.#sessionToken = null;
throw new SessionExpiredError();
}
if (!response.ok) throw new Error(parsed.error ?? `request failed (${response.status})`);
return parsed;
}

/**
* Turn a relay-socket failure into a {@link SessionExpiredError} when the
* session is the reason. A rejected WS upgrade reaches the browser as a bare
* `error` event with no status, so the only way to tell "session died" from
* "network is down" is to ask an authenticated route — which answers the
* question and costs one request on a path that has already failed.
*/
async #diagnoseSocketFailure(original: Error): Promise<never> {
if (this.#sessionToken === null) throw original;
try {
await this.#api<HostsResponse>(API_ROUTES.hosts, undefined, {
method: 'GET',
headers: { authorization: `Bearer ${this.#sessionToken}` },
});
} catch (err) {
if (err instanceof SessionExpiredError) throw err;
// Probe failed for its own reason — report the socket failure, which is
// what the user actually hit.
}
throw original;
}

#requireToken(): string {
if (!this.#sessionToken) throw new Error('sign in first');
return this.#sessionToken;
Expand Down
51 changes: 34 additions & 17 deletions lib/src/remote/pocket-app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { clsx } from 'clsx';
import { tv } from 'tailwind-variants';
import {
PocketClient,
SessionExpiredError,
type ConnectDecision,
type PocketSocket,
} from '../client/pocket-client';
Expand Down Expand Up @@ -230,18 +231,41 @@ export default function App(): React.ReactElement {
if (!client.socketOpen) await client.openSocket();
}, [client]);

const run = useCallback(async (label: string, fn: () => Promise<void>) => {
setError(null);
setBusy(label);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
/** Tear down the live session and return to the hosts list. */
const teardownAdapter = useCallback(() => {
void adapterRef.current?.dispose();
adapterRef.current = null;
disposeAllSessions();
}, []);

const run = useCallback(
async (label: string, fn: () => Promise<void>) => {
setError(null);
setBusy(label);
try {
await fn();
} catch (err) {
// A dead session is not reportable, it is actionable: the token is
// already discarded, so every view above sign-in would fail the same
// way, and an installed Pocket has no reload affordance to escape with.
// Drop to sign-in, where one passkey prompt restores everything —
// pairing and push registration both outlive the session.
if (err instanceof SessionExpiredError) {
teardownAdapter();
client.close();
setActiveHost(null);
setPhase('auth');
setError(err.message);
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
},
[client, teardownAdapter],
);

const loadHosts = useCallback(async () => {
await ensureSocket();
const list = await client.listHosts();
Expand All @@ -250,13 +274,6 @@ export default function App(): React.ReactElement {
setPhase('hosts');
}, [client, ensureSocket]);

/** Tear down the live session and return to the hosts list. */
const teardownAdapter = useCallback(() => {
void adapterRef.current?.dispose();
adapterRef.current = null;
disposeAllSessions();
}, []);

// Socket drop / host-gone: dispose the adapter and fall back to Hosts.
useEffect(() => {
client.setOnHostGone(() => {
Expand Down
Loading
Loading