demos for spacedust
at 5f7093dd467f3be2f4912a8d673bdc11cbdcc5b3 300 lines 9.3 kB view raw
1import fs from 'node:fs'; 2import http from 'http'; 3import { jwtVerify } from 'jose'; 4import cookie from 'cookie'; 5import cookieSig from 'cookie-signature'; 6import { v4 as uuidv4 } from 'uuid'; 7 8const replyJson = (res, code) => res.setHeader('Content-Type', 'application/json').writeHead(code); 9const errJson = (code, reason) => res => replyJson(res, code).end(JSON.stringify({ reason })); 10 11const ok = (res, data) => replyJson(res, 200).end(JSON.stringify(data)); 12const gotIt = res => res.writeHead(201).end(); 13const okBye = res => res.writeHead(204).end(); 14const notModified = res => res.writeHead(304).end(); 15const badRequest = (res, reason) => errJson(400, reason)(res); 16const forbidden = errJson(401, 'forbidden'); 17const unauthorized = errJson(403, 'unauthorized'); 18const notFound = errJson(404, 'not found'); 19const conflict = errJson(409, 'conflict'); 20const serverError = errJson(500, 'internal server error'); 21 22const getRequesBody = async req => new Promise((resolve, reject) => { 23 let body = ''; 24 req.on('data', chunk => body += chunk); 25 req.on('end', () => resolve(body)); 26 req.on('error', err => reject(err)); 27}); 28 29const COOKIE_BASE = { httpOnly: true, secure: true, partitioned: true, sameSite: 'None' }; 30const setAccountCookie = (res, did, session, appSecret) => res.setHeader('Set-Cookie', cookie.serialize( 31 'verified-account', 32 cookieSig.sign(JSON.stringify([did, session]), appSecret), 33 { ...COOKIE_BASE, maxAge: 90 * 86_400 }, 34)); 35const clearAccountCookie = res => res.setHeader('Set-Cookie', cookie.serialize( 36 'verified-account', 37 '', 38 { ...COOKIE_BASE, expires: new Date(0) }, 39)); 40 41const getUser = (req, res, db, appSecret, adminDid) => { 42 const cookies = cookie.parse(req.headers.cookie ?? ''); 43 const untrusted = cookies['verified-account'] ?? ''; 44 const json = cookieSig.unsign(untrusted, appSecret); 45 if (!json) { 46 clearAccountCookie(res); 47 return null; 48 } 49 let did, session; 50 try { 51 [did, session] = JSON.parse(json); 52 } catch (e) { 53 console.warn('validated account cookie but failed to parse json', e); 54 clearAccountCookie(res); 55 return null; 56 } 57 let role; 58 if (did === adminDid) { 59 role = 'admin'; 60 } else { 61 const account = db.getAccount(did); 62 if (!account) { 63 console.warn('valid account cookie but could not find in db'); 64 clearAccountCookie(res); 65 return null; 66 } 67 role = account.role ?? 'public'; 68 } 69 return { did, session, role }; 70}; 71 72/////// handlers 73 74// never EVER allow user-controllable input into fname (or just fix the path joining) 75const handleFile = (fname, ftype) => async (req, res, replace = {}) => { 76 let content 77 try { 78 content = await fs.promises.readFile(`./web-content/${fname}`); // DANGERDANGER 79 content = content.toString(); 80 } catch (err) { 81 console.error(err); 82 return serverError(res); 83 } 84 res.setHeader('Content-Type', ftype); 85 res.writeHead(200); 86 for (let k in replace) { 87 content = content.replace(k, JSON.stringify(replace[k])); 88 } 89 res.end(content); 90} 91const handleIndex = handleFile('index.html', 'text/html'); 92 93const handleVerify = async (db, req, res, secrets, jwks, adminDid) => { 94 const body = await getRequesBody(req); 95 const { token } = JSON.parse(body); 96 let did; 97 try { 98 const verified = await jwtVerify(token, jwks); 99 did = verified.payload.sub; 100 } catch (e) { 101 console.warn('jwks verification failed', e); 102 return badRequest(res, 'token verification failed'); 103 } 104 const isAdmin = did && did === adminDid; 105 db.addAccount(did); 106 const session = uuidv4(); 107 setAccountCookie(res, did, session, secrets.appSecret); 108 return ok(res, { 109 webPushPublicKey: secrets.pushKeys.publicKey, 110 role: isAdmin ? 'admin' : 'public', 111 did, 112 }); 113}; 114 115const handleHello = async (user, req, res, webPushPublicKey, whoamiHost) => 116 ok(res, { 117 whoamiHost, 118 webPushPublicKey, 119 role: user?.role ?? 'anonymous', 120 did: user?.did, 121 }); 122 123const handleSubscribe = async (db, user, req, res, updateSubs) => { 124 const body = await getRequesBody(req); 125 const { sub } = JSON.parse(body); 126 try { 127 db.addPushSub(user.did, user.session, JSON.stringify(sub)); 128 } catch (e) { 129 console.warn('failed to add sub', e); 130 return serverError(res); 131 } 132 updateSubs(db); 133 return gotIt(res); 134}; 135 136const handlePushTest = async (db, user, res, push) => { 137 const subscription = db.getSubBySession(user.session); 138 const payload = JSON.stringify({ 139 subject: user.did, 140 source: 'blue.microcosm.test.notification:hello', 141 source_record: `at://${user.did}/blue.microcosm.test.notification/test`, 142 timestamp: +new Date(), 143 }); 144 await push(db, subscription, payload); 145 return okBye(res); 146}; 147 148const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { 149 try { 150 db.deleteSub(user.session); 151 } catch (e) { 152 console.warn('failed to remove sub', e); 153 return serverError(res); 154 } 155 updateSubs(db); 156 clearAccountCookie(res); 157 return okBye(res); 158}; 159 160const handleTopSecret = async (db, user, req, res) => { 161 console.log('ts'); 162 // TODO: succeed early if they're already in? 163 const body = await getRequesBody(req); 164 const { secret_password } = JSON.parse(body); 165 const { did } = user; 166 const role = 'early'; 167 const updated = db.setRole({ did, role, secret_password }); 168 if (updated) { 169 return okBye(res); 170 } else { 171 return forbidden(res); 172 } 173}; 174 175const handleListSecrets = async (db, res) => { 176 const secrets = db.getSecrets(); 177 return ok(res, secrets); 178}; 179 180const handleAddSecret = async (db, req, res) => { 181 const body = await getRequesBody(req); 182 const { secret_password } = JSON.parse(body); 183 try { 184 db.addTopSecret(secret_password); 185 } catch (e) { 186 if (['SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_CHECK'].includes(e.code)) { 187 return conflict(res); 188 } 189 throw e; 190 } 191 return gotIt(res); 192}; 193 194const handleExpireSecret = async (db, req, res) => { 195 const body = await getRequesBody(req); 196 const { secret_password } = JSON.parse(body); 197 if (db.expireTopSecret(secret_password)) { 198 return gotIt(res); 199 } else { 200 return notModified(res); 201 } 202}; 203 204const handleTopSecretAccounts = async (db, req, res, searchParams) => { 205 const accounts = db.getSecretAccounts(searchParams.get('secret_password')); 206 return ok(res, accounts); 207}; 208 209 210/////// end handlers 211 212const attempt = listener => async (req, res) => { 213 console.log(`-> ${req.method} ${req.url}`); 214 try { 215 await listener(req, res); 216 console.log(` <-${req.method} ${req.url} (${res.statusCode})`); 217 } catch (e) { 218 console.error('listener errored:', e); 219 return serverError(res); 220 } 221}; 222 223const withCors = (allowedOrigin, listener) => { 224 const corsHeaders = new Headers({ 225 'Access-Control-Allow-Origin': allowedOrigin, 226 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 227 'Access-Control-Allow-Headers': 'Content-Type', 228 'Access-Control-Allow-Credentials': 'true', 229 }); 230 return (req, res) => { 231 res.setHeaders(corsHeaders); 232 if (req.method === 'OPTIONS') { 233 return okBye(res); 234 } 235 return listener(req, res); 236 } 237} 238 239export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, push, adminDid) => { 240 const handler = (req, res) => { 241 // don't love this but whatever 242 const { pathname, searchParams } = new URL(`http://localhost${req.url}`); 243 const { method } = req; 244 245 // public (we're doing fall-through auth, what could go wrong) 246 if (method === 'GET' && pathname === '/') { 247 return handleIndex(req, res, {}); 248 } 249 if (method === 'POST' && pathname === '/verify') { 250 return handleVerify(db, req, res, secrets, jwks, adminDid); 251 } 252 253 // semi-public 254 const user = getUser(req, res, db, secrets.appSecret, adminDid); 255 if (method === 'GET' && pathname === '/hello') { 256 return handleHello(user, req, res, secrets.pushKeys.publicKey, whoamiHost); 257 } 258 259 // login required 260 if (method === 'POST' && pathname === '/logout') { 261 if (!user) return unauthorized(res); 262 return handleLogout(db, user, req, res, secrets.appSecret, updateSubs); 263 } 264 if (method === 'POST' && pathname === '/super-top-secret-access') { 265 if (!user) return unauthorized(res); 266 return handleTopSecret(db, user, req, res); 267 } 268 269 // non-public access required 270 if (method === 'POST' && pathname === '/subscribe') { 271 if (!user || user.role === 'public') return forbidden(res); 272 return handleSubscribe(db, user, req, res, updateSubs); 273 } 274 if (method === 'POST' && pathname === '/push-test') { 275 if (!user || user.role === 'public') return forbidden(res); 276 return handlePushTest(db, user, res, push); 277 } 278 279 // admin required (just 404 for non-admin) 280 if (user?.role === 'admin') { 281 if (method === 'GET' && pathname === '/top-secrets') { 282 return handleListSecrets(db, res); 283 } 284 if (method === 'POST' && pathname === '/top-secret') { 285 return handleAddSecret(db, req, res); 286 } 287 if (method === 'POST' && pathname === '/expire-top-secret') { 288 return handleExpireSecret(db, req, res); 289 } 290 if (method === 'GET' && pathname === '/top-secret-accounts') { 291 return handleTopSecretAccounts(db, req, res, searchParams); 292 } 293 } 294 295 // sigh 296 return notFound(res); 297 }; 298 299 return http.createServer(attempt(withCors(allowedOrigin, handler))); 300}