Files
PaperClipAI/server
DottaandClaude Opus 5 f7023b205c fix(security): consume and actor-bind OAuth state on a denied callback
A denial or a cancel is a final answer to an authorization request, but
`completeOAuthCallback` treated it as merely a failed callback: the row
in `tool_oauth_states` survived, so the `state` the user just refused
stayed completable for the rest of its 10-minute TTL by anyone who could
produce a code. Any linked `request_confirmation` also stayed `pending`,
so the board kept offering an authorization link for a flow the user had
already declined and the requesting agent never learned the answer.

Route every terminal callback — success, denial, cancel — through one
`consumeOAuthState` helper. Two properties pull in opposite directions
and both matter:

- a stranger's callback must not *consume* the request, so the row is
  loaded and bound to the caller (expiry, actor type/id, session, or the
  subject user) before anything is deleted, and a failed binding check
  leaves the victim's flow live and completable;
- a replayed callback must not *complete* the request, so the delete is
  the single statement that decides ownership — `DELETE … RETURNING`
  hands the row to exactly one of two concurrent callbacks and the loser
  is told the state is spent rather than exchanging a code against it.

A denial then resolves any linked interaction as `rejected`, scoped to a
still-`pending` row so a board user who answered the card directly keeps
their own answer, with a Paperclip-authored reason rather than the
provider's.

The message and its `providerError` label are PAP-17108's, unchanged.

Five regression tests, both negative-controlled: a code obtained before
a denial can no longer be exchanged; an unrecognized cancel code behaves
the same; another session's or another user's denial is refused without
spending the request, which the owner then completes; the board's prompt
ends `rejected`; and two simultaneous callbacks produce exactly one
token exchange. That last one needs a second database handle — this
driver pipelines everything issued through one handle, which serializes
the two callbacks and makes a non-atomic delete pass — and it has to
connect before racing, or the first callback wins uncontested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:20:02 +00:00
..