A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
1import { assertEquals, assertMatch } from "@std/assert";
2
3// Helper functions that replicate the private PKCE methods for testing
4function generateCodeVerifier(): string {
5 const array = new Uint8Array(32);
6 crypto.getRandomValues(array);
7 return btoa(String.fromCharCode(...array))
8 .replace(/[+/]/g, (match) => match === "+" ? "-" : "_")
9 .replace(/=/g, "");
10}
11
12async function generateCodeChallenge(verifier: string): Promise<string> {
13 const encoder = new TextEncoder();
14 const data = encoder.encode(verifier);
15 const digest = await crypto.subtle.digest("SHA-256", data);
16 return btoa(String.fromCharCode(...new Uint8Array(digest)))
17 .replace(/[+/]/g, (match) => match === "+" ? "-" : "_")
18 .replace(/=/g, "");
19}
20
21Deno.test("PKCE utilities", async (t) => {
22 await t.step("generateCodeVerifier should create valid code verifier", () => {
23 const verifier = generateCodeVerifier();
24
25 // Should be base64url encoded (no +, /, or = characters)
26 assertMatch(verifier, /^[A-Za-z0-9_-]+$/);
27
28 // Should be reasonably long (43-128 characters for PKCE)
29 assertEquals(verifier.length, 43); // 32 bytes base64url encoded = 43 chars
30 });
31
32 await t.step("generateCodeVerifier should create unique values", () => {
33 const verifier1 = generateCodeVerifier();
34 const verifier2 = generateCodeVerifier();
35
36 // Should generate different values each time
37 assertEquals(verifier1 === verifier2, false);
38 });
39
40 await t.step("generateCodeChallenge should create valid code challenge", async () => {
41 const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
42 const challenge = await generateCodeChallenge(verifier);
43
44 // Should be base64url encoded
45 assertMatch(challenge, /^[A-Za-z0-9_-]+$/);
46
47 // Should be exactly 43 characters (SHA-256 hash base64url encoded)
48 assertEquals(challenge.length, 43);
49
50 // Should be deterministic for same input
51 const challenge2 = await generateCodeChallenge(verifier);
52 assertEquals(challenge, challenge2);
53 });
54
55 await t.step("generateCodeChallenge should match RFC 7636 test vector", async () => {
56 // Test vector from RFC 7636, Section 4.2
57 const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
58 const expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
59
60 const challenge = await generateCodeChallenge(verifier);
61 assertEquals(challenge, expectedChallenge);
62 });
63
64 await t.step("code verifier and challenge should work together", async () => {
65 const verifier = generateCodeVerifier();
66 const challenge = await generateCodeChallenge(verifier);
67
68 // Should produce consistent challenge for the same verifier
69 const challenge2 = await generateCodeChallenge(verifier);
70 assertEquals(challenge, challenge2);
71
72 // Different verifiers should produce different challenges
73 const verifier2 = generateCodeVerifier();
74 const challenge3 = await generateCodeChallenge(verifier2);
75 assertEquals(challenge === challenge3, false);
76 });
77});
78
79Deno.test("URL parsing utilities", async (t) => {
80 await t.step("should parse callback URL with code and state", () => {
81 const url = new URL("https://example.com/callback?code=auth_code&state=csrf_state");
82 const params = Object.fromEntries(url.searchParams.entries());
83
84 assertEquals(params.code, "auth_code");
85 assertEquals(params.state, "csrf_state");
86 assertEquals(params.error, undefined);
87 });
88
89 await t.step("should parse callback URL with error", () => {
90 const url = new URL(
91 "https://example.com/callback?error=access_denied&error_description=User+denied+request",
92 );
93 const params = Object.fromEntries(url.searchParams.entries());
94
95 assertEquals(params.error, "access_denied");
96 assertEquals(params.error_description, "User denied request");
97 assertEquals(params.code, undefined);
98 });
99
100 await t.step("should handle URL with fragment parameters", () => {
101 // Some OAuth flows use fragment parameters
102 const urlWithFragment = "https://example.com/callback#code=auth_code&state=csrf_state";
103 const url = new URL(urlWithFragment);
104
105 // Fragment parameters need special handling
106 const fragment = url.hash.substring(1);
107 const fragmentParams = Object.fromEntries(new URLSearchParams(fragment).entries());
108
109 assertEquals(fragmentParams.code, "auth_code");
110 assertEquals(fragmentParams.state, "csrf_state");
111 });
112
113 await t.step("should construct authorization URL correctly", () => {
114 const baseUrl = "https://auth.example.com/oauth/authorize";
115 const params = {
116 response_type: "code",
117 client_id: "https://client.example.com/metadata.json",
118 redirect_uri: "https://client.example.com/callback",
119 scope: "atproto transition:generic",
120 state: "csrf_token",
121 code_challenge: "challenge_string",
122 code_challenge_method: "S256",
123 };
124
125 const url = new URL(baseUrl);
126 Object.entries(params).forEach(([key, value]) => {
127 url.searchParams.set(key, value);
128 });
129
130 assertEquals(url.searchParams.get("response_type"), "code");
131 assertEquals(url.searchParams.get("client_id"), "https://client.example.com/metadata.json");
132 assertEquals(url.searchParams.get("redirect_uri"), "https://client.example.com/callback");
133 assertEquals(url.searchParams.get("scope"), "atproto transition:generic");
134 assertEquals(url.searchParams.get("state"), "csrf_token");
135 assertEquals(url.searchParams.get("code_challenge"), "challenge_string");
136 assertEquals(url.searchParams.get("code_challenge_method"), "S256");
137 });
138});
139
140Deno.test("Base64URL encoding utilities", async (t) => {
141 await t.step("should convert regular base64 to base64url", () => {
142 const regularBase64 = "SGVsbG8gV29ybGQ+Pz8/"; // "Hello World>???" with padding and special chars
143 const base64url = regularBase64
144 .replace(/[+]/g, "-")
145 .replace(/[/]/g, "_")
146 .replace(/=/g, "");
147
148 assertEquals(base64url, "SGVsbG8gV29ybGQ-Pz8_");
149 });
150
151 await t.step("should handle empty string", () => {
152 const empty = "";
153 const encoded = btoa(empty);
154 const base64url = encoded
155 .replace(/[+]/g, "-")
156 .replace(/[/]/g, "_")
157 .replace(/=/g, "");
158
159 assertEquals(base64url, "");
160 });
161
162 await t.step("should encode random bytes correctly", () => {
163 const bytes = new Uint8Array([255, 254, 253, 252, 251, 250]);
164 const base64 = btoa(String.fromCharCode(...bytes));
165 const base64url = base64
166 .replace(/[+]/g, "-")
167 .replace(/[/]/g, "_")
168 .replace(/=/g, "");
169
170 // Should not contain +, /, or = characters
171 assertMatch(base64url, /^[A-Za-z0-9_-]*$/);
172 });
173});
174
175Deno.test("Crypto utilities", async (t) => {
176 await t.step("should generate random values correctly", () => {
177 const array1 = new Uint8Array(32);
178 const array2 = new Uint8Array(32);
179
180 crypto.getRandomValues(array1);
181 crypto.getRandomValues(array2);
182
183 // Should fill the arrays
184 assertEquals(array1.length, 32);
185 assertEquals(array2.length, 32);
186
187 // Should generate different values
188 const same = array1.every((value, index) => value === array2[index]);
189 assertEquals(same, false);
190 });
191
192 await t.step("should hash data with SHA-256", async () => {
193 const data = new TextEncoder().encode("test data");
194 const hash1 = await crypto.subtle.digest("SHA-256", data);
195 const hash2 = await crypto.subtle.digest("SHA-256", data);
196
197 // Should produce consistent results
198 assertEquals(
199 new Uint8Array(hash1).toString(),
200 new Uint8Array(hash2).toString(),
201 );
202
203 // Should be 32 bytes (256 bits)
204 assertEquals(hash1.byteLength, 32);
205 });
206
207 await t.step("should generate unique UUIDs", () => {
208 const uuid1 = crypto.randomUUID();
209 const uuid2 = crypto.randomUUID();
210
211 // Should match UUID format
212 assertMatch(uuid1, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
213 assertMatch(uuid2, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
214
215 // Should be unique
216 assertEquals(uuid1 === uuid2, false);
217 });
218});