A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
at main 218 lines 8.1 kB view raw
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});