diff --git a/src/app.d.ts b/src/app.d.ts index ec2d998..6e4e789 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,4 +1,4 @@ -import type { OAuthSession } from "@atcute/oauth-node-client"; +import type { OAuthClient, OAuthSession } from "@atcute/oauth-node-client"; import type { Client } from "@atcute/client"; import type { Did, Handle } from "@atcute/lexicons"; @@ -8,6 +8,7 @@ declare global { namespace App { // interface Error {} interface Locals { + oAuthClient?: OAuthClient; user?: { client: Client; session: OAuthSession; diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts index ced3c7a..30af5fa 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/server/oauth.ts @@ -1,3 +1,4 @@ +import type { Cookies } from "@sveltejs/kit"; import { CompositeDidDocumentResolver, CompositeHandleResolver, @@ -7,19 +8,89 @@ import { WellKnownHandleResolver, } from "@atcute/identity-resolver"; import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; -import { - MemoryStore, - OAuthClient, - scope, - type StoredState, -} from "@atcute/oauth-node-client"; - -const TEN_MINUTES_MS = 10 * 60_000; - -/** - * {@link https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/node-client-public-example} - */ -export function createOAuthClient(): OAuthClient { +import { OAuthClient, scope, type Store } from "@atcute/oauth-node-client"; +import { decryptText, encryptText } from "$lib/server/crypto.ts"; +import { env } from "$env/dynamic/private"; +import { dev } from "$app/environment"; +import { Buffer } from "node:buffer"; + +class CookieStore implements Store { + #cookies: Cookies; + #prefix = "atproto_oauth_"; + #maxAge = 60 * 60 * 24 * 7; + + constructor(event: { cookies: Cookies }, options?: { maxAge?: number }) { + this.#cookies = event.cookies; + 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, + env.PRIVATE_COOKIE_KEY, + ); + 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), + env.PRIVATE_COOKIE_KEY, + ); + 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: { cookies: Cookies; locals: App.Locals }, +): OAuthClient { + if (event.locals.oAuthClient) { + return event.locals.oAuthClient; + } + // [TODO] dynamic hostname/port const redirectUri = `http://127.0.0.1:5173/oauth/callback`; @@ -28,7 +99,6 @@ export function createOAuthClient(): OAuthClient { redirect_uris: [redirectUri], scope: [scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" })], }, - actorResolver: new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { @@ -43,18 +113,12 @@ export function createOAuthClient(): OAuthClient { }, }), }), - // [TODO] custom database K/V store stores: { - sessions: new MemoryStore({ maxSize: 10 }), - states: new MemoryStore({ - maxSize: 10, - ttl: TEN_MINUTES_MS, - ttlAutopurge: true, - }), + sessions: new CookieStore(event), + states: new CookieStore(event, { maxAge: 60 * 10 }), }, }); + event.locals.oAuthClient = client; return client; } - -export const oAuthClient = createOAuthClient(); diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts index eb1b9d1..8f84f0b 100644 --- a/src/lib/server/session.ts +++ b/src/lib/server/session.ts @@ -1,7 +1,7 @@ import type { RequestEvent } from "@sveltejs/kit"; import { Client } from "@atcute/client"; import { isDid, isHandle } from "@atcute/lexicons/syntax"; -import { oAuthClient } from "$lib/server/oauth.ts"; +import { createOAuthClient } from "$lib/server/oauth.ts"; import { decryptText } from "./crypto.ts"; import { env } from "$env/dynamic/private"; @@ -11,11 +11,13 @@ export const destroySession = async (event: RequestEvent): Promise => { return; } try { + const oAuthClient = createOAuthClient(event); // await event.locals.user.session.signOut(); await oAuthClient.revoke(event.locals.user.did); } catch { // Do nothing? } + event.locals.oAuthClient = undefined; }; export const restoreSession = async (event: RequestEvent): Promise => { @@ -42,6 +44,7 @@ export const restoreSession = async (event: RequestEvent): Promise => { ) { throw new Error(); } + const oAuthClient = createOAuthClient(event); const session = await oAuthClient.restore(data.did); const client = new Client({ handler: session }); event.locals.user = { diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index fff2eb6..0572ef8 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,6 +1,6 @@ import { type Actions, fail, redirect } from "@sveltejs/kit"; import { isActorIdentifier } from "@atcute/lexicons/syntax"; -import { oAuthClient } from "$lib/server/oauth.ts"; +import { createOAuthClient } from "$lib/server/oauth.ts"; import { destroySession } from "../lib/server/session.ts"; import { dev } from "$app/environment"; @@ -9,20 +9,21 @@ export const actions = { await destroySession(event); redirect(303, "/"); }, - login: async ({ cookies, request }) => { - const formData = await request.formData(); + login: async (event) => { + const formData = await event.request.formData(); const handle = formData.get("handle"); if (isActorIdentifier(handle) === false) { return fail(400, { handle, invalid: true }); } + const oAuthClient = createOAuthClient(event); const { url } = await oAuthClient.authorize({ target: { "type": "account", identifier: handle, }, }); - // [TODO] encrypt handle? - cookies.set( + // [TODO] encrypt handle necessary? + event.cookies.set( "atproto_handle", handle, { diff --git a/src/routes/oauth/callback/+server.ts b/src/routes/oauth/callback/+server.ts index 4bbf236..af4d677 100644 --- a/src/routes/oauth/callback/+server.ts +++ b/src/routes/oauth/callback/+server.ts @@ -3,7 +3,7 @@ import type { OAuthSession } from "@atcute/oauth-node-client"; import { AppBskyActorGetProfile } from "@atcute/bluesky"; import { Client, ok } from "@atcute/client"; import { redirect } from "@sveltejs/kit"; -import { oAuthClient } from "$lib/server/oauth.ts"; +import { createOAuthClient } from "$lib/server/oauth.ts"; import { encryptText } from "$lib/server/crypto.ts"; import { env } from "$env/dynamic/private"; import { dev } from "$app/environment"; @@ -11,7 +11,6 @@ import { dev } from "$app/environment"; export const GET: RequestHandler = async (event) => { const { url, cookies } = event; - // [TODO] delete / handled by @atcute? const handle = cookies.get("atproto_handle"); if (handle === undefined) { return redirect(303, "/?error=expired"); @@ -20,13 +19,13 @@ export const GET: RequestHandler = async (event) => { let session: OAuthSession; try { + const oAuthClient = createOAuthClient(event); session = (await oAuthClient.callback(url.searchParams)).session; } catch (err) { console.error(err); redirect(303, "/?error=session"); } - // [TODO] remember handle from login form const data = { handle, did: session.did,