Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Support localhost/loopback URLs for local development

When baseUrl is a loopback address (localhost, 127.0.0.1, [::1]),
generate AT Protocol OAuth loopback client metadata:
- client_id: http://localhost?redirect_uri=...&scope=...
- redirect_uris: http://127.0.0.1:<port>/oauth/callback

This follows the AT Protocol OAuth spec for loopback clients, removing
the need for ngrok during local development.

Closes tijs/atproto-oauth#1

+192 -8
+13
CHANGELOG.md
··· 2 2 3 3 All notable changes to this project will be documented in this file. 4 4 5 + ## [2.9.0] - 2026-02-15 6 + 7 + ### Added 8 + 9 + - **Localhost/loopback development support**: When `baseUrl` is a localhost or 10 + loopback address (e.g., `http://localhost:8000`), the library now generates 11 + the correct AT Protocol OAuth loopback client metadata: 12 + - `client_id` uses the `http://localhost?redirect_uri=...&scope=...` format 13 + - `redirect_uris` use `http://127.0.0.1:<port>` instead of `localhost` 14 + 15 + This follows the AT Protocol OAuth spec for loopback clients, allowing local 16 + development without ngrok or other tunneling tools. 17 + 5 18 ## [2.8.0] - 2026-02-15 6 19 7 20 ### Added
+1 -1
deno.json
··· 1 1 { 2 2 "$schema": "https://jsr.io/schema/config-file.v1.json", 3 3 "name": "@tijs/atproto-oauth", 4 - "version": "2.8.0", 4 + "version": "2.9.0", 5 5 "license": "MIT", 6 6 "exports": "./mod.ts", 7 7 "publish": {
+87 -1
src/client-metadata.test.ts
··· 1 1 import { assertEquals } from "@std/assert"; 2 - import { generateClientMetadata } from "./client-metadata.ts"; 2 + import { 3 + buildLoopbackClientId, 4 + buildLoopbackRedirectUri, 5 + generateClientMetadata, 6 + isLoopbackUrl, 7 + } from "./client-metadata.ts"; 3 8 import type { ATProtoOAuthConfig } from "./types.ts"; 4 9 import { MemoryStorage } from "@tijs/atproto-storage"; 5 10 ··· 73 78 "https://myapp.example.com/oauth/callback", 74 79 ]); 75 80 }); 81 + 82 + // --- Loopback / localhost tests --- 83 + 84 + Deno.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 + 93 + Deno.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 + 104 + Deno.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 + 116 + Deno.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 + 147 + Deno.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 + });
+52 -3
src/client-metadata.ts
··· 5 5 import type { ATProtoOAuthConfig, ClientMetadata } from "./types.ts"; 6 6 7 7 /** 8 + * Check if a URL is a loopback/localhost address for local development. 9 + */ 10 + export function isLoopbackUrl(url: string): boolean { 11 + try { 12 + const parsed = new URL(url); 13 + const host = parsed.hostname; 14 + return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || 15 + host === "::1"; 16 + } catch { 17 + return false; 18 + } 19 + } 20 + 21 + /** 22 + * Build a loopback redirect URI from a localhost base URL. 23 + * Replaces "localhost" with "127.0.0.1" per the AT Protocol OAuth spec. 24 + */ 25 + export function buildLoopbackRedirectUri(baseUrl: string): string { 26 + const parsed = new URL(baseUrl); 27 + parsed.hostname = "127.0.0.1"; 28 + const origin = parsed.origin; // includes port 29 + return `${origin}/oauth/callback`; 30 + } 31 + 32 + /** 33 + * Build a loopback client_id per the AT Protocol OAuth spec. 34 + * Format: http://localhost?redirect_uri=<encoded>&scope=<encoded> 35 + */ 36 + export function buildLoopbackClientId( 37 + redirectUri: string, 38 + scope: string, 39 + ): string { 40 + const params = new URLSearchParams(); 41 + params.set("redirect_uri", redirectUri); 42 + params.set("scope", scope); 43 + return `http://localhost?${params.toString()}`; 44 + } 45 + 46 + /** 8 47 * Generate ATProto OAuth client metadata for the /.well-known/oauth-client endpoint 9 48 */ 10 49 export function generateClientMetadata( 11 50 config: ATProtoOAuthConfig, 12 51 ): ClientMetadata { 13 52 const baseUrl = config.baseUrl.replace(/\/$/, ""); 53 + const scope = config.scope || "atproto transition:generic"; 54 + const loopback = isLoopbackUrl(baseUrl); 55 + 56 + const redirectUri = loopback 57 + ? buildLoopbackRedirectUri(baseUrl) 58 + : `${baseUrl}/oauth/callback`; 59 + 60 + const clientId = loopback 61 + ? buildLoopbackClientId(redirectUri, scope) 62 + : `${baseUrl}/oauth-client-metadata.json`; 14 63 15 64 const metadata: ClientMetadata = { 16 65 client_name: config.appName, 17 - client_id: `${baseUrl}/oauth-client-metadata.json`, 66 + client_id: clientId, 18 67 client_uri: baseUrl, 19 - redirect_uris: [`${baseUrl}/oauth/callback`], 20 - scope: config.scope || "atproto transition:generic", 68 + redirect_uris: [redirectUri], 69 + scope, 21 70 grant_types: ["authorization_code", "refresh_token"], 22 71 response_types: ["code"], 23 72 application_type: "web",
+18
src/oauth.test.ts
··· 177 177 assertEquals(result.error?.type, "NO_COOKIE"); 178 178 }); 179 179 180 + Deno.test("createATProtoOAuth - localhost uses loopback client metadata", () => { 181 + const oauth = createATProtoOAuth({ 182 + baseUrl: "http://localhost:8000", 183 + appName: "Dev App", 184 + cookieSecret: "a".repeat(32), 185 + storage: new MemoryStorage(), 186 + }); 187 + 188 + const metadata = oauth.getClientMetadata(); 189 + 190 + // client_id should be loopback format 191 + assertEquals(metadata.client_id.startsWith("http://localhost?"), true); 192 + // redirect_uris should use 127.0.0.1 193 + assertEquals(metadata.redirect_uris, [ 194 + "http://127.0.0.1:8000/oauth/callback", 195 + ]); 196 + }); 197 + 180 198 Deno.test("createATProtoOAuth - handleLogout clears session", async () => { 181 199 const oauth = createATProtoOAuth({ 182 200 baseUrl: "https://myapp.example.com",
+21 -3
src/oauth.ts
··· 12 12 Logger, 13 13 } from "./types.ts"; 14 14 import { noopLogger } from "./types.ts"; 15 - import { generateClientMetadata } from "./client-metadata.ts"; 15 + import { 16 + buildLoopbackClientId, 17 + buildLoopbackRedirectUri, 18 + generateClientMetadata, 19 + isLoopbackUrl, 20 + } from "./client-metadata.ts"; 16 21 import { OAuthSessions } from "./sessions.ts"; 17 22 import { createRouteHandlers } from "./routes.ts"; 18 23 ··· 106 111 const baseUrl = config.baseUrl.replace(/\/$/, ""); 107 112 const sessionTtl = config.sessionTtl ?? DEFAULT_SESSION_TTL; 108 113 const logger: Logger = config.logger ?? noopLogger; 114 + const scope = config.scope || "atproto transition:generic"; 115 + const loopback = isLoopbackUrl(baseUrl); 116 + 117 + // For loopback URLs, use AT Protocol OAuth localhost convention: 118 + // - client_id: http://localhost?redirect_uri=...&scope=... 119 + // - redirect_uri: http://127.0.0.1:<port>/oauth/callback 120 + const redirectUri = loopback 121 + ? buildLoopbackRedirectUri(baseUrl) 122 + : `${baseUrl}/oauth/callback`; 123 + 124 + const clientId = loopback 125 + ? buildLoopbackClientId(redirectUri, scope) 126 + : `${baseUrl}/oauth-client-metadata.json`; 109 127 110 128 // Create OAuth client (Logger interfaces now match) 111 129 const oauthClient = new OAuthClient({ 112 - clientId: `${baseUrl}/oauth-client-metadata.json`, 113 - redirectUri: `${baseUrl}/oauth/callback`, 130 + clientId, 131 + redirectUri, 114 132 storage: config.storage, 115 133 logger, 116 134 });