Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
at main 193 lines 5.6 kB view raw
1/** 2 * Main factory function for creating ATProto OAuth integration 3 * Framework-agnostic - works with standard Request/Response APIs 4 */ 5 6import { OAuthClient } from "@tijs/oauth-client-deno"; 7import { SessionManager } from "@tijs/atproto-sessions"; 8 9import type { 10 ATProtoOAuthConfig, 11 ATProtoOAuthInstance, 12 Logger, 13} from "./types.ts"; 14import { noopLogger } from "./types.ts"; 15import { 16 buildLoopbackClientId, 17 buildLoopbackRedirectUri, 18 generateClientMetadata, 19 isLoopbackUrl, 20} from "./client-metadata.ts"; 21import { OAuthSessions } from "./sessions.ts"; 22import { createRouteHandlers } from "./routes.ts"; 23 24/** Default session TTL: 7 days in seconds */ 25const DEFAULT_SESSION_TTL = 60 * 60 * 24 * 7; 26 27/** 28 * Create a complete ATProto OAuth integration for any framework. 29 * 30 * This function sets up everything needed for ATProto/Bluesky OAuth authentication, 31 * with route handlers that work with standard Web Request/Response APIs. 32 * 33 * @param config - Configuration object for OAuth integration 34 * @returns ATProto OAuth instance with route handlers and session management 35 * 36 * @example Basic setup 37 * ```typescript 38 * import { createATProtoOAuth } from "@tijs/atproto-oauth"; 39 * import { SQLiteStorage, valTownAdapter } from "@tijs/atproto-storage"; 40 * 41 * const oauth = createATProtoOAuth({ 42 * baseUrl: "https://myapp.example.com", 43 * appName: "My App", 44 * cookieSecret: Deno.env.get("COOKIE_SECRET")!, 45 * storage: new SQLiteStorage(valTownAdapter(sqlite)), 46 * sessionTtl: 60 * 60 * 24 * 14, // 14 days 47 * }); 48 * 49 * // Use route handlers in your framework 50 * // Hono: 51 * app.get("/login", (c) => oauth.handleLogin(c.req.raw)); 52 * app.get("/oauth/callback", (c) => oauth.handleCallback(c.req.raw)); 53 * app.get("/oauth-client-metadata.json", () => oauth.handleClientMetadata()); 54 * app.post("/api/auth/logout", (c) => oauth.handleLogout(c.req.raw)); 55 * 56 * // Oak: 57 * router.get("/login", (ctx) => ctx.respond = false; return oauth.handleLogin(ctx.request.originalRequest)); 58 * 59 * // Fresh (Deno): 60 * export const handler = async (req) => oauth.handleLogin(req); 61 * ``` 62 * 63 * @example Getting authenticated session in routes 64 * ```typescript 65 * app.get("/api/profile", async (c) => { 66 * const { session, setCookieHeader, error } = await oauth.getSessionFromRequest(c.req.raw); 67 * 68 * if (!session) { 69 * return c.json({ error: error?.message || "Not authenticated" }, 401); 70 * } 71 * 72 * // Make authenticated API call 73 * const response = await session.makeRequest( 74 * "GET", 75 * `${session.pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${session.did}` 76 * ); 77 * 78 * const profile = await response.json(); 79 * 80 * const res = c.json(profile); 81 * if (setCookieHeader) { 82 * res.headers.set("Set-Cookie", setCookieHeader); 83 * } 84 * return res; 85 * }); 86 * ``` 87 */ 88export function createATProtoOAuth( 89 config: ATProtoOAuthConfig, 90): ATProtoOAuthInstance { 91 // Validate required config 92 if (!config.baseUrl) { 93 throw new Error("baseUrl is required"); 94 } 95 if (!config.appName) { 96 throw new Error("appName is required"); 97 } 98 if (!config.cookieSecret) { 99 throw new Error("cookieSecret is required"); 100 } 101 if (config.cookieSecret.length < 32) { 102 throw new Error( 103 "cookieSecret must be at least 32 characters for secure encryption", 104 ); 105 } 106 if (!config.storage) { 107 throw new Error("storage is required"); 108 } 109 110 // Normalize baseUrl 111 const baseUrl = config.baseUrl.replace(/\/$/, ""); 112 const sessionTtl = config.sessionTtl ?? DEFAULT_SESSION_TTL; 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`; 127 128 // Create OAuth client (Logger interfaces now match) 129 const oauthClient = new OAuthClient({ 130 clientId, 131 redirectUri, 132 storage: config.storage, 133 logger, 134 }); 135 136 // Create session manager for cookie handling 137 const sessionManager = new SessionManager({ 138 cookieSecret: config.cookieSecret, 139 cookieName: "sid", 140 sessionTtl, 141 logger, 142 }); 143 144 // Create OAuth sessions manager 145 const oauthSessions = new OAuthSessions({ 146 oauthClient, 147 storage: config.storage, 148 sessionTtl, 149 logger, 150 }); 151 152 // Create route handlers 153 const handlers = createRouteHandlers({ 154 baseUrl, 155 oauthClient, 156 sessionManager, 157 oauthSessions, 158 storage: config.storage, 159 sessionTtl, 160 logger, 161 mobileScheme: config.mobileScheme, 162 scope: config.scope, 163 }); 164 165 // Generate client metadata 166 const clientMetadata = generateClientMetadata({ 167 ...config, 168 baseUrl, 169 }); 170 171 /** 172 * Handle /oauth-client-metadata.json route 173 */ 174 function handleClientMetadata(): Response { 175 return new Response(JSON.stringify(clientMetadata), { 176 status: 200, 177 headers: { 178 "Content-Type": "application/json", 179 }, 180 }); 181 } 182 183 return { 184 handleLogin: handlers.handleLogin, 185 handleCallback: handlers.handleCallback, 186 handleClientMetadata, 187 handleLogout: handlers.handleLogout, 188 getSessionFromRequest: handlers.getSessionFromRequest, 189 getClientMetadata: () => clientMetadata, 190 getClearCookieHeader: () => sessionManager.getClearCookieHeader(), 191 sessions: handlers.sessions, 192 }; 193}