Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix: make OAuth2 refresh token redemption single-use under concurrency - #28752

Merged
BobbyHo merged 148 commits into
mainfrom
plat481-2-single-use-refresh
Sep 11, 2026
Merged

fix: make OAuth2 refresh token redemption single-use under concurrency#28752
BobbyHo merged 148 commits into
mainfrom
plat481-2-single-use-refresh

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Second and last in PLAT-481, which closes PLAT-470. Two concurrent refreshes of one refresh token both minted a replacement. #28744 fixed the same bug on the authorization code; this applies it to api_keys, so the review is a comparison against a merged precedent. It also absorbs what was #28753: the revoked-token refresh is pinned as invalid_grant, and a stored scope that names nothing stops being echoed back.

PR What it does
#28744 Code redemption is single-use under concurrency.
#28751 A refresh may name a narrower scope.
this Refresh token redemption is single-use under concurrency. A revoked token's refresh is pinned as invalid_grant. A stored scope that names nothing stops being echoed back.
  • No migration. No new sentinel, no new dispatch case, no docs: the error class is not documented behavior.

Implementation Details

The fix

  • A refresh deletes the API key the presented token hangs off and mints a replacement. That delete was a blind :exec with no affected-rows check, so the request that lost the race deleted nothing and minted anyway.
  • DeleteAPIKeyByIDReturningRow is the same DELETE ... RETURNING * shape as its code-side sibling: a delete that removed nothing surfaces sql.ErrNoRows. RETURNING * because fetchAndQuery needs an rbac.Objecter.
  • refreshTokenGrant maps that to errBadToken, the invalid_grant it already returns for a token it cannot find (RFC 6749 §5.2). RFC 6749 §10.4 is why the presented token is invalidated at all: a second use of it must be detectable, which holds only if exactly one refresh succeeds. Both grants now log that refusal at warn with the app and the row involved; the client still receives the generic invalid_grant.
  • refreshTokenGrant no longer reads the previous api_keys row before deleting it. The token row has carried user_id since migration 346, so the read supplied nothing, and it sat in the one window where a refresh that lost the race still answered 500: the winner's cascade removed the key between the token read and this lookup. The returning-row delete is now the first statement to touch the key, so a lost race can only surface where it already maps to invalid_grant.
  • Both grant transactions now name READ COMMITTED instead of inheriting default_transaction_isolation. The zero-row delete the race relies on is READ COMMITTED behavior; under REPEATABLE READ or above the same delete raises a serialization failure that nothing maps and InTx does not retry, so a raised server default would have turned every lost race into a 500.
  • The seven other DeleteAPIKeyByID call sites are untouched, including authorizationCodeGrant's previous-key delete, where the code delete already arbitrates single use and a returning-row delete would imply otherwise.

The revoked-token error class

  • Refreshing a revoked token returned HTTP 500, because the removed GetAPIKeyByID read returned its sql.ErrNoRows raw and it fell past the sentinel dispatch to the generic handler. Removing the read is the fix; the delete already answers invalid_grant when it finds nothing.
  • The two revocation paths a client can reach both cascade the oauth2_provider_app_tokens row away, so the prefix lookup answers errBadToken first. Deleting the API key and deleting the app secret are pinned anyway: the property a client depends on is the response, not which statement notices it, and the cascades that make them pass are schema this function does not control.
  • The case that answered 500 is a token row whose api_key_id names no key. The FK cascade makes that unreachable through any API, so TestOAuth2RefreshKeyMissing disables the constraints to seed it, and takes a database of its own because disabling them applies to every table. It now reaches the delete and answers 400.
  • A third path, deleting the app, never reaches the grant: the client_id no longer resolves, so ExtractOAuth2ProviderAppWithOAuth2Errors answers 401 invalid_client first. AppDeleted pins that, and the spec is corrected.

The scope that names nothing

  • CHECK (scope <> '') admits a whitespace-only scope, and scopeStringToAPIKeyScopes echoed it into error_description as an empty pair of quotes. There is no name to report, so the rejection carries a fixed message instead.
  • StoredScopeOutsideEnumRejectedOnRefresh covers an unmintable stored scope reached through a refresh rather than through an authorization code, which is the likelier way a name dropped from the enum surfaces: a grant outlives the code that issued it.
