Skip to content

Navigation Menu

Sign in
Sign up

Sign out everywhere, including the MCP connectors #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
distronode-com wants to merge 1 commit into Calnode:main
base: main
Choose a base branch
Loading
from distronode-com:feat/sign-out-everywhere
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
`-copy-2`, `-copy-3`, ...) slug, and keeps `price_cents`/`currency` verbatim: zeroing a
copied price is how a paid meeting quietly starts selling for nothing. Bookings are not
copied.
- **Sign out everywhere.** `POST /v1/auth/sessions/revoke-all` ends every session you
Comment thread
pullfrog[bot] marked this conversation as resolved.
have except the one you asked from, so losing a laptop no longer means waiting out a
30-day cookie. Pass `{"user_id": "..."}` and an admin can do the same for someone
else: an admin may revoke a member, only the owner may revoke another admin, and the
owner's own sessions can only be ended by the owner.

It also revokes that person's MCP OAuth tokens, which is the part that makes it an
offboarding tool rather than a convenience. A connected agent authenticates with a
bearer token and not the session cookie, so ending the sessions alone would have left
it holding exactly the access that was just withdrawn.

- **Empty days and minimum-notice gaps now explain themselves** on all three booking
surfaces (booking page, manage/reschedule page, embed widget). Closes
[#20](https://github.com/Calnode/calnode/issues/20).
Expand Down
12 changes: 12 additions & 0 deletions docs/ARCHITECTURE.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ the platform/recovery secret doesn't expose secrets.
Owner-gated actions: grant/revoke admin, transfer ownership. Admins can cancel
any booking, see all bookings, manage teams/members. Safe-removal + archive
guards prevent orphaning.
- **Sign out everywhere** (`POST /v1/auth/sessions/revoke-all`, `session.go`). 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.) With `{"user_id": "..."}` it is an offboarding tool, gated on the same tiers as
`roles.go`: 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, so the 404 cannot be used to enumerate user ids.
⛔ It also deletes the target's rows in **`oauth_access_tokens`**, cutting off any MCP
connector (§19) — those authenticate with a bearer token, not the session cookie, so
revoking sessions alone would leave an agent holding the authority just withdrawn.
Both deletes run in one transaction, so "revoked" is never half-true.
- **Offboarding = archive** (`users.archived_at`), never hard-delete — preserves
bookings, event-type ownership, team links. Archived ⇒ no login, hidden from
lists, skipped in routing/slots, event types deactivated. Reversible (restore).
Expand Down
142 changes: 142 additions & 0 deletions internal/handler/session.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package handler
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
Expand Down Expand Up @@ -32,3 +36,141 @@ func (h *Handler) createSession(ctx context.Context, w http.ResponseWriter, user
})
return nil
}

// RevokeAllSessions handles POST /v1/auth/sessions/revoke-all.
//
// Body: `{"user_id": "..."}`, optional.
//
// - Omitted (or naming the caller): signs the caller out everywhere **except the
// session that made the request**. "Sign out my other devices" is the action people
// actually want; dropping the current session too would log the operator out of the
// page they clicked it on, which is what Logout is for. A caller authenticating with
// an API key has no current session, so for them every session goes.
// - Naming someone else: an offboarding tool. Admin-only, and mirroring roles.go's
// tiers — an admin may revoke a member, only the owner may revoke another admin, and
// nobody may revoke the owner's sessions but the owner (there is exactly one owner,
// so that case is the self branch).
//
// It also deletes the target's MCP OAuth access tokens. An MCP connector authenticates
// with a bearer token rather than the session cookie (§19), so revoking sessions alone
// would leave an agent connected with exactly the authority that was just taken away —
// the failure mode being cut off from is a laptop that walked out of the building with a
// signed-in browser AND a connected agent on it.
func (h *Handler) RevokeAllSessions(w http.ResponseWriter, r *http.Request) {
actor, ok := userFromContext(r.Context())
if !ok {
h.writeError(w, http.StatusUnauthorized, "authentication required")
return
}

r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
var req struct {
UserID string `json:"user_id"`
}
// An empty body is the common case (revoke my own), so EOF is not an error here.
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
h.writeError(w, http.StatusBadRequest, "invalid JSON")
return
}

