social components
inlay.at
atproto
components
sdui
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}