Tests
  • TestOAuth2RefreshSingleUse races two refreshes on one barrier and requires exactly one 200 and one 400 invalid_grant. A sequential pair passes pre-fix, so the race is the test; the barrier shape is shared with TestOAuth2TokenExchangeSingleUse as requireExactlyOneAccepted. Both tests check the accepted token authenticates and that exactly two requests reached the barriered read; the refresh test also checks the presented refresh token's row is gone.
  • TestSingleUseDeleteNotFound in dbauthz pins that a fetch miss in the fetch-then-query wrapper still matches sql.ErrNoRows and never reaches the delete. That wrapping is what makes a refused single-use delete answer invalid_grant rather than 500.
  • An APIKey subtest in the existing TestSingleUseDelete pins the second delete returning sql.ErrNoRows.
  • MethodTestSuite fails on any untested database.Store method, so DeleteAPIKeyByIDReturningRow gets a case alongside DeleteAPIKeyByID.

Stack: #28237, #28740, #28744, #28751, this PR.


Manual Tests

Scenario summary, all 32
# Scenario Result
1 Exchange for an allowlisted app states the granted scope (coder:workspaces.access) Pass
2 Exchange for an app with no allowlist grants coder:all Pass
3 Minted tokens are bounded differently at the API (composite cannot create a key, coder:all can) Pass
4 The same code exchanged twice sequentially is refused invalid_grant Pass
5 Two concurrent exchanges of one code mint exactly one token Pass
6 The delete arbiter fires under a barrier client, logged code already used Pass
7 The losing exchange writes nothing (one key, one token row) Pass
8 A PKCE-failure revokes the code, so a correct retry is refused Pass
9 A refresh narrows the access token; the grant row is unchanged Pass
10 A narrowing does not bind later refreshes (sibling scope, then whole grant) Pass
11 A refresh cannot widen, and a refusal mints nothing and keeps the token usable Pass
12 An unknown and an internal-only scope both answer invalid_scope, not 500 Pass
13 A coder:all grant narrows to workspace:read (coverage, not membership) Pass
14 The code exchange narrows too Pass
15 The code exchange cannot widen, and a scope refusal does not spend the code Pass
16 The narrowed key is enforced at the API Pass
17 Refusals log phase=refresh/redeem; the field is ceiling, not allowlist Pass
18 A hostile scope name comes back NQSCHAR-clean and still names the reason Pass
19 A 16 KiB scope name comes back capped at 2060 bytes, marked (truncated) Pass
20 A fixed message (RFC 7636 section 4.1) survives the sanitizer intact Pass
21 The same refresh token used twice sequentially is refused invalid_grant Pass
22 Two concurrent refreshes of one token mint exactly one replacement Pass
23 The refresh delete arbiter fires under a barrier client, logged refresh token already used Pass
24 A successful refresh kills the previous access token; the loser writes nothing Pass
25 Refreshing after the API key is deleted answers invalid_grant Pass
26 Refreshing after the app secret is deleted answers invalid_grant Pass
27 Refreshing after the app is deleted answers invalid_client (401) Pass
28 Refreshing an orphaned token row answers invalid_grant, not the old 500 Pass
29 A refresh with a wrong or absent client_secret is accepted Pass
30 A whitespace-only stored scope answers invalid_grant with a fixed message, no '' Pass
31 Both single-use races hold under default_transaction_isolation = repeatable read, no 500 Pass
32 Cleanup drains the child tables; DCR disabled and registration refused Pass
Shell helpers used throughout

Run beside the server (workspace localhost:3000), fixtures registered through DCR.

BASE_URL=http://localhost:3000
AUTH_HEADER="Coder-Session-Token: $(cat .coderv2/session)"
pgc() { PGPASSWORD=$(cat .coderv2/postgres/password) psql -h localhost -p "$(cat .coderv2/postgres/port)" -U coder -d coder -tA -c "$1"; }
urlenc() { jq -rn --arg v "$1" '$v|@uri'; }

