chore: refactored to use atproto oauth lib c04e4383
Steve · 2026-02-21 19:03 8 file(s) · +340 −5
bun.lock +9 −2
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
docs/package.json +3 −0
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",
docs/src/index.ts +4 −3
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" });
docs/src/lib/kv-stores.ts (added) +82 −0
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 +
}
docs/src/lib/oauth-client.ts (added) +43 −0
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 +
}
docs/src/lib/session.ts (added) +47 −0
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 +
}
docs/src/routes/auth.ts (added) +144 −0
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;
docs/wrangler.toml +8 −0
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"