Files
pad/internal/store/task3399_reader_census_test.go
T
xarmian 6365ac08fa fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678) (#2121)
* fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678)

Fosite's refresh validates the presented token, then calls
RotateRefreshToken, CreateAccessTokenSession and
CreateRefreshTokenSession, each committing on its own. Two windows
followed:

- A revoke landing after validation: rotation flipped the family and
  the new pair was inserted active, so a revoked connection came back.
  The account claim and fosite's own RevokeRefreshToken (RFC 7009,
  refresh replay) had the same hole.
- Two concurrent refreshes of one token: both validated, both rotated
  (rotation ignored the presented signature), and both pairs ended up
  minted.

Now:

- RotateRefreshToken spends the presented token conditionally, in one
  transaction with the family flips. A token already inactive fails
  with ErrOAuthRefreshNotActive, which the adapter returns as
  fosite.ErrSerializationFailure (invalid_request, "multiple concurrent
  requests").
- A revoked grant is recorded in oauth_revoked_grants (migration 143).
  RevokeOAuthGrant (connected-apps revoke, adapter RevokeRefreshToken)
  takes the subject's users row FOR NO KEY UPDATE first, then records
  the grant and flips the families. The claim records every grant of
  the account under the users row its first statement holds.
- Issuance refuses a recorded request_id under the users FOR SHARE
  that requireActiveUserTx already takes (ErrOAuthGrantRevoked, mapped
  to fosite.ErrInactiveToken). So a racing refresh either committed
  before the revoke's flips, which then see its rows, or finds the
  record.

Disable, installed-app grants and client deletion were already fenced
(BUG-3349, TASK-3394/3399, the client FK).

Tests: store repros for both windows, the claim and a double rotation,
plus test-pg legs for concurrent refreshes and a revoke racing a
refresh. HTTP tests drive fosite's real token endpoint through a
before-rotation seam. Each production guard was removed in turn, and
the matching tests went red.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): the install barrier refuses a revoked grant; rotation holds no row across the family flips (BUG-3678, codex r1)

P1: an installed app's tokens are minted through installIssuanceBarrierTx,
which returned before the revoked-grant check. The check now runs there
under the install row. RevokeOAuthGrant locks that row (not the bot's
users row, which the install lifecycle takes after the install) for an
install client's grant.

P2: RotateRefreshToken held the presented refresh row in a transaction
while flipping the access family, and the claim and disable flip access
before refresh under the users row, so the two could deadlock on
Postgres. The conditional spend is its own statement again, and the
flips follow it as before.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): a fosite revoke of an installed app's grant takes the app-grant path (BUG-3678, codex r2)

Round 2: RevokeOAuthGrant locked the install row for an installed app's
grant and then flipped refresh before access, while the account claim
holds the person's row and flips access before refresh. Neither shared a
lock with the other, so the two could deadlock on Postgres.

Installed-app grants already have their own tombstone (the binding's
revoked_at, which the issuance barrier refuses) and a lock order that
the barrier, RevokeUserAppGrant, the claim and disable share through the
binding rows. RevokeOAuthGrant now ends a bound grant through that same
path (revokeAppGrantOnce, split out of RevokeUserAppGrant, retried on
deadlock). The round-1 check in the barrier and the install-row lock in
RevokeOAuthGrant are gone. oauth_revoked_grants covers only grants with
no binding, under the subject's users row.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): a tokenless grant's revoke still locks the person (BUG-3678, codex r3)

Round 3: RevokeOAuthGrant took the subject from token rows only, so a
grant whose consent was recorded but whose first tokens were not yet
committed had no person to lock. The revoke could then record the grant
and flip nothing while a first issuance that had already passed its
check committed an active token. The lookup now falls back to the
consent's oauth_connections row. A Postgres test holds the person's row
the way issuance does and requires the revoke to wait on it.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): the claim also ends grants with no token yet (BUG-3678, codex r4)

Round 4: the claim recorded revoked grants from token rows only. A grant
consented to before the claim but whose code was not yet exchanged had
none, so an exchange that validated before the claim minted an active
token after it. The claim now also records the request ids of the
account's oauth_connections rows, read before it deletes them.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): the revoked-grants table exists on Postgres too (BUG-3678)

