@venturewild/workspace 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Google sign-in vouch (req 2) — the LOCAL-server half of "Sign in with Google".
|
|
2
|
+
//
|
|
3
|
+
// Why a vouch at all: Google's OAuth redirect URI must be a single, pre-registered
|
|
4
|
+
// HTTPS URL, and the code exchange needs the client SECRET — neither can live on a
|
|
5
|
+
// per-user, behind-the-tunnel local server. So bmo-sync (the central relay, which
|
|
6
|
+
// already holds the account registry) owns the Google flow: it exchanges the code,
|
|
7
|
+
// reads the user's verified email, checks it against the workspace's OWNER email,
|
|
8
|
+
// and — only on a match — mints a short-lived VOUCH that it hands back to the local
|
|
9
|
+
// server via `<slug>.venturewild.llc/?gv=<vouch>`. The local server verifies the
|
|
10
|
+
// vouch and mints its own durable device token (reusing the same machinery as a
|
|
11
|
+
// device-approval), so the cookie/role/revocation story is unchanged.
|
|
12
|
+
//
|
|
13
|
+
// The trust anchor is a secret BOTH sides already share, with NO new key to
|
|
14
|
+
// distribute: the account token. bmo-sync stores `sha256(account_token)` in hex
|
|
15
|
+
// (accounts.account_token_hash); the local server has the raw account token and
|
|
16
|
+
// derives the same hex. That hex is the HS256 signing key. A third party can't
|
|
17
|
+
// forge a vouch without the account token (or bmo-sync's at-rest hash), and the
|
|
18
|
+
// local server already trusts bmo-sync (it routes all its traffic), so vouching
|
|
19
|
+
// for identity adds no party that wasn't already in the trust base.
|
|
20
|
+
|
|
21
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
export const VOUCH_AUDIENCE = 'wild-workspace-google-vouch';
|
|
25
|
+
export const VOUCH_ISSUER = 'bmo-sync';
|
|
26
|
+
// Vouches are single-hop and consumed immediately on the return redirect, so the
|
|
27
|
+
// lifetime is tiny — long enough to survive the 302 + the SPA's POST, no more.
|
|
28
|
+
export const VOUCH_TTL_SECONDS = 120;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The HS256 key shared with bmo-sync: the lowercase-hex sha256 of the account
|
|
32
|
+
* token, exactly as bmo-sync stores it in `accounts.account_token_hash`
|
|
33
|
+
* (routes/slug.rs::hash_token = `hex::encode(Sha256::digest(token))`). Returns
|
|
34
|
+
* null when the install has no account token (not slug-linked → no Google sign-in).
|
|
35
|
+
*/
|
|
36
|
+
export function vouchKey(accountToken) {
|
|
37
|
+
if (!accountToken) return null;
|
|
38
|
+
const hex = createHash('sha256').update(String(accountToken)).digest('hex');
|
|
39
|
+
return new TextEncoder().encode(hex);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mint a Google vouch (HS256). This is the contract bmo-sync implements in Rust;
|
|
44
|
+
* we keep a JS minter so the local-server verifier can be tested round-trip and
|
|
45
|
+
* the claim shape has one source of truth. NOT used in production by the server.
|
|
46
|
+
*/
|
|
47
|
+
export async function mintGoogleVouch({ accountToken, email, slug, nowSec = Math.floor(Date.now() / 1000), ttlSeconds = VOUCH_TTL_SECONDS }) {
|
|
48
|
+
const key = vouchKey(accountToken);
|
|
49
|
+
if (!key) throw new Error('no account token');
|
|
50
|
+
return new SignJWT({ email: String(email), slug: slug ? String(slug) : undefined })
|
|
51
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
52
|
+
.setIssuer(VOUCH_ISSUER)
|
|
53
|
+
.setAudience(VOUCH_AUDIENCE)
|
|
54
|
+
.setIssuedAt(nowSec)
|
|
55
|
+
.setExpirationTime(nowSec + ttlSeconds)
|
|
56
|
+
.sign(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Verify a Google vouch against this install's account token. Returns
|
|
61
|
+
* `{ ok: true, email, slug }` or `{ ok: false, reason }`. Never throws. The caller
|
|
62
|
+
* still enforces that `email` matches the configured owner email + `slug` matches
|
|
63
|
+
* this install — the vouch only proves "bmo-sync verified this Google identity for
|
|
64
|
+
* this account", not "this is the owner".
|
|
65
|
+
*/
|
|
66
|
+
export async function verifyGoogleVouch(token, accountToken) {
|
|
67
|
+
const key = vouchKey(accountToken);
|
|
68
|
+
if (!token || !key) return { ok: false, reason: 'no-token-or-key' };
|
|
69
|
+
try {
|
|
70
|
+
const { payload } = await jwtVerify(token, key, {
|
|
71
|
+
issuer: VOUCH_ISSUER,
|
|
72
|
+
audience: VOUCH_AUDIENCE,
|
|
73
|
+
});
|
|
74
|
+
if (!payload.email || typeof payload.email !== 'string') {
|
|
75
|
+
return { ok: false, reason: 'no-email' };
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, email: payload.email, slug: typeof payload.slug === 'string' ? payload.slug : null };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// jose throws on bad signature / expiry / aud-iss mismatch — all "not valid".
|
|
80
|
+
return { ok: false, reason: e?.code || 'invalid' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Case-insensitive owner-email match (Google emails are case-insensitive). */
|
|
85
|
+
export function emailMatches(a, b) {
|
|
86
|
+
return Boolean(a) && Boolean(b) && String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
|
|
87
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
TokenRegistry,
|
|
31
31
|
} from './share.mjs';
|
|
32
32
|
import { PairingStore } from './pairing.mjs';
|
|
33
|
+
import { verifyGoogleVouch, emailMatches } from './google-vouch.mjs';
|
|
33
34
|
import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
|
|
34
35
|
import { InboxWatcher } from './inbox.mjs';
|
|
35
36
|
import { ActivityBus } from './activity.mjs';
|
|
@@ -588,6 +589,14 @@ export async function createServer(overrides = {}) {
|
|
|
588
589
|
return attrs.join('; ');
|
|
589
590
|
}
|
|
590
591
|
|
|
592
|
+
// The host-owner loopback grant (req 1) is only sound when the server is BOUND
|
|
593
|
+
// to loopback — then the port is reachable solely by on-host processes, so the
|
|
594
|
+
// (client-controlled) Host header can't be spoofed from the network. On a
|
|
595
|
+
// 0.0.0.0 / public bind we must NOT trust loopback-looking headers; tokens only.
|
|
596
|
+
const isLocalBind = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(
|
|
597
|
+
String(config.host || '127.0.0.1').toLowerCase(),
|
|
598
|
+
);
|
|
599
|
+
|
|
591
600
|
// --- auth + role resolution ---
|
|
592
601
|
async function resolveRole(c) {
|
|
593
602
|
// 1. Authorization: Bearer — the only path that may carry the operator token.
|
|
@@ -619,7 +628,16 @@ export async function createServer(overrides = {}) {
|
|
|
619
628
|
if (!config.publicMode) {
|
|
620
629
|
return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
|
|
621
630
|
}
|
|
622
|
-
//
|
|
631
|
+
// Host-machine owner over GENUINE loopback (req 1: "same machine = no
|
|
632
|
+
// approval"). The owner opens localhost on the box that runs the workspace and
|
|
633
|
+
// is in — no device-approval. A tunneled visitor can never reach this: the
|
|
634
|
+
// relay stamps x-forwarded-host on every tunneled request (rejected by
|
|
635
|
+
// loopbackHeaders) and the server binds loopback. The only thing trusted here
|
|
636
|
+
// is an on-host process, which already holds the owner's files + secrets.
|
|
637
|
+
if (isLocalBind && isGenuineLoopback(c)) {
|
|
638
|
+
return { role: ROLES.PARTNER, sub: 'local-owner', source: 'loopback' };
|
|
639
|
+
}
|
|
640
|
+
// Public mode, not local, no valid token: deny. No anonymous viewer access —
|
|
623
641
|
// a share JWT or the partner token is required. (Concern C1.)
|
|
624
642
|
return { role: null, sub: 'anon', source: 'unauth', denied: true };
|
|
625
643
|
}
|
|
@@ -692,6 +710,10 @@ export async function createServer(overrides = {}) {
|
|
|
692
710
|
// request can reach it; the handler itself 404s anything that isn't real
|
|
693
711
|
// loopback, so a public visitor can never use it.
|
|
694
712
|
'/api/auth/bootstrap',
|
|
713
|
+
// Google sign-in return (req 2). A browser coming back from Google has no
|
|
714
|
+
// workspace token yet — it carries a bmo-sync vouch, which the handler
|
|
715
|
+
// verifies (an invalid vouch is rejected there), so being public is safe.
|
|
716
|
+
'/api/auth/google',
|
|
695
717
|
]);
|
|
696
718
|
app.use('*', async (c, next) => {
|
|
697
719
|
const session = await resolveRole(c);
|
|
@@ -760,14 +782,22 @@ export async function createServer(overrides = {}) {
|
|
|
760
782
|
// with no forwarding headers + a loopback Host. (B1 — basis for first-device
|
|
761
783
|
// bootstrap. A local process already has the user's filesystem + secrets, so
|
|
762
784
|
// trusting it grants nothing it didn't already have.)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
785
|
+
// Shared loopback predicate over a header getter — reused by the Hono path
|
|
786
|
+
// (isGenuineLoopback) and the raw-Node WS upgrade path (req.headers). A GENUINE
|
|
787
|
+
// local request carries NONE of the relay's forwarding headers (bmo-sync strips
|
|
788
|
+
// any client-supplied X-Forwarded-*/X-Real-IP and stamps its own on every
|
|
789
|
+
// tunneled request, so they can't be faked from the internet) and has a loopback
|
|
790
|
+
// Host. The server binds loopback, so only an on-host process can produce this.
|
|
791
|
+
function loopbackHeaders(get) {
|
|
792
|
+
if (get('x-forwarded-for') || get('x-forwarded-host') || get('x-forwarded-proto') || get('x-real-ip')) {
|
|
766
793
|
return false;
|
|
767
794
|
}
|
|
768
|
-
const hostname = String(
|
|
795
|
+
const hostname = String(get('host') || '').toLowerCase().split(':')[0];
|
|
769
796
|
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
|
|
770
797
|
}
|
|
798
|
+
function isGenuineLoopback(c) {
|
|
799
|
+
return loopbackHeaders((n) => c.req.header(n));
|
|
800
|
+
}
|
|
771
801
|
|
|
772
802
|
// --- auth: first-device bootstrap (B1) -------------------------------------
|
|
773
803
|
// The everyday problem: a brand-new owner runs `wild-workspace` and wants to
|
|
@@ -862,6 +892,47 @@ export async function createServer(overrides = {}) {
|
|
|
862
892
|
return c.json({ ok: true, role: session.role, cookie: true });
|
|
863
893
|
});
|
|
864
894
|
|
|
895
|
+
// Google sign-in return (req 2). bmo-sync ran the Google OAuth centrally, checked
|
|
896
|
+
// the verified email against THIS account's owner, and handed the browser back a
|
|
897
|
+
// short-lived vouch via <slug>.venturewild.llc/?gv=<vouch>. The SPA POSTs it here;
|
|
898
|
+
// we verify it against the shared account secret, re-confirm the owner email +
|
|
899
|
+
// slug, then mint the SAME durable, individually-revocable device token a device
|
|
900
|
+
// approval would — so any device the owner signs into with Google is in, no
|
|
901
|
+
// second-device approval and no nag. Public-allowlisted: the vouch IS the
|
|
902
|
+
// credential, so an invalid one is rejected here (401/403), never trusted.
|
|
903
|
+
app.post('/api/auth/google', async (c) => {
|
|
904
|
+
// accountToken is kept top-level on config (out of the broadcast config.account).
|
|
905
|
+
if (!config.accountToken) {
|
|
906
|
+
return c.json({ error: 'not-linked' }, 400); // not slug-linked → no Google sign-in
|
|
907
|
+
}
|
|
908
|
+
let body = {};
|
|
909
|
+
try { body = await c.req.json(); } catch { /* empty body ok */ }
|
|
910
|
+
const vouch = typeof body.vouch === 'string' ? body.vouch : c.req.query('gv');
|
|
911
|
+
const v = await verifyGoogleVouch(vouch, config.accountToken);
|
|
912
|
+
if (!v.ok) {
|
|
913
|
+
log('[auth]', `google vouch rejected: ${v.reason}`);
|
|
914
|
+
return c.json({ error: 'invalid-vouch' }, 401);
|
|
915
|
+
}
|
|
916
|
+
// The vouch proves "bmo-sync verified this Google identity for this account".
|
|
917
|
+
// Re-enforce that it's the OWNER of THIS install (email + slug) so a vouch
|
|
918
|
+
// minted for another workspace can't be replayed here.
|
|
919
|
+
if (!emailMatches(v.email, config.account.email)) {
|
|
920
|
+
log('[auth]', `google vouch email mismatch (${v.email})`);
|
|
921
|
+
return c.json({ error: 'not-owner' }, 403);
|
|
922
|
+
}
|
|
923
|
+
if (v.slug && config.account.slug && v.slug !== config.account.slug) {
|
|
924
|
+
return c.json({ error: 'wrong-workspace' }, 403);
|
|
925
|
+
}
|
|
926
|
+
const device = await mintDeviceToken({ secret: config.shareSecret, workspaceId: config.workspaceId });
|
|
927
|
+
tokenRegistry.add({ ...device, kind: 'device', label: `Google (${v.email})`, createdAt: Date.now() });
|
|
928
|
+
const now = Math.floor(Date.now() / 1000);
|
|
929
|
+
const maxAge = Math.max(60, device.exp - now);
|
|
930
|
+
c.header('Set-Cookie', authCookieAttrs(device.token, maxAge));
|
|
931
|
+
auditAction(c, 'google-signin', `email=${v.email} sub=${device.sub}`);
|
|
932
|
+
log('[auth]', `google sign-in → durable device sub=${device.sub} email=${v.email} ttl=${maxAge}s`);
|
|
933
|
+
return c.json({ ok: true, role: 'partner', cookie: true });
|
|
934
|
+
});
|
|
935
|
+
|
|
865
936
|
app.post('/api/auth/logout', (c) => {
|
|
866
937
|
c.header('Set-Cookie', authCookieAttrs('', 0));
|
|
867
938
|
return c.json({ ok: true });
|
|
@@ -2078,6 +2149,11 @@ export async function createServer(overrides = {}) {
|
|
|
2078
2149
|
} else if (!cookieToken && !tokenFromQuery && !config.publicMode) {
|
|
2079
2150
|
role = ROLES.PARTNER;
|
|
2080
2151
|
sub = 'local-partner';
|
|
2152
|
+
} else if (!cookieToken && !tokenFromQuery && isLocalBind && loopbackHeaders((n) => req.headers[n])) {
|
|
2153
|
+
// Host-machine owner over genuine loopback (req 1) — mirror resolveRole so
|
|
2154
|
+
// the chat WS works for the local owner with no token, same as the SPA.
|
|
2155
|
+
role = ROLES.PARTNER;
|
|
2156
|
+
sub = 'local-owner';
|
|
2081
2157
|
}
|
|
2082
2158
|
// Deny: public mode with no token, or any invalid/revoked token. An
|
|
2083
2159
|
// invalid token must NOT silently fall back to partner. (Concern C1.)
|
|
@@ -606,7 +606,7 @@ export class WorkspaceSupervisor {
|
|
|
606
606
|
process.on('exit', () => this.releaseLock());
|
|
607
607
|
process.on('SIGTERM', () => process.exit(0));
|
|
608
608
|
process.on('SIGINT', () => process.exit(0));
|
|
609
|
-
this.log(`supervisor start pid=${process.pid} watching http://127.0.0.1:${this.port}/api/health (workspace=${this.workspaceDir})`);
|
|
609
|
+
this.log(`supervisor start pid=${process.pid} v${this.selfVersion || '?'} watching http://127.0.0.1:${this.port}/api/health (workspace=${this.workspaceDir})`);
|
|
610
610
|
this.timer = setInterval(() => { this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`)); }, this.pollMs);
|
|
611
611
|
this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`));
|
|
612
612
|
|