From 65509e82c8bfee9d29d1562042620e5d38285e46 Mon Sep 17 00:00:00 2001 From: David Bushell Date: Thu, 5 Mar 2026 07:08:19 +0000 Subject: [PATCH] sveltekit session --- src/app.d.ts | 15 +++++- src/hooks.server.ts | 22 ++++++++ src/lib/assets/favicon.svg | 1 - src/lib/index.ts | 1 - src/lib/server/crypto.ts | 59 +++++++++++++++++++++ src/lib/server/oauth.ts | 2 +- src/lib/server/session.ts | 56 ++++++++++++++++++++ src/routes/+layout.server.ts | 15 ++++++ src/routes/+layout.svelte | 6 --- src/routes/+page.server.ts | 40 +++++++------- src/routes/+page.svelte | 26 ++++++---- src/routes/oauth/callback/+server.ts | 78 ++++++++++++++++++---------- static/robots.txt | 3 +- svelte.config.js | 4 ++ 14 files changed, 260 insertions(+), 68 deletions(-) create mode 100644 src/hooks.server.ts delete mode 100644 src/lib/assets/favicon.svg delete mode 100644 src/lib/index.ts create mode 100644 src/lib/server/crypto.ts create mode 100644 src/lib/server/session.ts create mode 100644 src/routes/+layout.server.ts diff --git a/src/app.d.ts b/src/app.d.ts index 520c421..ec2d998 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,9 +1,22 @@ +import type { OAuthSession } from "@atcute/oauth-node-client"; +import type { Client } from "@atcute/client"; +import type { Did, Handle } from "@atcute/lexicons"; + // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user?: { + client: Client; + session: OAuthSession; + did: Did; + handle: Handle; + displayName: string; + avatar: string; + }; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..6733020 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,22 @@ +import type { Handle } from "@sveltejs/kit"; +import { dev } from "$app/environment"; +import { sequence } from "@sveltejs/kit/hooks"; +import { restoreSession } from "$lib/server/session.ts"; + +/** + * {@link https://svelte.dev/docs/cli/devtools-json} + */ +const devHandle: Handle = ({ event, resolve }) => { + const path = "/.well-known/appspecific/com.chrome.devtools.json"; + if (dev && event.url.pathname === path) { + return new Response(null, { status: 404 }); + } + return resolve(event); +}; + +export const defaultHandle: Handle = async ({ event, resolve }) => { + await restoreSession(event); + return resolve(event); +}; + +export const handle: Handle = sequence(devHandle, defaultHandle); diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg deleted file mode 100644 index cc5dc66..0000000 --- a/src/lib/assets/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -svelte-logo \ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/server/crypto.ts b/src/lib/server/crypto.ts new file mode 100644 index 0000000..4e586a3 --- /dev/null +++ b/src/lib/server/crypto.ts @@ -0,0 +1,59 @@ +import { Buffer } from "node:buffer"; + +const { crypto, crypto: { subtle } } = globalThis; + +export const sha256Hash = (value: string): Promise => + subtle.digest("SHA-256", new TextEncoder().encode(value)); + +export const randomIV = (length: number): Uint8Array => + crypto.getRandomValues(new Uint8Array(length)); + +export const importKey = async (password: string): Promise => { + const key = await subtle.importKey( + "raw", + await sha256Hash(password), + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"], + ); + return key; +}; + +export const encryptText = async ( + value: string, + key: CryptoKey | string, +): Promise => { + const theKey = key instanceof CryptoKey ? key : await importKey(key); + const iv = randomIV(12); + const decryptedValue = new TextEncoder().encode(value); + const encryptedValue = await subtle.encrypt( + { + name: "AES-GCM", + iv, + }, + theKey, + decryptedValue, + ); + const ivBase64 = Buffer.from(iv).toString("base64"); + const encryptedBase64 = Buffer.from(encryptedValue).toString("base64"); + return `${ivBase64}:${encryptedBase64}`; +}; + +export const decryptText = async ( + value: string, + key: CryptoKey | string, +): Promise => { + const base64 = value.split(":"); + const iv = Buffer.from(base64[0], "base64"); + const encryptedValue = Buffer.from(base64[1], "base64"); + const theKey = key instanceof CryptoKey ? key : await importKey(key); + const decryptedValue = await subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + theKey, + encryptedValue, + ); + return new TextDecoder().decode(decryptedValue); +}; diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts index 2f3a34e..ced3c7a 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/server/oauth.ts @@ -19,7 +19,7 @@ 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() { +export function createOAuthClient(): OAuthClient { // [TODO] dynamic hostname/port const redirectUri = `http://127.0.0.1:5173/oauth/callback`; diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts new file mode 100644 index 0000000..eb1b9d1 --- /dev/null +++ b/src/lib/server/session.ts @@ -0,0 +1,56 @@ +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 { decryptText } from "./crypto.ts"; +import { env } from "$env/dynamic/private"; + +export const destroySession = async (event: RequestEvent): Promise => { + event.cookies.delete("atproto_session", { path: "/" }); + if (event.locals.user === undefined) { + return; + } + try { + // await event.locals.user.session.signOut(); + await oAuthClient.revoke(event.locals.user.did); + } catch { + // Do nothing? + } +}; + +export const restoreSession = async (event: RequestEvent): Promise => { + const { cookies } = event; + // Read the cookie + const encrypted = cookies.get("atproto_session"); + if (encrypted === undefined) { + return; + } + // Parse and validate or delete + let data; + try { + const decrypted = await decryptText(encrypted, env.PRIVATE_COOKIE_KEY); + data = JSON.parse(decrypted); + } catch { + cookies.delete("atproto_session", { path: "/" }); + return; + } + // [TODO] validate data type? + try { + if ( + isDid(data.did) === false || + isHandle(data.handle) === false + ) { + throw new Error(); + } + const session = await oAuthClient.restore(data.did); + const client = new Client({ handler: session }); + event.locals.user = { + ...data, + client, + session, + }; + } catch { + cookies.delete("atproto_session", { path: "/" }); + return; + } +}; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..b3930e8 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,15 @@ +import type { LayoutServerLoad } from "./$types"; + +export const load: LayoutServerLoad = (event) => { + let user = undefined; + if (event.locals.user) { + user = { + handle: event.locals.user.handle, + displayName: event.locals.user.displayName, + avatar: event.locals.user.avatar, + }; + } + return { + user, + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ccc155c..2ccd9ab 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,5 @@ - - - - {@render children()} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index aa1f94d..fff2eb6 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,14 +1,17 @@ import { type Actions, fail, redirect } from "@sveltejs/kit"; import { isActorIdentifier } from "@atcute/lexicons/syntax"; -// import crypto from "node:crypto"; import { oAuthClient } from "$lib/server/oauth.ts"; -// import { dev } from "$app/environment"; +import { destroySession } from "../lib/server/session.ts"; +import { dev } from "$app/environment"; export const actions = { - login: async ({ request }) => { + logout: async (event) => { + await destroySession(event); + redirect(303, "/"); + }, + login: async ({ cookies, request }) => { const formData = await request.formData(); const handle = formData.get("handle"); - if (isActorIdentifier(handle) === false) { return fail(400, { handle, invalid: true }); } @@ -17,24 +20,19 @@ export const actions = { "type": "account", identifier: handle, }, - // scope: [ - // "atproto", - // ].join(" "), }); - // [TODO] delete / handled by @atcute? - // cookies.set( - // "atproto_oauth_request", - // crypto.createHash("sha256") - // .update(stateId, "utf8") - // .digest("hex"), - // { - // httpOnly: true, - // maxAge: 60 * 5, - // path: "/", - // secure: !dev, - // sameSite: "lax", - // }, - // ); + // [TODO] encrypt handle? + cookies.set( + "atproto_handle", + handle, + { + httpOnly: true, + maxAge: 60 * 5, + path: "/", + sameSite: "lax", + secure: !dev, + }, + ); redirect(303, url); }, } satisfies Actions; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e073d50..1a98d2a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,12 +5,20 @@ let handle = $derived(form?.handle ?? ""); -
-

