Files
pad/internal/server/bug3678_refresh_window_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

105 lines
3.7 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/url"
"testing"
)
// BUG-3678, through fosite's real token endpoint: a revoke or a second
// refresh that lands after fosite validated a refresh token and before it
// rotated it. The store seam runs inside that window.
func bug3678Refresh(srv *Server, clientID, refresh string) (int, map[string]any) {
rr := postOAuthForm(srv, "/oauth/token", url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refresh},
"client_id": {clientID},
"audience": {testCanonicalAudience},
})
var body map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &body)
return rr.Code, body
}
func bug3678Active(t *testing.T, srv *Server, token, bearer string) bool {
t.Helper()
rr := postOAuthFormBearer(srv, "/oauth/introspect", url.Values{"token": {token}}, bearer)
if rr.Code != http.StatusOK {
t.Fatalf("introspect: got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
parseJSON(t, rr, &resp)
active, _ := resp["active"].(bool)
return active
}
func TestBUG3678_RevokeInsideRefreshWindowMintsNothing(t *testing.T) {
srv, _ := oauthEnabledTestServer(t)
user, sessionToken := loginTestUser(t, srv)
csrfTok := readCSRFFromCookie(t, srv, sessionToken)
clientID := registerTestClient(t, srv, "https://app.test/cb")
tokens := runAuthCodeFlow(t, srv, sessionToken, csrfTok, clientID, "verifier-3678-revoke-quick-brown-fox-1234567890")
refresh0, _ := tokens["refresh_token"].(string)
bearer, _ := runAuthCodeFlow(t, srv, sessionToken, csrfTok, clientID, "verifier-3678-bearer-quick-brown-fox-1234567890")["access_token"].(string)
fired := false
restore := srv.store.SetBeforeRotateRefreshHookForTesting(func(requestID string) {
if fired {
return
}
fired = true
if err := srv.store.RevokeUserOAuthConnection(user.ID, requestID); err != nil {
t.Errorf("revoke inside the window: %v", err)
}
})
code, body := bug3678Refresh(srv, clientID, refresh0)
restore()
if !fired {
t.Fatal("the seam never ran: the refresh did not reach rotation")
}
if code == http.StatusOK {
access, _ := body["access_token"].(string)
t.Errorf("a refresh validated before the revoke got 200; minted access active=%v", bug3678Active(t, srv, access, bearer))
}
}
func TestBUG3678_SecondRefreshInsideWindowLeavesOneWinner(t *testing.T) {
srv, _ := oauthEnabledTestServer(t)
_, sessionToken := loginTestUser(t, srv)
csrfTok := readCSRFFromCookie(t, srv, sessionToken)
clientID := registerTestClient(t, srv, "https://app.test/cb")
tokens := runAuthCodeFlow(t, srv, sessionToken, csrfTok, clientID, "verifier-3678-pair-quick-brown-fox-1234567890")
refresh0, _ := tokens["refresh_token"].(string)
bearer, _ := runAuthCodeFlow(t, srv, sessionToken, csrfTok, clientID, "verifier-3678-pbear-quick-brown-fox-1234567890")["access_token"].(string)
var innerCode int
var innerBody map[string]any
fired := false
restore := srv.store.SetBeforeRotateRefreshHookForTesting(func(string) {
if fired {
return
}
fired = true
// The same token, presented again while the first request sits
// between validation and rotation.
innerCode, innerBody = bug3678Refresh(srv, clientID, refresh0)
})
outerCode, outerBody := bug3678Refresh(srv, clientID, refresh0)
restore()
if !fired {
t.Fatal("the seam never ran: the refresh did not reach rotation")
}
if innerCode != http.StatusOK {
t.Fatalf("the refresh that rotated first: got %d %v, want 200", innerCode, innerBody)
}
if outerCode == http.StatusOK {
t.Errorf("both refreshes of one token succeeded; second body %v", outerBody)
}
innerAccess, _ := innerBody["access_token"].(string)
if !bug3678Active(t, srv, innerAccess, bearer) {
t.Error("the winning refresh's access token is not active")
}
}