In Theseus, Pithos now links the person's Theseus ID to their hosted account by itself, once, right after signing in to Silent Mode (or on the first look at an account already signed in): one consent prompt, then Pithos is listed under Settings › Theseus ID. On a Theseus without the add-on API it does nothing, so the add-on can ship before the Theseus release that carries it. The gateway serves /console on its own origin: sign in with the Theseus ID and see the account's state, sign-up, usage and Theseus ID, with a console session that cannot reach files. Three fixed files under a CSP that allows only this origin. The bundled copy in TheseusNavigator/bundled-addons/pithos is rebuilt from Pithos/ with no version bump; the release that ships it bumps the version.
81 lines
4.3 KiB
JavaScript
81 lines
4.3 KiB
JavaScript
// Pithos links the person's Theseus ID to their hosted account by itself,
|
|
// once, right after signing in (DESIGN-theseus-id.md §11.3), and does nothing
|
|
// on hosts that do not offer Theseus ID. Runs the real gateway and the real
|
|
// control server; Theseus is played by the reference library in TheseusID/.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { createHosted } from '../hosted/server.js';
|
|
import { createPithos } from '../core/server.js';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const tidDir = path.join(here, '..', '..', 'TheseusID');
|
|
const haveRef = fs.existsSync(path.join(tidDir, 'node_modules', '@noble', 'curves')) && fs.existsSync(path.join(here, '..', 'hosted', 'node_modules', '@bitauth'));
|
|
const ref = haveRef ? await import(pathToFileURL(path.join(tidDir, 'lib', 'index.mjs')).href) : null;
|
|
const deps = haveRef ? await import(pathToFileURL(path.join(tidDir, 'test', 'deps.mjs')).href) : null;
|
|
|
|
const iso = (ms) => new Date(ms).toISOString();
|
|
// What Theseus's api.theseusId.signIn returns for the Pithos add-on.
|
|
const theseusFor = (priv, calls) => async ({ projectId, nonce }) => {
|
|
calls.push(projectId);
|
|
const now = Date.now();
|
|
const message = ref.buildMessage({
|
|
kind: 'signin', origin: 'theseus-addon://pithos', account: deps.crypto.account(priv), uri: 'theseus-addon://pithos/',
|
|
chainId: 'tid:1', projectId, nonce, issuedAt: iso(now), expirationTime: iso(now + 300_000),
|
|
});
|
|
return { message, signature: deps.crypto.sign(priv, message) };
|
|
};
|
|
|
|
async function until(fn, ms = 5000) {
|
|
const end = Date.now() + ms;
|
|
for (;;) {
|
|
const v = await fn();
|
|
if (v || Date.now() > end) return v;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
}
|
|
|
|
test('signing in links the Theseus ID once; without the API nothing happens', { skip: !haveRef && 'TheseusID/node_modules or hosted/node_modules missing' }, async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-tid-client-'));
|
|
const port = 19100 + Math.floor(Math.random() * 200);
|
|
const gwUrl = `http://127.0.0.1:${port}`;
|
|
const h = await createHosted({
|
|
listen: `127.0.0.1:${port}`, publicUrl: gwUrl, root: path.join(root, 'gw'), s3dBinary: null, portBase: 23400, maxRunning: 1, idleMinutes: 1,
|
|
gate: { invites: ['a', 'b'] },
|
|
}, () => {});
|
|
t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
|
|
|
async function pithosWith(name, theseusIdSignIn) {
|
|
const p = await createPithos({
|
|
configFile: path.join(root, name, 'pithos.yml'), handoffPort: null, hostName: 'theseus',
|
|
secrets: { kind: 'vault', identitySeed: async () => crypto.randomBytes(32), encryptionKey: async () => crypto.randomBytes(32), theseusIdSignIn },
|
|
});
|
|
t.after(() => p.close());
|
|
const cookie = p.internalSession();
|
|
return (method, apiPath, body) => fetch(new URL(apiPath, p.url), {
|
|
method, headers: { cookie, 'x-pithos': '1', ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined,
|
|
}).then(async (r) => ({ status: r.status, data: await r.json().catch(() => null) }));
|
|
}
|
|
|
|
// In Theseus: sign up, and the ID is linked without being asked for.
|
|
const tidKey = crypto.randomBytes(32);
|
|
const calls = [];
|
|
const api = await pithosWith('in-theseus', theseusFor(tidKey, calls));
|
|
const signin = await api('POST', '/api/hosted/signin', { url: gwUrl, signup: { type: 'invite', code: 'a' } });
|
|
assert.equal(signin.status, 200, JSON.stringify(signin.data));
|
|
const linked = await until(async () => (await api('GET', '/api/hosted')).data?.me?.theseusId);
|
|
assert.equal(linked, deps.crypto.account(tidKey));
|
|
assert.deepEqual(calls, ['pithos'], 'signed for project "pithos", once');
|
|
await api('GET', '/api/hosted');
|
|
assert.equal(calls.length, 1, 'not again once linked');
|
|
|
|
// Elsewhere (no Theseus ID API): sign-in works and nothing is linked.
|
|
const plain = await pithosWith('elsewhere', null);
|
|
assert.equal((await plain('POST', '/api/hosted/signin', { url: gwUrl, signup: { type: 'invite', code: 'b' } })).status, 200);
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
assert.equal((await plain('GET', '/api/hosted')).data?.me?.theseusId, null);
|
|
});
|