Atproto AMA app
1import {
2 NodeOAuthClient,
3 type NodeSavedSession,
4 type NodeSavedState,
5 type OAuthClientMetadataInput,
6} from "@atproto/oauth-client-node";
7import { eq } from "drizzle-orm";
8
9import { db } from "~/lib/db";
10import { oauthSessions, oauthStates } from "~/lib/schema";
11
12function resolveUrl(): string {
13 const publicUrl = process.env.PUBLIC_URL;
14 if (publicUrl) return publicUrl.replace(/\/$/, "");
15 // APP_URL is used in dev to set a loopback-compatible origin (e.g. http://[::1]:3000)
16 // so the app and the PDS are cross-site in the browser (required by oauth-provider).
17 const appUrl = process.env.APP_URL;
18 if (appUrl) return appUrl.replace(/\/$/, "");
19 const port = process.env.PORT || 3000;
20 return `http://127.0.0.1:${port}`;
21}
22
23export function getOAuthClientMetadata(): OAuthClientMetadataInput {
24 const publicUrl = process.env.PUBLIC_URL;
25 const url = resolveUrl();
26 const enc = encodeURIComponent;
27
28 return {
29 client_name: "Askimut",
30 client_id: publicUrl
31 ? `${url}/client-metadata.json`
32 : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc("atproto transition:generic")}`,
33 client_uri: url,
34 redirect_uris: [`${url}/oauth/callback`],
35 scope: "atproto transition:generic",
36 grant_types: ["authorization_code", "refresh_token"],
37 response_types: ["code"],
38 application_type: "web",
39 token_endpoint_auth_method: "none",
40 dpop_bound_access_tokens: true,
41 };
42}
43
44function drizzleStateStore() {
45 return {
46 async set(key: string, val: NodeSavedState): Promise<void> {
47 const state = JSON.stringify(val);
48 await db
49 .insert(oauthStates)
50 .values({ key, state, createdAt: new Date() })
51 .onConflictDoUpdate({
52 target: oauthStates.key,
53 set: { state, createdAt: new Date() },
54 });
55 },
56 async get(key: string): Promise<NodeSavedState | undefined> {
57 const row = await db.query.oauthStates.findFirst({
58 where: eq(oauthStates.key, key),
59 });
60 if (!row) return undefined;
61 return JSON.parse(row.state) as NodeSavedState;
62 },
63 async del(key: string): Promise<void> {
64 await db.delete(oauthStates).where(eq(oauthStates.key, key));
65 },
66 };
67}
68
69function drizzleSessionStore() {
70 return {
71 async set(key: string, val: NodeSavedSession): Promise<void> {
72 const session = JSON.stringify(val);
73 await db
74 .insert(oauthSessions)
75 .values({ key, session, createdAt: new Date() })
76 .onConflictDoUpdate({
77 target: oauthSessions.key,
78 set: { session, createdAt: new Date() },
79 });
80 },
81 async get(key: string): Promise<NodeSavedSession | undefined> {
82 const row = await db.query.oauthSessions.findFirst({
83 where: eq(oauthSessions.key, key),
84 });
85 if (!row) return undefined;
86 return JSON.parse(row.session) as NodeSavedSession;
87 },
88 async del(key: string): Promise<void> {
89 await db.delete(oauthSessions).where(eq(oauthSessions.key, key));
90 },
91 };
92}
93
94function oauthAllowHttp(): boolean {
95 // Without PUBLIC_URL we already use http://127.0.0.1 for the app → allow http PDS.
96 if (!process.env.PUBLIC_URL) return true;
97 // .env often sets PUBLIC_URL for redirects while DEV_PDS_URL is still http (dev-network).
98 const pds = process.env.DEV_PDS_URL;
99 try {
100 return Boolean(pds && new URL(pds).protocol === "http:");
101 } catch {
102 return false;
103 }
104}
105
106export async function createOAuthClient(): Promise<NodeOAuthClient> {
107 const devPdsUrl = process.env.DEV_PDS_URL;
108 const devPlcUrl = process.env.DEV_PLC_URL;
109
110 return new NodeOAuthClient({
111 clientMetadata: getOAuthClientMetadata(),
112 stateStore: drizzleStateStore(),
113 sessionStore: drizzleSessionStore(),
114 allowHttp: oauthAllowHttp(),
115 ...(devPdsUrl && { handleResolver: devPdsUrl }),
116 ...(devPlcUrl && { plcDirectoryUrl: devPlcUrl }),
117 });
118}
119
120let clientPromise: Promise<NodeOAuthClient> | undefined;
121
122export function getOAuthClient(): Promise<NodeOAuthClient> {
123 clientPromise ??= createOAuthClient();
124 return clientPromise;
125}