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
15 changes: 13 additions & 2 deletions src/oidc-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}

function normalizedMediaType(contentType: string | null): string {
return (contentType ?? "")
.split(";", 1)[0]
.trim()
.toLowerCase();
}

function validJti(jti: string): boolean {
return (
jti.length > 0
Expand Down Expand Up @@ -113,6 +120,10 @@ export async function claimOidcTokenUsage(
body: JSON.stringify({ expires_at_epoch_seconds: expiresAtEpochSeconds }),
});

if (normalizedMediaType(response.headers.get("content-type")) !== "application/json") {
throw new OidcReplayUnavailable("OIDC replay guard returned an unexpected content type");
}

let body: unknown;
try {
body = await response.json();
Expand All @@ -128,7 +139,7 @@ export async function claimOidcTokenUsage(
if (response.status === 409 && !body.accepted) {
throw new OidcReplayDetected(body.expires_at_epoch_seconds);
}
if (!response.ok || !body.accepted) {
if (response.status !== 201 || !body.accepted) {
throw new OidcReplayUnavailable(`OIDC replay guard returned HTTP ${response.status}`);
}
return body;
Expand Down Expand Up @@ -158,7 +169,7 @@ export class NoemaOidcReplayGuard {
if (request.method !== "POST" || url.pathname !== "/claim") {
return jsonResponse({ ok: false, error: "not_found" }, 404);
}
if (!(request.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) {
if (normalizedMediaType(request.headers.get("content-type")) !== "application/json") {
return jsonResponse({ ok: false, error: "content_type_required" }, 415);
}

Expand Down
67 changes: 67 additions & 0 deletions test/oidc-replay-media-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NoemaOidcReplayGuard } from "../src/oidc-replay";

function fakeDurableObjectState() {
const records = new Map<string, unknown>();
const setAlarm = vi.fn(async () => undefined);
const storage = {
async transaction<T>(callback: (transaction: {
get<V>(key: string): Promise<V | undefined>;
put<V>(key: string, value: V): Promise<void>;
}) => Promise<T>): Promise<T> {
return callback({
async get<V>(key: string): Promise<V | undefined> {
return records.get(key) as V | undefined;
},
async put<V>(key: string, value: V): Promise<void> {
records.set(key, value);
},
});
},
setAlarm,
deleteAll: vi.fn(async () => {
records.clear();
}),
};

return {
state: { storage } as unknown as DurableObjectState,
records,
};
}

function requestWithContentType(contentType: string): Request {
return new Request("https://noema-oidc-replay.internal/claim", {
method: "POST",
headers: { "content-type": contentType },
body: JSON.stringify({ expires_at_epoch_seconds: 2_600 }),
});
}

describe("OIDC replay media-type boundary", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("accepts application/json case-insensitively with ordinary parameters", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_000_000);
const fake = fakeDurableObjectState();
const guard = new NoemaOidcReplayGuard(fake.state);

const response = await guard.fetch(requestWithContentType("Application/JSON; charset=utf-8"));

expect(response.status).toBe(201);
expect(fake.records.size).toBe(1);
});

it("rejects JSON-suffixed media types when the endpoint requires application/json", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_000_000);
const fake = fakeDurableObjectState();
const guard = new NoemaOidcReplayGuard(fake.state);

const response = await guard.fetch(requestWithContentType("application/problem+json"));

expect(response.status).toBe(415);
expect(fake.records.size).toBe(0);
});
});
64 changes: 64 additions & 0 deletions test/oidc-replay-status-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
claimOidcTokenUsage,
OidcReplayUnavailable,
} from "../src/oidc-replay";

function namespaceReturning(
handler: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
): DurableObjectNamespace {
return {
idFromName(name: string) {
return { toString: () => name } as DurableObjectId;
},
get() {
return { fetch: handler } as unknown as DurableObjectStub;
},
} as unknown as DurableObjectNamespace;
}

describe("OIDC replay guard status contract", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it.each([200, 202, 206])(
"fails closed when an accepted replay decision uses unexpected HTTP %s",
async (status) => {
vi.spyOn(Date, "now").mockReturnValue(2_000_000);
const namespace = namespaceReturning(async () => Response.json({
accepted: true,
expires_at_epoch_seconds: 2_600,
}, { status }));

await expect(claimOidcTokenUsage(
"safe-jti",
2_600,
{ NOEMA_OIDC_REPLAY_GUARD: namespace },
)).rejects.toMatchObject({
name: "OidcReplayUnavailable",
message: `OIDC replay guard returned HTTP ${status}`,
} satisfies Partial<OidcReplayUnavailable>);
},
);

it("fails closed when a successful replay response is not application/json", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_000_000);
const namespace = namespaceReturning(async () => new Response(JSON.stringify({
accepted: true,
expires_at_epoch_seconds: 2_600,
}), {
status: 201,
headers: { "content-type": "text/plain; charset=utf-8" },
}));

await expect(claimOidcTokenUsage(
"safe-jti",
2_600,
{ NOEMA_OIDC_REPLAY_GUARD: namespace },
)).rejects.toMatchObject({
name: "OidcReplayUnavailable",
message: "OIDC replay guard returned an unexpected content type",
} satisfies Partial<OidcReplayUnavailable>);
});
});
20 changes: 20 additions & 0 deletions test/oidc-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,26 @@ describe("OIDC replay protection", () => {
expect((await guard.fetch(claimRequest(5_601))).status).toBe(400);
});

it("rejects misleading non-JSON media types without consuming replay state", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_000_000);
const fake = fakeDurableObjectState();
const guard = new NoemaOidcReplayGuard(fake.state);
const misleading = new Request("https://noema-oidc-replay.internal/claim", {
method: "POST",
headers: { "content-type": "text/plain; profile=application/json" },
body: JSON.stringify({ expires_at_epoch_seconds: 2_600 }),
});

const rejected = await guard.fetch(misleading);
expect(rejected.status).toBe(415);
expect(fake.records.size).toBe(0);
expect(fake.setAlarm).not.toHaveBeenCalled();

const valid = await guard.fetch(claimRequest(2_600));
expect(valid.status).toBe(201);
expect(fake.records.size).toBe(1);
});

it("keeps replay consumption after successful JWT and GitHub token validation", () => {
const workerSource = readFileSync(
new URL("../src/worker.ts", import.meta.url),
Expand Down
Loading