new_pkce() {
  VERIFIER=$(openssl rand 32 | base64 | tr -d '\n=' | tr '+/' '-_')
  CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_')
  STATE=$(openssl rand -hex 16)
}
# authorize CLIENT_ID [SCOPE] -> sets CODE
authorize() {
  new_pkce
  local url="$BASE_URL/oauth2/authorize?client_id=$1&response_type=code&redirect_uri=$(urlenc http://localhost:9876/callback)&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
  [ -n "${2:-}" ] && url="$url&scope=$(urlenc "$2")"
  local loc; loc=$(curl -s -o /dev/null -X POST "$url" -H "$AUTH_HEADER" -w '%{redirect_url}')
  CODE=$(python3 -c 'import sys,urllib.parse as u;print(dict(u.parse_qsl(u.urlparse(sys.argv[1]).query)).get("code",""))' "$loc")
}
exchange_form() { printf 'grant_type=authorization_code&client_id=%s&client_secret=%s&code=%s&code_verifier=%s&redirect_uri=%s' "$1" "$2" "$CODE" "$VERIFIER" "$(urlenc http://localhost:9876/callback)"; [ -n "${3:-}" ] && printf '&scope=%s' "$(urlenc "$3")"; }
refresh_form()  { printf 'grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s' "$1" "$2" "$3"; [ -n "${4:-}" ] && printf '&scope=%s' "$(urlenc "$4")"; }
# tok FORM: prints HTTP status + body, sets AT/RT on 200
tok() { local s; s=$(curl -s -o /tmp/tok.body -w '%{http_code}' -X POST "$BASE_URL/oauth2/tokens" -H 'Content-Type: application/x-www-form-urlencoded' --data-binary "$1"); echo "HTTP $s"; jq . /tmp/tok.body; [ "$s" = 200 ] && { AT=$(jq -r .access_token /tmp/tok.body); RT=$(jq -r .refresh_token /tmp/tok.body); }; }
latest_token() { pgc "SELECT t.api_key_id, t.scope, k.scopes FROM oauth2_provider_app_tokens t JOIN api_keys k ON k.id=t.api_key_id JOIN oauth2_provider_apps a ON a.id=t.app_id WHERE a.name='$1' ORDER BY t.created_at DESC LIMIT 1;"; }

A barrier-synchronized racer (racer.py) fires N POSTs at once over warmed
sockets, the black-box analogue of the suite's barrierStore:

import sys, threading, http.client, urllib.parse
n=int(sys.argv[1]); form=sys.argv[2]; barrier=threading.Barrier(n); res=[None]*n
def worker(i):
    c=http.client.HTTPConnection("localhost",3000); c.connect(); barrier.wait()
    c.request("POST","/oauth2/tokens",body=form,headers={"Content-Type":"application/x-www-form-urlencoded"})
    r=c.getresponse(); r.read(); res[i]=r.status; c.close()
ts=[threading.Thread(target=worker,args=(i,)) for i in range(n)]
[t.start() for t in ts]; [t.join() for t in ts]
from collections import Counter; print(dict(Counter(res)))

Fixtures, both registered through DCR:

plat481-access   coder:workspaces.access   (allowlist; the composite that makes narrowing observable)
plat481-plain    (no allowlist -> coder:all)
1. Baseline: exchange succeeds and states the grant
authorize "$ACCESS_ID"; tok "$(exchange_form "$ACCESS_ID" "$ACCESS_SECRET")"; latest_token plat481-access
authorize "$PLAIN_ID";  tok "$(exchange_form "$PLAIN_ID"  "$PLAIN_SECRET")";  latest_token plat481-plain
probe "$ACCESS_AT"; probe "$PLAIN_AT"
plat481-access: HTTP 200  scope=coder:workspaces.access   row: coder:workspaces.access | {coder:workspaces.access}
plat481-plain : HTTP 200  scope=coder:all                 row: coder:all | {coder:all}

composite token: GET /workspaces 200,  create-token 404 (denied)
coder:all token: GET /workspaces 200,  create-token 201 (created)

RFC 6749 §5.1 states the scope even though none was requested (§3.3 default: the
whole allowlist, or coder:all). The two tokens are genuinely bounded
differently. The composite's create denial is 404, not 403,
because Coder hides existence on that endpoint; pre-existing.

