A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

chore: refactored to use atproto oauth lib

authored by stevedylan.dev and committed by tangled.org c04e4383 b41590bf

+340 -5
+9 -2
bun.lock
··· 13 13 "name": "docs", 14 14 "version": "0.0.0", 15 15 "dependencies": { 16 + "@atproto-labs/handle-resolver": "latest", 17 + "@atproto/jwk-jose": "latest", 18 + "@atproto/oauth-client": "latest", 16 19 "hono": "latest", 17 20 "react": "latest", 18 21 "react-dom": "latest", ··· 92 95 93 96 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 94 97 95 - "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 98 + "@atproto/oauth-client": ["@atproto/oauth-client@0.6.0", "", { "dependencies": { "@atproto-labs/did-resolver": "^0.2.6", "@atproto-labs/fetch": "^0.2.3", "@atproto-labs/handle-resolver": "^0.3.6", "@atproto-labs/identity-resolver": "^0.3.6", "@atproto-labs/simple-store": "^0.3.0", "@atproto-labs/simple-store-memory": "^0.1.4", "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "@atproto/oauth-types": "^0.6.3", "@atproto/xrpc": "^0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q=="], 96 99 97 100 "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], 98 101 99 - "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], 102 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.3", "", { "dependencies": { "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "zod": "^3.23.8" } }, "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng=="], 100 103 101 104 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 102 105 ··· 1535 1538 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1536 1539 1537 1540 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 1541 + 1542 + "@atproto/oauth-client-node/@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 1543 + 1544 + "@atproto/oauth-client-node/@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], 1538 1545 1539 1546 "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 1540 1547
+3
docs/package.json
··· 11 11 "preview": "vocs preview" 12 12 }, 13 13 "dependencies": { 14 + "@atproto/oauth-client": "latest", 15 + "@atproto/jwk-jose": "latest", 16 + "@atproto-labs/handle-resolver": "latest", 14 17 "hono": "latest", 15 18 "react": "latest", 16 19 "react-dom": "latest",
+4 -3
docs/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 + import auth from "./routes/auth"; 2 3 3 4 type Bindings = { 4 5 ASSETS: Fetcher; 6 + SEQUOIA_SESSIONS: KVNamespace; 7 + CLIENT_URL: string; 5 8 }; 6 9 7 10 const app = new Hono<{ Bindings: Bindings }>(); 8 11 9 - app.get("/oauth/callback", (c) => { 10 - return c.text("Not Implemented", 501); 11 - }); 12 + app.route("/oauth", auth); 12 13 13 14 app.get("/api/health", (c) => { 14 15 return c.json({ status: "ok" });
+82
docs/src/lib/kv-stores.ts
··· 1 + import { JoseKey } from "@atproto/jwk-jose"; 2 + import type { 3 + Key, 4 + InternalStateData, 5 + SessionStore, 6 + StateStore, 7 + } from "@atproto/oauth-client"; 8 + 9 + type SerializedStateData = Omit<InternalStateData, "dpopKey"> & { 10 + dpopJwk: Record<string, unknown>; 11 + }; 12 + 13 + type SerializedSession = Omit< 14 + Parameters<SessionStore["set"]>[1], 15 + "dpopKey" 16 + > & { 17 + dpopJwk: Record<string, unknown>; 18 + }; 19 + 20 + function serializeKey(key: Key): Record<string, unknown> { 21 + const jwk = key.privateJwk; 22 + if (!jwk) throw new Error("Private DPoP JWK is missing"); 23 + return jwk as Record<string, unknown>; 24 + } 25 + 26 + async function deserializeKey(jwk: Record<string, unknown>): Promise<Key> { 27 + return JoseKey.fromJWK(jwk); 28 + } 29 + 30 + export function createStateStore( 31 + kv: KVNamespace, 32 + ttl = 600, 33 + ): StateStore { 34 + return { 35 + async set(key, { dpopKey, ...rest }) { 36 + const data: SerializedStateData = { 37 + ...rest, 38 + dpopJwk: serializeKey(dpopKey), 39 + }; 40 + await kv.put(`oauth_state:${key}`, JSON.stringify(data), { 41 + expirationTtl: ttl, 42 + }); 43 + }, 44 + async get(key) { 45 + const raw = await kv.get(`oauth_state:${key}`); 46 + if (!raw) return undefined; 47 + const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw); 48 + const dpopKey = await deserializeKey(dpopJwk); 49 + return { ...rest, dpopKey }; 50 + }, 51 + async del(key) { 52 + await kv.delete(`oauth_state:${key}`); 53 + }, 54 + }; 55 + } 56 + 57 + export function createSessionStore( 58 + kv: KVNamespace, 59 + ttl = 60 * 60 * 24 * 14, 60 + ): SessionStore { 61 + return { 62 + async set(sub, { dpopKey, ...rest }) { 63 + const data: SerializedSession = { 64 + ...rest, 65 + dpopJwk: serializeKey(dpopKey), 66 + }; 67 + await kv.put(`oauth_session:${sub}`, JSON.stringify(data), { 68 + expirationTtl: ttl, 69 + }); 70 + }, 71 + async get(sub) { 72 + const raw = await kv.get(`oauth_session:${sub}`); 73 + if (!raw) return undefined; 74 + const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw); 75 + const dpopKey = await deserializeKey(dpopJwk); 76 + return { ...rest, dpopKey }; 77 + }, 78 + async del(sub) { 79 + await kv.delete(`oauth_session:${sub}`); 80 + }, 81 + }; 82 + }
+43
docs/src/lib/oauth-client.ts
··· 1 + import { JoseKey } from "@atproto/jwk-jose"; 2 + import { OAuthClient } from "@atproto/oauth-client"; 3 + import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; 4 + import { createStateStore, createSessionStore } from "./kv-stores"; 5 + 6 + export function createOAuthClient(kv: KVNamespace, clientUrl: string) { 7 + const clientId = `${clientUrl}/oauth/client-metadata.json`; 8 + const redirectUri = `${clientUrl}/oauth/callback`; 9 + 10 + return new OAuthClient({ 11 + responseMode: "query", 12 + handleResolver: new AtprotoDohHandleResolver({ 13 + dohEndpoint: "https://cloudflare-dns.com/dns-query", 14 + }), 15 + clientMetadata: { 16 + client_id: clientId, 17 + client_name: "Sequoia", 18 + client_uri: clientUrl, 19 + redirect_uris: [redirectUri], 20 + grant_types: ["authorization_code", "refresh_token"], 21 + response_types: ["code"], 22 + scope: "atproto transition:generic", 23 + token_endpoint_auth_method: "none", 24 + application_type: "web", 25 + dpop_bound_access_tokens: true, 26 + }, 27 + runtimeImplementation: { 28 + createKey: (algs: string[]) => JoseKey.generate(algs), 29 + getRandomValues: (length: number) => 30 + crypto.getRandomValues(new Uint8Array(length)), 31 + digest: async (data: Uint8Array, { name }: { name: string }) => { 32 + const buf = await crypto.subtle.digest( 33 + name.replace("sha", "SHA-"), 34 + new Uint8Array(data), 35 + ); 36 + return new Uint8Array(buf); 37 + }, 38 + requestLock: <T>(_name: string, fn: () => T | PromiseLike<T>) => fn(), 39 + }, 40 + stateStore: createStateStore(kv), 41 + sessionStore: createSessionStore(kv), 42 + }); 43 + }
+47
docs/src/lib/session.ts
··· 1 + import type { Context } from "hono"; 2 + 3 + const SESSION_COOKIE_NAME = "session_id"; 4 + const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds 5 + 6 + /** 7 + * Get DID from session cookie 8 + */ 9 + export function getSessionDid(c: Context): string | null { 10 + const cookie = c.req.header("Cookie"); 11 + if (!cookie) return null; 12 + 13 + const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`)); 14 + return match ? decodeURIComponent(match[1]) : null; 15 + } 16 + 17 + /** 18 + * Set session cookie with the user's DID 19 + */ 20 + export function setSessionCookie( 21 + c: Context, 22 + did: string, 23 + clientUrl: string, 24 + ): void { 25 + const isLocalhost = clientUrl.includes("localhost"); 26 + const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; 27 + const secure = isLocalhost ? "" : "; Secure"; 28 + 29 + c.header( 30 + "Set-Cookie", 31 + `${SESSION_COOKIE_NAME}=${encodeURIComponent(did)}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`, 32 + ); 33 + } 34 + 35 + /** 36 + * Clear session cookie 37 + */ 38 + export function clearSessionCookie(c: Context, clientUrl: string): void { 39 + const isLocalhost = clientUrl.includes("localhost"); 40 + const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; 41 + const secure = isLocalhost ? "" : "; Secure"; 42 + 43 + c.header( 44 + "Set-Cookie", 45 + `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`, 46 + ); 47 + }
+144
docs/src/routes/auth.ts
··· 1 + import { Hono } from "hono"; 2 + import { createOAuthClient } from "../lib/oauth-client"; 3 + import { 4 + getSessionDid, 5 + setSessionCookie, 6 + clearSessionCookie, 7 + } from "../lib/session"; 8 + 9 + interface Env { 10 + SEQUOIA_SESSIONS: KVNamespace; 11 + CLIENT_URL: string; 12 + } 13 + 14 + const auth = new Hono<{ Bindings: Env }>(); 15 + 16 + // OAuth client metadata endpoint 17 + auth.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 36 + auth.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 56 + auth.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 96 + auth.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 117 + auth.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 + 144 + export default auth;
+8
docs/wrangler.toml
··· 1 1 name = "sequoia-docs" 2 2 main = "src/index.ts" 3 3 compatibility_date = "2025-04-01" 4 + compatibility_flags = ["nodejs_compat"] 4 5 5 6 [assets] 6 7 directory = "./docs/dist" ··· 8 9 not_found_handling = "single-page-application" 9 10 html_handling = "auto-trailing-slash" 10 11 run_worker_first = ["/api/*", "/oauth/*"] 12 + 13 + [[kv_namespaces]] 14 + binding = "SEQUOIA_SESSIONS" 15 + id = "b9fedf2798a249669b3aeeaca70a0bf8" 16 + 17 + [vars] 18 + CLIENT_URL = "https://sequoia.pub"