social components inlay.at
atproto components sdui
86
fork

Configure Feed

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

at main 162 lines 4.7 kB view raw
1import { 2 NodeOAuthClient, 3 type NodeSavedSession, 4 type NodeSavedSessionStore, 5 type NodeSavedState, 6 type NodeSavedStateStore, 7} from "@atproto/oauth-client-node"; 8import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 9import { sealData, unsealData } from "iron-session"; 10import type { Context } from "hono"; 11import Redis from "ioredis"; 12 13const SESSION_COOKIE = "inlay_session"; 14const SESSION_TTL = 60 * 60 * 24 * 30; // 30 days 15const STATE_TTL = 60 * 15; // 15 minutes 16 17let oauthClient: NodeOAuthClient | null = null; 18let redis: Redis | null = null; 19 20function getRedis(): Redis { 21 if (!redis) { 22 redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", { 23 maxRetriesPerRequest: 3, 24 lazyConnect: true, 25 }); 26 } 27 return redis; 28} 29 30function getCookieSecret(): string { 31 const secret = process.env.COOKIE_SECRET; 32 if (!secret) { 33 if (process.env.NODE_ENV === "production") { 34 throw new Error("COOKIE_SECRET is required in production"); 35 } 36 return "development-secret-at-least-32-characters-long!!"; 37 } 38 return secret; 39} 40 41// --- Redis-backed stores --- 42 43class RedisStateStore implements NodeSavedStateStore { 44 async get(key: string): Promise<NodeSavedState | undefined> { 45 const data = await getRedis().get(`inlay:oauth:state:${key}`); 46 return data ? JSON.parse(data) : undefined; 47 } 48 async set(key: string, val: NodeSavedState): Promise<void> { 49 await getRedis().set( 50 `inlay:oauth:state:${key}`, 51 JSON.stringify(val), 52 "EX", 53 STATE_TTL 54 ); 55 } 56 async del(key: string): Promise<void> { 57 await getRedis().del(`inlay:oauth:state:${key}`); 58 } 59} 60 61class RedisSessionStore implements NodeSavedSessionStore { 62 async get(key: string): Promise<NodeSavedSession | undefined> { 63 const data = await getRedis().get(`inlay:oauth:session:${key}`); 64 return data ? JSON.parse(data) : undefined; 65 } 66 async set(key: string, val: NodeSavedSession): Promise<void> { 67 await getRedis().set( 68 `inlay:oauth:session:${key}`, 69 JSON.stringify(val), 70 "EX", 71 SESSION_TTL 72 ); 73 } 74 async del(key: string): Promise<void> { 75 await getRedis().del(`inlay:oauth:session:${key}`); 76 } 77} 78 79// --- OAuth client --- 80 81export async function initOAuthClient(): Promise<NodeOAuthClient> { 82 if (oauthClient) return oauthClient; 83 84 const publicUrl = process.env.PUBLIC_URL; 85 const isLocal = !publicUrl; 86 const loopback = "http://127.0.0.1:3001"; 87 88 oauthClient = new NodeOAuthClient({ 89 clientMetadata: { 90 client_name: "Inlay Proto", 91 client_id: isLocal 92 ? `http://localhost?redirect_uri=${encodeURIComponent(`${loopback}/oauth/callback`)}&scope=${encodeURIComponent("atproto transition:generic")}` 93 : `${publicUrl}/oauth/client-metadata.json`, 94 redirect_uris: [ 95 isLocal ? `${loopback}/oauth/callback` : `${publicUrl}/oauth/callback`, 96 ], 97 scope: "atproto transition:generic", 98 grant_types: ["authorization_code", "refresh_token"], 99 response_types: ["code"], 100 application_type: "web", 101 token_endpoint_auth_method: "none", 102 dpop_bound_access_tokens: true, 103 }, 104 stateStore: new RedisStateStore(), 105 sessionStore: new RedisSessionStore(), 106 }); 107 108 return oauthClient; 109} 110 111// --- Session cookie --- 112 113export async function getViewerDid(c: Context): Promise<string | null> { 114 const sealed = getCookie(c, SESSION_COOKIE); 115 if (!sealed) return null; 116 try { 117 const data = await unsealData<{ did: string }>(sealed, { 118 password: getCookieSecret(), 119 }); 120 return data.did || null; 121 } catch { 122 return null; 123 } 124} 125 126export async function setViewerDid(c: Context, did: string): Promise<void> { 127 const sealed = await sealData({ did }, { password: getCookieSecret() }); 128 setCookie(c, SESSION_COOKIE, sealed, { 129 httpOnly: true, 130 secure: process.env.NODE_ENV === "production", 131 sameSite: "Lax", 132 maxAge: SESSION_TTL, 133 path: "/", 134 }); 135} 136 137export async function clearViewer(c: Context): Promise<void> { 138 deleteCookie(c, SESSION_COOKIE, { path: "/" }); 139} 140 141// --- Service JWT --- 142 143export async function getServiceJwt( 144 viewerDid: string, 145 componentDid: string, 146 procedure: string 147): Promise<string | null> { 148 const client = await initOAuthClient(); 149 try { 150 const session = await client.restore(viewerDid); 151 const params = new URLSearchParams({ aud: componentDid, lxm: procedure }); 152 const res = await session.fetchHandler( 153 `/xrpc/com.atproto.server.getServiceAuth?${params}` 154 ); 155 if (!res.ok) throw new Error(`getServiceAuth ${res.status}`); 156 const data = (await res.json()) as { token: string }; 157 return data.token; 158 } catch (err) { 159 console.error("[auth] Failed to get service JWT:", err); 160 return null; 161 } 162}