Skip to content

Navigation Menu

Sign in
Sign up

Sign out everywhere, including the MCP connectors - #41

Open
distronode-com wants to merge 1 commit into
Calnode:main from
distronode-com:feat/sign-out-everywhere
Open

Sign out everywhere, including the MCP connectors #41
distronode-com wants to merge 1 commit into
Calnode:main from
distronode-com:feat/sign-out-everywhere

Conversation

@distronode-com

@distronode-com distronode-com commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Third of the features split out of #30. POST /v1/auth/sessions/revoke-all.

Two things behind one endpoint

With no body it drops all of the caller's sessions except the one that made the request. That is "sign out my other devices", as distinct from POST /v1/auth/logout, which ends the current one. Losing a laptop no longer means waiting out a 30-day cookie. (An API-key caller has no current session, so for them every session goes — which is the right reading of "all of mine" for that caller.)

With {"user_id": "..."} it is an offboarding tool, gated on the tiers roles.go already establishes: an admin may revoke a member, only the owner may revoke another admin, and the owner's sessions are reachable only by the owner.

⛔ The ordering check is load-bearing

The actor's tier is checked before the target is loaded. If the target were loaded first, the 404 for an unknown id and the 403 for a protected one would be distinguishable, and the endpoint becomes a way for a member to enumerate user ids one guess at a time.

TestRevokeAllSessions_memberCannotProbeForUserIDs holds that, and it had to be written for this pull request rather than coming across with the rest. The neighbouring "member cannot target another user" test names a real user, so it passes with the two checks in either order and says nothing about the ordering at all. The new one asks for an existing id and a made-up one and requires the same status and the same body, because a differing message is the same oracle in prose.

Moving the tier check below the target load fails that test and fails nothing else in the package, which is the evidence the gap was real rather than theoretical.

⛔ It cuts the MCP tokens too, and that is the point

The delete also covers the target's rows in oauth_access_tokens, which is what makes this an offboarding tool rather than a convenience. An MCP connector (§19) authenticates with a bearer token, not the session cookie — so revoking sessions alone would leave a connected agent holding exactly the access that was just withdrawn, and the person who ran the revoke would have no way to know.

Both deletes run in one transaction, so "revoked" is never half-true. A session sweep that succeeded while the token sweep failed is the worst possible outcome: it looks done and is not.

Verification

Mutation-checked rather than assumed: with the oauth_access_tokens delete replaced by a no-op, TestRevokeAllSessions_cutsMCPTokensToo and TestRevokeAllSessions_adminRevokesAMember both fail. Restored, they pass.

go vet ./... and go test ./... are clean on this branch — 26 packages, zero failures.

Five files, +444. One new route, no schema change, no new dependency.

Not in this pull request

The commit this is split from also carried the SSO hand-off's documentation, since the two were written together. That half is removed here — it belongs on its own thread, as you asked on #30, and it will arrive changed rather than as you saw it. The owner bootstrap in that version was check-then-act, so two concurrent hand-offs claiming owner on an unowned instance could both succeed. It needs a partial unique index rather than a code fix, which is a schema change and deserves its own discussion.

🤖 Generated with Claude Code

pullfrog[bot] reacted with eyes emoji

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i️ Two rough edges worth a look — handler/RBAC core looks solid.

Reviewed changes One new authenticated endpoint that revokes sessions (and MCP OAuth tokens) for the caller or, with admin tiers, for another user.

  • POST /v1/auth/sessions/revoke-all — Self mode keeps the calling session and wipes the rest; targeting another user follows the existing owner/admin/member ladder, with the admin check before the target load so members cannot 404-oracle ids.
  • MCP token sweep in the same transaction — Deletes the target's oauth_access_tokens rows with the session deletes so a partial failure cannot report success while an agent bearer still works.
  • Tests — Cover keep-current, API-key wipe-all, MCP cut, RBAC, same status+body for existing vs unknown targets (member), and self-via-user_id.

i️ Archive still does not cut MCP agents

