fix(endpoint-auth): bind code exchange to the code’s own claims, not application state - #893
fix(endpoint-auth): bind code exchange to the code’s own claims, not application state#893rmdes wants to merge 2 commits into
Conversation
`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
93445e1 to
97ed07f
Compare
67b3847 to
fa1d368
Compare
97ed07f to
7748650
Compare
|
@rmdes I have rebased your fork with |
|
checking now.. |
`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. Eight 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.
7748650 to
d4aadda
Compare
|
Fixed and pushed — The failure was const code = signToken({
access_token: "token",
me: "https://website.example",
scope: "create update delete media",
token_type: "Bearer",
});This PR treats such a code as invalid, because The seven tests that already signed codes this way were updated in this branch when it was written. #891 added an eighth after that, and your rebase brought it in unchanged. It now signs the same two claims as its siblings: client_id: "https://auth-endpoint.example",
redirect_uri: "https://auth-endpoint.example/redirect",Nothing in |
| */ | ||
| export const codeValidator = async (request, response, next) => { | ||
| try { | ||
| const { client, usePkce } = request.app.locals; |
There was a problem hiding this comment.
If we’re no longer storing these in locals, can we not remove where they are set in lib/controllers/authorization.js?
There was a problem hiding this comment.
I checked before removing them, and I think they need to stay — they are still read, just not by this middleware.
What this PR dropped from code.js is the line const { client, usePkce } = request.app.locals. But authorization.js sets those two for the consent screen, and views/consent.njk still consumes both:
{{ authorize({
client: client, ← consent.njk:14
...
{{ warningText({
text: __("auth.consent.pkce.text", { client: client.name }) ← :20
}) if not usePkce }} ← :21app.locals is merged into every render, so those come straight from the assignments at authorization.js:94 and :97. Removing either would render the consent form without the client name, and would show the "this client is not using PKCE" warning unconditionally.
So the assignments have one remaining consumer — the view — and this PR only removes the second consumer, which was the one reading cross-request state to make a security decision. Happy to remove them if you would rather the view got them explicitly via response.render(...) locals instead, but that felt like a separate change to this fix.
| const { code_challenge } = request.verifiedToken; | ||
| // PKCE (Proof Key for Code Exchange). Whether it applies is recorded in | ||
| // the code itself, by the presence of the challenge it was issued with. | ||
| const { code_challenge } = request.verifiedToken; |
There was a problem hiding this comment.
I note that lib/controllers/authorization.js tests for the presence of both code_challenge and code_challenge_method. Should we continue to do that here?
There was a problem hiding this comment.
Good catch — there is a real gap here, though I think mirroring authorization.js exactly would be the wrong direction. Three things I checked:
1. The code does carry the method. consent.js signs both, each conditionally:
...(code_challenge && { code_challenge }),
...(code_challenge_method && { code_challenge_method }),So it is available to read here. Today this middleware ignores it and calls verifyCode(code_verifier, code_challenge), which falls back to the default — meaning the method is effectively hardcoded to sha256.
2. But it cannot be passed straight through. verifyCode feeds its third argument to createHash, and the value clients send per the spec is S256, not an OpenSSL digest name:
createHash("S256") -> throws: Digest method not supported
createHash("sha256") -> OK
So honouring the method needs a mapping (S256 → sha256), not just forwarding the claim. That is why I left it alone rather than half-wiring it.
3. Requiring both would weaken this check. authorization.js sets usePkce = code_challenge && code_challenge_method, so PKCE counts as "in use" only when both are present. If this middleware adopted the same test, a code carrying a code_challenge but no method would skip verification entirely. Verifying whenever a challenge is present fails closed instead — if a challenge was issued, it has to be satisfied.
For what it is worth, RFC 7636 §4.6 treats a missing method as plain (compare the verifier literally) rather than as "no PKCE", which also argues against skipping.
Which would you like? I would suggest either leaving it as-is for this PR and handling the method properly in its own change, or doing it here as:
const { code_challenge, code_challenge_method } = request.verifiedToken;
if (code_challenge) {
const method = code_challenge_method === "S256" ? "sha256" : code_challenge_method;
...
}I would rather not fold a PKCE behaviour change into a client-binding fix without you picking the direction, since plain support is a spec question of its own.
Both comments explained the change by reference to `app.locals`, which this middleware no longer touches. Describe what the code does instead: the exchange is tied to the authorization request that produced the code, because the claims are read from the code itself.
Fixes #892.
codeValidatortook the client an authorization code was issued to fromrequest.app.locals.client. That is application-wide state, holding whichever authorization 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 be recorded. PKCE now keys off the challenge in the code rather thanapp.locals.usePkce, which removes the last read of application state here.validateRedirectis no longer called at redemption:redirect_uriis validated 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 preceding authorization request.mainreturns 500 with aTypeError.401-token-grant-code-issued-to-other-client.js— a code issued to one client, 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 that cannot occur. They now sign the claims a real code carries. Two of them — invalidclient_idand invalidredirect_uri— assert the same statuses and messages as before, now reached through the code's claims rather than throughapp.locals.node --testinpackages/endpoint-auth: 68 tests, 68 passing. Reverting onlycode.jsand keeping the tests fails exactly the two new ones.mainis 66/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 changepackages/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.jsdoes merge cleanly — #891 adds agrant_typeallowance to the required-parameter list, 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 with neitherclient_idnorredirect_uri— the same gap as the seven fixtures this PR 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.