import { type RequestEvent } from "@sveltejs/kit"; import type { App } from '@sveltejs/kit'; import { OAuthClient, MemoryStore, type StoredState, type OAuthSession, } from "@atcute/oauth-node-client"; import type { ClientAssertionPrivateJwk } from "@atcute/oauth-crypto"; import { CompositeDidDocumentResolver, CompositeHandleResolver, LocalActorResolver, WellKnownHandleResolver, } from "@atcute/identity-resolver"; import type { Did } from "@atcute/lexicons/syntax"; import { Client } from "@atcute/client"; /** * OAuth scope requested by this app. * * Every collection the app writes must be listed here or the user's PDS rejects * the write. Keep it in sync with the `collection:` values used in `src/routes`. */ export const OAUTH_SCOPE = "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.game?action=delete repo:boo.sky.go.action?action=create repo:boo.sky.go.action?action=delete repo:boo.sky.go.move?action=create repo:boo.sky.go.move?action=delete repo:boo.sky.go.pass?action=create repo:boo.sky.go.pass?action=delete repo:boo.sky.go.resign?action=create repo:boo.sky.go.resign?action=delete repo:boo.sky.go.reaction?action=create repo:boo.sky.go.profile?action=create repo:boo.sky.go.profile?action=update"; /** * Path of the client metadata document, which doubles as the OAuth `client_id`. * * Authorization servers cache this document by URL and there is no guaranteed * TTL, so **bump the version here whenever the scope above changes**: a server * still holding the previous copy rejects the new scope with "not declared in * the client metadata". The route matches any version, so bumping needs no new * files. Bumping does sign every user out once, since sessions are bound to the * client_id that created them. */ export const OAUTH_CLIENT_METADATA_PATH = "/oauth/v2/client-metadata.json"; /** * Is this origin a loopback address (`localhost`, `127.0.0.0/8`, `[::1]`)? * * Loopback origins get a different OAuth client — the atproto spec's localhost * exception lets such a client skip publishing metadata altogether, see * `getOAuthClient`. */ export function isLoopbackOrigin(origin: string): boolean { try { const { hostname } = new URL(origin); return ( hostname === "localhost" || hostname === "::1" || hostname.endsWith(".localhost") || /^127\./.test(hostname) ); } catch { return false; } } // Fetch-based DNS resolver for Cloudflare Workers class FetchDnsHandleResolver { async resolve(handle: string): Promise { try { const dnsUrl = `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`; const response = await fetch(dnsUrl, { headers: { 'Accept': 'application/dns-json' } }); if (!response.ok) return null; const data: any = await response.json(); const txtRecords = data.Answer?.filter((a: any) => a.type === 16) || []; for (const record of txtRecords) { const text = record.data.replace(/"/g, ''); if (text.startsWith('did=')) { return text.substring(4); } } return null; } catch { return null; } } } // Custom PLC DID resolver with better error handling for Cloudflare Workers class CloudflarePlcDidDocumentResolver { private plcUrl = 'https://plc.directory'; async resolve(did: string): Promise { if (!did.startsWith('did:plc:')) { return undefined; } try { const url = `${this.plcUrl}/${did}`; const response = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'CloudGo/1.0', }, }); if (!response.ok) { console.error('[PLC Resolver] HTTP error:', response.status, did); return undefined; } return await response.json(); } catch (err) { console.error('[PLC Resolver] Fetch error:', err); return undefined; } } } // Custom Web DID resolver for Cloudflare Workers class CloudflareWebDidDocumentResolver { async resolve(did: string): Promise { if (!did.startsWith('did:web:')) { return undefined; } try { // did:web:example.com -> https://example.com/.well-known/did.json // did:web:example.com:path:to -> https://example.com/path/to/did.json const domainAndPath = did.slice('did:web:'.length); const parts = domainAndPath.split(':').map(decodeURIComponent); const domain = parts[0]; const path = parts.length > 1 ? '/' + parts.slice(1).join('/') : '/.well-known'; const url = `https://${domain}${path}/did.json`; const response = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'CloudGo/1.0', }, }); if (!response.ok) { console.error('[Web Resolver] HTTP error:', response.status, did); return undefined; } return await response.json(); } catch (err) { console.error('[Web Resolver] Fetch error:', err); return undefined; } } } // KV-backed state store for Cloudflare Workers class KVStateStore { constructor( private kv: KVNamespace, private prefix: string, private ttlSeconds?: number ) {} async set(key: K, value: V): Promise { await this.kv.put( `${this.prefix}:${key}`, JSON.stringify(value), this.ttlSeconds ? { expirationTtl: this.ttlSeconds } : undefined ); } async get(key: K): Promise { const value = await this.kv.get(`${this.prefix}:${key}`, 'json'); return value || undefined; } async delete(key: K): Promise { await this.kv.delete(`${this.prefix}:${key}`); } } const ONE_MINUTE_MS = 60_000; const TEN_MINUTES_MS = 10 * ONE_MINUTE_MS; // Module-level cache for OAuth client instances const oauthClients = new Map(); export async function getOAuthClient(platform: App.Platform | undefined): Promise { // Get env vars from platform (Cloudflare) or process.env (local dev) const env = platform?.env; const PRIVATE_KEY_JWK = env?.PRIVATE_KEY_JWK ?? (typeof process !== 'undefined' ? process.env?.PRIVATE_KEY_JWK : undefined); const PUBLIC_BASE_URL = env?.PUBLIC_BASE_URL ?? (typeof process !== 'undefined' ? process.env?.PUBLIC_BASE_URL : undefined); if (!PUBLIC_BASE_URL) { throw new Error( "PUBLIC_BASE_URL environment variable is required for OAuth. Check your configuration.", ); } // Use a cache key based on the base URL const cacheKey = PUBLIC_BASE_URL; const cached = oauthClients.get(cacheKey); if (cached) { return cached; } const publicUrl = new URL(PUBLIC_BASE_URL); const actorResolver = new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { dns: new FetchDnsHandleResolver() as any, // Type cast for compatibility http: new WellKnownHandleResolver(), }, }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new CloudflarePlcDidDocumentResolver() as any, web: new CloudflareWebDidDocumentResolver() as any, }, }), }); const stores = platform?.env?.SESSIONS_KV && platform?.env?.STATES_KV ? { // Production: Use KV storage sessions: new KVStateStore(platform.env.SESSIONS_KV, "session") as any, states: new KVStateStore( platform.env.STATES_KV, "state", 600, ) as any, // 10 min TTL } : { // Local development: Use MemoryStore sessions: new MemoryStore({ maxSize: 10_000 }) as any, states: new MemoryStore({ maxSize: 10_000, ttl: TEN_MINUTES_MS, ttlAutopurge: true, }) as any, }; let client: OAuthClient; // The atproto spec allows a `http://localhost` client_id for local // development: the authorization server synthesises the client metadata from // the client_id's own query parameters, so nothing has to be published and no // signing key is involved - it is a public client, not a confidential one. // Note the asymmetry the spec requires: the client_id is `http://localhost`, // while the redirect URI has to be on 127.0.0.1 or [::1]. if (isLoopbackOrigin(PUBLIC_BASE_URL)) { client = new OAuthClient({ metadata: { redirect_uris: [ `http://127.0.0.1:${publicUrl.port || "80"}/auth/callback`, ], scope: OAUTH_SCOPE, }, actorResolver, stores, }); } else { if (!PRIVATE_KEY_JWK) { throw new Error( "PRIVATE_KEY_JWK environment variable is required for OAuth", ); } client = new OAuthClient({ metadata: { client_id: new URL(OAUTH_CLIENT_METADATA_PATH, publicUrl).href, client_name: "Cloud Go", redirect_uris: [new URL("/auth/callback", publicUrl).href], scope: OAUTH_SCOPE, jwks_uri: new URL("/jwks.json", publicUrl).href, }, // Parse the JWK directly - the new API accepts JWK objects keyset: [JSON.parse(PRIVATE_KEY_JWK) as ClientAssertionPrivateJwk], actorResolver, stores, }); } oauthClients.set(cacheKey, client); return client; return client; } export interface Session { did: string; handle?: string; } const COOKIE_NAME = "go_session"; /** * Is this restore failure definitive (the stored session is unusable) or * transient (a retry could recover it)? */ function isSessionGone(error: unknown): boolean { const err = error as { status?: number; error?: string; message?: string } | null | undefined; const described = `${err?.error ?? ""} ${err?.message ?? ""}`.toLowerCase(); if (/invalid_grant|invalid_token|expired_token|session not found|no session/.test(described)) { return true; } // 401/403 from the token endpoint means the refresh token was rejected. // 5xx responses and transport errors do not. return err?.status === 401 || err?.status === 403; } export async function getSession(event: RequestEvent): Promise { const did = event.cookies.get(COOKIE_NAME); if (!did) return null; try { const oauth = await getOAuthClient(event.platform); const oauthSession = await oauth.restore(did as Did, { refresh: "auto" }); // Return session with DID and optionally fetch handle if needed return { did: oauthSession.did, }; } catch (error) { // Only discard the cookie when the session is definitively unusable. // Anything else (network blip, cold KV read, a 5xx from the PDS) is // transient, and deleting the cookie on those turned a momentary failure // into a permanent logout. if (isSessionGone(error)) { event.cookies.delete(COOKIE_NAME, { path: "/" }); } else { console.error("[getSession] transient session restore failure, keeping cookie:", error); } return null; } } export async function setSession(event: RequestEvent, session: Session) { const PUBLIC_BASE_URL = event.platform?.env?.PUBLIC_BASE_URL; const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false; event.cookies.set(COOKIE_NAME, session.did, { path: "/", httpOnly: true, sameSite: "lax", secure, maxAge: 60 * 60 * 24 * 30, // 30 days }); } export async function clearSession(event: RequestEvent) { const PUBLIC_BASE_URL = event.platform?.env?.PUBLIC_BASE_URL; const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false; event.cookies.delete(COOKIE_NAME, { path: "/", httpOnly: true, sameSite: "lax", secure, }); } export async function getAgent(event: RequestEvent): Promise { const did = event.cookies.get(COOKIE_NAME); if (!did) return null; try { const oauth = await getOAuthClient(event.platform); const oauthSession = await oauth.restore(did as Did, { refresh: "auto" }); // Create a client using the OAuth session as the handler const client = new Client({ handler: oauthSession }); return client; } catch (error) { console.error("Failed to get agent:", error); return null; } }