Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/auth/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ export async function authenticatePrivateToken(env: Env, token: string | undefin
}

export async function authenticateInternalToken(env: Env, token: string | undefined): Promise<AuthIdentity | null> {
if (await timingSafeEqual(token, env.INTERNAL_JOB_TOKEN)) return { kind: "static", actor: "internal" };
// Mirror authenticatePrivateToken's normalization (#9713): early-exit on a falsy bearer, and compare against the
// trimmed secret via nonBlank(). extractBearerToken already trims the incoming token, so an INTERNAL_JOB_TOKEN
// secret carrying a trailing newline (the shape a `wrangler secret put` piped from a file produces) would
// otherwise never match any caller and 401 every /v1/internal/* route, unlike the three secrets above.
if (!token) return null;
if (await timingSafeEqual(token, nonBlank(env.INTERNAL_JOB_TOKEN))) return { kind: "static", actor: "internal" };
return null;
}

Expand Down
34 changes: 34 additions & 0 deletions test/unit/auth-security-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
authenticateInternalToken,
buildBrowserSessionCookie,
buildClearedBrowserSessionCookie,
buildClearedGitHubOAuthStateCookie,
Expand Down Expand Up @@ -136,3 +137,36 @@ describe("session/oauth cookie builders", () => {
);
});
});

describe("authenticateInternalToken (#9713)", () => {
// authenticateInternalToken reads only env.INTERNAL_JOB_TOKEN, so a minimal cast keeps this pure-helper
// suite dependency-light (no D1) while still exercising both the nonBlank() and the early-exit branches.
const envWith = (internalJobToken: string): Env => ({ INTERNAL_JOB_TOKEN: internalJobToken }) as unknown as Env;

it("trims surrounding whitespace on INTERNAL_JOB_TOKEN, matching the three private secrets (regression for #9713)", async () => {
const env = envWith(" secret ");
await expect(authenticateInternalToken(env, "secret")).resolves.toEqual({ kind: "static", actor: "internal" });
});

it("authenticates a correct, untrimmed secret (bearer already trimmed by extractBearerToken)", async () => {
const env = envWith("secret");
await expect(authenticateInternalToken(env, "secret")).resolves.toEqual({ kind: "static", actor: "internal" });
});

it("authenticates nobody when INTERNAL_JOB_TOKEN is whitespace-only", async () => {
const env = envWith(" ");
// The blank secret trims to undefined via nonBlank(), so even the same whitespace never matches.
await expect(authenticateInternalToken(env, " ")).resolves.toBeNull();
await expect(authenticateInternalToken(env, "")).resolves.toBeNull();
});

it("early-returns null for an absent bearer without consulting the secret", async () => {
const env = envWith("secret");
await expect(authenticateInternalToken(env, undefined)).resolves.toBeNull();
});

it("rejects a token that does not match the configured secret", async () => {
const env = envWith("secret");
await expect(authenticateInternalToken(env, "wrong")).resolves.toBeNull();
});
});