diff --git a/bun.lock b/bun.lock index 3385f65..ed61607 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,9 @@ "name": "docs", "version": "0.0.0", "dependencies": { + "@atproto-labs/handle-resolver": "latest", + "@atproto/jwk-jose": "latest", + "@atproto/oauth-client": "latest", "hono": "latest", "react": "latest", "react-dom": "latest", @@ -92,11 +95,11 @@ "@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=="], - "@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=="], + "@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=="], "@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=="], - "@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=="], + "@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=="], "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], @@ -1536,6 +1539,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@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=="], + + "@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=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], diff --git a/docs/package.json b/docs/package.json index 0d8a1c9..215475e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,6 +11,9 @@ "preview": "vocs preview" }, "dependencies": { + "@atproto/oauth-client": "latest", + "@atproto/jwk-jose": "latest", + "@atproto-labs/handle-resolver": "latest", "hono": "latest", "react": "latest", "react-dom": "latest", diff --git a/docs/src/index.ts b/docs/src/index.ts index c0d1fca..96a69aa 100644 --- a/docs/src/index.ts +++ b/docs/src/index.ts @@ -1,14 +1,15 @@ import { Hono } from "hono"; +import auth from "./routes/auth"; type Bindings = { ASSETS: Fetcher; + SEQUOIA_SESSIONS: KVNamespace; + CLIENT_URL: string; }; const app = new Hono<{ Bindings: Bindings }>(); -app.get("/oauth/callback", (c) => { - return c.text("Not Implemented", 501); -}); +app.route("/oauth", auth); app.get("/api/health", (c) => { return c.json({ status: "ok" }); diff --git a/docs/src/lib/kv-stores.ts b/docs/src/lib/kv-stores.ts new file mode 100644 index 0000000..6de9756 --- /dev/null +++ b/docs/src/lib/kv-stores.ts @@ -0,0 +1,82 @@ +import { JoseKey } from "@atproto/jwk-jose"; +import type { + Key, + InternalStateData, + SessionStore, + StateStore, +} from "@atproto/oauth-client"; + +type SerializedStateData = Omit & { + dpopJwk: Record; +}; + +type SerializedSession = Omit< + Parameters[1], + "dpopKey" +> & { + dpopJwk: Record; +}; + +function serializeKey(key: Key): Record { + const jwk = key.privateJwk; + if (!jwk) throw new Error("Private DPoP JWK is missing"); + return jwk as Record; +} + +async function deserializeKey(jwk: Record): Promise { + return JoseKey.fromJWK(jwk); +} + +export function createStateStore( + kv: KVNamespace, + ttl = 600, +): StateStore { + return { + async set(key, { dpopKey, ...rest }) { + const data: SerializedStateData = { + ...rest, + dpopJwk: serializeKey(dpopKey), + }; + await kv.put(`oauth_state:${key}`, JSON.stringify(data), { + expirationTtl: ttl, + }); + }, + async get(key) { + const raw = await kv.get(`oauth_state:${key}`); + if (!raw) return undefined; + const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw); + const dpopKey = await deserializeKey(dpopJwk); + return { ...rest, dpopKey }; + }, + async del(key) { + await kv.delete(`oauth_state:${key}`); + }, + }; +} + +export function createSessionStore( + kv: KVNamespace, + ttl = 60 * 60 * 24 * 14, +): SessionStore { + return { + async set(sub, { dpopKey, ...rest }) { + const data: SerializedSession = { + ...rest, + dpopJwk: serializeKey(dpopKey), + }; + await kv.put(`oauth_session:${sub}`, JSON.stringify(data), { + expirationTtl: ttl, + }); + }, + async get(sub) { + const raw = await kv.get(`oauth_session:${sub}`); + if (!raw) return undefined; + const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw); + const dpopKey = await deserializeKey(dpopJwk); + return { ...rest, dpopKey }; + }, + async del(sub) { + await kv.delete(`oauth_session:${sub}`); + }, + }; +} diff --git a/docs/src/lib/oauth-client.ts b/docs/src/lib/oauth-client.ts new file mode 100644 index 0000000..554e9e4 --- /dev/null +++ b/docs/src/lib/oauth-client.ts @@ -0,0 +1,43 @@ +import { JoseKey } from "@atproto/jwk-jose"; +import { OAuthClient } from "@atproto/oauth-client"; +import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; +import { createStateStore, createSessionStore } from "./kv-stores"; + +export function createOAuthClient(kv: KVNamespace, clientUrl: string) { + const clientId = `${clientUrl}/oauth/client-metadata.json`; + const redirectUri = `${clientUrl}/oauth/callback`; + + return new OAuthClient({ + responseMode: "query", + handleResolver: new AtprotoDohHandleResolver({ + dohEndpoint: "https://cloudflare-dns.com/dns-query", + }), + clientMetadata: { + client_id: clientId, + client_name: "Sequoia", + client_uri: clientUrl, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: "atproto transition:generic", + token_endpoint_auth_method: "none", + application_type: "web", + dpop_bound_access_tokens: true, + }, + runtimeImplementation: { + createKey: (algs: string[]) => JoseKey.generate(algs), + getRandomValues: (length: number) => + crypto.getRandomValues(new Uint8Array(length)), + digest: async (data: Uint8Array, { name }: { name: string }) => { + const buf = await crypto.subtle.digest( + name.replace("sha", "SHA-"), + new Uint8Array(data), + ); + return new Uint8Array(buf); + }, + requestLock: (_name: string, fn: () => T | PromiseLike) => fn(), + }, + stateStore: createStateStore(kv), + sessionStore: createSessionStore(kv), + }); +} diff --git a/docs/src/lib/session.ts b/docs/src/lib/session.ts new file mode 100644 index 0000000..7ed88bd --- /dev/null +++ b/docs/src/lib/session.ts @@ -0,0 +1,47 @@ +import type { Context } from "hono"; + +const SESSION_COOKIE_NAME = "session_id"; +const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds + +/** + * Get DID from session cookie + */ +export function getSessionDid(c: Context): string | null { + const cookie = c.req.header("Cookie"); + if (!cookie) return null; + + const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +/** + * Set session cookie with the user's DID + */ +export function setSessionCookie( + c: Context, + did: string, + clientUrl: string, +): void { + const isLocalhost = clientUrl.includes("localhost"); + const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; + const secure = isLocalhost ? "" : "; Secure"; + + c.header( + "Set-Cookie", + `${SESSION_COOKIE_NAME}=${encodeURIComponent(did)}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`, + ); +} + +/** + * Clear session cookie + */ +export function clearSessionCookie(c: Context, clientUrl: string): void { + const isLocalhost = clientUrl.includes("localhost"); + const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; + const secure = isLocalhost ? "" : "; Secure"; + + c.header( + "Set-Cookie", + `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`, + ); +} diff --git a/docs/src/routes/auth.ts b/docs/src/routes/auth.ts new file mode 100644 index 0000000..c30ef18 --- /dev/null +++ b/docs/src/routes/auth.ts @@ -0,0 +1,144 @@ +import { Hono } from "hono"; +import { createOAuthClient } from "../lib/oauth-client"; +import { + getSessionDid, + setSessionCookie, + clearSessionCookie, +} from "../lib/session"; + +interface Env { + SEQUOIA_SESSIONS: KVNamespace; + CLIENT_URL: string; +} + +const auth = new Hono<{ Bindings: Env }>(); + +// OAuth client metadata endpoint +auth.get("/client-metadata.json", (c) => { + const clientId = `${c.env.CLIENT_URL}/oauth/client-metadata.json`; + const redirectUri = `${c.env.CLIENT_URL}/oauth/callback`; + + return c.json({ + client_id: clientId, + client_name: "Sequoia", + client_uri: c.env.CLIENT_URL, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: "atproto transition:generic", + token_endpoint_auth_method: "none", + application_type: "web", + dpop_bound_access_tokens: true, + }); +}); + +// Start OAuth login flow +auth.get("/login", async (c) => { + try { + const handle = c.req.query("handle"); + if (!handle) { + return c.redirect(`${c.env.CLIENT_URL}/?error=missing_handle`); + } + + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); + const authUrl = await client.authorize(handle, { + scope: "atproto transition:generic", + }); + + return c.redirect(authUrl.toString()); + } catch (error) { + console.error("Login error:", error); + return c.redirect(`${c.env.CLIENT_URL}/?error=login_failed`); + } +}); + +// OAuth callback handler +auth.get("/callback", async (c) => { + try { + const params = new URLSearchParams(c.req.url.split("?")[1] || ""); + + if (params.get("error")) { + const error = params.get("error"); + console.error("OAuth error:", error, params.get("error_description")); + return c.redirect( + `${c.env.CLIENT_URL}/?error=${encodeURIComponent(error!)}`, + ); + } + + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); + const { session } = await client.callback(params); + + // Resolve handle from DID + let handle: string | undefined; + try { + const identity = await client.identityResolver.resolve(session.did); + handle = identity.handle; + } catch { + // Handle resolution is best-effort + } + + // Store handle in KV alongside the session for quick lookup + if (handle) { + await c.env.SEQUOIA_SESSIONS.put(`oauth_handle:${session.did}`, handle, { + expirationTtl: 60 * 60 * 24 * 14, + }); + } + + setSessionCookie(c, session.did, c.env.CLIENT_URL); + return c.redirect(`${c.env.CLIENT_URL}/`); + } catch (error) { + console.error("Callback error:", error); + return c.redirect(`${c.env.CLIENT_URL}/?error=callback_failed`); + } +}); + +// Logout endpoint +auth.post("/logout", async (c) => { + const did = getSessionDid(c); + + if (did) { + try { + const client = createOAuthClient( + c.env.SEQUOIA_SESSIONS, + c.env.CLIENT_URL, + ); + await client.revoke(did); + } catch (error) { + console.error("Revoke error:", error); + } + await c.env.SEQUOIA_SESSIONS.delete(`oauth_handle:${did}`); + } + + clearSessionCookie(c, c.env.CLIENT_URL); + return c.json({ success: true }); +}); + +// Check auth status +auth.get("/status", async (c) => { + const did = getSessionDid(c); + + if (!did) { + return c.json({ authenticated: false }); + } + + try { + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); + const session = await client.restore(did); + + const handle = await c.env.SEQUOIA_SESSIONS.get( + `oauth_handle:${session.did}`, + ); + + return c.json({ + authenticated: true, + did: session.did, + handle: handle || undefined, + }); + } catch (error) { + console.error("Session restore failed:", error); + clearSessionCookie(c, c.env.CLIENT_URL); + return c.json({ authenticated: false }); + } +}); + +export default auth; diff --git a/docs/wrangler.toml b/docs/wrangler.toml index 4179606..9216832 100644 --- a/docs/wrangler.toml +++ b/docs/wrangler.toml @@ -1,6 +1,7 @@ name = "sequoia-docs" main = "src/index.ts" compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] [assets] directory = "./docs/dist" @@ -8,3 +9,10 @@ binding = "ASSETS" not_found_handling = "single-page-application" html_handling = "auto-trailing-slash" run_worker_first = ["/api/*", "/oauth/*"] + +[[kv_namespaces]] +binding = "SEQUOIA_SESSIONS" +id = "b9fedf2798a249669b3aeeaca70a0bf8" + +[vars] +CLIENT_URL = "https://sequoia.pub"