Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
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}