| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | 1 | import * as NodeCrypto from "node:crypto"; |
| 2 | | 2 | |
| 3 | import { describe, expect, it } from "@effect/vitest"; | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | | 4 | |
| 5 | import { | 5 | import { |
| 6 | computeDpopAccessTokenHash, | 6 | computeDpopAccessTokenHash, |
| ... | @@ -56,59 +56,93 @@ describe("verifyDpopProof", () => { | ... | @@ -56,59 +56,93 @@ describe("verifyDpopProof", () => { |
| 56 | | 56 | |
| 57 | it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { | 57 | it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { |
| 58 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | 58 | const thumbprint = computeDpopJwkThumbprint(publicJwk); |
| 59 | expect( | 59 | const result = verifyDpopProof({ |
| 60 | verifyDpopProof({ | 60 | proof, |
| 61 | proof, | 61 | method: "POST", |
| 62 | method: "POST", | 62 | url: "https://example.com/oauth/token", |
| 63 | url: "https://example.com/oauth/token", | 63 | nowEpochSeconds: 101, |
| 64 | nowEpochSeconds: 101, | 64 | expectedThumbprint: thumbprint, |
| 65 | expectedThumbprint: thumbprint, | 65 | }); |
| 66 | }), | 66 | |
| 67 | ).toMatchObject({ | 67 | if (!result.ok) { |
| 68 | ok: true, | 68 | assert.fail(result.reason); |
| 69 | thumbprint, | 69 | } |
| 70 | jti: "proof-1", | 70 | assert.equal(result.thumbprint, thumbprint); |
| | 71 | assert.equal(result.jti, "proof-1"); |
| | 72 | }); |
| | 73 | |
| | 74 | it("rejects malformed DPoP header and payload JSON", () => { |
| | 75 | const [header, payload, signature] = proof.split("."); |
| | 76 | if (!header || !payload || !signature) { |
| | 77 | assert.fail("Expected the test DPoP proof to use compact JWT format."); |
| | 78 | } |
| | 79 | const malformedJson = Buffer.from("{").toString("base64url"); |
| | 80 | |
| | 81 | const malformedHeader = verifyDpopProof({ |
| | 82 | proof: `${malformedJson}.${payload}.${signature}`, |
| | 83 | method: "POST", |
| | 84 | url: "https://example.com/oauth/token", |
| | 85 | nowEpochSeconds: 101, |
| 71 | }); | 86 | }); |
| | 87 | if (malformedHeader.ok) { |
| | 88 | assert.fail("Expected malformed DPoP header JSON to fail."); |
| | 89 | } |
| | 90 | assert.equal(malformedHeader.reason, "Invalid DPoP JWT header."); |
| | 91 | |
| | 92 | const malformedPayload = verifyDpopProof({ |
| | 93 | proof: `${header}.${malformedJson}.${signature}`, |
| | 94 | method: "POST", |
| | 95 | url: "https://example.com/oauth/token", |
| | 96 | nowEpochSeconds: 101, |
| | 97 | }); |
| | 98 | if (malformedPayload.ok) { |
| | 99 | assert.fail("Expected malformed DPoP payload JSON to fail."); |
| | 100 | } |
| | 101 | assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload."); |
| 72 | }); | 102 | }); |
| 73 | | 103 | |
| 74 | it("rejects method, URL, thumbprint, and time-window mismatches", () => { | 104 | it("rejects method, URL, thumbprint, and time-window mismatches", () => { |
| 75 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | 105 | const thumbprint = computeDpopJwkThumbprint(publicJwk); |
| 76 | expect( | 106 | assert.equal( |
| 77 | verifyDpopProof({ | 107 | verifyDpopProof({ |
| 78 | proof, | 108 | proof, |
| 79 | method: "GET", | 109 | method: "GET", |
| 80 | url: "https://example.com/oauth/token", | 110 | url: "https://example.com/oauth/token", |
| 81 | nowEpochSeconds: 101, | 111 | nowEpochSeconds: 101, |
| 82 | expectedThumbprint: thumbprint, | 112 | expectedThumbprint: thumbprint, |
| 83 | }), | 113 | }).ok, |
| 84 | ).toMatchObject({ ok: false }); | 114 | false, |
| 85 | expect( | 115 | ); |
| | 116 | assert.equal( |
| 86 | verifyDpopProof({ | 117 | verifyDpopProof({ |
| 87 | proof, | 118 | proof, |
| 88 | method: "POST", | 119 | method: "POST", |
| 89 | url: "https://example.com/other", | 120 | url: "https://example.com/other", |
| 90 | nowEpochSeconds: 101, | 121 | nowEpochSeconds: 101, |
| 91 | expectedThumbprint: thumbprint, | 122 | expectedThumbprint: thumbprint, |
| 92 | }), | 123 | }).ok, |
| 93 | ).toMatchObject({ ok: false }); | 124 | false, |
| 94 | expect( | 125 | ); |
| | 126 | assert.equal( |
| 95 | verifyDpopProof({ | 127 | verifyDpopProof({ |
| 96 | proof, | 128 | proof, |
| 97 | method: "POST", | 129 | method: "POST", |
| 98 | url: "https://example.com/oauth/token", | 130 | url: "https://example.com/oauth/token", |
| 99 | nowEpochSeconds: 101, | 131 | nowEpochSeconds: 101, |
| 100 | expectedThumbprint: "other-thumbprint", | 132 | expectedThumbprint: "other-thumbprint", |
| 101 | }), | 133 | }).ok, |
| 102 | ).toMatchObject({ ok: false }); | 134 | false, |
| 103 | expect( | 135 | ); |
| | 136 | assert.equal( |
| 104 | verifyDpopProof({ | 137 | verifyDpopProof({ |
| 105 | proof, | 138 | proof, |
| 106 | method: "POST", | 139 | method: "POST", |
| 107 | url: "https://example.com/oauth/token", | 140 | url: "https://example.com/oauth/token", |
| 108 | nowEpochSeconds: 1_000, | 141 | nowEpochSeconds: 1_000, |
| 109 | expectedThumbprint: thumbprint, | 142 | expectedThumbprint: thumbprint, |
| 110 | }), | 143 | }).ok, |
| 111 | ).toMatchObject({ ok: false }); | 144 | false, |
| | 145 | ); |
| 112 | }); | 146 | }); |
| 113 | | 147 | |
| 114 | it("requires the RFC 9449 access token hash when an access token is expected", () => { | 148 | it("requires the RFC 9449 access token hash when an access token is expected", () => { |
| ... | @@ -122,7 +156,7 @@ describe("verifyDpopProof", () => { | ... | @@ -122,7 +156,7 @@ describe("verifyDpopProof", () => { |
| 122 | accessToken: "clerk-access-token", | 156 | accessToken: "clerk-access-token", |
| 123 | }); | 157 | }); |
| 124 | | 158 | |
| 125 | expect( | 159 | assert.equal( |
| 126 | verifyDpopProof({ | 160 | verifyDpopProof({ |
| 127 | proof: accessTokenProof, | 161 | proof: accessTokenProof, |
| 128 | method: "POST", | 162 | method: "POST", |
| ... | @@ -130,32 +164,40 @@ describe("verifyDpopProof", () => { | ... | @@ -130,32 +164,40 @@ describe("verifyDpopProof", () => { |
| 130 | nowEpochSeconds: 101, | 164 | nowEpochSeconds: 101, |
| 131 | expectedThumbprint: thumbprint, | 165 | expectedThumbprint: thumbprint, |
| 132 | expectedAccessToken: "clerk-access-token", | 166 | expectedAccessToken: "clerk-access-token", |
| 133 | }), | 167 | }).ok, |
| 134 | ).toMatchObject({ ok: true }); | 168 | true, |
| 135 | expect( | 169 | ); |
| 136 | verifyDpopProof({ | 170 | |
| 137 | proof, | 171 | const missingHash = verifyDpopProof({ |
| 138 | method: "POST", | 172 | proof, |
| 139 | url: "https://example.com/oauth/token", | 173 | method: "POST", |
| 140 | nowEpochSeconds: 101, | 174 | url: "https://example.com/oauth/token", |
| 141 | expectedThumbprint: thumbprint, | 175 | nowEpochSeconds: 101, |
| 142 | expectedAccessToken: "clerk-access-token", | 176 | expectedThumbprint: thumbprint, |
| 143 | }), | 177 | expectedAccessToken: "clerk-access-token", |
| 144 | ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); | 178 | }); |
| 145 | expect( | 179 | if (missingHash.ok) { |
| 146 | verifyDpopProof({ | 180 | assert.fail("Expected DPoP proof without an access token hash to fail."); |
| 147 | proof: accessTokenProof, | 181 | } |
| 148 | method: "POST", | 182 | assert.equal(missingHash.reason, "DPoP access token hash mismatch."); |
| 149 | url: "https://example.com/v1/environments/env/connect", | 183 | |
| 150 | nowEpochSeconds: 101, | 184 | const mismatchedHash = verifyDpopProof({ |
| 151 | expectedThumbprint: thumbprint, | 185 | proof: accessTokenProof, |
| 152 | expectedAccessToken: "other-access-token", | 186 | method: "POST", |
| 153 | }), | 187 | url: "https://example.com/v1/environments/env/connect", |
| 154 | ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); | 188 | nowEpochSeconds: 101, |
| | 189 | expectedThumbprint: thumbprint, |
| | 190 | expectedAccessToken: "other-access-token", |
| | 191 | }); |
| | 192 | if (mismatchedHash.ok) { |
| | 193 | assert.fail("Expected DPoP proof with a mismatched access token hash to fail."); |
| | 194 | } |
| | 195 | assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch."); |
| 155 | }); | 196 | }); |
| 156 | | 197 | |
| 157 | it("normalizes htu by excluding query and fragment components per RFC 9449", () => { | 198 | it("normalizes htu by excluding query and fragment components per RFC 9449", () => { |
| 158 | expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe( | 199 | assert.equal( |
| | 200 | normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"), |
| 159 | "https://example.com/v1/environments/env/connect", | 201 | "https://example.com/v1/environments/env/connect", |
| 160 | ); | 202 | ); |
| 161 | | 203 | |
| ... | @@ -168,15 +210,16 @@ describe("verifyDpopProof", () => { | ... | @@ -168,15 +210,16 @@ describe("verifyDpopProof", () => { |
| 168 | publicJwk, | 210 | publicJwk, |
| 169 | }); | 211 | }); |
| 170 | | 212 | |
| 171 | expect( | 213 | assert.equal( |
| 172 | verifyDpopProof({ | 214 | verifyDpopProof({ |
| 173 | proof: queryProof, | 215 | proof: queryProof, |
| 174 | method: "POST", | 216 | method: "POST", |
| 175 | url: "https://example.com/v1/environments/env/connect?foo=bar#frag", | 217 | url: "https://example.com/v1/environments/env/connect?foo=bar#frag", |
| 176 | nowEpochSeconds: 101, | 218 | nowEpochSeconds: 101, |
| 177 | expectedThumbprint: thumbprint, | 219 | expectedThumbprint: thumbprint, |
| 178 | }), | 220 | }).ok, |
| 179 | ).toMatchObject({ ok: true }); | 221 | true, |
| | 222 | ); |
| 180 | }); | 223 | }); |
| 181 | | 224 | |
| 182 | it("rejects DPoP public JWK headers that expose private key material", () => { | 225 | it("rejects DPoP public JWK headers that expose private key material", () => { |
| ... | @@ -192,14 +235,17 @@ describe("verifyDpopProof", () => { | ... | @@ -192,14 +235,17 @@ describe("verifyDpopProof", () => { |
| 192 | publicJwk: privateJwk, | 235 | publicJwk: privateJwk, |
| 193 | }); | 236 | }); |
| 194 | | 237 | |
| 195 | expect( | 238 | const result = verifyDpopProof({ |
| 196 | verifyDpopProof({ | 239 | proof: proofWithPrivateJwk, |
| 197 | proof: proofWithPrivateJwk, | 240 | method: "POST", |
| 198 | method: "POST", | 241 | url: "https://example.com/oauth/token", |
| 199 | url: "https://example.com/oauth/token", | 242 | nowEpochSeconds: 101, |
| 200 | nowEpochSeconds: 101, | 243 | expectedThumbprint: thumbprint, |
| 201 | expectedThumbprint: thumbprint, | 244 | }); |
| 202 | }), | 245 | |
| 203 | ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." }); | 246 | if (result.ok) { |
| | 247 | assert.fail("Expected DPoP proof with private JWK material to fail."); |
| | 248 | } |
| | 249 | assert.equal(result.reason, "Invalid DPoP JWT header."); |
| 204 | }); | 250 | }); |
| 205 | }); | 251 | }); |