Repository navigation
fix(endpoint-auth): bind code exchange to the code’s own claims, not application state #893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,6 @@ import { IndiekitError } from "@indiekit/error"; | |
| import { getCanonicalUrl } from "@indiekit/util"; | ||
|
|
||
| import { verifyCode } from "../pkce.js"; | ||
| import { validateRedirect } from "../redirect.js"; | ||
| import { verifyToken } from "../token.js"; | ||
| import { getRequestParameters } from "../utils.js"; | ||
|
|
||
|
|
@@ -12,7 +11,6 @@ import { getRequestParameters } from "../utils.js"; | |
| */ | ||
| export const codeValidator = async (request, response, next) => { | ||
| try { | ||
| const { client, usePkce } = request.app.locals; | ||
| const parameters = getRequestParameters(request); | ||
| const { client_id, code, code_verifier, grant_type, redirect_uri } = | ||
| parameters; | ||
|
|
@@ -45,33 +43,52 @@ export const codeValidator = async (request, response, next) => { | |
| ); | ||
| } | ||
|
|
||
| // Validate `client_id` against that provided in authorization request | ||
| if (getCanonicalUrl(client_id) !== getCanonicalUrl(client.id)) { | ||
| // Verify the code before reading anything from it | ||
| try { | ||
| request.verifiedToken = verifyToken(code); | ||
| } catch { | ||
| throw IndiekitError.unauthorized( | ||
| response.locals.__("BadRequestError.invalidValue", "client_id"), | ||
| response.locals.__("UnauthorizedError.invalidToken"), | ||
| ); | ||
| } | ||
|
|
||
| // Validate `redirect_uri` | ||
| const validRedirect = await validateRedirect(redirect_uri, client_id); | ||
| if (!validRedirect) { | ||
| throw IndiekitError.badRequest( | ||
| response.locals.__("BadRequestError.invalidValue", "redirect_uri"), | ||
| // An authorization code records the client it was issued to and the | ||
| // redirect it was issued for. A code missing either cannot be checked | ||
| // against the request, so it is not one this server issued. | ||
| if ( | ||
| !request.verifiedToken.client_id || | ||
| !request.verifiedToken.redirect_uri | ||
| ) { | ||
| throw IndiekitError.unauthorized( | ||
| response.locals.__("UnauthorizedError.invalidToken"), | ||
| ); | ||
| } | ||
|
|
||
| // Verify token | ||
| try { | ||
| request.verifiedToken = verifyToken(code); | ||
| } catch { | ||
| // Validate `client_id` against the client the code was issued to. Reading | ||
| // it from the code, rather than from state shared across requests, is what | ||
| // ties this exchange to the authorization request that produced the code. | ||
| if ( | ||
| getCanonicalUrl(client_id) !== | ||
| getCanonicalUrl(String(request.verifiedToken.client_id)) | ||
| ) { | ||
| throw IndiekitError.unauthorized( | ||
| response.locals.__("UnauthorizedError.invalidToken"), | ||
| response.locals.__("BadRequestError.invalidValue", "client_id"), | ||
| ); | ||
| } | ||
|
|
||
| // Validate `redirect_uri` against the one the code was issued for. It was | ||
| // checked against the client's metadata during the authorization request, | ||
| // so matching it here is what remains to be done. | ||
| if (redirect_uri !== request.verifiedToken.redirect_uri) { | ||
| throw IndiekitError.badRequest( | ||
| response.locals.__("BadRequestError.invalidValue", "redirect_uri"), | ||
| ); | ||
| } | ||
|
|
||
| // PKCE (Proof Key for Code Exchange) | ||
| if (usePkce) { | ||
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I note that
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — there is a real gap here, though I think mirroring 1. The code does carry the method. ...(code_challenge && { code_challenge }),
...(code_challenge_method && { code_challenge_method }),So it is available to read here. Today this middleware ignores it and calls 2. But it cannot be passed straight through. So honouring the method needs a mapping ( 3. Requiring both would weaken this check. For what it is worth, RFC 7636 §4.6 treats a missing method as 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 |
||
| if (code_challenge) { | ||
| const verifiedCode = verifyCode(code_verifier, code_challenge); | ||
| if (!verifiedCode) { | ||
| throw IndiekitError.unauthorized( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { strict as assert } from "node:assert"; | ||
| import { after, before, describe, it } from "node:test"; | ||
|
|
||
| import { mockAgent } from "@indiekit-test/mock-agent"; | ||
| import { testServer } from "@indiekit-test/server"; | ||
| import supertest from "supertest"; | ||
|
|
||
| import { signToken } from "../../lib/token.js"; | ||
|
|
||
| await mockAgent("endpoint-auth"); | ||
| const server = await testServer(); | ||
| const request = supertest.agent(server); | ||
|
|
||
| describe("endpoint-auth POST /auth/token", () => { | ||
| // Begin an authorization request as this client, so that whatever | ||
| // application-wide state the server keeps refers to it. | ||
| before(async () => { | ||
| await request | ||
| .get("/auth") | ||
| .query({ client_id: "https://auth-endpoint.example" }) | ||
| .query({ redirect_uri: "https://auth-endpoint.example/redirect" }) | ||
| .query({ response_type: "code" }) | ||
| .query({ state: "12345" }); | ||
| }); | ||
|
|
||
| // The authorization code records the client it was issued to. Redeeming it | ||
| // as a different client must fail, whatever authorization request happened | ||
| // most recently on the server. | ||
| it("Rejects a code issued to a different client", async () => { | ||
| const code = signToken({ | ||
| client_id: "https://other-client.example", | ||
| me: "https://website.example", | ||
| redirect_uri: "https://other-client.example/redirect", | ||
| scope: "create", | ||
| }); | ||
| const result = await request | ||
| .post("/auth/token") | ||
| .set("accept", "application/json") | ||
| .query({ client_id: "https://auth-endpoint.example" }) | ||
| .query({ code }) | ||
| .query({ grant_type: "authorization_code" }) | ||
| .query({ redirect_uri: "https://auth-endpoint.example/redirect" }); | ||
|
|
||
| assert.notEqual(result.status, 200); | ||
| assert.equal(result.body.access_token, undefined); | ||
| }); | ||
|
|
||
| after(() => server.close()); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { strict as assert } from "node:assert"; | ||
| import { after, describe, it } from "node:test"; | ||
|
|
||
| import { testServer } from "@indiekit-test/server"; | ||
| import supertest from "supertest"; | ||
|
|
||
| import { signToken } from "../../lib/token.js"; | ||
|
|
||
| const server = await testServer(); | ||
| const request = supertest.agent(server); | ||
|
|
||
| describe("endpoint-auth POST /auth/token", () => { | ||
| // No authorization request precedes this exchange, so the code itself has to | ||
| // carry everything needed to validate it. The response should describe the | ||
| // problem, not fail with an unhandled error. | ||
| it("Returns an error, not 500, with no preceding authorization request", async () => { | ||
| const code = signToken({ | ||
| client_id: "https://client.example", | ||
| me: "https://website.example", | ||
| redirect_uri: "https://client.example/redirect", | ||
| scope: "create", | ||
| }); | ||
| const result = await request | ||
| .post("/auth/token") | ||
| .set("accept", "application/json") | ||
| .query({ client_id: "https://other-client.example" }) | ||
| .query({ code }) | ||
| .query({ grant_type: "authorization_code" }) | ||
| .query({ redirect_uri: "https://other-client.example/redirect" }); | ||
|
|
||
| assert.notEqual(result.status, 500); | ||
| assert.equal(result.body.error, "unauthorized"); | ||
| }); | ||
|
|
||
| after(() => server.close()); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.jsis the lineconst { client, usePkce } = request.app.locals. Butauthorization.jssets those two for the consent screen, andviews/consent.njkstill consumes both:app.localsis merged into every render, so those come straight from the assignments atauthorization.js:94and: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.