targetID := req.UserID
self := targetID == "" || targetID == actor.ID
if self {
targetID = actor.ID
} else {
// The actor's capability class is checked before the target is looked up, so a
// member cannot use this endpoint's 404 to probe which user ids exist.
if !actor.IsAdmin {
h.writeError(w, http.StatusForbidden, "admin access required")
return
}
var targetIsAdmin, targetIsOwner int
err := h.db.QueryRowContext(r.Context(),
`SELECT is_admin, is_owner FROM users WHERE id = ?`, targetID).
Scan(&targetIsAdmin, &targetIsOwner)
if err == sql.ErrNoRows {
h.writeError(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
h.logger.ErrorContext(r.Context(), "revoke sessions: load target", "error", err)
h.writeError(w, http.StatusInternalServerError, "internal error")
return
}
if targetIsOwner != 0 {
h.writeError(w, http.StatusForbidden, "the owner's sessions can only be revoked by the owner")
return
}
if targetIsAdmin != 0 && !actor.IsOwner {
h.writeError(w, http.StatusForbidden, "only the workspace owner can revoke another admin's sessions")
return
}
}

// One transaction: a caller told "revoked" must not have kept an MCP token because
// the second statement failed after the first committed.
tx, err := h.db.BeginTx(r.Context(), nil)
if err != nil {
h.logger.ErrorContext(r.Context(), "revoke sessions: begin tx", "error", err)
h.writeError(w, http.StatusInternalServerError, "internal error")
return
}
defer tx.Rollback() //nolint:errcheck

var sessionRes sql.Result
if self {
// The session spared is the one the caller AUTHENTICATED WITH, which is not the
// same thing as the one it happened to send.
//
// ⛔ The API-key test mirrors RequireAuth's own precedence: it tries the key
// first, so a request carrying both is an API-key request and its cookie played
// no part in authenticating it. Reading the cookie unconditionally would spare a
// session on the strength of a header the caller was not authenticated by — so a
// script holding an API key and a stale cookie would ask to end all its sessions,
// be told it had, and leave one alive. Silently, because the response counts what
// was deleted and not what was kept.
current := ""
if extractAPIKey(r) == "" {
if c, cerr := r.Cookie(sessionCookieName); cerr == nil {
current = c.Value
}
}
sessionRes, err = tx.ExecContext(r.Context(),
`DELETE FROM sessions WHERE user_id = ? AND id <> ?`, targetID, current)
Comment thread
pullfrog[bot] marked this conversation as resolved.
} else {
sessionRes, err = tx.ExecContext(r.Context(),
`DELETE FROM sessions WHERE user_id = ?`, targetID)
}
if err != nil {
h.logger.ErrorContext(r.Context(), "revoke sessions: delete sessions", "error", err)
h.writeError(w, http.StatusInternalServerError, "internal error")
return
}

tokenRes, err := tx.ExecContext(r.Context(),
`DELETE FROM oauth_access_tokens WHERE user_id = ?`, targetID)
if err != nil {
h.logger.ErrorContext(r.Context(), "revoke sessions: delete oauth tokens", "error", err)
h.writeError(w, http.StatusInternalServerError, "internal error")
return
}

if err := tx.Commit(); err != nil {
h.logger.ErrorContext(r.Context(), "revoke sessions: commit", "error", err)
h.writeError(w, http.StatusInternalServerError, "internal error")
return
}

sessions, _ := sessionRes.RowsAffected()
tokens, _ := tokenRes.RowsAffected()
h.logger.InfoContext(r.Context(), "sessions revoked",
"actor_id", actor.ID, "user_id", targetID, "self", self,
"sessions", sessions, "oauth_tokens", tokens)

h.writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"user_id": targetID,
"sessions_revoked": sessions,
"oauth_tokens_revoked": tokens,
})
}
Loading
Loading

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