Attic is a cozy space with lofty ambitions. attic.social

sveltekit session

dbushell.com 65509e82 f5dc2e34

verified
+260 -68
+14 -1
src/app.d.ts
··· 1 + import type { OAuthSession } from "@atcute/oauth-node-client"; 2 + import type { Client } from "@atcute/client"; 3 + import type { Did, Handle } from "@atcute/lexicons"; 4 + 1 5 // See https://svelte.dev/docs/kit/types#app.d.ts 2 6 // for information about these interfaces 3 7 declare global { 4 8 namespace App { 5 9 // interface Error {} 6 - // interface Locals {} 10 + interface Locals { 11 + user?: { 12 + client: Client; 13 + session: OAuthSession; 14 + did: Did; 15 + handle: Handle; 16 + displayName: string; 17 + avatar: string; 18 + }; 19 + } 7 20 // interface PageData {} 8 21 // interface PageState {} 9 22 // interface Platform {}
+22
src/hooks.server.ts
··· 1 + import type { Handle } from "@sveltejs/kit"; 2 + import { dev } from "$app/environment"; 3 + import { sequence } from "@sveltejs/kit/hooks"; 4 + import { restoreSession } from "$lib/server/session.ts"; 5 + 6 + /** 7 + * {@link https://svelte.dev/docs/cli/devtools-json} 8 + */ 9 + const devHandle: Handle = ({ event, resolve }) => { 10 + const path = "/.well-known/appspecific/com.chrome.devtools.json"; 11 + if (dev && event.url.pathname === path) { 12 + return new Response(null, { status: 404 }); 13 + } 14 + return resolve(event); 15 + }; 16 + 17 + export const defaultHandle: Handle = async ({ event, resolve }) => { 18 + await restoreSession(event); 19 + return resolve(event); 20 + }; 21 + 22 + export const handle: Handle = sequence(devHandle, defaultHandle);
-1
src/lib/assets/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
-1
src/lib/index.ts
··· 1 - // place files you want to import through the `$lib` alias in this folder.
+59
src/lib/server/crypto.ts
··· 1 + import { Buffer } from "node:buffer"; 2 + 3 + const { crypto, crypto: { subtle } } = globalThis; 4 + 5 + export const sha256Hash = (value: string): Promise<ArrayBuffer> => 6 + subtle.digest("SHA-256", new TextEncoder().encode(value)); 7 + 8 + export const randomIV = (length: number): Uint8Array<ArrayBuffer> => 9 + crypto.getRandomValues(new Uint8Array(length)); 10 + 11 + export const importKey = async (password: string): Promise<CryptoKey> => { 12 + const key = await subtle.importKey( 13 + "raw", 14 + await sha256Hash(password), 15 + { name: "AES-GCM" }, 16 + false, 17 + ["encrypt", "decrypt"], 18 + ); 19 + return key; 20 + }; 21 + 22 + export const encryptText = async ( 23 + value: string, 24 + key: CryptoKey | string, 25 + ): Promise<string> => { 26 + const theKey = key instanceof CryptoKey ? key : await importKey(key); 27 + const iv = randomIV(12); 28 + const decryptedValue = new TextEncoder().encode(value); 29 + const encryptedValue = await subtle.encrypt( 30 + { 31 + name: "AES-GCM", 32 + iv, 33 + }, 34 + theKey, 35 + decryptedValue, 36 + ); 37 + const ivBase64 = Buffer.from(iv).toString("base64"); 38 + const encryptedBase64 = Buffer.from(encryptedValue).toString("base64"); 39 + return `${ivBase64}:${encryptedBase64}`; 40 + }; 41 + 42 + export const decryptText = async ( 43 + value: string, 44 + key: CryptoKey | string, 45 + ): Promise<string> => { 46 + const base64 = value.split(":"); 47 + const iv = Buffer.from(base64[0], "base64"); 48 + const encryptedValue = Buffer.from(base64[1], "base64"); 49 + const theKey = key instanceof CryptoKey ? key : await importKey(key); 50 + const decryptedValue = await subtle.decrypt( 51 + { 52 + name: "AES-GCM", 53 + iv, 54 + }, 55 + theKey, 56 + encryptedValue, 57 + ); 58 + return new TextDecoder().decode(decryptedValue); 59 + };
+1 -1
src/lib/server/oauth.ts
··· 19 19 /** 20 20 * {@link https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/node-client-public-example} 21 21 */ 22 - export function createOAuthClient() { 22 + export function createOAuthClient(): OAuthClient { 23 23 // [TODO] dynamic hostname/port 24 24 const redirectUri = `http://127.0.0.1:5173/oauth/callback`; 25 25
+56
src/lib/server/session.ts
··· 1 + import type { RequestEvent } from "@sveltejs/kit"; 2 + import { Client } from "@atcute/client"; 3 + import { isDid, isHandle } from "@atcute/lexicons/syntax"; 4 + import { oAuthClient } from "$lib/server/oauth.ts"; 5 + import { decryptText } from "./crypto.ts"; 6 + import { env } from "$env/dynamic/private"; 7 + 8 + export const destroySession = async (event: RequestEvent): Promise<void> => { 9 + event.cookies.delete("atproto_session", { path: "/" }); 10 + if (event.locals.user === undefined) { 11 + return; 12 + } 13 + try { 14 + // await event.locals.user.session.signOut(); 15 + await oAuthClient.revoke(event.locals.user.did); 16 + } catch { 17 + // Do nothing? 18 + } 19 + }; 20 + 21 + export const restoreSession = async (event: RequestEvent): Promise<void> => { 22 + const { cookies } = event; 23 + // Read the cookie 24 + const encrypted = cookies.get("atproto_session"); 25 + if (encrypted === undefined) { 26 + return; 27 + } 28 + // Parse and validate or delete 29 + let data; 30 + try { 31 + const decrypted = await decryptText(encrypted, env.PRIVATE_COOKIE_KEY); 32 + data = JSON.parse(decrypted); 33 + } catch { 34 + cookies.delete("atproto_session", { path: "/" }); 35 + return; 36 + } 37 + // [TODO] validate data type? 38 + try { 39 + if ( 40 + isDid(data.did) === false || 41 + isHandle(data.handle) === false 42 + ) { 43 + throw new Error(); 44 + } 45 + const session = await oAuthClient.restore(data.did); 46 + const client = new Client({ handler: session }); 47 + event.locals.user = { 48 + ...data, 49 + client, 50 + session, 51 + }; 52 + } catch { 53 + cookies.delete("atproto_session", { path: "/" }); 54 + return; 55 + } 56 + };
+15
src/routes/+layout.server.ts
··· 1 + import type { LayoutServerLoad } from "./$types"; 2 + 3 + export const load: LayoutServerLoad = (event) => { 4 + let user = undefined; 5 + if (event.locals.user) { 6 + user = { 7 + handle: event.locals.user.handle, 8 + displayName: event.locals.user.displayName, 9 + avatar: event.locals.user.avatar, 10 + }; 11 + } 12 + return { 13 + user, 14 + }; 15 + };
-6
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import favicon from "$lib/assets/favicon.svg"; 3 - 4 2 let { children } = $props(); 5 3 </script> 6 - 7 - <svelte:head> 8 - <link rel="icon" href={favicon} /> 9 - </svelte:head> 10 4 11 5 {@render children()}
+19 -21
src/routes/+page.server.ts
··· 1 1 import { type Actions, fail, redirect } from "@sveltejs/kit"; 2 2 import { isActorIdentifier } from "@atcute/lexicons/syntax"; 3 - // import crypto from "node:crypto"; 4 3 import { oAuthClient } from "$lib/server/oauth.ts"; 5 - // import { dev } from "$app/environment"; 4 + import { destroySession } from "../lib/server/session.ts"; 5 + import { dev } from "$app/environment"; 6 6 7 7 export const actions = { 8 - login: async ({ request }) => { 8 + logout: async (event) => { 9 + await destroySession(event); 10 + redirect(303, "/"); 11 + }, 12 + login: async ({ cookies, request }) => { 9 13 const formData = await request.formData(); 10 14 const handle = formData.get("handle"); 11 - 12 15 if (isActorIdentifier(handle) === false) { 13 16 return fail(400, { handle, invalid: true }); 14 17 } ··· 17 20 "type": "account", 18 21 identifier: handle, 19 22 }, 20 - // scope: [ 21 - // "atproto", 22 - // ].join(" "), 23 23 }); 24 - // [TODO] delete / handled by @atcute? 25 - // cookies.set( 26 - // "atproto_oauth_request", 27 - // crypto.createHash("sha256") 28 - // .update(stateId, "utf8") 29 - // .digest("hex"), 30 - // { 31 - // httpOnly: true, 32 - // maxAge: 60 * 5, 33 - // path: "/", 34 - // secure: !dev, 35 - // sameSite: "lax", 36 - // }, 37 - // ); 24 + // [TODO] encrypt handle? 25 + cookies.set( 26 + "atproto_handle", 27 + handle, 28 + { 29 + httpOnly: true, 30 + maxAge: 60 * 5, 31 + path: "/", 32 + sameSite: "lax", 33 + secure: !dev, 34 + }, 35 + ); 38 36 redirect(303, url); 39 37 }, 40 38 } satisfies Actions;
+17 -9
src/routes/+page.svelte
··· 5 5 let handle = $derived(form?.handle ?? ""); 6 6 </script> 7 7 8 - <form method="POST" action="?/login"> 9 - <h2>Login</h2> 10 - {#if form?.invalid} 11 - <p><strong>Invalid handle</strong></p> 12 - {/if} 13 - <label for="handle">Handle</label> 14 - <input type="text" id="handle" name="handle" bind:value={handle} /> 15 - <button type="submit">Login</button> 16 - </form> 8 + {#if data.user} 9 + <h2>Hello, {data.user.displayName}!</h2> 10 + <form method="POST" action="?/logout"> 11 + <button type="submit">Logout</button> 12 + </form> 13 + {:else} 14 + <form method="POST" action="?/login"> 15 + <h2>Connect</h2> 16 + <p>Connect with your Bluesky / Atmosphere account.</p> 17 + {#if form?.invalid} 18 + <p><strong>Invalid handle</strong></p> 19 + {/if} 20 + <label for="handle">Handle</label> 21 + <input type="text" id="handle" name="handle" bind:value={handle} /> 22 + <button type="submit">Login</button> 23 + </form> 24 + {/if}
+52 -26
src/routes/oauth/callback/+server.ts
··· 1 + import type { RequestHandler } from "./$types"; 2 + import type { OAuthSession } from "@atcute/oauth-node-client"; 1 3 import { AppBskyActorGetProfile } from "@atcute/bluesky"; 2 4 import { Client, ok } from "@atcute/client"; 3 5 import { redirect } from "@sveltejs/kit"; 4 6 import { oAuthClient } from "$lib/server/oauth.ts"; 5 - import type { RequestHandler } from "./$types"; 6 - import type { OAuthSession } from "@atcute/oauth-node-client"; 7 + import { encryptText } from "$lib/server/crypto.ts"; 8 + import { env } from "$env/dynamic/private"; 9 + import { dev } from "$app/environment"; 7 10 8 11 export const GET: RequestHandler = async (event) => { 9 12 const { url, cookies } = event; 10 13 11 14 // [TODO] delete / handled by @atcute? 12 - // const state = cookies.get("atproto_oauth_request"); 13 - // if (state === undefined) { 14 - // return redirect(303, "/?error=expired"); 15 - // } 16 - // cookies.delete( 17 - // "atproto_oauth_request", 18 - // { path: "/" }, 19 - // ); 20 - 21 - console.log(...url.searchParams); 15 + const handle = cookies.get("atproto_handle"); 16 + if (handle === undefined) { 17 + return redirect(303, "/?error=expired"); 18 + } 19 + cookies.delete("atproto_handle", { path: "/" }); 22 20 23 21 let session: OAuthSession; 24 22 try { ··· 27 25 console.error(err); 28 26 redirect(303, "/?error=session"); 29 27 } 30 - console.log(session); 31 28 32 - const rpc = new Client({ handler: session }); 33 - const profile = await ok( 34 - rpc.call(AppBskyActorGetProfile, { 35 - params: { actor: session.did }, 36 - }), 37 - ); 29 + // [TODO] remember handle from login form 30 + const data = { 31 + handle, 32 + did: session.did, 33 + displayName: "", 34 + avatar: "", 35 + }; 38 36 39 - console.log(profile); 37 + try { 38 + const rpc = new Client({ handler: session }); 39 + const profile = await ok( 40 + rpc.call(AppBskyActorGetProfile, { 41 + params: { actor: session.did }, 42 + }), 43 + ); 44 + // if (profile.handle) { 45 + // data.handle = profile.handle; 46 + // } 47 + if (profile.displayName) { 48 + data.displayName = profile.displayName; 49 + } 50 + if (profile.avatar) { 51 + data.avatar = profile.avatar; 52 + } 53 + } catch { 54 + // No Bluesky account? 55 + } 40 56 41 - /** 42 - * [TODO] 43 - * parse session params 44 - * encrypt session cookie 45 - * redirect to? 46 - */ 57 + const encrypted = await encryptText( 58 + JSON.stringify(data), 59 + env.PRIVATE_COOKIE_KEY, 60 + ); 61 + 62 + cookies.set( 63 + "atproto_session", 64 + encrypted, 65 + { 66 + httpOnly: true, 67 + maxAge: 60 * 60 * 24, 68 + path: "/", 69 + sameSite: "lax", 70 + secure: !dev, 71 + }, 72 + ); 47 73 48 74 redirect(303, "/?success"); 49 75 };
+1 -2
static/robots.txt
··· 1 - # allow crawling everything by default 2 1 User-agent: * 3 - Disallow: 2 + Disallow: /
+4
svelte.config.js
··· 10 10 alias: { 11 11 $lib: "src/lib", 12 12 }, 13 + env: { 14 + publicPrefix: "PUBLIC", 15 + privatePrefix: "PRIVATE", 16 + }, 13 17 }, 14 18 }; 15 19