2. Authorization code redemption is single-use (#28744)
# sequential
authorize "$ACCESS_ID"; F=$(exchange_form "$ACCESS_ID" "$ACCESS_SECRET"); tok "$F"; tok "$F"
# concurrent (barrier racer), and the log
python3 racer.py 8 "$(exchange_form ...)"        # per fresh code
grep -c 'code already used' coderd-dev.log
sequential:   HTTP 200, then HTTP 400 invalid_grant "The authorization code is invalid or expired"
plain curl race (2-8 way):   exactly one 200, rest 400;   'code already used' warns: 0
barrier racer (8 sockets, 8 rounds):   {200:1, 400:7} each;   warns: 55
   [warn] code already used  request_id=c4838d63... code_id=97ac3f63...   (same code_id, distinct request_ids)
after: tokens=1 keys=1 codes=0;  deployment oauth2 keys=2
PKCE failure then correct retry:   400 (PKCE), then 400 (code revoked), no 500

Exactly one token in every configuration across ~70 contested redemptions.
Note: the plain curl race is decided at the prefix read on fast local
Postgres; only the barrier client reaches the DELETE ... RETURNING * arbiter
(the 55 warns, same code_id under distinct request_ids), which is what the
suite's barrierStore forces deterministically.

3. A refresh may narrow, and may not widen (#28751)
authorize "$ACCESS_ID"; tok "$(exchange_form "$ACCESS_ID" "$ACCESS_SECRET")"
tok "$(refresh_form "$ACCESS_ID" "$ACCESS_SECRET" "$RT" workspace:read)"; latest_token plat481-access
# ... chained: workspace:ssh, then no scope; template:update; banana:read; debug_info:read
# coder:all grant narrowed; exchange with scope; exchange over-ask
refresh workspace:read -> 200  scope=workspace:read   row grant=coder:workspaces.access (unchanged), key={workspace:read}
chain read -> ssh -> (no scope): 200 ws:read, 200 ws:ssh, 200 coder:workspaces.access   (ceiling never moved)
refresh template:update -> 400 invalid_scope "beyond the scope originally granted"; nothing minted; token still redeems
refresh banana:read / debug_info:read -> 400 invalid_scope "unknown or unsupported scope"  (not 500)
coder:all grant, refresh workspace:read -> 200  key={workspace:read}, grant coder:all
exchange with scope=workspace:read -> 200 key={workspace:read}, grant coder:workspaces.access
exchange with scope=template:update -> 400 invalid_scope; code NOT spent (codes=1, retries)
narrowed key at API: GET /workspaces 200, create-token 404
log: phase=refresh / phase=redeem; allowlist= absent (renamed ceiling=)

The narrowing lands on the access token, never the grant row (OAuth 2.1 §4.3.3);
the ceiling is the grant, not the previous request (§6); coverage lets a
coder:all grant narrow, which membership could not. On the exchange path the over-ask message still says "a refresh cannot widen a grant".

4. Token-endpoint error descriptions are sanitized and capped (#28751)
# exact bytes via a Python client: BEL ESC[31m " \ é § <b>
# 16 KiB scope; and code_verifier=short
hostile scope -> 400 invalid_scope; decoded error_description has 0 bytes outside NQSCHAR
   (the ", \, controls and non-ASCII removed; reason "unknown or unsupported scope" kept)
16 KiB scope  -> 400 invalid_scope; error_description length 2060 (2048 cap + " (truncated)")
code_verifier=short -> 400 invalid_request; message ends "(RFC 7636 section 4.1)", no § , 0 offending bytes

writeTokenError runs sanitizeErrorDescription then capErrorDescription at
the write, so the RFC 6749 §5.2 character set holds on the decoded value (JSON
escaping alone would not) and the cap matches the authorize path.

5. Refresh token redemption is single-use (#28752)
authorize "$ACCESS_ID"; tok "$(exchange_form ...)"; F=$(refresh_form "$ACCESS_ID" "$ACCESS_SECRET" "$RT"); tok "$F"; tok "$F"
python3 racer.py 6 "$(refresh_form ...)"          # per fresh token
sequential:   200, then 400 invalid_grant "The refresh token is invalid or expired"
plain curl race:   one 200, one 400;   'refresh token already used' warns: 0
barrier racer (6 sockets, 5 rounds):   {200:1, 400:5} each;   warns: 24  (same api_key_id, distinct request_ids)
after a refresh:   old access token -> 401 (its key deleted),  new -> 200;  tokens=1 keys=1; deployment keys=2

The refresh-path twin of section 2, on api_keys via
DeleteAPIKeyByIDReturningRow. The old access token dying (section 2 has no
analogue) confirms the delete is real, not just an arbitration signal.

6. Revoked-token paths answer 4xx, never 500 (#28752)
# key deleted / secret deleted / app deleted / FK-bypassed orphan
pgc "SET session_replication_role = replica; DELETE FROM api_keys WHERE id='$KEY_ID'; SET session_replication_role = DEFAULT;"
# control: refresh with a wrong / absent client_secret
API key deleted   -> refresh 400 invalid_grant
app secret deleted-> refresh 400 invalid_grant   (matches AppSecretDeleted; not 401)
app deleted       -> refresh 401 invalid_client  (client_id no longer resolves)
orphaned token row-> refresh 400 invalid_grant   (was HTTP 500 before #28752)
control: wrong client_secret -> 200 ;  no client_secret -> 200 ;  (code exchange wrong secret -> 401 invalid_client)

The removed pre-delete GetAPIKeyByID read is what turns the orphan case from
500 into 400. Note: the refresh grant never compares client_secret, so
a confidential client's refresh token is redeemed without authenticating (both
wrong and absent secret return 200), while the exchange path authenticates
correctly. RFC 6749 §§3.2.1 and 6 require authentication; pre-existing, and wider
than #28751's stolen-refresh-token framing.

7 and 8. Whitespace scope, and the isolation level (#28752)
pgc "UPDATE oauth2_provider_app_tokens SET scope=' ' WHERE api_key_id='$KEY_ID';"; tok "$(refresh_form ...)"
pgc "ALTER DATABASE coder SET default_transaction_isolation='repeatable read';"  # + drop pooled conns, rerun both barrier races
whitespace-only stored scope -> 400 invalid_grant "the grant names no scope: ..."  (no echoed '')
under repeatable read:  code + refresh barrier races -> {200:1, rest 400} each;  500s: 0;  serialization errors: 0
                        arbiter warns still fired (80 code + 50 refresh), so the deletes did contend

The CHECK (scope <> '') gap for a whitespace value now reports a fixed message.
The explicit sql.LevelReadCommitted in singleUseTxOptions overrides a raised
server default: the deletes contended (warns fired) yet no 40001 serialization
failure surfaced as a 500.

9. Cleanup
plat481 apps deleted (204); child tables drained: apps 0, codes 0, tokens 0, oauth2 keys 0
DCR disabled; register after disable -> 403;  default_transaction_isolation -> read committed

The cascade from app deletion took every code, token, and session key, and the
run left the deployment in the clean state it started from.

BobbyHo and others added 30 commits August 14, 2026 17:02
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.
@BobbyHo
BobbyHo force-pushed the plat481-2-single-use-refresh branch from f499e46 to 81bad9e Compare September 6, 2026 01:22

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panel round (round 1 was a Netero-only first pass). The core change is correct and unusually well-tested: the delete-arbitrates shape matches the merged code-side precedent line for line, the dbauthz swap is authorization-equivalent, and about ten reviewers independently confirmed the race test fails 10/10 against the pre-fix blind delete and passes 100-200/200 with it, with the new errBadToken branch covered. The barrier-at-the-token-read design and the value-returning requireExactlyOneMinted are the right shape for a concurrency test. Hisoka summed up the review's best find: "a security control that answers 200 while doing nothing is the shape I look for."

Prior round: CRF-2, CRF-3, CRF-4 verified fixed; CRF-5 accepted (with one correction, see below).

The panel's central concern is a framing one. The description says "the seven other DeleteAPIKeyByID call sites are untouched" and treats them as one safe class. They are not one class. Two of them contend for the same (user, app) session-key row this PR now arbitrates: the exchange's prev-key delete (CRF-9, tokens.go:601) and revocation (CRF-10, revoke.go:150). Both are blind deletes that treat a zero-row delete as success, so a refresh racing either leaves two live sessions (CRF-9, proven by two reviewers) or lets revocation report 200 while the rotated credential stays live (CRF-10, analytical). Neither is a regression this PR introduces, but the PR looked directly at tokens.go:601 and recorded "the code delete already arbitrates single use" against it, and that reasoning answers a different question (it arbitrates the code, not the key). At minimum the description's claim needs correcting; the fixes need migrations, so whether to fix here or file a ticket is a human decision. Do not accept these as permanent silently.

CRF-1 (deferred to #28753): the panel's disposition is that the deferral is acceptable only if the stack merges in order. Merged alone this PR answers HTTP 500 (not invalid_grant) on the prevKey-read interleaving, and Komugi forced that interleaving to hard-fail the test this PR adds, so merging before #28753 lands a flaky test on main. Meruem offers a subtractive in-PR alternative (CRF-13): the prevKey read at 693 is redundant with dbToken, and removing it collapses both the 500 window and the CRF-8 fetch window into the single arbitrating delete. Recommend pulling #28753's one-branch fix in or applying CRF-13, or confirm the stack merges atomically.

Three process/description items (no code change to this diff): the commit body and PR description cite RFC 6749 §10.5 (Authorization Codes) for refresh single-use; refresh tokens are §10.4, which is the stronger justification (rotation so replay is detectable), and the squash body inherits the wrong citation (CRF-23). The description names a test TestSingleUseDeleteByIDReturningRow that does not exist; it is TestSingleUseDelete (CRF-24). And the fix changes behaviour for a client that races itself (two working sessions become one invalid_grant): spec-correct, but worth a release-note line (CRF-25).

Pre-existing and out of this PR's scope, surfaced for tickets rather than to block: the unindexed api_key_id FK cascade (CRF-16, measured 197x on the hot path this PR creates), the refresh-vs-revoke deadlock (CRF-17), the missing confidential-client auth on refresh (CRF-18, a documented gap from #27712), and api_keys purge cascading refresh tokens away before their stated expiry (CRF-19).

Severity count (new this round): 2 P2, 10 P3, 2 P4, 4 Nit, 1 Note, plus CRF-1 re-raised. Ging-go found nothing.


coderd/oauth2provider/tokens.go:601

P2 [CRF-9] The exchange's blind prev-key delete arbitrates code redemption, not the shared (user, app) session key that both grants now contend for. (Kurapika P2, Takumi P3, Knuckle P3, Pariston P3)

The PR excludes this site because "the code delete already arbitrates single use." The code delete arbitrates the oauth2_provider_app_codes row; it does not serialize the api_keys row, and an exchange and a refresh (or two exchanges) for the same user and app race that row with only one side checking affected rows. Kurapika and Takumi both proved the interleaving leaves two live api_keys/tokens with the same token_name:

live key YtE0Qpmt2p name="…_oauth_session_token"
live key qUIqgIhkCq name="…_oauth_session_token"

No constraint catches it: idx_api_key_name is UNIQUE (user_id, token_name) WHERE login_type = 'token' and these keys are oauth2_provider_app. Consequence (Kurapika): a stolen refresh token survives the user re-consenting, and RFC 7009 revocation by the presented token reaches only one of the two. The full fix is a partial unique index plus mapping the 23505 (migration; the PR says it has none), so this is a human decision: fix here or file a ticket. Either way, correct the description's "already arbitrates" claim, which is false.

🤖

coderd/oauth2provider/revoke.go:150

P3 [CRF-10] revokeRefreshTokenInTx deletes the shared session key blind and treats a zero-row delete as success, so a revoke racing a refresh reports 200 while the rotated credential stays live. (Hisoka P2)

err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
    return xerrors.Errorf("delete api key: %w", err)
}
return nil

Revoke reads the token (K1); a concurrent refresh deletes K1, inserts K2/RT2, commits; revoke's delete removes nothing, the ErrNoRows branch swallows it, and RevokeToken answers 200. The client is told the grant is gone; K2 is live for its full lifetime and RT2 rotates forever (RFC 7009 §2.1). Hisoka rates this P2 but did not land the probe ("the window is four lines wide"); I am recording it as P3 pending verification and because it is pre-existing and outside this diff. This is the same class as CRF-9 and contradicts the description's treatment of the seven call sites as one safe class. Verify and fix, or file a ticket.

🤖

coderd/oauth2provider/revoke.go:229

P3 [CRF-17] Refresh and revoke take opposite lock orders on the same row pair, so a refresh racing a revoke deadlocks and returns 500. (Knuckle P3)

Refresh locks the api_keys row then the token via cascade (key→token); RevokeApp (DeleteOAuth2ProviderAppTokensByAppAndUserID) locks the token row then the key via trigger_delete_oauth2_provider_app_token (token→key). Knuckle reproduced 39 deadlocks racing 20k key deletes against 20k token deletes over the same ids; the victim gets SQLSTATE 40P01, which IsSerializedError does not match and InTx does not retry, so the refresh returns 500 (or the revoke silently no-ops) after up to deadlock_timeout. Pre-existing; reported because the PR's contract is that a concurrent redemption answers invalid_grant, and this is the pair that answers 500. Cheapest fix: make RevokeApp delete keys and let the cascade take the tokens, putting every path on key→token order. Ticket.

🤖

coderd/oauth2provider/tokens.go:644

P4 [CRF-18] refreshTokenGrant authenticates no client, so a confidential app's refresh token is redeemable by anyone holding the token and the public client_id. (Kurapika P3)

extractTokenRequest requires client_secret only for authorization_code, and refreshTokenGrant has no equivalent to authorizationCodeGrant's secret check for non-public apps (RFC 6749 §6). Kurapika verified a refresh with the secret removed returns 200 for a confidential app. The dbToken.AppID != app.ID binding stops cross-app replay, so this is not takeover on its own, but a refresh token from storage, a proxy log, or a crash dump is a complete credential and a confidential app is no safer than a public one here. Pre-existing and documented as a known gap from #27712 review; rated P4 as it predates this PR and is tracked. Surfaced because this is the exact endpoint the PR hardens.

🤖

coderd/database/dbpurge/dbpurge.go:249

P4 [CRF-19] api_keys purging cascades to oauth2_provider_app_tokens, so a refresh token dies at access-key expiry plus retention, not at its own expiry. (Mafuuu P4)

--default-oauth-refresh-lifetime defaults to 30 days and Validate enforces it strictly greater than the 24h session duration, so the design intends the refresh token to outlive its API key. But DeleteExpiredAPIKeys deletes every api_keys row past expires_at - retention (retention default 7 days) with no login_type filter, and the FK cascade drops the token. So an OAuth2 session idle ~8 days loses its refresh token 22 days early, and the next refresh answers invalid_grant. Pre-existing and outside this diff; filed because nothing else will. Either exclude keys still referenced by a live token row, or correct the flag's help text. Ticket.

🤖

coderd/database/queries/apikeys.sql:89

Nit [CRF-20] DeleteAPIKeyByID :exec carries no comment marking it as the twin that cannot arbitrate. (Zoro, Gon)

CRF-5 (the ReturningRow name) was accepted on the grounds that renaming touches seven call sites for no behaviour change. A comment on line 89 touches nothing and retires the same risk: -- Reports nothing when the row is absent. Use DeleteAPIKeyByIDReturningRow to arbitrate single use. The merged precedent had exactly this pointer before it was folded away. Note also that the recorded CRF-5 rationale is slightly off: converting this query to :one in place would be a behaviour change at apikey.go:419 and userauth.go:717,2056, which today treat a zero-row delete as success.

🤖

coderd/oauth2provider/tokens_test.go:557

Nit [CRF-22] The refusal assertion substring-matches the raw body instead of the file's own error helper. (Chopper)

require.Contains(t, result.body, string(codersdk.OAuth2ErrorCodeInvalidGrant)) accepts the code anywhere in the response, including inside error_description. requireTokenGrantError unmarshals codersdk.OAuth2Error and asserts oauthErr.Error equals the code; the sibling replay test uses it. This PR extends the pattern to a grant whose handler maps five distinct conditions to invalid_grant. Not a live bug (both goroutines post the identical form), hence Nit; swap for requireTokenGrantError(t, result.status, result.body).

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens_test.go Outdated
Comment thread coderd/oauth2provider/tokens_test.go Outdated
Comment thread coderd/database/dbauthz/dbauthz_test.go
Comment thread coderd/oauth2provider/tokens.go
Comment thread coderd/database/queries/apikeys.sql Outdated
Comment thread coderd/oauth2provider/tokens.go
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens_test.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
…e race

refreshTokenGrant read the api_keys row before deleting it, for the user
id. That row has carried nothing the token row lacks since migration 346
denormalized user_id onto oauth2_provider_app_tokens, and the read sat in
the one window where a refresh that lost the race answered 500: the
winner's cascade removed the key between the token read and this lookup,
and the resulting sql.ErrNoRows was unmapped.

Drop the read and take the user id and key id from the token row. The
returning-row delete is now the first statement to touch the key, so a
lost race can only surface there, where it already maps to invalid_grant.
…y is gone

A refresh presented for a revoked token must answer invalid_grant, not a
server fault. The two revocation paths a client can reach, deleting the
API key and deleting the app secret, both cascade the token row away, so
the prefix lookup refuses them; deleting the app never reaches the grant
because the client_id stops resolving. All three are pinned as responses.

The case that used to answer HTTP 500 is a token row whose api_key_id
names no key. The refresh no longer reads that key before deleting it, so
the returning-row delete finds nothing and answers invalid_grant. The FK
cascade makes the row unreachable through any API, so its test disables
the constraints to seed one.
CHECK (scope <> '') admits a whitespace-only scope, which
scopeStringToAPIKeyScopes echoed into error_description as an empty pair of
quotes. There is no name to report, so the rejection carries a fixed message
instead.

Tests widen the whitespace table and pin that the message does not vary with
the value it rejected, and cover an unmintable stored scope reached through a
refresh as well as through an authorization code.
@linear-code

linear-code Bot commented Sep 6, 2026

Copy link
Copy Markdown

PLAT-470

…etes

Both grants arbitrate a race with a delete that removes nothing when it
loses, and map that sql.ErrNoRows to invalid_grant. That zero-row answer
is READ COMMITTED behavior: under REPEATABLE READ or above the same
delete raises a serialization failure, which nothing here maps and InTx
does not retry, so every lost race would answer 500.

The transactions were opened with nil options, which sends a bare BEGIN
and inherits default_transaction_isolation from the server, database, or
role. Name the level at both call sites so the dependency is visible and
a raised server default cannot change the response.
…token

The single-use delete finding nothing is the one place the server can see
that a code or refresh token was presented twice. RFC 6749 §10.4 rotates
refresh tokens for exactly this reason, so log it at warn with the app and
the row involved. The client still receives the generic invalid_grant.

Also cite §10.4 rather than §10.5 for the refresh delete, and shorten the
comments around it.
… as its code sibling

The only caller reaches it through a fetch-then-query wrapper, so "instead of reading first" was wrong, and the isolation note the sibling carries applies here too.
…sql.ErrNoRows

The OAuth2 grants map that error to invalid_grant, so the wrapping in fetchAndQuery decides whether a refused single-use delete answers 400 or 500. Neither method suite case covered the miss.
…epted token

The barrier was a WaitGroup sized in advance: one arrival short hung the
package until the go test timeout, one too many panicked. It now releases
on a closed channel or the request context, and the tests assert the
arrival count instead.

The refresh race seeded a narrowed scope copied from the scope tests, so
the accepted token could not be checked against /users/me. Seed it
unnarrowed, check the accepted token authenticates, and check the
presented refresh token's row is gone. Assertion messages now say what is
being asserted.
…esh-scope

# Conflicts:
#	coderd/oauth2provider/tokens_test.go
#	docs/admin/integrations/oauth2-provider.md
Base automatically changed from plat481-1-narrow-refresh-scope to main September 9, 2026 18:08
…refresh

# Conflicts:
#	coderd/oauth2provider/tokens.go
#	coderd/oauth2provider/tokens_test.go
@BobbyHo
BobbyHo marked this pull request as ready for review September 9, 2026 18:29

@dylanhuff-at-coder dylanhuff-at-coder left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm! Removing the extra key lookup closes the remaining 500 path. Nice to see the concurrency tests check that the winning token still works too.

@BobbyHo
BobbyHo merged commit 7a57b17 into main Sep 11, 2026
28 checks passed
@BobbyHo
BobbyHo deleted the plat481-2-single-use-refresh branch September 11, 2026 19:47
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants