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