import { dev } from "$app/environment"; import { OAUTH_COOKIE_PREFIX, OAUTH_MAX_AGE, SESSION_MAX_AGE, } from "$lib/server/constants"; import { decryptText, encryptText } from "$lib/server/crypto"; import type { AuthEvent } from "$lib/types"; import { CompositeDidDocumentResolver, CompositeHandleResolver, DohJsonHandleResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver, } from "@atcute/identity-resolver"; import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; import { type ClientAssertionPrivateJwk, OAuthClient, type OAuthClientOptions, scope, type Store, } from "@atcute/oauth-node-client"; import type { Cookies } from "@sveltejs/kit"; import { Buffer } from "node:buffer"; class CookieStore implements Store { #cookies: Cookies; #secret: string; #prefix = OAUTH_COOKIE_PREFIX; #maxAge = SESSION_MAX_AGE; constructor( event: { cookies: Cookies; platform: App.Platform }, options?: { maxAge?: number }, ) { this.#cookies = event.cookies; this.#secret = event.platform.env.PRIVATE_COOKIE_KEY; if (options?.maxAge) { this.#maxAge = options.maxAge; } } cookieName(key: K) { const name = Buffer.from(key).toString("base64url"); return `${this.#prefix}${name}`; } async get(key: K) { const cookieName = this.cookieName(key); const cookieValue = this.#cookies.get(cookieName); if (cookieValue === undefined) { return undefined; } try { const value = await decryptText( cookieValue, this.#secret, ); return JSON.parse(value); } catch { return undefined; } } async set(key: K, value: V) { const cookieName = this.cookieName(key); const cookieValue = await encryptText( JSON.stringify(value), this.#secret, ); if (cookieValue.length > 4000) { throw new Error("too large"); } this.#cookies.set( cookieName, cookieValue, { httpOnly: true, maxAge: this.#maxAge, path: "/", sameSite: "lax", secure: !dev, }, ); } delete(key: K) { const cookieName = this.cookieName(key); this.#cookies.delete(cookieName, { path: "/" }); } clear() { for (const { name } of this.#cookies.getAll()) { if (name.startsWith(this.#prefix)) { this.#cookies.delete(name, { path: "/" }); } } } } export function createOAuthClient(event: AuthEvent): OAuthClient { if (event.locals.oAuthClient) { return event.locals.oAuthClient; } const dns = dev ? new NodeDnsHandleResolver() : new DohJsonHandleResolver({ dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", }); const redirect = new URL("/oauth/callback", event.platform.env.ORIGIN); const scopes = [ scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" }), scope.repo({ collection: [ "social.attic.actor.profile", "social.attic.bookmark.entity", ], }), ]; let keyset: ClientAssertionPrivateJwk[] | undefined; let metadata: OAuthClientOptions["metadata"]; if (dev) { metadata = { redirect_uris: [redirect.href], scope: scopes, }; } else { metadata = { client_id: new URL("/oauth-client-metadata.json", event.platform.env.ORIGIN).href, redirect_uris: [redirect.href], jwks_uri: new URL("/.well-known/jwks.json", event.platform.env.ORIGIN).href, scope: scopes, }; if (event.platform.env.PRIVATE_KEY_JWK) { keyset = [ JSON.parse( event.platform.env.PRIVATE_KEY_JWK, ) as ClientAssertionPrivateJwk, ]; } else throw new Error(); } const client = new OAuthClient({ metadata, keyset, actorResolver: new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { dns, http: new WellKnownHandleResolver(), }, }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }), stores: { sessions: new CookieStore(event), states: new CookieStore(event, { maxAge: OAUTH_MAX_AGE }), }, }); event.locals.oAuthClient = client; return client; }