Migration 143 added oauth_revoked_grants to SQLite only; pgmigrations is a
separate tree, so every Postgres read and write of it failed with 42P01
(CI Go (PostgreSQL) on #2121). 113 is its twin.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 22:34:03 -04:00

136 lines
6.0 KiB
Go

package store
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// TASK-3399 (lead ruling Q1): a delegated grant to an installed app writes
// no oauth_connections row, so it must not surface as an MCP connection
// anywhere. Every store function whose SQL reads the connection tables or
// treats token rows as chains is classified here. A new one fails until it
// is classified.
var task3399ReaderCensus = map[string]string{
"account_claim.go::claimAccountTx": censusUserRevoke,
"app_grants.go::ListUserAppGrants": censusAppPath,
"connected_apps.go::ListUserOAuthConnections": censusFilters,
"connected_apps.go::RevokeUserOAuthConnection": censusPerGrant,
"install_clients.go::DeleteInstallClientTx": censusAppPath,
"mcp_audit.go::MCPAuditNames": censusPerGrant,
"mcp_resume.go::CountLiveOAuthConnections": censusFilters,
"oauth.go::CountActiveOAuthAccessTokens": censusByDesign,
"oauth.go::DeleteAccessToken": censusPerGrant,
"oauth.go::DeleteOAuthClient": censusPerGrant,
"oauth.go::DeleteRefreshToken": censusPerGrant,
"oauth.go::OldestAccessTokenIssuedAtByRequestID": censusPerGrant,
"oauth.go::RevokeOAuthGrant": censusPerGrant,
"oauth.go::queryOAuthRequestRow": censusPerGrant,
"oauth_connections.go::AddConnectionWorkspace": censusNoRow,
"oauth_connections.go::AddCreatedWorkspaceIfPermitted": censusNoRow,
"oauth_connections.go::ConnectionWorkspaceCount": censusNoRow,
"oauth_connections.go::CreateOAuthConnection": censusNoRow,
"oauth_connections.go::DeleteOAuthConnection": censusNoRow,
"oauth_connections.go::GetOAuthConnection": censusNoRow,
"oauth_connections.go::GetOAuthConnectionAccess": censusNoRow,
"oauth_connections.go::HasActiveConnectionForUser": censusNoRow,
"oauth_connections.go::IsConnectionWorkspaceAllowed": censusNoRow,
"oauth_connections.go::IsWorkspaceCoveredForUser": censusNoRow,
"oauth_connections.go::LimitConnectionToCurrentWorkspaces": censusNoRow,
"oauth_connections.go::ListConnectionWorkspaceSlugs": censusNoRow,
"oauth_connections.go::RemoveConnectionWorkspace": censusNoRow,
"oauth_connections.go::RemoveConnectionWorkspaceUnlessLast": censusNoRow,
"oauth_connections.go::RenameConnection": censusNoRow,
"oauth_connections.go::SetScopeFlags": censusNoRow,
"oauth_connections.go::SetScopeFlagsGuarded": censusNoRow,
"oauth_connections.go::assertRowAffected": censusNoRow,
"oauth_connections.go::lockConnectionTx": censusNoRow,
"oauth_connections.go::snapshotCurrentWorkspacesSQL": censusNoRow,
"oauth_connections_backfill.go::BackfillOAuthConnections": censusFilters,
"oauth_connections_backfill.go::backfillOneChain": censusFilters,
"oauth_connections_backfill.go::collectGrantChainsForBackfill": censusFilters,
"oauth_connections_narrow.go::NarrowCurrentOnlyWildcardConnections": censusNoRow,
"oauth_sweep.go::SweepExpiredOAuthRows": censusByDesign,
"users.go::disableUserAndRevokeAccessTx": censusUserRevoke,
"users.go::eraseUserTx": censusUserRevoke,
}
const (
censusNoRow = "reads oauth_connections rows: an install chain has none, so it is untouched by construction"
censusPerGrant = "addressed by one signature, request id or client id: never lists a user's chains"
censusFilters = "lists or counts chains as connections: filters install clients (notInstallClientSQL)"
censusAppPath = "the app-grant path itself (bindings, the barrier, app lifecycle)"
censusByDesign = "counts or sweeps every token on purpose (an ops gauge, the expiry sweep)"
censusUserRevoke = "revokes or erases every credential of one user: delegated app codes through their bindings (delegated_user_id), tokens by subject"
)
func TestTask3399_EveryConnectionReaderIsClassified(t *testing.T) {
needles := []string{"oauth_connections", "oauth_connection_workspaces", "FROM oauth_access_tokens", "FROM oauth_refresh_tokens"}
fset := token.NewFileSet()
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
found := map[string]bool{}
for _, name := range files {
if strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
f, err := parser.ParseFile(fset, name, src, 0)
if err != nil {
t.Fatal(err)
}
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
hit := false
ast.Inspect(fn.Body, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(lit.Value)
if err != nil {
v = lit.Value
}
for _, nd := range needles {
if strings.Contains(v, nd) {
hit = true
}
}
return true
})
if hit {
found[name+"::"+fn.Name.Name] = true
}
}
}
var missing []string
for k := range found {
if _, ok := task3399ReaderCensus[k]; !ok {
missing = append(missing, k)
}
}
sort.Strings(missing)
for _, k := range missing {
t.Errorf("%s reads connection or token-chain tables and is not classified in task3399ReaderCensus", k)
}
for k := range task3399ReaderCensus {
if !found[k] {
t.Errorf("task3399ReaderCensus names %s, which no longer reads those tables", k)
}
}
}