A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import { Hono } from "hono";
2import type { Database } from "bun:sqlite";
3import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client";
4import { kvGet, kvSet, kvDel } from "../lib/db";
5import {
6 getSessionDid,
7 setSessionCookie,
8 clearSessionCookie,
9 getReturnToCookie,
10 clearReturnToCookie,
11} from "../lib/session";
12import type { Env } from "../env";
13
14type Variables = { env: Env; db: Database };
15
16const auth = new Hono<{ Variables: Variables }>();
17
18// OAuth client metadata endpoint
19auth.get("/client-metadata.json", (c) => {
20 const env = c.get("env");
21 const clientId = `${env.CLIENT_URL}/oauth/client-metadata.json`;
22 const redirectUri = `${env.CLIENT_URL}/oauth/callback`;
23
24 return c.json({
25 client_id: clientId,
26 client_name: env.CLIENT_NAME,
27 client_uri: env.CLIENT_URL,
28 redirect_uris: [redirectUri],
29 grant_types: ["authorization_code", "refresh_token"],
30 response_types: ["code"],
31 scope: OAUTH_SCOPE,
32 token_endpoint_auth_method: "none",
33 application_type: "web",
34 dpop_bound_access_tokens: true,
35 });
36});
37
38// Start OAuth login flow
39auth.get("/login", async (c) => {
40 const env = c.get("env");
41 const db = c.get("db");
42
43 try {
44 const handle = c.req.query("handle");
45 if (!handle) {
46 return c.redirect(`${env.CLIENT_URL}/?error=missing_handle`);
47 }
48
49 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
50 const authUrl = await client.authorize(handle, {
51 scope: OAUTH_SCOPE,
52 });
53
54 return c.redirect(authUrl.toString());
55 } catch (error) {
56 console.error("Login error:", error);
57 return c.redirect(`${env.CLIENT_URL}/?error=login_failed`);
58 }
59});
60
61// OAuth callback handler
62auth.get("/callback", async (c) => {
63 const env = c.get("env");
64 const db = c.get("db");
65
66 try {
67 const params = new URLSearchParams(c.req.url.split("?")[1] || "");
68
69 if (params.get("error")) {
70 const error = params.get("error");
71 console.error("OAuth error:", error, params.get("error_description"));
72 return c.redirect(
73 `${env.CLIENT_URL}/?error=${encodeURIComponent(error!)}`,
74 );
75 }
76
77 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
78 const { session } = await client.callback(params);
79
80 // Resolve handle from DID
81 let handle: string | undefined;
82 try {
83 const identity = await client.identityResolver.resolve(session.did);
84 handle = identity.handle;
85 } catch {
86 // Handle resolution is best-effort
87 }
88
89 // Store handle alongside the session for quick lookup
90 if (handle) {
91 kvSet(db, `oauth_handle:${session.did}`, handle, 60 * 60 * 24 * 14);
92 }
93
94 setSessionCookie(c, session.did, env.CLIENT_URL);
95
96 // If a subscribe flow set a return URL before initiating OAuth, honor it
97 const returnTo = getReturnToCookie(c);
98 clearReturnToCookie(c, env.CLIENT_URL);
99
100 return c.redirect(returnTo ?? `${env.CLIENT_URL}/`);
101 } catch (error) {
102 console.error("Callback error:", error);
103 return c.redirect(`${env.CLIENT_URL}/?error=callback_failed`);
104 }
105});
106
107// Logout endpoint
108auth.post("/logout", async (c) => {
109 const env = c.get("env");
110 const db = c.get("db");
111 const did = getSessionDid(c);
112
113 if (did) {
114 try {
115 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
116 await client.revoke(did);
117 } catch (error) {
118 console.error("Revoke error:", error);
119 }
120 kvDel(db, `oauth_handle:${did}`);
121 }
122
123 clearSessionCookie(c, env.CLIENT_URL);
124 return c.json({ success: true });
125});
126
127// Check auth status
128auth.get("/status", async (c) => {
129 const env = c.get("env");
130 const db = c.get("db");
131 const did = getSessionDid(c);
132
133 if (!did) {
134 return c.json({ authenticated: false });
135 }
136
137 try {
138 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
139 const session = await client.restore(did);
140
141 const handle = kvGet(db, `oauth_handle:${session.did}`);
142
143 return c.json({
144 authenticated: true,
145 did: session.did,
146 handle: handle || undefined,
147 });
148 } catch (error) {
149 console.error("Session restore failed:", error);
150 clearSessionCookie(c, env.CLIENT_URL);
151 return c.json({ authenticated: false });
152 }
153});
154
155export default auth;