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