A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
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});