VerifyMCPBearer's OAuth path never checks users.archived_at (the API-key path does), and ArchiveUser does not delete oauth_access_tokens. So the documented primary offboarding path (archive) still leaves a connected agent holding a bearer until something else deletes the rows. This PR correctly adds the wipe; operators who only archive remain exposed. Worth either deleting tokens inside ArchiveUser (same transaction as archived_at) or stating the two-step offboarding sequence next to both bullets in ARCHITECTURE.md.

Technical details
# Archive vs MCP bearer lifetime
## Affected sites
- `internal/handler/mcp_oauth.go` (`VerifyMCPBearer`) — OAuth branch selects only `user_id, expires_at` from `oauth_access_tokens`; no join/filter on `users.archived_at`
- `internal/handler/archive.go` (`ArchiveUser`) — sets `archived_at`, deactivates event types; does not touch sessions or oauth tokens
- `docs/ARCHITECTURE.md` §6 — adjacent "Sign out everywhere" and "Offboarding = archive" bullets
## Required outcome
- Either archive alone ends MCP access for that user, or docs make the dual-step (archive + revoke-all) explicit so operators do not assume archive is sufficient
## Suggested approach (optional)
- Prefer deleting `oauth_access_tokens` (and optionally sessions) inside the archive transaction; mirrors what this endpoint already does for the credential half of offboarding

Pullfrog | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Grok | X

Comment thread CHANGELOG.md
Comment thread internal/handler/session.go
Third of the features split out of Calnode#30. POST /v1/auth/sessions/revoke-all.
With no body it drops all of the caller's sessions EXCEPT the one that made the
request — "sign out my other devices", as distinct from POST /v1/auth/logout,
which ends the current one. An API-key caller has no current session, so for
them every session goes. Losing a laptop no longer means waiting out a 30-day
cookie.
With {"user_id": "..."} it is an offboarding tool, gated on the same tiers
roles.go already uses: an admin may revoke a member, only the owner may revoke
another admin, and the owner's sessions are reachable only by the owner.
⛔ The actor's tier is checked BEFORE the target is loaded. Otherwise the 404
for an unknown id and the 403 for a protected one are distinguishable, and the
endpoint becomes a way for a member to enumerate user ids.
⛔ It also deletes the target's rows in oauth_access_tokens, and that is the
part that makes this an offboarding tool rather than a convenience. An MCP
connector authenticates with a bearer token, not the session cookie, so ending
the sessions alone would leave a connected agent holding exactly the access
that was just withdrawn. Both deletes are in one transaction, so "revoked" is
never half-true.
TestRevokeAllSessions_memberCannotProbeForUserIDs holds the ordering, and it
had to be written for this: the neighbouring member-cannot-target test names a
REAL user, so it passes with the two checks in either order. The new one asks
for an existing id and a made-up one and requires the same status AND the same
body, since a differing message is the same oracle in prose. Moving the tier
check below the target load fails it, and fails nothing else in the package.
⛔ Self-mode spares the session the caller AUTHENTICATED WITH, which is not the
same as the one it happened to send. The cookie is read only when no API key is
present, mirroring RequireAuth's own precedence — it tries the key first, so a
request carrying both is an API-key request and its cookie authenticated
nothing. Without that test a script holding a key and a stale cookie would ask
to end all its sessions, be told it had, and leave one alive; the response
counts what was deleted, not what was kept, so nothing would say so. The
existing API-key test sends no cookie, so it passes either way.
Mutation-checked rather than assumed: with the oauth_access_tokens delete
replaced by a no-op, TestRevokeAllSessions_cutsMCPTokensToo and
TestRevokeAllSessions_adminRevokesAMember both fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes Delta since the prior pullfrog review (c1e1722be05370): both prior inline findings fixed cleanly.

  • CHANGELOG placement — Moved the Sign-out bullet to after the full Duplicate entry so Duplicate’s continuation paragraphs stay under the right feature.
  • API-key + cookie spare — Self-mode only reads calnode_session when extractAPIKey(r) == "", matching RequireAuth’s key-first precedence; a stale cookie on an API-key call no longer leaves a session alive.
  • Regression testTestRevokeAllSessions_apiKeyCallerWithAStaleCookieStillRevokesEveryone covers the dual-credential case the old key-only test could not catch.

Pullfrog | View workflow run | Using Grok | X

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@pullfrog pullfrog[bot] pullfrog[bot] left review comments

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /