Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions packages/endpoint-auth/lib/middleware/code.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -12,7 +11,6 @@ import { getRequestParameters } from "../utils.js";
*/
export const codeValidator = async (request, response, next) => {
try {
const { client, usePkce } = request.app.locals;

Copy link
Copy Markdown
Collaborator

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?

Copy link
Copy Markdown
Collaborator Author

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.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 }}                                            ← :21

app.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 parameters = getRequestParameters(request);
const { client_id, code, code_verifier, grant_type, redirect_uri } =
parameters;
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

if (code_challenge) {
const verifiedCode = verifyCode(code_verifier, code_challenge);
if (!verifiedCode) {
throw IndiekitError.unauthorized(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ describe("endpoint-auth POST /auth", () => {
it("Returns JSON profile", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
me: "https://website.example",
redirect_uri: "https://auth-endpoint.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ describe("endpoint-auth POST /auth", () => {
it("Returns profile when `grant_type` omitted", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
me: "https://website.example",
redirect_uri: "https://auth-endpoint.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ describe("endpoint-auth POST /auth", () => {
it("Returns URL encoded profile", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
me: "https://website.example",
redirect_uri: "https://auth-endpoint.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ describe("endpoint-auth POST /auth/token", () => {
it("Returns JSON access token", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
me: "https://website.example",
redirect_uri: "https://auth-endpoint.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ describe("endpoint-auth POST /auth/token", () => {
it("Returns URL encoded access token", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
me: "https://website.example",
redirect_uri: "https://auth-endpoint.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ describe("endpoint-auth POST /auth/token", () => {
it("Returns 401 error fails PKCE code challenge", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
code_challenge: codeChallenge,
code_challenge_method: "S256",
redirect_uri: "https://auth-endpoint.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ describe("endpoint-auth POST /auth/token", () => {
it("Returns 400 error invalid `redirect_uri`", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
me: "https://website.example",
redirect_uri: "https://auth-endpoint.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
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
Expand Up @@ -24,7 +24,9 @@ describe("endpoint-auth POST /auth/token", () => {
it("Returns 401 error invalid `client_id`", async () => {
const code = signToken({
access_token: "token",
client_id: "https://auth-endpoint.example",
me: "https://website.example",
redirect_uri: "https://website.example/redirect",
scope: "create update delete media",
token_type: "Bearer",
});
Expand Down
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());
});