A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
1import { assertEquals, assertNotEquals } from "@std/assert";
2import { generateDPoPKeyPair, generateDPoPProof } from "../src/dpop.ts";
3import { decodeJwt } from "@panva/jose";
4
5Deno.test("DPoP proof - htu normalization", async (t) => {
6 const keyPair = await generateDPoPKeyPair();
7
8 await t.step("strips query parameters from htu", async () => {
9 const proof = await generateDPoPProof(
10 "GET",
11 "https://example.com/api?foo=bar&baz=qux",
12 keyPair.privateKey,
13 keyPair.publicKeyJWK,
14 );
15 const payload = decodeJwt(proof);
16 assertEquals(payload.htu, "https://example.com/api");
17 });
18
19 await t.step("strips fragment from htu", async () => {
20 const proof = await generateDPoPProof(
21 "POST",
22 "https://example.com/api#section",
23 keyPair.privateKey,
24 keyPair.publicKeyJWK,
25 );
26 const payload = decodeJwt(proof);
27 assertEquals(payload.htu, "https://example.com/api");
28 });
29
30 await t.step("preserves path in htu", async () => {
31 const proof = await generateDPoPProof(
32 "GET",
33 "https://example.com/oauth/token",
34 keyPair.privateKey,
35 keyPair.publicKeyJWK,
36 );
37 const payload = decodeJwt(proof);
38 assertEquals(payload.htu, "https://example.com/oauth/token");
39 });
40
41 await t.step("includes nonce when provided", async () => {
42 const proof = await generateDPoPProof(
43 "POST",
44 "https://example.com/oauth/token",
45 keyPair.privateKey,
46 keyPair.publicKeyJWK,
47 undefined,
48 "server-nonce-123",
49 );
50 const payload = decodeJwt(proof);
51 assertEquals(payload.nonce, "server-nonce-123");
52 });
53
54 await t.step("generates unique jti for each proof", async () => {
55 const proof1 = await generateDPoPProof(
56 "GET",
57 "https://example.com/api",
58 keyPair.privateKey,
59 keyPair.publicKeyJWK,
60 );
61 const proof2 = await generateDPoPProof(
62 "GET",
63 "https://example.com/api",
64 keyPair.privateKey,
65 keyPair.publicKeyJWK,
66 );
67 const payload1 = decodeJwt(proof1);
68 const payload2 = decodeJwt(proof2);
69 assertNotEquals(payload1.jti, payload2.jti);
70 });
71});
72
73Deno.test("DPoP nonce cache", async (t) => {
74 // Import cache functions
75 const { getCachedNonce, updateNonceCache } = await import("../src/dpop.ts");
76
77 await t.step("returns undefined for unknown origins", () => {
78 const nonce = getCachedNonce("https://unknown-origin.example.com/path");
79 assertEquals(nonce, undefined);
80 });
81
82 await t.step("stores and retrieves nonce per origin", () => {
83 const mockResponse = new Response(null, {
84 headers: { "DPoP-Nonce": "nonce-abc" },
85 });
86 updateNonceCache("https://cache-test.example.com/oauth/token", mockResponse);
87
88 assertEquals(getCachedNonce("https://cache-test.example.com/other"), "nonce-abc");
89 });
90
91 await t.step("updates nonce from new response", () => {
92 const response1 = new Response(null, {
93 headers: { "DPoP-Nonce": "nonce-1" },
94 });
95 updateNonceCache("https://update-test.example.com/a", response1);
96 assertEquals(getCachedNonce("https://update-test.example.com/b"), "nonce-1");
97
98 const response2 = new Response(null, {
99 headers: { "DPoP-Nonce": "nonce-2" },
100 });
101 updateNonceCache("https://update-test.example.com/c", response2);
102 assertEquals(getCachedNonce("https://update-test.example.com/d"), "nonce-2");
103 });
104
105 await t.step("ignores responses without DPoP-Nonce header", () => {
106 const response = new Response(null);
107 updateNonceCache("https://no-nonce.example.com/path", response);
108 assertEquals(getCachedNonce("https://no-nonce.example.com/path"), undefined);
109 });
110});