Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2import { JoseKey } from "@atproto/jwk-jose";
3import { db } from "./db";
4
5const stateStore = {
6 async set(key: string, data: any) {
7 console.debug('[stateStore] set', key)
8 await db`
9 INSERT INTO oauth_states (key, data)
10 VALUES (${key}, ${JSON.stringify(data)})
11 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
12 `;
13 },
14 async get(key: string) {
15 console.debug('[stateStore] get', key)
16 const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
17 return result[0] ? JSON.parse(result[0].data) : undefined;
18 },
19 async del(key: string) {
20 console.debug('[stateStore] del', key)
21 await db`DELETE FROM oauth_states WHERE key = ${key}`;
22 }
23};
24
25const sessionStore = {
26 async set(sub: string, data: any) {
27 console.debug('[sessionStore] set', sub)
28 await db`
29 INSERT INTO oauth_sessions (sub, data)
30 VALUES (${sub}, ${JSON.stringify(data)})
31 ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
32 `;
33 },
34 async get(sub: string) {
35 console.debug('[sessionStore] get', sub)
36 const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
37 return result[0] ? JSON.parse(result[0].data) : undefined;
38 },
39 async del(sub: string) {
40 console.debug('[sessionStore] del', sub)
41 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
42 }
43};
44
45export { sessionStore };
46
47export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
48 // Use editor.wisp.place for OAuth endpoints since that's where the API routes live
49 return {
50 client_id: `${config.domain}/client-metadata.json`,
51 client_name: config.clientName,
52 client_uri: `https://wisp.place`,
53 logo_uri: `${config.domain}/logo.png`,
54 tos_uri: `${config.domain}/tos`,
55 policy_uri: `${config.domain}/policy`,
56 redirect_uris: [`${config.domain}/api/auth/callback`],
57 grant_types: ['authorization_code', 'refresh_token'],
58 response_types: ['code'],
59 application_type: 'web',
60 token_endpoint_auth_method: 'private_key_jwt',
61 token_endpoint_auth_signing_alg: "ES256",
62 scope: "atproto transition:generic",
63 dpop_bound_access_tokens: true,
64 jwks_uri: `${config.domain}/jwks.json`,
65 subject_type: 'public',
66 authorization_signed_response_alg: 'ES256'
67 };
68};
69
70const persistKey = async (key: JoseKey) => {
71 const priv = key.privateJwk;
72 if (!priv) return;
73 const kid = key.kid ?? crypto.randomUUID();
74 await db`
75 INSERT INTO oauth_keys (kid, jwk)
76 VALUES (${kid}, ${JSON.stringify(priv)})
77 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
78 `;
79};
80
81const loadPersistedKeys = async (): Promise<JoseKey[]> => {
82 const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
83 const keys: JoseKey[] = [];
84 for (const row of rows) {
85 try {
86 const obj = JSON.parse(row.jwk);
87 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
88 keys.push(key);
89 } catch (err) {
90 console.error('Could not parse stored JWK', err);
91 }
92 }
93 return keys;
94};
95
96const ensureKeys = async (): Promise<JoseKey[]> => {
97 let keys = await loadPersistedKeys();
98 const needed: string[] = [];
99 for (let i = 1; i <= 3; i++) {
100 const kid = `key${i}`;
101 if (!keys.some(k => k.kid === kid)) needed.push(kid);
102 }
103 for (const kid of needed) {
104 const newKey = await JoseKey.generate(['ES256'], kid);
105 await persistKey(newKey);
106 keys.push(newKey);
107 }
108 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
109 return keys;
110};
111
112let currentKeys: JoseKey[] = [];
113
114export const getCurrentKeys = () => currentKeys;
115
116export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
117 if (currentKeys.length === 0) {
118 currentKeys = await ensureKeys();
119 }
120
121 return new NodeOAuthClient({
122 clientMetadata: createClientMetadata(config),
123 keyset: currentKeys,
124 stateStore,
125 sessionStore
126 });
127};