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