288 lines · 8.4 KB · TypeScript
RawDownload
import * as NodeCrypto from "node:crypto"; import { assert, describe, it } from "@effect/vitest"; import {  computeDpopAccessTokenHash,  computeDpopJwkThumbprint,  normalizeDpopHtu,  type DpopPublicJwk,  verifyDpopProof,} from "./dpop.ts"; function signDpopProof(input: {  readonly method: string;  readonly url: string;  readonly iat: number;  readonly privateKey: NodeCrypto.KeyObject;  readonly publicJwk: DpopPublicJwk | (DpopPublicJwk & { readonly d: string });  readonly accessToken?: string;}) {  const header = Buffer.from(    JSON.stringify({      typ: "dpop+jwt",      alg: "ES256",      jwk: input.publicJwk,    }),  ).toString("base64url");  const payload = Buffer.from(    JSON.stringify({      htm: input.method,      htu: input.url,      jti: "proof-1",      iat: input.iat,      ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),    }),  ).toString("base64url");  const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), {    key: input.privateKey,    dsaEncoding: "ieee-p1363",  }).toString("base64url");  return `${header}.${payload}.${signature}`;} describe("verifyDpopProof", () => {  const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {    namedCurve: "P-256",  });  const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk;  const proof = signDpopProof({    method: "POST",    url: "https://example.com/oauth/token",    iat: 100,    privateKey,    publicJwk,  });   it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {    const thumbprint = computeDpopJwkThumbprint(publicJwk);    const result = verifyDpopProof({      proof,      method: "POST",      url: "https://example.com/oauth/token",      nowEpochSeconds: 101,      expectedThumbprint: thumbprint,    });     if (!result.ok) {      assert.fail(result.reason);    }    assert.equal(result.thumbprint, thumbprint);    assert.equal(result.jti, "proof-1");  });   it("rejects malformed DPoP header and payload JSON", () => {    const [header, payload, signature] = proof.split(".");    if (!header || !payload || !signature) {      assert.fail("Expected the test DPoP proof to use compact JWT format.");    }    const malformedJson = Buffer.from("{").toString("base64url");     const malformedHeader = verifyDpopProof({      proof: `${malformedJson}.${payload}.${signature}`,      method: "POST",      url: "https://example.com/oauth/token",      nowEpochSeconds: 101,    });    if (malformedHeader.ok) {      assert.fail("Expected malformed DPoP header JSON to fail.");    }    assert.equal(malformedHeader.reason, "Invalid DPoP JWT header.");     const malformedPayload = verifyDpopProof({      proof: `${header}.${malformedJson}.${signature}`,      method: "POST",      url: "https://example.com/oauth/token",      nowEpochSeconds: 101,    });    if (malformedPayload.ok) {      assert.fail("Expected malformed DPoP payload JSON to fail.");    }    assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload.");  });   it("rejects method, URL, thumbprint, and time-window mismatches", () => {    const thumbprint = computeDpopJwkThumbprint(publicJwk);    assert.equal(      verifyDpopProof({        proof,        method: "GET",        url: "https://example.com/oauth/token",        nowEpochSeconds: 101,        expectedThumbprint: thumbprint,      }).ok,      false,    );    assert.equal(      verifyDpopProof({        proof,        method: "POST",        url: "https://example.com/other",        nowEpochSeconds: 101,        expectedThumbprint: thumbprint,      }).ok,      false,    );    assert.equal(      verifyDpopProof({        proof,        method: "POST",        url: "https://example.com/oauth/token",        nowEpochSeconds: 101,        expectedThumbprint: "other-thumbprint",      }).ok,      false,    );    assert.equal(      verifyDpopProof({        proof,        method: "POST",        url: "https://example.com/oauth/token",        nowEpochSeconds: 1_000,        expectedThumbprint: thumbprint,      }).ok,      false,    );  });   it("reports a time-window failure only after the proof signature is valid", () => {    const thumbprint = computeDpopJwkThumbprint(publicJwk);    const outsideWindow = verifyDpopProof({      proof,      method: "POST",      url: "https://example.com/oauth/token",      nowEpochSeconds: 1_000,      expectedThumbprint: thumbprint,    });    if (outsideWindow.ok) {      assert.fail("Expected an old DPoP proof to fail.");    }    assert.equal(outsideWindow.code, "time_window");     const { privateKey: otherPrivateKey } = NodeCrypto.generateKeyPairSync("ec", {      namedCurve: "P-256",    });    const invalidSignatureProof = signDpopProof({      method: "POST",      url: "https://example.com/oauth/token",      iat: 100,      privateKey: otherPrivateKey,      publicJwk,    });    const invalidSignature = verifyDpopProof({      proof: invalidSignatureProof,      method: "POST",      url: "https://example.com/oauth/token",      nowEpochSeconds: 1_000,      expectedThumbprint: thumbprint,    });    if (invalidSignature.ok) {      assert.fail("Expected a proof signed by a different key to fail.");    }    assert.equal(invalidSignature.code, "invalid_signature");  });   it("requires the RFC 9449 access token hash when an access token is expected", () => {    const thumbprint = computeDpopJwkThumbprint(publicJwk);    const accessTokenProof = signDpopProof({      method: "POST",      url: "https://example.com/v1/environments/env/connect",      iat: 100,      privateKey,      publicJwk,      accessToken: "clerk-access-token",    });     assert.equal(      verifyDpopProof({        proof: accessTokenProof,        method: "POST",        url: "https://example.com/v1/environments/env/connect",        nowEpochSeconds: 101,        expectedThumbprint: thumbprint,        expectedAccessToken: "clerk-access-token",      }).ok,      true,    );     const missingHash = verifyDpopProof({      proof,      method: "POST",      url: "https://example.com/oauth/token",      nowEpochSeconds: 101,      expectedThumbprint: thumbprint,      expectedAccessToken: "clerk-access-token",    });    if (missingHash.ok) {      assert.fail("Expected DPoP proof without an access token hash to fail.");    }    assert.equal(missingHash.reason, "DPoP access token hash mismatch.");     const mismatchedHash = verifyDpopProof({      proof: accessTokenProof,      method: "POST",      url: "https://example.com/v1/environments/env/connect",      nowEpochSeconds: 101,      expectedThumbprint: thumbprint,      expectedAccessToken: "other-access-token",    });    if (mismatchedHash.ok) {      assert.fail("Expected DPoP proof with a mismatched access token hash to fail.");    }    assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch.");  });   it("normalizes htu by excluding query and fragment components per RFC 9449", () => {    assert.equal(      normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"),      "https://example.com/v1/environments/env/connect",    );     const thumbprint = computeDpopJwkThumbprint(publicJwk);    const queryProof = signDpopProof({      method: "POST",      url: "https://example.com/v1/environments/env/connect",      iat: 100,      privateKey,      publicJwk,    });     assert.equal(      verifyDpopProof({        proof: queryProof,        method: "POST",        url: "https://example.com/v1/environments/env/connect?foo=bar#frag",        nowEpochSeconds: 101,        expectedThumbprint: thumbprint,      }).ok,      true,    );  });   it("rejects DPoP public JWK headers that expose private key material", () => {    const thumbprint = computeDpopJwkThumbprint(publicJwk);    const privateJwk = privateKey.export({ format: "jwk" }) as DpopPublicJwk & {      readonly d: string;    };    const proofWithPrivateJwk = signDpopProof({      method: "POST",      url: "https://example.com/oauth/token",      iat: 100,      privateKey,      publicJwk: privateJwk,    });     const result = verifyDpopProof({      proof: proofWithPrivateJwk,      method: "POST",      url: "https://example.com/oauth/token",      nowEpochSeconds: 101,      expectedThumbprint: thumbprint,    });     if (result.ok) {      assert.fail("Expected DPoP proof with private JWK material to fail.");    }    assert.equal(result.reason, "Invalid DPoP JWT header.");  });});