Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
1import { assertEquals } from "@std/assert";
2import {
3 buildLoopbackClientId,
4 buildLoopbackRedirectUri,
5 generateClientMetadata,
6 isLoopbackUrl,
7} from "./client-metadata.ts";
8import type { ATProtoOAuthConfig } from "./types.ts";
9import { MemoryStorage } from "@tijs/atproto-storage";
10
11Deno.test("generateClientMetadata - basic config", () => {
12 const config: ATProtoOAuthConfig = {
13 baseUrl: "https://myapp.example.com",
14 appName: "Test App",
15 cookieSecret: "a".repeat(32),
16 storage: new MemoryStorage(),
17 };
18
19 const metadata = generateClientMetadata(config);
20
21 assertEquals(metadata.client_name, "Test App");
22 assertEquals(
23 metadata.client_id,
24 "https://myapp.example.com/oauth-client-metadata.json",
25 );
26 assertEquals(metadata.client_uri, "https://myapp.example.com");
27 assertEquals(metadata.redirect_uris, [
28 "https://myapp.example.com/oauth/callback",
29 ]);
30 assertEquals(metadata.scope, "atproto transition:generic");
31 assertEquals(metadata.grant_types, ["authorization_code", "refresh_token"]);
32 assertEquals(metadata.response_types, ["code"]);
33 assertEquals(metadata.application_type, "web");
34 assertEquals(metadata.token_endpoint_auth_method, "none");
35 assertEquals(metadata.dpop_bound_access_tokens, true);
36 assertEquals(metadata.logo_uri, undefined);
37 assertEquals(metadata.policy_uri, undefined);
38});
39
40Deno.test("generateClientMetadata - with optional fields", () => {
41 const config: ATProtoOAuthConfig = {
42 baseUrl: "https://myapp.example.com/",
43 appName: "Test App",
44 cookieSecret: "a".repeat(32),
45 storage: new MemoryStorage(),
46 logoUri: "https://myapp.example.com/logo.png",
47 policyUri: "https://myapp.example.com/privacy",
48 scope: "atproto transition:generic transition:chat.bsky",
49 };
50
51 const metadata = generateClientMetadata(config);
52
53 assertEquals(metadata.client_uri, "https://myapp.example.com"); // trailing slash removed
54 assertEquals(metadata.logo_uri, "https://myapp.example.com/logo.png");
55 assertEquals(metadata.policy_uri, "https://myapp.example.com/privacy");
56 assertEquals(
57 metadata.scope,
58 "atproto transition:generic transition:chat.bsky",
59 );
60});
61
62Deno.test("generateClientMetadata - removes trailing slash from baseUrl", () => {
63 const config: ATProtoOAuthConfig = {
64 baseUrl: "https://myapp.example.com/",
65 appName: "Test App",
66 cookieSecret: "a".repeat(32),
67 storage: new MemoryStorage(),
68 };
69
70 const metadata = generateClientMetadata(config);
71
72 assertEquals(metadata.client_uri, "https://myapp.example.com");
73 assertEquals(
74 metadata.client_id,
75 "https://myapp.example.com/oauth-client-metadata.json",
76 );
77 assertEquals(metadata.redirect_uris, [
78 "https://myapp.example.com/oauth/callback",
79 ]);
80});
81
82// --- Loopback / localhost tests ---
83
84Deno.test("isLoopbackUrl - detects localhost", () => {
85 assertEquals(isLoopbackUrl("http://localhost:8000"), true);
86 assertEquals(isLoopbackUrl("http://localhost"), true);
87 assertEquals(isLoopbackUrl("http://127.0.0.1:3000"), true);
88 assertEquals(isLoopbackUrl("http://[::1]:8080"), true);
89 assertEquals(isLoopbackUrl("https://myapp.example.com"), false);
90 assertEquals(isLoopbackUrl("not-a-url"), false);
91});
92
93Deno.test("buildLoopbackRedirectUri - replaces localhost with 127.0.0.1", () => {
94 assertEquals(
95 buildLoopbackRedirectUri("http://localhost:8000"),
96 "http://127.0.0.1:8000/oauth/callback",
97 );
98 assertEquals(
99 buildLoopbackRedirectUri("http://localhost:3000"),
100 "http://127.0.0.1:3000/oauth/callback",
101 );
102});
103
104Deno.test("buildLoopbackClientId - builds correct loopback client_id", () => {
105 const redirectUri = "http://127.0.0.1:8000/oauth/callback";
106 const scope = "atproto transition:generic";
107 const clientId = buildLoopbackClientId(redirectUri, scope);
108
109 assertEquals(clientId.startsWith("http://localhost?"), true);
110 // Verify params are encoded in the client_id
111 const url = new URL(clientId);
112 assertEquals(url.searchParams.get("redirect_uri"), redirectUri);
113 assertEquals(url.searchParams.get("scope"), scope);
114});
115
116Deno.test("generateClientMetadata - localhost uses loopback format", () => {
117 const config: ATProtoOAuthConfig = {
118 baseUrl: "http://localhost:8000",
119 appName: "Dev App",
120 cookieSecret: "a".repeat(32),
121 storage: new MemoryStorage(),
122 };
123
124 const metadata = generateClientMetadata(config);
125
126 // redirect_uris should use 127.0.0.1
127 assertEquals(metadata.redirect_uris, [
128 "http://127.0.0.1:8000/oauth/callback",
129 ]);
130
131 // client_id should be loopback format
132 assertEquals(metadata.client_id.startsWith("http://localhost?"), true);
133 const url = new URL(metadata.client_id);
134 assertEquals(
135 url.searchParams.get("redirect_uri"),
136 "http://127.0.0.1:8000/oauth/callback",
137 );
138 assertEquals(
139 url.searchParams.get("scope"),
140 "atproto transition:generic",
141 );
142
143 // client_uri stays as provided
144 assertEquals(metadata.client_uri, "http://localhost:8000");
145});
146
147Deno.test("generateClientMetadata - 127.0.0.1 uses loopback format", () => {
148 const config: ATProtoOAuthConfig = {
149 baseUrl: "http://127.0.0.1:3000",
150 appName: "Dev App",
151 cookieSecret: "a".repeat(32),
152 storage: new MemoryStorage(),
153 };
154
155 const metadata = generateClientMetadata(config);
156
157 assertEquals(metadata.redirect_uris, [
158 "http://127.0.0.1:3000/oauth/callback",
159 ]);
160 assertEquals(metadata.client_id.startsWith("http://localhost?"), true);
161});