Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
at main 161 lines 5.4 kB view raw
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});