Login

- {#if form?.invalid} -

Invalid handle

- {/if} - - - -
+{#if data.user} +

Hello, {data.user.displayName}!

+
+ +
+{:else} +
+

Connect

+

Connect with your Bluesky / Atmosphere account.

+ {#if form?.invalid} +

Invalid handle

+ {/if} + + + +
+{/if} diff --git a/src/routes/oauth/callback/+server.ts b/src/routes/oauth/callback/+server.ts index 3c4bc8e..4bbf236 100644 --- a/src/routes/oauth/callback/+server.ts +++ b/src/routes/oauth/callback/+server.ts @@ -1,24 +1,22 @@ +import type { RequestHandler } from "./$types"; +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 type { RequestHandler } from "./$types"; -import type { OAuthSession } from "@atcute/oauth-node-client"; +import { encryptText } from "$lib/server/crypto.ts"; +import { env } from "$env/dynamic/private"; +import { dev } from "$app/environment"; export const GET: RequestHandler = async (event) => { const { url, cookies } = event; // [TODO] delete / handled by @atcute? - // const state = cookies.get("atproto_oauth_request"); - // if (state === undefined) { - // return redirect(303, "/?error=expired"); - // } - // cookies.delete( - // "atproto_oauth_request", - // { path: "/" }, - // ); - - console.log(...url.searchParams); + const handle = cookies.get("atproto_handle"); + if (handle === undefined) { + return redirect(303, "/?error=expired"); + } + cookies.delete("atproto_handle", { path: "/" }); let session: OAuthSession; try { @@ -27,23 +25,51 @@ export const GET: RequestHandler = async (event) => { console.error(err); redirect(303, "/?error=session"); } - console.log(session); - const rpc = new Client({ handler: session }); - const profile = await ok( - rpc.call(AppBskyActorGetProfile, { - params: { actor: session.did }, - }), - ); + // [TODO] remember handle from login form + const data = { + handle, + did: session.did, + displayName: "", + avatar: "", + }; + + try { + const rpc = new Client({ handler: session }); + const profile = await ok( + rpc.call(AppBskyActorGetProfile, { + params: { actor: session.did }, + }), + ); + // if (profile.handle) { + // data.handle = profile.handle; + // } + if (profile.displayName) { + data.displayName = profile.displayName; + } + if (profile.avatar) { + data.avatar = profile.avatar; + } + } catch { + // No Bluesky account? + } - console.log(profile); + const encrypted = await encryptText( + JSON.stringify(data), + env.PRIVATE_COOKIE_KEY, + ); - /** - * [TODO] - * parse session params - * encrypt session cookie - * redirect to? - */ + cookies.set( + "atproto_session", + encrypted, + { + httpOnly: true, + maxAge: 60 * 60 * 24, + path: "/", + sameSite: "lax", + secure: !dev, + }, + ); redirect(303, "/?success"); }; diff --git a/static/robots.txt b/static/robots.txt index b6dd667..1f53798 100644 --- a/static/robots.txt +++ b/static/robots.txt @@ -1,3 +1,2 @@ -# allow crawling everything by default User-agent: * -Disallow: +Disallow: / diff --git a/svelte.config.js b/svelte.config.js index 1c5c46a..b178593 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -10,6 +10,10 @@ const config = { alias: { $lib: "src/lib", }, + env: { + publicPrefix: "PUBLIC", + privatePrefix: "PRIVATE", + }, }, }; -- 2.51.2