Attic is a cozy space with lofty ambitions. attic.social
at main 176 lines 4.4 kB view raw
1import { dev } from "$app/environment"; 2import { 3 OAUTH_COOKIE_PREFIX, 4 OAUTH_MAX_AGE, 5 SESSION_MAX_AGE, 6} from "$lib/server/constants"; 7import { decryptText, encryptText } from "$lib/server/crypto"; 8import type { AuthEvent } from "$lib/types"; 9import { 10 CompositeDidDocumentResolver, 11 CompositeHandleResolver, 12 DohJsonHandleResolver, 13 LocalActorResolver, 14 PlcDidDocumentResolver, 15 WebDidDocumentResolver, 16 WellKnownHandleResolver, 17} from "@atcute/identity-resolver"; 18import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; 19import { 20 type ClientAssertionPrivateJwk, 21 OAuthClient, 22 type OAuthClientOptions, 23 scope, 24 type Store, 25} from "@atcute/oauth-node-client"; 26import type { Cookies } from "@sveltejs/kit"; 27import { Buffer } from "node:buffer"; 28 29class CookieStore<K extends string, V> implements Store<K, V> { 30 #cookies: Cookies; 31 #secret: string; 32 #prefix = OAUTH_COOKIE_PREFIX; 33 #maxAge = SESSION_MAX_AGE; 34 35 constructor( 36 event: { cookies: Cookies; platform: App.Platform }, 37 options?: { maxAge?: number }, 38 ) { 39 this.#cookies = event.cookies; 40 this.#secret = event.platform.env.PRIVATE_COOKIE_KEY; 41 if (options?.maxAge) { 42 this.#maxAge = options.maxAge; 43 } 44 } 45 46 cookieName(key: K) { 47 const name = Buffer.from(key).toString("base64url"); 48 return `${this.#prefix}${name}`; 49 } 50 51 async get(key: K) { 52 const cookieName = this.cookieName(key); 53 const cookieValue = this.#cookies.get(cookieName); 54 if (cookieValue === undefined) { 55 return undefined; 56 } 57 try { 58 const value = await decryptText( 59 cookieValue, 60 this.#secret, 61 ); 62 return JSON.parse(value); 63 } catch { 64 return undefined; 65 } 66 } 67 68 async set(key: K, value: V) { 69 const cookieName = this.cookieName(key); 70 const cookieValue = await encryptText( 71 JSON.stringify(value), 72 this.#secret, 73 ); 74 if (cookieValue.length > 4000) { 75 throw new Error("too large"); 76 } 77 this.#cookies.set( 78 cookieName, 79 cookieValue, 80 { 81 httpOnly: true, 82 maxAge: this.#maxAge, 83 path: "/", 84 sameSite: "lax", 85 secure: !dev, 86 }, 87 ); 88 } 89 90 delete(key: K) { 91 const cookieName = this.cookieName(key); 92 this.#cookies.delete(cookieName, { path: "/" }); 93 } 94 95 clear() { 96 for (const { name } of this.#cookies.getAll()) { 97 if (name.startsWith(this.#prefix)) { 98 this.#cookies.delete(name, { path: "/" }); 99 } 100 } 101 } 102} 103 104export function createOAuthClient(event: AuthEvent): OAuthClient { 105 if (event.locals.oAuthClient) { 106 return event.locals.oAuthClient; 107 } 108 109 const dns = dev ? new NodeDnsHandleResolver() : new DohJsonHandleResolver({ 110 dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 111 }); 112 113 const redirect = new URL("/oauth/callback", event.platform.env.ORIGIN); 114 115 const scopes = [ 116 scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" }), 117 scope.repo({ 118 collection: [ 119 "social.attic.actor.profile", 120 "social.attic.bookmark.entity", 121 ], 122 }), 123 ]; 124 125 let keyset: ClientAssertionPrivateJwk[] | undefined; 126 127 let metadata: OAuthClientOptions["metadata"]; 128 if (dev) { 129 metadata = { 130 redirect_uris: [redirect.href], 131 scope: scopes, 132 }; 133 } else { 134 metadata = { 135 client_id: 136 new URL("/oauth-client-metadata.json", event.platform.env.ORIGIN).href, 137 redirect_uris: [redirect.href], 138 jwks_uri: 139 new URL("/.well-known/jwks.json", event.platform.env.ORIGIN).href, 140 scope: scopes, 141 }; 142 if (event.platform.env.PRIVATE_KEY_JWK) { 143 keyset = [ 144 JSON.parse( 145 event.platform.env.PRIVATE_KEY_JWK, 146 ) as ClientAssertionPrivateJwk, 147 ]; 148 } else throw new Error(); 149 } 150 151 const client = new OAuthClient({ 152 metadata, 153 keyset, 154 actorResolver: new LocalActorResolver({ 155 handleResolver: new CompositeHandleResolver({ 156 methods: { 157 dns, 158 http: new WellKnownHandleResolver(), 159 }, 160 }), 161 didDocumentResolver: new CompositeDidDocumentResolver({ 162 methods: { 163 plc: new PlcDidDocumentResolver(), 164 web: new WebDidDocumentResolver(), 165 }, 166 }), 167 }), 168 stores: { 169 sessions: new CookieStore(event), 170 states: new CookieStore(event, { maxAge: OAUTH_MAX_AGE }), 171 }, 172 }); 173 174 event.locals.oAuthClient = client; 175 return client; 176}