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

oauthclient cookie store

dbushell.com 5d78a193 65509e82

verified
+99 -31
+2 -1
src/app.d.ts
··· 1 - import type { OAuthSession } from "@atcute/oauth-node-client"; 1 + import type { OAuthClient, OAuthSession } from "@atcute/oauth-node-client"; 2 2 import type { Client } from "@atcute/client"; 3 3 import type { Did, Handle } from "@atcute/lexicons"; 4 4 ··· 8 8 namespace App { 9 9 // interface Error {} 10 10 interface Locals { 11 + oAuthClient?: OAuthClient; 11 12 user?: { 12 13 client: Client; 13 14 session: OAuthSession;
+85 -21
src/lib/server/oauth.ts
··· 1 + import type { Cookies } from "@sveltejs/kit"; 1 2 import { 2 3 CompositeDidDocumentResolver, 3 4 CompositeHandleResolver, ··· 7 8 WellKnownHandleResolver, 8 9 } from "@atcute/identity-resolver"; 9 10 import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; 10 - import { 11 - MemoryStore, 12 - OAuthClient, 13 - scope, 14 - type StoredState, 15 - } from "@atcute/oauth-node-client"; 11 + import { OAuthClient, scope, type Store } from "@atcute/oauth-node-client"; 12 + import { decryptText, encryptText } from "$lib/server/crypto.ts"; 13 + import { env } from "$env/dynamic/private"; 14 + import { dev } from "$app/environment"; 15 + import { Buffer } from "node:buffer"; 16 + 17 + class CookieStore<K extends string, V> implements Store<K, V> { 18 + #cookies: Cookies; 19 + #prefix = "atproto_oauth_"; 20 + #maxAge = 60 * 60 * 24 * 7; 21 + 22 + constructor(event: { cookies: Cookies }, options?: { maxAge?: number }) { 23 + this.#cookies = event.cookies; 24 + if (options?.maxAge) { 25 + this.#maxAge = options.maxAge; 26 + } 27 + } 28 + 29 + cookieName(key: K) { 30 + const name = Buffer.from(key).toString("base64url"); 31 + return `${this.#prefix}${name}`; 32 + } 16 33 17 - const TEN_MINUTES_MS = 10 * 60_000; 34 + async get(key: K) { 35 + const cookieName = this.cookieName(key); 36 + const cookieValue = this.#cookies.get(cookieName); 37 + if (cookieValue === undefined) { 38 + return undefined; 39 + } 40 + try { 41 + const value = await decryptText( 42 + cookieValue, 43 + env.PRIVATE_COOKIE_KEY, 44 + ); 45 + return JSON.parse(value); 46 + } catch { 47 + return undefined; 48 + } 49 + } 18 50 19 - /** 20 - * {@link https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/node-client-public-example} 21 - */ 22 - export function createOAuthClient(): OAuthClient { 51 + async set(key: K, value: V) { 52 + const cookieName = this.cookieName(key); 53 + const cookieValue = await encryptText( 54 + JSON.stringify(value), 55 + env.PRIVATE_COOKIE_KEY, 56 + ); 57 + if (cookieValue.length > 4000) { 58 + throw new Error("too large"); 59 + } 60 + this.#cookies.set( 61 + cookieName, 62 + cookieValue, 63 + { 64 + httpOnly: true, 65 + maxAge: this.#maxAge, 66 + path: "/", 67 + sameSite: "lax", 68 + secure: !dev, 69 + }, 70 + ); 71 + } 72 + 73 + delete(key: K) { 74 + const cookieName = this.cookieName(key); 75 + this.#cookies.delete(cookieName, { path: "/" }); 76 + } 77 + 78 + clear() { 79 + for (const { name } of this.#cookies.getAll()) { 80 + if (name.startsWith(this.#prefix)) { 81 + this.#cookies.delete(name, { path: "/" }); 82 + } 83 + } 84 + } 85 + } 86 + 87 + export function createOAuthClient( 88 + event: { cookies: Cookies; locals: App.Locals }, 89 + ): OAuthClient { 90 + if (event.locals.oAuthClient) { 91 + return event.locals.oAuthClient; 92 + } 93 + 23 94 // [TODO] dynamic hostname/port 24 95 const redirectUri = `http://127.0.0.1:5173/oauth/callback`; 25 96 ··· 28 99 redirect_uris: [redirectUri], 29 100 scope: [scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" })], 30 101 }, 31 - 32 102 actorResolver: new LocalActorResolver({ 33 103 handleResolver: new CompositeHandleResolver({ 34 104 methods: { ··· 43 113 }, 44 114 }), 45 115 }), 46 - // [TODO] custom database K/V store 47 116 stores: { 48 - sessions: new MemoryStore({ maxSize: 10 }), 49 - states: new MemoryStore<string, StoredState>({ 50 - maxSize: 10, 51 - ttl: TEN_MINUTES_MS, 52 - ttlAutopurge: true, 53 - }), 117 + sessions: new CookieStore(event), 118 + states: new CookieStore(event, { maxAge: 60 * 10 }), 54 119 }, 55 120 }); 56 121 122 + event.locals.oAuthClient = client; 57 123 return client; 58 124 } 59 - 60 - export const oAuthClient = createOAuthClient();
+4 -1
src/lib/server/session.ts
··· 1 1 import type { RequestEvent } from "@sveltejs/kit"; 2 2 import { Client } from "@atcute/client"; 3 3 import { isDid, isHandle } from "@atcute/lexicons/syntax"; 4 - import { oAuthClient } from "$lib/server/oauth.ts"; 4 + import { createOAuthClient } from "$lib/server/oauth.ts"; 5 5 import { decryptText } from "./crypto.ts"; 6 6 import { env } from "$env/dynamic/private"; 7 7 ··· 11 11 return; 12 12 } 13 13 try { 14 + const oAuthClient = createOAuthClient(event); 14 15 // await event.locals.user.session.signOut(); 15 16 await oAuthClient.revoke(event.locals.user.did); 16 17 } catch { 17 18 // Do nothing? 18 19 } 20 + event.locals.oAuthClient = undefined; 19 21 }; 20 22 21 23 export const restoreSession = async (event: RequestEvent): Promise<void> => { ··· 42 44 ) { 43 45 throw new Error(); 44 46 } 47 + const oAuthClient = createOAuthClient(event); 45 48 const session = await oAuthClient.restore(data.did); 46 49 const client = new Client({ handler: session }); 47 50 event.locals.user = {
+6 -5
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 { oAuthClient } from "$lib/server/oauth.ts"; 3 + import { createOAuthClient } from "$lib/server/oauth.ts"; 4 4 import { destroySession } from "../lib/server/session.ts"; 5 5 import { dev } from "$app/environment"; 6 6 ··· 9 9 await destroySession(event); 10 10 redirect(303, "/"); 11 11 }, 12 - login: async ({ cookies, request }) => { 13 - const formData = await request.formData(); 12 + login: async (event) => { 13 + const formData = await event.request.formData(); 14 14 const handle = formData.get("handle"); 15 15 if (isActorIdentifier(handle) === false) { 16 16 return fail(400, { handle, invalid: true }); 17 17 } 18 + const oAuthClient = createOAuthClient(event); 18 19 const { url } = await oAuthClient.authorize({ 19 20 target: { 20 21 "type": "account", 21 22 identifier: handle, 22 23 }, 23 24 }); 24 - // [TODO] encrypt handle? 25 - cookies.set( 25 + // [TODO] encrypt handle necessary? 26 + event.cookies.set( 26 27 "atproto_handle", 27 28 handle, 28 29 {
+2 -3
src/routes/oauth/callback/+server.ts
··· 3 3 import { AppBskyActorGetProfile } from "@atcute/bluesky"; 4 4 import { Client, ok } from "@atcute/client"; 5 5 import { redirect } from "@sveltejs/kit"; 6 - import { oAuthClient } from "$lib/server/oauth.ts"; 6 + import { createOAuthClient } from "$lib/server/oauth.ts"; 7 7 import { encryptText } from "$lib/server/crypto.ts"; 8 8 import { env } from "$env/dynamic/private"; 9 9 import { dev } from "$app/environment"; ··· 11 11 export const GET: RequestHandler = async (event) => { 12 12 const { url, cookies } = event; 13 13 14 - // [TODO] delete / handled by @atcute? 15 14 const handle = cookies.get("atproto_handle"); 16 15 if (handle === undefined) { 17 16 return redirect(303, "/?error=expired"); ··· 20 19 21 20 let session: OAuthSession; 22 21 try { 22 + const oAuthClient = createOAuthClient(event); 23 23 session = (await oAuthClient.callback(url.searchParams)).session; 24 24 } catch (err) { 25 25 console.error(err); 26 26 redirect(303, "/?error=session"); 27 27 } 28 28 29 - // [TODO] remember handle from login form 30 29 const data = { 31 30 handle, 32 31 did: session.did,