fix(endpoint-auth): bind code exchange to the code’s own claims, not application state - #893
Open
rmdes wants to merge 1 commit into
Open
fix(endpoint-auth): bind code exchange to the code’s own claims, not application state#893rmdes wants to merge 1 commit into
rmdes wants to merge 1 commit into
Conversation
rmdes
added a commit
to rmdes/indiekit-endpoint-auth
that referenced
this pull request
Aug 20, 2026
`codeValidator` read the client an authorization code was issued to from `request.app.locals.client`, set during the authorization request. `app.locals` is shared by every request an Express application handles, so it holds whichever authorization request happened last on the server, not necessarily the one that issued the code being redeemed. Two consequences. A code exchange with no preceding authorization request found `client` undefined and failed at `client.id` with an unhandled TypeError and a 500, reachable unauthenticated. And the check that a code is redeemed by the client it was issued to could be satisfied by any client beginning its own authorization request first — a code issued to one client was redeemed by another, returning an access token for the profile URL and scope it carried. `consent.js` already signs `client_id` and `redirect_uri` into the code, and nothing read them. Verify the code first, compare the request against those claims, and reject a code missing either. PKCE keys off the challenge recorded in the code, so `app.locals` is no longer read here at all. `validateRedirect` is no longer called at redemption: the redirect was checked against client metadata during the authorization request, leaving only the match against the value the code was issued for. Seven existing tests signed codes carrying neither claim — codes this server cannot issue — and now sign what a real code carries. Two tests added, one per failure. Verified in the monorepo with this exact `lib/middleware/code.js` alongside the beta.35 `grant_type` change: 70 tests, 70 passing. The fork's own integration tests cannot run standalone (they need `@indiekit-test/*`), so that combined run is the evidence. Identical to the change proposed upstream in getindiekit/indiekit#893. Release: 1.0.0-beta.36 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGHR7MuyvBaDbAFfAGUxeT
`codeValidator` read the client an authorization code was issued to from
`request.app.locals.client`, set by the authorization request in
`authorization.js`. `app.locals` is shared by every request an Express
application handles, so it holds whichever authorization request happened last
on the server, which need not be the one that produced the code being redeemed.
Two consequences.
A code exchange arriving with no preceding authorization request finds
`client` undefined and fails at `client.id` with an unhandled TypeError and a
500, reachable unauthenticated:
{"error":"TypeError",
"error_description":"Cannot read properties of undefined (reading 'id')"}
More seriously, the check that a code is being redeemed by the client it was
issued to can be satisfied by any client that begins its own authorization
request first, because both sides of the comparison then refer to that client.
A code issued to one client is redeemed by another, and an access token is
returned for the profile URL and scope the code carries.
The authorization code already records what is needed: `consent.js` signs
`client_id` and `redirect_uri` into it. Verify the code first, then compare the
request against those claims rather than against application state, and treat a
code missing either claim as invalid. Whether PKCE applies is likewise recorded
in the code, by the presence of the challenge it was issued with, so that no
longer depends on `app.locals.usePkce` either.
`validateRedirect` is no longer called here: `redirect_uri` was checked against
the client's metadata during the authorization request, so matching the value
recorded in the code is what remains to be done at redemption.
Adds a test for each failure. Seven existing tests signed codes carrying
neither `client_id` nor `redirect_uri` — codes this server cannot issue — and
now sign the claims a real code would carry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGHR7MuyvBaDbAFfAGUxeT
rmdes
force-pushed
the
fix/code-binding-from-claims
branch
from
August 22, 2026 19:34
93445e1 to
97ed07f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #892.
codeValidatortook the client an authorization code was issued to fromrequest.app.locals.client. That is application-wide state, holding whicheverauthorization request last reached the server, so it can be absent (500) or
refer to a different client than the one the code was issued to (a code
redeemable by another client).
The change
Verify the code first, then compare the request against the claims it carries:
consent.js:82already signs both into the code, so nothing new has to berecorded. PKCE now keys off the challenge in the code rather than
app.locals.usePkce, which removes the last read of application state here.validateRedirectis no longer called at redemption:redirect_uriisvalidated against the client's metadata during the authorization request, so
what remains is matching the value the code was issued for. That is a behaviour
change worth a look — it trades a network-dependent check for an equality one.
Tests
Two added, both failing on
main:401-token-grant-no-authorization-request.js— an exchange with no precedingauthorization request.
mainreturns 500 with aTypeError.401-token-grant-code-issued-to-other-client.js— a code issued to oneclient, redeemed by another that began its own authorization request first.
mainreturns 200 with an access token.Seven existing tests signed codes carrying neither
client_idnorredirect_uri, whichconsent.jscannot produce, so they exercised codes thatcannot occur. They now sign the claims a real code carries. Two of them —
invalid
client_idand invalidredirect_uri— assert the same statuses andmessages as before, now reached through the code's claims rather than through
app.locals.node --testinpackages/endpoint-auth: 68 tests, 68 passing. Revertingonly
code.jsand keeping the tests fails exactly the two new ones.mainis66/66 before this change.
Relationship to the other open branches
Independent of #884, #887 and #889. This branch is cut from
main, as is #891,and both change
packages/endpoint-auth/lib/middleware/code.js.I described that overlap as a trivial rebase when opening this. Having since
merged the two locally, that was understated, so to correct it:
code.jsdoesmerge cleanly — #891 adds a
grant_typeallowance to the required-parameterlist, this one replaces the checks that follow it, and git resolves that without
conflict. But the merged result fails a test, and it is #891's own:
200-authorization-profile-no-grant-type.js, added there, signs a code withneither
client_idnorredirect_uri— the same gap as the seven fixtures thisPR updates. It passes on either branch alone and fails on both together, because
this PR starts rejecting codes missing those claims. Adding them to that fixture
resolves it, after which the combined branches give 70 tests, 70 passing.
So whichever merges second needs a one-line fixture change as well as the merge,
and CI on the second branch will go red until it is made. Nothing structural —
but it will not go green on the merge alone, which is what "trivial rebase"
implied.