A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
at main 278 lines 8.2 kB view raw
1import { assertEquals, assertThrows } from "@std/assert"; 2import { 3 requireHttpsUrl, 4 validateAuthServerMetadata, 5 validateTokenResponse, 6} from "../src/validation.ts"; 7import { MetadataValidationError, TokenValidationError } from "../src/errors.ts"; 8 9// --- requireHttpsUrl --- 10 11Deno.test("requireHttpsUrl", async (t) => { 12 await t.step("accepts HTTPS URLs", () => { 13 requireHttpsUrl("https://example.com", "test"); 14 requireHttpsUrl("https://auth.example.com/path", "test"); 15 }); 16 17 await t.step("rejects HTTP URLs", () => { 18 assertThrows( 19 () => requireHttpsUrl("http://example.com", "test"), 20 MetadataValidationError, 21 "must use HTTPS", 22 ); 23 }); 24 25 await t.step("rejects invalid URLs", () => { 26 assertThrows( 27 () => requireHttpsUrl("not-a-url", "test"), 28 MetadataValidationError, 29 "not a valid URL", 30 ); 31 }); 32}); 33 34// --- validateAuthServerMetadata --- 35 36Deno.test("validateAuthServerMetadata", async (t) => { 37 const validMetadata = { 38 issuer: "https://bsky.social", 39 authorization_endpoint: "https://bsky.social/oauth/authorize", 40 token_endpoint: "https://bsky.social/oauth/token", 41 pushed_authorization_request_endpoint: "https://bsky.social/oauth/par", 42 revocation_endpoint: "https://bsky.social/oauth/revoke", 43 dpop_signing_alg_values_supported: ["ES256"], 44 }; 45 46 await t.step("accepts valid metadata", () => { 47 const result = validateAuthServerMetadata(validMetadata, "https://bsky.social"); 48 assertEquals(result.issuer, "https://bsky.social"); 49 assertEquals(result.authorization_endpoint, "https://bsky.social/oauth/authorize"); 50 assertEquals(result.token_endpoint, "https://bsky.social/oauth/token"); 51 }); 52 53 await t.step("rejects non-object metadata", () => { 54 assertThrows( 55 () => validateAuthServerMetadata(null, "https://bsky.social"), 56 MetadataValidationError, 57 "not an object", 58 ); 59 assertThrows( 60 () => validateAuthServerMetadata("string", "https://bsky.social"), 61 MetadataValidationError, 62 "not an object", 63 ); 64 }); 65 66 await t.step("rejects missing issuer", () => { 67 assertThrows( 68 () => 69 validateAuthServerMetadata( 70 { ...validMetadata, issuer: undefined }, 71 "https://bsky.social", 72 ), 73 MetadataValidationError, 74 "issuer", 75 ); 76 }); 77 78 await t.step("rejects issuer origin mismatch", () => { 79 assertThrows( 80 () => 81 validateAuthServerMetadata( 82 { ...validMetadata, issuer: "https://evil.com" }, 83 "https://bsky.social", 84 ), 85 MetadataValidationError, 86 "does not match", 87 ); 88 }); 89 90 await t.step("accepts issuer with same origin but different path", () => { 91 const md = { 92 ...validMetadata, 93 issuer: "https://bsky.social/some/path", 94 }; 95 const result = validateAuthServerMetadata(md, "https://bsky.social"); 96 assertEquals(result.issuer, "https://bsky.social/some/path"); 97 }); 98 99 await t.step("rejects missing authorization_endpoint", () => { 100 assertThrows( 101 () => 102 validateAuthServerMetadata( 103 { ...validMetadata, authorization_endpoint: "" }, 104 "https://bsky.social", 105 ), 106 MetadataValidationError, 107 "authorization_endpoint", 108 ); 109 }); 110 111 await t.step("rejects missing token_endpoint", () => { 112 assertThrows( 113 () => 114 validateAuthServerMetadata( 115 { ...validMetadata, token_endpoint: "" }, 116 "https://bsky.social", 117 ), 118 MetadataValidationError, 119 "token_endpoint", 120 ); 121 }); 122 123 await t.step("rejects HTTP endpoints", () => { 124 assertThrows( 125 () => 126 validateAuthServerMetadata( 127 { ...validMetadata, authorization_endpoint: "http://bsky.social/oauth/authorize" }, 128 "https://bsky.social", 129 ), 130 MetadataValidationError, 131 "must use HTTPS", 132 ); 133 }); 134 135 await t.step("rejects DPoP algs without ES256", () => { 136 assertThrows( 137 () => 138 validateAuthServerMetadata( 139 { ...validMetadata, dpop_signing_alg_values_supported: ["RS256"] }, 140 "https://bsky.social", 141 ), 142 MetadataValidationError, 143 "ES256", 144 ); 145 }); 146 147 await t.step("accepts metadata without dpop_signing_alg_values_supported", () => { 148 const md = { ...validMetadata }; 149 delete (md as Record<string, unknown>).dpop_signing_alg_values_supported; 150 const result = validateAuthServerMetadata(md, "https://bsky.social"); 151 assertEquals(result.dpop_signing_alg_values_supported, undefined); 152 }); 153 154 await t.step("accepts metadata without optional endpoints", () => { 155 const md = { 156 issuer: "https://bsky.social", 157 authorization_endpoint: "https://bsky.social/oauth/authorize", 158 token_endpoint: "https://bsky.social/oauth/token", 159 }; 160 const result = validateAuthServerMetadata(md, "https://bsky.social"); 161 assertEquals(result.pushed_authorization_request_endpoint, undefined); 162 assertEquals(result.revocation_endpoint, undefined); 163 }); 164}); 165 166// --- validateTokenResponse --- 167 168Deno.test("validateTokenResponse", async (t) => { 169 const validResponse = { 170 access_token: "at_token_123", 171 token_type: "DPoP", 172 scope: "atproto transition:generic", 173 sub: "did:plc:abc123", 174 expires_in: 3600, 175 refresh_token: "rt_token_456", 176 }; 177 178 await t.step("accepts valid token response", () => { 179 const result = validateTokenResponse(validResponse); 180 assertEquals(result.access_token, "at_token_123"); 181 assertEquals(result.token_type, "DPoP"); 182 assertEquals(result.scope, "atproto transition:generic"); 183 assertEquals(result.sub, "did:plc:abc123"); 184 assertEquals(result.expires_in, 3600); 185 assertEquals(result.refresh_token, "rt_token_456"); 186 }); 187 188 await t.step("accepts DPoP case-insensitive", () => { 189 const result = validateTokenResponse({ ...validResponse, token_type: "dpop" }); 190 assertEquals(result.token_type, "dpop"); 191 }); 192 193 await t.step("rejects non-object response", () => { 194 assertThrows( 195 () => validateTokenResponse(null), 196 TokenValidationError, 197 "not an object", 198 ); 199 }); 200 201 await t.step("rejects missing access_token", () => { 202 assertThrows( 203 () => validateTokenResponse({ ...validResponse, access_token: "" }), 204 TokenValidationError, 205 "access_token", 206 ); 207 }); 208 209 await t.step("rejects wrong token_type", () => { 210 assertThrows( 211 () => validateTokenResponse({ ...validResponse, token_type: "Bearer" }), 212 TokenValidationError, 213 "DPoP", 214 ); 215 }); 216 217 await t.step("rejects missing sub", () => { 218 assertThrows( 219 () => validateTokenResponse({ ...validResponse, sub: "" }), 220 TokenValidationError, 221 "sub", 222 ); 223 }); 224 225 await t.step("rejects sub not starting with did:", () => { 226 assertThrows( 227 () => validateTokenResponse({ ...validResponse, sub: "user:abc" }), 228 TokenValidationError, 229 "did:", 230 ); 231 }); 232 233 await t.step("rejects missing scope", () => { 234 assertThrows( 235 () => validateTokenResponse({ ...validResponse, scope: "" }), 236 TokenValidationError, 237 "scope", 238 ); 239 }); 240 241 await t.step("rejects scope without atproto", () => { 242 assertThrows( 243 () => validateTokenResponse({ ...validResponse, scope: "openid profile" }), 244 TokenValidationError, 245 "atproto", 246 ); 247 }); 248 249 await t.step("rejects zero expires_in", () => { 250 assertThrows( 251 () => validateTokenResponse({ ...validResponse, expires_in: 0 }), 252 TokenValidationError, 253 "expires_in", 254 ); 255 }); 256 257 await t.step("rejects negative expires_in", () => { 258 assertThrows( 259 () => validateTokenResponse({ ...validResponse, expires_in: -1 }), 260 TokenValidationError, 261 "expires_in", 262 ); 263 }); 264 265 await t.step("accepts response without refresh_token", () => { 266 const { refresh_token: _, ...noRefresh } = validResponse; 267 const result = validateTokenResponse(noRefresh); 268 assertEquals(result.refresh_token, undefined); 269 }); 270 271 await t.step("rejects non-string refresh_token", () => { 272 assertThrows( 273 () => validateTokenResponse({ ...validResponse, refresh_token: 123 }), 274 TokenValidationError, 275 "refresh_token", 276 ); 277 }); 278});