Skip to content
Draft
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
84 changes: 58 additions & 26 deletions packages/shared/src/dpop.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import * as NodeCrypto from "node:crypto";

import { describe, expect, it } from "@effect/vitest";
import { assert, describe, it } from "@effect/vitest";
import * as Option from "effect/Option";

import {
computeDpopAccessTokenHash,
computeDpopJwkThumbprint,
normalizeDpopHtu,
normalizeDpopHtuOption,
type DpopPublicJwk,
type DpopVerificationResult,
verifyDpopProof,
} from "./dpop.ts";

Expand Down Expand Up @@ -41,6 +44,24 @@ function signDpopProof(input: {
return `${header}.${payload}.${signature}`;
}

type DpopRejectedResult = Extract<DpopVerificationResult, { readonly ok: false }>;

function assertAccepted(result: DpopVerificationResult) {
if (!result.ok) {
assert.fail(`Expected accepted DPoP proof, got ${result.error._tag}`);
}
return result;
}

function assertRejected(result: DpopVerificationResult, tag: DpopRejectedResult["error"]["_tag"]) {
if (result.ok) {
assert.fail("Expected rejected DPoP proof.");
}
assert.equal(result.error._tag, tag);
assert.equal(result.reason, result.error.message);
return result;
}

describe("verifyDpopProof", () => {
const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
namedCurve: "P-256",
Expand All @@ -56,59 +77,61 @@ describe("verifyDpopProof", () => {

it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {
const thumbprint = computeDpopJwkThumbprint(publicJwk);
expect(
const result = assertAccepted(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({
ok: true,
thumbprint,
jti: "proof-1",
});
);
assert.equal(result.thumbprint, thumbprint);
assert.equal(result.jti, "proof-1");
});

it("rejects method, URL, thumbprint, and time-window mismatches", () => {
const thumbprint = computeDpopJwkThumbprint(publicJwk);
expect(
assertRejected(
verifyDpopProof({
proof,
method: "GET",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
expect(
"DpopMethodMismatchError",
);
assertRejected(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/other",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
expect(
"DpopUrlMismatchError",
);
assertRejected(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: "other-thumbprint",
}),
).toMatchObject({ ok: false });
expect(
"DpopThumbprintMismatchError",
);
assertRejected(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 1_000,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
"DpopTimeWindowError",
);
});

it("requires the RFC 9449 access token hash when an access token is expected", () => {
Expand All @@ -122,7 +145,7 @@ describe("verifyDpopProof", () => {
accessToken: "clerk-access-token",
});

expect(
assertAccepted(
verifyDpopProof({
proof: accessTokenProof,
method: "POST",
Expand All @@ -131,8 +154,8 @@ describe("verifyDpopProof", () => {
expectedThumbprint: thumbprint,
expectedAccessToken: "clerk-access-token",
}),
).toMatchObject({ ok: true });
expect(
);
assertRejected(
verifyDpopProof({
proof,
method: "POST",
Expand All @@ -141,8 +164,9 @@ describe("verifyDpopProof", () => {
expectedThumbprint: thumbprint,
expectedAccessToken: "clerk-access-token",
}),
).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });
expect(
"DpopAccessTokenHashMismatchError",
);
assertRejected(
verifyDpopProof({
proof: accessTokenProof,
method: "POST",
Expand All @@ -151,13 +175,20 @@ describe("verifyDpopProof", () => {
expectedThumbprint: thumbprint,
expectedAccessToken: "other-access-token",
}),
).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });
"DpopAccessTokenHashMismatchError",
);
});

it("normalizes htu by excluding query and fragment components per RFC 9449", () => {
expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe(
assert.equal(
normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"),
"https://example.com/v1/environments/env/connect",
);
assert.deepStrictEqual(
normalizeDpopHtuOption("https://example.com/v1/environments/env/connect?foo=bar#frag"),
Option.some("https://example.com/v1/environments/env/connect"),
);
assert.deepStrictEqual(normalizeDpopHtuOption("not a url"), Option.none());

const thumbprint = computeDpopJwkThumbprint(publicJwk);
const queryProof = signDpopProof({
Expand All @@ -168,15 +199,15 @@ describe("verifyDpopProof", () => {
publicJwk,
});

expect(
assertAccepted(
verifyDpopProof({
proof: queryProof,
method: "POST",
url: "https://example.com/v1/environments/env/connect?foo=bar#frag",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: true });
);
});

it("rejects DPoP public JWK headers that expose private key material", () => {
Expand All @@ -192,14 +223,15 @@ describe("verifyDpopProof", () => {
publicJwk: privateJwk,
});

expect(
assertRejected(
verifyDpopProof({
proof: proofWithPrivateJwk,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." });
"InvalidDpopJwtHeaderError",
);
});
});
Loading
Loading