fix: make OAuth2 code redemption single-use under concurrency - #28744
Conversation
Add ScopesCover, which reports whether every permission a requested scope grants is also granted by at least one of a set of allowed scopes. It expands both sides and compares the resulting permissions, so coder:workspaces.access covers workspace:read even though it never names it, and coder:all covers everything. The comparison is deliberately asymmetric. Positive permissions on the allowed side that it does not model are dropped, which can only make the answer stricter. Anything unmodelled on the requested side is an error instead, because ignoring it would answer "covered" about authority that was never compared. Negative permissions are the exception and fail closed on both sides, since dropping an anti-grant from the ceiling would widen it rather than narrow it. Add CanonicalScopeName, which maps the backward-compatibility aliases IsExternalScope accepts onto the names the api_key_scope enum stores. IsExternalScope answers whether a name may be requested, not how that name is spelled once persisted, so a caller that stores what it validated has to canonicalize in between. Both functions are added without production callers. The OAuth2 authorize endpoint uses them to negotiate a requested scope against an app's configured allowlist, which follows in a separate change.
State the rule the guards enforce, site-level grants only, instead of describing the asymmetry abstractly. The allow-list case is now covered alongside negative permissions, which the previous wording omitted even though the code treats them identically. Co-Authored-By: Claude Opus 5 <[email protected]>
ScopesCover checked the requested scope for org and user grants but not the allowed scopes, whose User and ByOrgID permissions were discarded unread. A scope granting workspace:* at site level while negating workspace:delete for the user would have covered a request for workspace:delete, because the negative that carves the action back out lives in the half coverage never examined. No catalog scope populates those fields today, so nothing was miscompared in practice. The gap mattered because these guards exist to keep the comparison fail-closed, and this one failed open. Both sides now run the same checkCoverable helper, which refuses a scope carrying org or user grants, a negative permission, or a resource allow list. The helper names the side, so an error reports which half of the comparison was undecidable. The doc comment claimed an unmodeled grant on the allowed side is dropped; nothing is dropped now, so it is gone. ScopesCover builds every Scope it reads from ExpandScope, which cannot produce these shapes, so the guards are unreachable through the public API. scopes_internal_test.go drives synthetic Scope values through checkCoverable instead. Co-Authored-By: Claude Opus 5 <[email protected]>
permissionCovered skipped negative permissions, but checkCoverable now refuses a scope carrying one on either side, so the branch was dead. It was never defense in depth. Had a negative reached it, skipping the anti-grant would leave any wildcard beside it free to match, and a scope granting workspace:* while negating workspace:delete would report workspace:delete as covered. The skip widened the ceiling while looking like it narrowed it. The precondition moves to the doc comment, which names checkCoverable as what enforces it and says why subsumption cannot answer the question an anti-grant poses. No behavior change: the branch was unreachable. permissionCovered goes from 88.9% to 100% statement coverage.
Five review findings on the coverage tests, all in scopes_test.go. CanonicalScopeName had both alias arms at zero coverage. Its only caller in the tests loops over ExternalScopeNames, which yields canonical names only, so the canonicalizing call returned its input unchanged on every iteration and read as coverage without being any. Swapping the arms, so that `all` persisted application_connect and the reverse, kept the suite green. TestCanonicalScopeName now pins the mapping and the loop appends the aliases, taking the function from 50% to 100%. The appended aliases raise branch coverage and assert a requestable name is comparable once canonicalized, but they cannot detect a swapped mapping, since both aliases resolve to scopes that cover themselves. The comment says so rather than implying the loop guards more than it does. CompositeDoesNotCoverNonMember and CompositeDoesNotCoverWiderActionOnCoveredResource both asked for an ungranted action on a resource coder:workspaces.access does grant, so they tested one branch twice and left "resource not granted at all" untested. They are now split along that line, with names that describe which failure each one is. The three wantErr rows shared a bare require.Error, so any error passed any row and a bug failing every input on the requested side would have left the allowed-side row green. wantErrContains replaces the bool and names the side. Rewording the allowed-side message as the requested-side one now fails three rows that previously all passed. Alias rejection was tested for one alias on one side. Both aliases are now tested on both sides. The allowed-side rows are the ones that earn their place: they are what would catch someone canonicalizing inside the allowed loop and widening the contract without a caller asking.
ScopesCover expanded and compared in a single pass, so the invariant guards only ever ran on scopes ExpandScope had produced. Every such scope satisfies them, which left the guards unverified in the position that matters: the existing test called checkCoverable directly and could not tell whether ScopesCover consulted it on both sides, or at all. Split the comparison into scopesCoverExpanded, which takes already expanded scopes paired with the names they came from. Tests drive synthetic Scope values through it, so dropping the guard from either side now fails, as does an allowed scope that grants every workspace action except delete answering a request for delete. Expanding every allowed scope before any guard runs reorders two error paths against each other: a requested scope that fails a guard alongside an unknown allowed name now reports the expansion failure rather than the guard failure. Both return (false, error), and no ScopeName reaches that combination today.
…ontract The knowledge of which spellings are backward-compatibility aliases lived in two switches, one in IsExternalScope and one in CanonicalScopeName, kept in step by discipline. Drift between them is asymmetric: a name the first accepts and the second does not rewrite is declared public and then fails to expand on every request naming it. Both now read one table, so they agree by construction, and an internal test walks that table asserting each alias is public, resolves to a public name, and resolves to one ExpandScope accepts. A third alias is covered the day it is added. ScopesCover stated "names must be canonical" in prose only, which is wrong for exactly the two inputs IsExternalScope accepts and ExpandScope does not. The parameters are now canonicalAllowed and canonicalRequested, so the requirement shows up in editor hints at every call site rather than only in a doc comment the caller may not have opened. Naming the parameters was chosen over canonicalizing inside ScopesCover. The single downstream caller already canonicalizes both sides in bulk before comparing, so absorbing the step would remove nothing from it while dissolving the distinction between a public spelling and a stored one at the layer that should hold it.
…roken The site-only, wildcard-allow-list, no-negatives invariant was described on ScopesCover and enforced by its guards, but ExpandScope, which is what produces those values, had no doc comment at all. Someone adding a scope reads ExpandScope and its neighbors; nothing there warned that populating User or adding a negative makes the scope uncomparable. State it there, along with the canonicalization requirement, and name the consequence rather than just the rule. Also note on ScopesCover that a wildcard request needs a wildcard grant. Enumerating today's concrete actions genuinely is narrower than `workspace:*`, so the rejection is intended. The OneActionDoesNotCoverResourceWildcard row already pins the behavior; the note stops the next reader of an authorize endpoint from taking it for a bug and closing the gap. Comments only. Checked that the documented invariant actually holds for all three builtin scopes and all seven composites.
TestScopesCoverAllowedNegativeDoesNotWiden drove the same scope shape as the NegativeUserPermission row of TestScopesCoverGuards, but asserted only that some error came back. The row asserts the message, the side it names, and that the comparison reports no coverage, and it runs the shape on both sides rather than one. The weaker copy could pass on a regression that returned the wrong error or stopped naming the side. Fold the scenario it documented into the row's comment and drop the copy. Rename the shared permission fixtures after the value they hold. The site prefix read as "belongs in Role.Site", while two of the three are placed in Role.User to build the shapes the guards refuse, and the No suffix gave no hint that it means Negate. Co-Authored-By: Claude Opus 5 <[email protected]>
The allowed-side wrap printed the scope name and then wrapped an error that prints it again, so the two sides of one comparison read differently: expand allowed scope "foo": no scope named "foo" expand requested scope: no scope named "foo" Drop the redundant verb and let the inner error carry the name on both sides. Co-Authored-By: Claude Opus 5 <[email protected]>
The docstring said the list includes the `all` and `application_connect` special scopes. It appends ScopeAll and ScopeApplicationConnect, which are the `coder:` spellings, so the bare aliases are absent. Two callers already compensate by appending them by hand, one of them with a comment stating the mismatch. Describe what the function returns and name the helper that bridges the gap. Co-Authored-By: Claude Opus 5 <[email protected]>
The invariant that expansion populates Site only was stated in full on ExpandScope, checkCoverable and ScopesCover, and the "everything except delete" example appeared on checkCoverable and again on permissionCovered twenty lines below. State it once on ScopesCover, which is the function whose behaviour depends on it, and cross-reference from the other two. Drop framing that ranked implementation choices nobody proposed, and cut the two test comments down to the facts the assertions do not already carry. Kept in full: what each guard in checkCoverable defends, since no other comment says it, and the wildcard rule on ScopesCover. Co-Authored-By: Claude Opus 5 <[email protected]>
checkCoverable said a negative site permission would be skipped, naming a branch permissionCovered no longer has. A negative reaching it matches on resource type and action like any other grant, so the anti-grant would read as a grant. Name that instead, so the cross-reference lands on a doc that matches the code.
The docstring listed the aliases and the low-level scopes, omitting the curated composites the function also accepts. A caller consulting it to decide whether coder:workspaces.access is public read no from the doc and yes from the code.
ExternalScopeNames promises it offers each scope under one canonical spelling, and no test held it to that. TestScopesCoverEveryExternalScope appended the two aliases, but canonicalized them back into names the list already carries, so it re-ran assertions the list iteration had made and left the promise itself unpinned. Assert on the alias table instead: the list omits the alias and offers its canonical target. Every offered name is already proven coverable, so the aliases inherit coverage, and a third alias inherits both invariants the day it is added rather than needing a third hardcoded pair here.
The authorize endpoint parsed the scope parameter and discarded it, so an app's configured allowlist never restricted anything and a client asking for more than it should get was never told no. Phase 1 added the columns that carry a negotiated scope from a code to the token it becomes, but nothing wrote one, so every code was stamped unrestricted. Negotiate the scope at authorization time and persist the result: - Requested names must be in the external scope catalog, and the app's stored allowlist is filtered through that same catalog. Filtering only ever narrows what can be granted. - The allowlist bounds authority, not spelling. A request is granted when every permission it grants is also granted by the allowlist, whether or not the allowlist names it, so an app allowed coder:workspaces.access can approve a client asking only for workspace:ssh. - Omitting scope grants the filtered allowlist, per RFC 6749 section 3.3. - Both handlers negotiate, so a request that cannot succeed fails before the consent page renders rather than after the user clicks Allow. Each reports the failure the way it already reports its own errors: a static error page on the GET side, an OAuth2 error body on the POST side. - Two paths produce an empty result and are deliberately distinct. No allowlist and no request keeps the previous unrestricted grant, written as an explicit sentinel because the column is NOT NULL with a non-empty CHECK. An allowlist that filters to nothing is rejected, since falling back would grant strictly more than the allowlist ever permitted. Dynamic client registration performs no catalog validation, so apps registered with scopes such as openid or admin hold allowlists this server cannot grant from. They now fail authorization in both directions. Grandfathering unknown names would seed the enforcement path with values it cannot evaluate, trading a visible negotiation-time error for a silent enforcement-time hole. The failure names the registered scopes and the remedy. Issued tokens are still unrestricted: the exchange copies the negotiated scope onto the token record, but the API key it mints carries no scope. This changes which authorization requests succeed, not what a token can do.
The consent page told every user the app was getting full access to their account, which stopped being true once the authorize endpoint began negotiating a narrower scope. A user approving a request has no other place to learn what they are handing over, so the page has to follow the grant rather than a fixed sentence. List the negotiated permissions when the grant is bounded, and keep the original full-access wording when it is not. An unrestricted grant is reported as full access rather than as "coder:all", since the scope name tells a user less than the sentence does. The list collapses to the full-access wording whenever the unrestricted scope is present, not only when it stands alone: an allowlist registered as `coder:all coder:workspaces.access` grants everything, and naming the narrower entry beside it would describe the grant as bounded. role="list" and role="listitem" are explicit because WebKit drops the implicit list semantics from a list styled with list-style: none, which would otherwise leave VoiceOver announcing the permissions as loose text. Also narrow the fragment the tests match for one rejection branch. The GET side renders its description into HTML, which escapes the apostrophe in "this app's allowed scope list", so the fragment stops before it.
…back A rejected authorization request answered on Coder, which reaches only the user's screen. The client's error handling never ran, and the state it sent was dropped, so it could not correlate the failure with the request that caused it. RFC 6749 section 4.1.2.1 requires the error be delivered to the client's redirect URI once the client is known. Redirect to the app's registered callback with error, error_description, and the state exactly as it arrived. Both handlers use this, replacing the static error page on the GET side and the OAuth2 error body on the POST side. This is safe here specifically because of ordering: extractAuthorizeParams exact-matches the redirect URI against the app's registered callback, and it runs before the scope check, so the destination is the app's own no matter what the request carried. Only errors raised after that point may use this helper, which its precondition states. Errors from extractAuthorizeParams itself must not, since the URI is unvalidated there. MismatchedRedirectURINotRedirected pins the ordering: an unregistered redirect_uri fails on Coder with no Location header on either verb, even when the same request also carries a scope the app cannot be granted. The other error paths in this file are unchanged, since several of them are where redirect URI validation fails.
permissionCovered could drop its action comparison and the suite stayed
green: no ScopeName expands to {*, <specific action>}, since the wildcard
entry in policy.RBACPermissions carries no actions and coder:all is the
only wildcard resource the catalog spells. Reach the shape through
scopesCoverExpanded instead, with a positive control so the case fails on
the action rather than on resource matching, and a mirror pinning that a
single-resource grant does not cover a request for every resource.
Every allowed-side error row named the bad scope as the only entry, so an
implementation that answers as soon as one entry covers the request never
reached it. Add a row where the bad name sits behind coder:all, the only
row that fails when ScopesCover expands inside the comparison loop rather
than up front.
…otiateScope The function does not check a requested scope and hand back a verdict. It decides what scope the code will carry, which for an omitted request is the app's allowlist and for an app with no allowlist is coder:all. Neither is a value the caller asked for, so the name promised the wrong thing.
… sentinels The black-box tests assert on the description that reaches the client, and they did so through hand-copied fragments of the sentinel messages. A reworded sentinel would leave every case asserting on text no branch produces, and each case would still pass through whichever branch happened to match next. The sentinels live in package oauth2provider and the tests live in oauth2provider_test, so they are bound through exported values declared in the package's internal test file, which compiles into the same binary.
…turning it rbac.ScopesCover reports an error when it cannot expand one of the names it was handed. That is a deployment-side condition: the app's stored allowlist holds something RBAC will not resolve, and no client can fix it by asking differently. Folding it into errScopeNotAllowed both told the client it had asked for too much, which is not what happened, and rendered RBAC internals into error_description. The failure now goes to the log with the app that provoked it, and the client receives a sentinel of its own. negotiateScope takes the whole app rather than its scope alone so the log line can name it.
… is grantable The rejection named the filter's input, rejoined from fields. For a whitespace-only allowlist that input is empty, so the app owner was shown "" as the value they had to change: the one configuration where the message is the only clue anything is set at all. It now names the stored value verbatim.
…asons Two reasons said things the code does not do. "scope is not in this app's allowed scope list" described membership, but the check is permission coverage: a scope the allowlist never names is granted when a listed composite already confers it. A client reading the old text would go looking for its scope in a list it was never matched against. "re-register the app with supported scopes" prescribed the one remedy a DCR client has. An admin-created app is edited, not re-registered, and a DCR client can update itself in place through RFC 7592. The new text carries an apostrophe on the path the GET handler renders through an HTML template, so the helper that reads those responses now unescapes before matching.
The swagger annotation said a requested scope must be within the app's configured allowlist, which is wrong twice over. The allowlist is checked by permission coverage, not name membership, and it is not the only gate: every requested name must also be in this deployment's scope catalog, including for an app that has no allowlist at all. The omitted-scope default was likewise stated only for apps that have one. Two code comments went stale the same way. The branch table called the omitted-scope default the whole allowlist when it is the catalog-filtered one, and the comment over the persisted scope said the token minted from the code will carry it, which is the next phase's work, not this one's.
Nineteen review findings. The substantive ones: rejections put a double quote into error_description, which RFC 6749 §5.2 excludes; the docs and a comment named an administrator as the actor that narrows a registration, when only the client can through RFC 7592; the refresh exemption cited §6, which caps what a client may request rather than obliging the server; the shared coverage warning named no endpoint; and the refusal the check exists to produce left no server-side trace. firstScopeOutsideAllowlist takes appID rather than the app, so the log cannot name an allowlist the comparison did not use, and defers to a new rbac.FirstScopeNotCovered that expands the allowed side once rather than once per granted scope.
Plainer wording for the scope checks and their error classification, and drop rationale that repeats what the code or the docs already say.
|
/coder-agents-review |
BobbyHo
left a comment
There was a problem hiding this comment.
Review: single-use code redemption
Reviewed against the stack: findings below were checked against #28751, #28752 and #28753 first, and anything those fix is not raised here.
Verdict: looks correct, non-blocking comments only.
What I verified locally
- Built the branch and ran the new tests against Postgres:
TestOAuth2TokenExchangeSingleUsepasses-count=3.- Reverting only the
tokens.gohunk back to the blindDeleteOAuth2ProviderAppCodeByIDmakes it fail 5/5, so this is a real regression test, not a test that passes either way. TestSingleUseDeleteByIDReturningRowandTestMethodTestSuite/TestOAuth2ProviderAppCodespass.
- The arbitration argument holds:
DefaultTXOptionsissql.LevelDefault(read committed), so the losingDELETEblocks on the winner's row lock, re-evaluates, removes nothing, andQueryRowContext.Scanyieldssql.ErrNoRows. errBadCodereturned from insideInTxstill reaches theinvalid_grantbranch:runTxwraps with%wand the dispatch attokens.go:281useserrors.Is.- Generated surfaces are consistent:
querier.go,queries.sql.go,dbmetrics,dbmock, plus thedbauthzwrapper and itsMethodTestSuitecase. - The
dbauthzvariant authorizes identically to the existing delete (GetOAuth2ProviderAppCodeByID+ActionDelete), just throughfetchAndQuery. Its pre-fetch can also surfacesql.ErrNoRows(wrapped) when the winner committed before the loser's transaction started, which lands on the sameerrBadCode. Worth knowing, not a problem.
Non-blocking
-
requireinside the spawned goroutine can hang the test instead of failing it (tokens_test.go, thego func() { other <- redeem() }()path).postTokenRequestcallsrequire.NoError, which isFailNow->runtime.Goexitoff the test goroutine, so the channel send never happens and the parent blocks on<-otheruntil the package timeout. Not hypothetical for a networked call. #28752 lifts this shape intorequireExactlyOneMintedunchanged, so it is not fixed downstream. Capturing the request error and asserting it on the test goroutine (orassertvariants) keeps a failure diagnosable. -
The test only asserts the two HTTP statuses. The interesting property of the losing transaction is that it rolled back: no extra
api_keysrow and no extraoauth2_provider_app_tokensrow. A count assertion after the race would pin that the loser minted nothing, which a status-only check cannot distinguish from a mint that failed to serialize. Also not added in #28752. -
The query comment says the caller races the delete "instead of reading first", but the caller does read first (
GetOAuth2ProviderAppCodeByPrefix, and again infetchAndQuery). The accurate claim is narrower: the read cannot arbitrate, the delete does. Same wording is duplicated inqueries/oauth2.sqlandquerier.go. -
DeleteOAuth2ProviderAppCodeByIDnow has exactly one caller,revokeOAuth2CodeOnPKCEFailure. Two near-identical deletes are fine if that is deliberate, but they diverge in a small way worth a comment or consolidation: the old wrapper returnssql.ErrNoRowsraw, the new one wraps it asfetch object: %w. -
Nit:
_, err = tx.DeleteOAuth2ProviderAppCodeByIDReturningRow(...)assigns to the function-scopeerrcaptured by the closure, which is also the variable receivingInTx's result, while the very next statement uses a closure-localprevKey, err := .... A local:=for the delete would keep one meaning per name.
Out of scope, flagging for the epic
RFC 6749 §4.1.2 also says the server SHOULD revoke tokens previously issued from a replayed code. This PR denies the replay, which is the MUST, but nothing in the stack (#28751 / #28752 / #28753) revokes on replay. The DeleteAPIKeyByID on the previous key covers the same-token_name case only, and incidentally. Reasonable as a follow-up rather than here.
This review was generated by Coder Agents on behalf of @BobbyHo.
|
/coder-agents-review |
1 similar comment
|
/coder-agents-review |
…odes No endpoint behavior changes; this is review follow-up on the single-use authorization code work. - Add TestOAuth2TokenExchangeReplay for the ordinary sequential replay. The race test cannot cover that path deterministically, since which read or delete arbitrates there depends on scheduling. - Assert exactly one API key and one token after both redemptions. A status code cannot distinguish a redemption that wrote rows and then failed from one that never wrote. - Split tryTokenRequest out of postTokenRequest so the racing goroutine carries transport errors back to the test goroutine, instead of require running runtime.Goexit and skipping what it still owed. - Correct the comments on the paired DeleteOAuth2ProviderAppCodeByID queries: the caller does read before the delete, and the pair exists so callers that do not arbitrate single use keep the cheaper :exec. - Scope the delete's err to the closure so it cannot collide with the InTx result.
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9d032d55b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
**TL;DR** Second of the PLAT-480 stack. #28237 made the negotiated scope bound the issued token; this PR makes a narrowing of an app's registered scopes apply to codes that were already issued. | PR | What it does | |---|---| | #28237 (merged) | Mints access tokens with the negotiated scope, so a grant for one scope stops behaving as full access, and reports the granted scope per RFC 6749 §5.1. | | **#28740 (this)** | Re-checks the code's scope against the app's registered scopes at redemption, closing the ten minute window in which a narrowing was ignored. | | #28744 | Makes code redemption single-use under concurrency. Carries no scope logic and is independent of this PR. | - #28237 has merged, so this targets main. - No new endpoints and no schema change. --- An authorization code is valid for ten minutes, and its scope is checked against the app's registered scopes exactly once, when the code is issued. A registration narrowed inside that window had the narrowing ignored: the code still redeemed for a token carrying the wider scope. - The token exchange re-checks the code's scope against the registration as it stands at redemption and refuses with `invalid_grant`. RFC 6749 §5.2 reserves `invalid_scope` for the scope the client asked for, and this one comes from the stored grant. - The check is coverage rather than membership, and reuses the comparison the authorize endpoint already runs. That comparison now goes through a new `rbac.FirstScopeNotCovered`, which expands the allowlist once rather than once per granted scope. - Only the client can change its registered `scope`, through RFC 7592; no administrator surface writes the column. The re-check reflects the registration at redemption rather than constraining the client. - Refresh keeps the scope originally granted. That is a product choice, not an RFC requirement: a narrowing applies at the next authorization rather than mid-session, so tightening an app's scopes does not break integrations already running. - Codes issued before migration 000569 carry `coder:all`. For an app registered with a narrower `scope` those are refused at redemption until they expire, at most ten minutes. Stack: #28237 (merged), this PR, #28744. --------- Co-authored-by: Claude Opus 5 <[email protected]> Co-authored-by: Steven Masley <[email protected]>
…code The parent branch landed on main as a squash (#28740), so its commits conflicted with the copies this branch carries. Resolved by taking main's newer parent work and keeping this branch's single-use redemption changes: - authorize.go, tokens_internal_test.go, oauth2-provider.md: main's version, which bounds the registered scope read and keeps it out of the response. - tokens.go: main's log-and-refuse for an ungrantable allowlist, over the older version that echoed the registered scope. - tokens_test.go: kept this branch's `io` import and the single-use, replay, and requireOneTokenForApp additions.
|
/coder-agents-review |
…h-scope Picks up the resolved main merge from #28744, which brings in the squashed parent work (#28740) plus the single-use code redemption changes. Merged without conflicts. The overlapping regions combined as intended: checkScopeStillCovered keeps the parent's log-and-refuse for an ungrantable allowlist while taking this branch's firstScopeBeyondCeiling rename, and grantableScopes keeps the parent's bounded allocation.
BobbyHo
left a comment
There was a problem hiding this comment.
Review: single-use code redemption (follow-up pass)
Re-reviewed the current head (8acedcd) rather than repeating the earlier review. Everything raised there is addressed: tryTokenRequest carries the error back to the test goroutine, requireOneTokenForApp pins the loser's rollback, the query comment now says the read cannot arbitrate rather than that nothing reads, the blind delete is documented, and the closure uses a local err.
Verdict: approve-quality. Two non-blocking suggestions, one PR-body nit.
Verified locally
TestOAuth2TokenExchangeSingleUseandTestOAuth2TokenExchangeReplaypass-count=3against Postgres.- Arbitration argument holds under
sql.LevelDefault(read committed): the loser'sDELETEblocks on the winner's row lock, re-evaluates, removes nothing,Scanyieldssql.ErrNoRows. errBadCodereturned from insideInTxsurvivesrunTx: every wrap is%wanderrBadCodeis not a serialization error, so no retry anderrors.Isattokens.go:276still routes it toinvalid_grant.- The
dbauthzpre-fetch infetchAndQuerywrapsErrNoRowswith%w(fetch object: %w), so the case where the winner commits before the loser's transaction begins also lands onerrBadCode. requireOneTokenForAppfiltersGetAPIKeysByUserIDbyLoginTypeOAuth2ProviderApp, so the owner's password session key does not pollute the count.
Non-blocking
- Consolidation question (inline on
oauth2.sql): the only remaining caller of the blindDeleteOAuth2ProviderAppCodeByIDalready toleratessql.ErrNoRows, so the:execvariant could be replaced outright instead of kept alongside. - Race test coverage strength (inline on
tokens_test.go): the Codex thread is right that the barrier is client-side. Your 100/100 measurement is convincing that it catches the regression today; the suggestion is a cheap way to make that independent of the read-to-delete window.
PR body
#28740 merged, so "Diff is against #28740, not main. Read it first." and "Depends on #28740" are stale. The diff is now 10 files against main.
This review was generated by Coder Agents on behalf of @BobbyHo.
BobbyHo
left a comment
There was a problem hiding this comment.
First-pass review only. These are mechanical findings from the first-pass reviewer; the full review panel has not yet reviewed this PR and will do so once these are addressed. They are defects worth clearing before the panel spends parallel review time.
The production change is sound and the arbitration argument holds: DefaultTXOptions() is sql.LevelDefault, so the losing DELETE blocks on the winner's row lock, re-evaluates, removes nothing, and Scan yields sql.ErrNoRows, which maps to invalid_grant. Coverage sits at three layers (SQL, dbauthz, end to end), and TestOAuth2TokenExchangeSingleUse is a genuine regression test: reverting the tokens.go hunk to the blind :exec delete fails it 5/5. go build ./coderd/... and go vet are clean, and the generated surfaces match the SQL.
1 P2, 1 P3, 1 Nit, 2 Notes.
The P2 is the one that matters, and it was proven rather than argued. requireOneTokenForApp cannot see the thing it is named for: oauth2_provider_app_tokens_api_key_id_fkey is ON DELETE CASCADE, and a second successful redemption deletes the previous API key by name before inserting its own, so token 1 disappears with key 1. The database converges to one key and one token no matter how many redemptions succeed. Reverting the fix and instrumenting the test produced PROBE minted=2 rejected=0 while the helper passed 5/5. In the first-pass reviewer's words: "It counts both rows and both counts are insensitive to the thing being counted."
That compounds with the Note on TestOAuth2TokenExchangeReplay, which also passes against the parent implementation. Between the two, that test currently asserts nothing this PR changed. TestOAuth2TokenExchangeSingleUse is unaffected: its minted count still catches the regression.
One finding overlaps an open thread already on this PR (the consolidation question on oauth2.sql:163); it is raised here as an independent finding, not as agreement by default.
🤖 This review was automatically generated with Coder Agents.
…used redemption requireOneTokenForApp could not fail. The grant deletes any key holding the name it is about to write and oauth2_provider_app_tokens cascades on that delete, so the rows converge on one key and one token however many redemptions succeed. Reverting single use and probing showed minted=2 with the helper still passing. Replace it with requireTokenAuthenticates: keep the access token the accepted redemption returned and call the API with it afterward. A second redemption would have rotated that key out, which a row count cannot see. Both tests now grant coder:all so the probed endpoint is in scope.
DeleteOAuth2ProviderAppCodeByID and its ReturningRow twin differed only by RETURNING *, and the pair propagated through seven layers: two SQL queries, two querier entries, two dbauthz wrappers, two dbmetrics wrappers, two dbmock methods and two MethodTestSuite subtests. The :exec variant's one caller, revokeOAuth2CodeOnPKCEFailure, already treats sql.ErrNoRows as success, and both dbauthz wrappers fetched the code and authorized ActionDelete, so the swap preserves behavior. Removing the variant also frees the plain name: no other Delete... :one query in the repo carries a suffix naming its RETURNING clause. scripts/dbgen reuses existing method bodies and does not drop removed methods, so the dbmetrics wrapper needed the orphan deleted and the survivor's body widened by hand.
…reak The comment at the delete explained the race mechanics, which db.InTx already implies. Replace it with the reason the row is deleted at all: RFC 6749 §10.5 single use, spent inside the transaction so a later failure leaves the code redeemable. The premise worth recording is that arbitration depends on READ COMMITTED. That belongs on the query, next to the ErrNoRows contract it qualifies, where sqlc carries it into the generated Go for callers.
The client-side barrier only aligned the request sends. Nothing stopped one handler from committing before the other read the code, in which case the pre-transaction read refuses the second redemption and the delete never arbitrates. The test then passes against an implementation that does not enforce single use. barrierStore holds both redemptions at that read until each has taken it. Against the parent implementation the test now fails 10/10 on minted=2.
dylanhuff-at-coder
left a comment
There was a problem hiding this comment.
overall lgtm, two comments
TL;DR
Third of the PLAT-480 stack, stacked on #28740. An authorization code could be redeemed twice under concurrency; this makes the redeeming delete the arbiter of single use.
RFC 6749 §10.5 requires an authorization code to be single-use. The redeeming delete was a blind
:execwith no affected-rows check, so two concurrent redemptions of one code both minted a token.sql.ErrNoRowsand is refused withinvalid_grant, the same response an unknown code already gets.Depends on #28740
Stack: #28237, #28740, this PR.