Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
1/**
2 * Generate ATProto OAuth client metadata
3 */
4
5import type { ATProtoOAuthConfig, ClientMetadata } from "./types.ts";
6
7/**
8 * Check if a URL is a loopback/localhost address for local development.
9 */
10export 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 */
25export 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 */
36export 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/**
47 * Generate ATProto OAuth client metadata for the /.well-known/oauth-client endpoint
48 */
49export function generateClientMetadata(
50 config: ATProtoOAuthConfig,
51): ClientMetadata {
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`;
63
64 const metadata: ClientMetadata = {
65 client_name: config.appName,
66 client_id: clientId,
67 client_uri: baseUrl,
68 redirect_uris: [redirectUri],
69 scope,
70 grant_types: ["authorization_code", "refresh_token"],
71 response_types: ["code"],
72 application_type: "web",
73 token_endpoint_auth_method: "none",
74 dpop_bound_access_tokens: true,
75 };
76
77 if (config.logoUri) {
78 metadata.logo_uri = config.logoUri;
79 }
80
81 if (config.policyUri) {
82 metadata.policy_uri = config.policyUri;
83 }
84
85 return metadata;
86}