Skip to content

Add a Connected clients page to self-host - #2077

Open
midego1 wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
midego1:feat/selfhost-connected-clients
Open

midego1 wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
midego1:feat/selfhost-connected-clients

Conversation

@midego1

@midego1 midego1 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Self-host had no view of what is signed in as you. MCP clients register themselves over OAuth (Claude Code, Cursor, Codex, …), and once connected there was no way to see them and no way to disconnect one short of deleting rows from the database. API keys had a page; browser sessions and OAuth clients had nothing.

What this adds

A Connected clients page listing, for the signed-in user only:

  • MCP clients — the name each registered, when it last signed in, whether it still holds a usable token.
  • API keys — read from the existing /account/api-keys surface rather than a second implementation; creating keys stays on the API keys page.
  • Browser sessions — device, last active, expiry, with the current one marked.

Each row can be revoked behind a confirmation. Revoking an MCP client deletes its access tokens and its consent, so it is refused on its next request — a session it already has open included, because the MCP envelope authenticates every request — and it has to pass the approval screen again before it can call a tool.

Connected clients

Security

The page and its API (/api/access/*) exist to cut off credentials, so the rules are the interesting part:

  • The signed-in browser only. A request carrying Authorization or x-api-key is refused, even though Better Auth would resolve it. These are exactly the credentials an agent holds; an agent must not be able to enumerate or revoke the credentials of the person it acts for, or quietly re-authorize itself.
  • Same-origin mutations. A browser always sends Origin on a DELETE, so a page elsewhere cannot drive a revoke with the user's cookie.
  • Your own credentials only. Every read and delete is filtered on the caller's user id, and another user's client or session id reads as not found rather than forbidden, so ids cannot be probed.
  • Nothing secret is served. No token, key hash or client secret leaves the server; a client's self-chosen name is cleaned and bounded before it is rendered.
  • Rows are decoded per row: one row the server cannot read is skipped and logged by field types (never values — these rows hold tokens) instead of blanking the list.

Tests

apps/host-selfhost/src/access/connected-clients.test.ts boots the real app and walks the real OAuth flow:

  • lists clients and sessions, and asserts no access token, refresh token, session token or cookie value appears in the response;
  • refuses an anonymous request (401), a bearer session, an MCP OAuth token and an x-api-key header (403);
  • revokes an MCP client and shows its already-open session refused on the next request (200 → 401);
  • refuses a revoke from another origin and one with no origin, leaving the client connected;
  • refuses revoking the session making the request, revokes another, and shows that browser signed out;
  • treats another user's client id as not found, leaving their client connected.

Plus row-decoding.test.ts, a regression test for the decoding bug this hit in practice: Better Auth omits empty columns, so a session with no recorded IP arrives with no ipAddress key at all, and Effect v4's NullishOr accepts a null value but still requires the key.

I removed each guard in turn and confirmed the matching test fails.

Notes

  • Self-host only: it reads Better Auth's own tables. Cloud's equivalent would be a WorkOS surface.
  • Running on a self-hosted instance (v1.6.10) with real MCP clients connected.

🤖 Generated with Claude Code

midego1 and others added 2 commits September 19, 2026 18:46
Nothing in the console showed which MCP clients had connected over OAuth,
and disconnecting one meant editing the database. The page lists the
signed-in user's MCP clients, API keys (the existing /account surface) and
browser sessions, each revocable after a confirmation.

Revoking an MCP client deletes its access tokens and its consent: it is
refused on its next request, open session included (MCP authenticates every
request), and must be approved again. The /api/access plane answers the
signed-in browser only, refusing any Authorization or x-api-key header, so
an agent cannot list or revoke its own user's credentials; mutations also
require a same-origin Origin. Everything is scoped to the caller's own rows,
another user's ids read as not found, and no token or secret is served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In production the Connected clients page failed with "Unreadable rows
(session)": Better Auth omits empty columns, so a session with no recorded
IP arrives without an ipAddress key, and Effect v4's NullishOr accepts a
null/undefined value but still requires the key. Optional fields are now
optional(NullOr(...)), timestamps accept Date, ISO string or epoch ms, and
sessions are read through the adapter like the OAuth rows.

A row that still fails to decode is skipped and logged by field TYPES only
(never values: these rows hold tokens) instead of blanking the whole list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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