Attic is a cozy space with lofty ambitions.
attic.social
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}