Skip to content

Commit b642fba

Browse files
mpstatonclaude
andcommitted
feat(workspace-service): didi.sh sessions verified on the WS gate — augment-it becomes the identity service's first consumer
The workspace WebSocket upgrade now verifies the didi_session cookie locally — jose + remote JWKS, EdDSA-only, issuer-checked — and attaches the verified didi_id to the session and the session frame. Spec increment 2, dev mode. Three modes via DIDI_AUTH: off, optional (dev default — legacy continuity tokens keep working, identity rides along when present), and required (4401 on upgrades without a valid cookie — the posture once invites exist). The adapter is ~80 copied-in lines per the spec's no-shared-package discipline. Local dev mirrors production topology: cookies ignore ports, so the id service on localhost:4000 sets a host-only cookie that rides every localhost WS upgrade — the same-host analog of the .didi.sh domain cookie. The container fetches JWKS via host.docker.internal; deploy is an env swap (ID_JWKS_URL / ID_ISSUER / DIDI_AUTH in docker-compose). Proven by scripts/prove-didi-auth.mjs against the live local stack: magic-link redeem -> cookie -> WS upgrade carries verified didi_id -> cookieless upgrade still works -> tampered token treated as absent. All green on first run after container rebuild. workspace typecheck 0 errors. Files changed: - services/workspace/src/didi.ts (new — the verify adapter) - services/workspace/src/ws.ts (upgrade handler + session frame) - services/workspace/package.json (+jose; ws as devDep for the prover) - docker-compose.yml (ID_JWKS_URL / ID_ISSUER / DIDI_AUTH env) - scripts/prove-didi-auth.mjs (the acceptance proof) - changelog/2026-07-06_01_Workspace-Meets-Its-Identity-Didi-Sessions-Verified-On-The-WS-Gate.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent be61e3c commit b642fba

7 files changed

Lines changed: 307 additions & 21 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
date_created: 2026-07-06
3+
date_modified: 2026-07-06
4+
title: "Workspace meets its identity — didi.sh sessions verified on the WS gate"
5+
lede: "augment-it becomes the didi.sh identity service's first consumer: the workspace WS upgrade now verifies the didi_session cookie locally (jose + JWKS, EdDSA-only), attaches didi_id to the session, and keeps the legacy continuity token working — proven end-to-end against local dev, id service to Docker container."
6+
publish: true
7+
authors:
8+
- Michael Staton
9+
augmented_with:
10+
- Claude Code on Claude Fable 5
11+
files_changed:
12+
- services/workspace/src/didi.ts
13+
- services/workspace/src/ws.ts
14+
- services/workspace/package.json
15+
- docker-compose.yml
16+
- scripts/prove-didi-auth.mjs
17+
tags:
18+
- Progress-Update
19+
- Auth
20+
- Didi-Platform
21+
- Identity-Service
22+
- Workspace-Service
23+
- JWKS
24+
---
25+
26+
## Why Care?
27+
28+
Until today the workspace gate was session *continuity*, not authentication —
29+
any browser that connected got a minted token. Now the gate knows **who** is
30+
connecting: a `didi_session` cookie from the didi.sh identity service is
31+
verified on the WebSocket upgrade, and the person's stable `didi_id` rides
32+
the session. This is spec increment 2 landing in dev mode — the wiring that
33+
turns "reach-edu and humain-vc want logins" from an auth-service feature
34+
into something augment-it actually enforces.
35+
36+
## What's New?
37+
38+
- **The verify adapter** (`services/workspace/src/didi.ts`) — ~80 lines, per
39+
the spec's copy-in discipline (no shared package with the identity
40+
service, by design). Verifies signature + expiry + issuer against the id
41+
service's JWKS: **EdDSA only** (a symmetric algorithm would let any
42+
verifier mint), fetched once and cached, locally checked per upgrade — no
43+
network call to the identity service on the hot path.
44+
- **Three modes** via `DIDI_AUTH`: `off`, `optional` (dev default — identity
45+
attaches when the cookie is present, the legacy continuity token still
46+
works), `required` (upgrades without a valid cookie are rejected with
47+
4401 — the posture once operators are didi users).
48+
- **The session frame carries `didi_id`** so the shell can know who it is
49+
the moment the socket opens.
50+
- **Local-dev topology that mirrors production.** Cookies ignore ports, so
51+
the dev id service on `localhost:4000` sets a host-only cookie that rides
52+
every localhost WS upgrade — the same-host analog of the `.didi.sh`
53+
domain cookie. The container reaches the host's id service via
54+
`host.docker.internal` for the JWKS fetch. Deploy-time is an env swap.
55+
56+
## Proven, not promised
57+
58+
`scripts/prove-didi-auth.mjs` walks the whole loop against the live local
59+
stack: magic-link issue + redeem at the id service → cookie → WS upgrade
60+
with the cookie → session frame carrying the verified `didi_id` → upgrade
61+
without the cookie still works (optional mode) → tampered token treated as
62+
absent. All four steps green on first run after the container rebuild.
63+
64+
## What's Next
65+
66+
The shell's access panel (sign-in UI calling the id service's magic-link
67+
endpoints from inside augment-it, per the headless GTM contract), the
68+
per-capability org→workspace authorization mapping, and — once invites
69+
exist (id spec increment 3) — flipping `DIDI_AUTH=required`.

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ services:
2727
# absent → first slug alphabetically (humain-vc today).
2828
CLIENTS_ROOT: /clients
2929
ACTIVE_CLIENT_ID: ${ACTIVE_CLIENT_ID:-}
30+
# didi.sh identity (spec increment 2) — verify the didi_session
31+
# cookie on WS upgrade against the id service's JWKS. Local dev runs
32+
# the id service on the HOST at :4000 (mix phx.server in id-didi-sh),
33+
# reached from this container via host.docker.internal. 'optional'
34+
# keeps the legacy continuity-token flow working; flip to 'required'
35+
# once operators are didi users. Prod swaps the URLs to id.didi.sh.
36+
ID_JWKS_URL: ${ID_JWKS_URL:-http://host.docker.internal:4000/.well-known/jwks.json}
37+
ID_ISSUER: ${ID_ISSUER:-http://localhost:4000}
38+
DIDI_AUTH: ${DIDI_AUTH:-optional}
3039
volumes:
3140
- workspace-data:/data
3241
- ./clients:/clients:ro

pnpm-lock.yaml

Lines changed: 7 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/prove-didi-auth.mjs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env node
2+
// ============================================================================
3+
// prove-didi-auth.mjs — spec increment 2's acceptance proof (dev mode).
4+
//
5+
// Proves the didi.sh identity plane works against LOCAL augment-it dev:
6+
// 1. magic-link issue + redeem against the local id service (:4000)
7+
// → didi_session cookie (EdDSA JWT)
8+
// 2. WS upgrade to workspace-service (:3001) WITH the cookie
9+
// → session frame carries didi_id (verified locally via JWKS)
10+
// 3. WS upgrade WITHOUT the cookie
11+
// → still connects (DIDI_AUTH=optional), didi_id null
12+
//
13+
// Prereqs:
14+
// - id service: cd ../id-didi-sh && mix phx.server (user seeded)
15+
// - augment-it: docker compose up --build -d workspace-service
16+
//
17+
// Usage: node scripts/prove-didi-auth.mjs [email]
18+
// ============================================================================
19+
20+
import { createRequire } from 'node:module';
21+
const require = createRequire(
22+
new URL('../services/workspace/package.json', import.meta.url),
23+
);
24+
const WebSocket = require('ws');
25+
26+
const ID_BASE = process.env.ID_BASE ?? 'http://localhost:4000';
27+
const WS_URL = process.env.WS_URL ?? 'ws://localhost:3001/ws';
28+
const EMAIL = process.argv[2] ?? 'alice@example.com';
29+
30+
const fail = (msg) => {
31+
console.error(`\x1b[31mFAIL: ${msg}\x1b[0m`);
32+
process.exit(1);
33+
};
34+
const step = (msg) => console.log(`\n\x1b[1m== ${msg}\x1b[0m`);
35+
36+
// ── 1. magic link → didi_session cookie ────────────────────────────────────
37+
step('1. issue + redeem magic link against local id service');
38+
const issue = await fetch(`${ID_BASE}/api/magic-links`, {
39+
method: 'POST',
40+
headers: { 'content-type': 'application/json' },
41+
body: JSON.stringify({ email: EMAIL, app: 'augment-it' }),
42+
}).then((r) => r.json());
43+
if (!issue.dev_token) fail('no dev_token — is the user seeded and the id service in dev mode?');
44+
45+
const redeem = await fetch(`${ID_BASE}/api/magic-links/redeem`, {
46+
method: 'POST',
47+
headers: { 'content-type': 'application/json' },
48+
body: JSON.stringify({ token: issue.dev_token }),
49+
});
50+
const setCookie = redeem.headers.get('set-cookie') ?? '';
51+
const jwt = /didi_session=([^;]+)/.exec(setCookie)?.[1];
52+
if (!jwt) fail('redeem did not set didi_session');
53+
const { didi_id } = await redeem.json();
54+
console.log(`didi_session minted for didi_id=${didi_id}`);
55+
56+
// ── 2. WS upgrade WITH the cookie → identity attached ─────────────────────
57+
step('2. workspace WS upgrade WITH didi_session cookie');
58+
const withCookie = await firstFrame(WS_URL, { Cookie: `didi_session=${jwt}` });
59+
console.log('session frame:', JSON.stringify(withCookie));
60+
if (withCookie.kind !== 'session') fail('expected a session frame');
61+
if (withCookie.didi_id !== didi_id) {
62+
fail(
63+
`workspace did not verify the identity — expected didi_id=${didi_id}, got ${withCookie.didi_id}. ` +
64+
'Is the container rebuilt with ID_JWKS_URL set, and can it reach host.docker.internal:4000?',
65+
);
66+
}
67+
console.log('identity verified by workspace via JWKS ✓');
68+
69+
// ── 3. WS upgrade WITHOUT the cookie → legacy flow, no identity ───────────
70+
step('3. workspace WS upgrade WITHOUT cookie (DIDI_AUTH=optional)');
71+
const bare = await firstFrame(WS_URL, {});
72+
if (bare.kind !== 'session') fail('expected a session frame');
73+
if (bare.didi_id !== null && bare.didi_id !== undefined) {
74+
fail('expected no identity without a cookie');
75+
}
76+
console.log('legacy continuity flow intact, didi_id null ✓');
77+
78+
// ── 4. tampered cookie → treated as absent ─────────────────────────────────
79+
step('4. WS upgrade with a TAMPERED cookie → no identity');
80+
const [h, p] = jwt.split('.');
81+
const forged = `${h}.${p}.AAAA`;
82+
const tampered = await firstFrame(WS_URL, { Cookie: `didi_session=${forged}` });
83+
if (tampered.didi_id) fail('tampered token must not verify');
84+
console.log('tampered token rejected ✓');
85+
86+
console.log('\n\x1b[32mDIDI AUTH PROVEN AGAINST LOCAL DEV\x1b[0m');
87+
process.exit(0);
88+
89+
function firstFrame(url, headers) {
90+
return new Promise((resolve, reject) => {
91+
const ws = new WebSocket(url, { headers });
92+
const timer = setTimeout(() => {
93+
ws.terminate();
94+
reject(new Error('timeout waiting for session frame'));
95+
}, 8000);
96+
ws.on('message', (raw) => {
97+
clearTimeout(timer);
98+
ws.close();
99+
resolve(JSON.parse(raw.toString('utf8')));
100+
});
101+
ws.on('close', (code, reason) => {
102+
clearTimeout(timer);
103+
reject(new Error(`ws closed: ${code} ${reason}`));
104+
});
105+
ws.on('error', (err) => {
106+
clearTimeout(timer);
107+
reject(err);
108+
});
109+
}).catch((err) => fail(err.message));
110+
}

services/workspace/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
"dependencies": {
1313
"@fastify/websocket": "^11.0.0",
1414
"fastify": "^5.9.0",
15-
"@nats-io/transport-node": "^3.4.0"
15+
"@nats-io/transport-node": "^3.4.0",
16+
"jose": "^6.0.11"
1617
},
1718
"devDependencies": {
1819
"@types/node": "^26.1.0",
1920
"tsx": "^4.22.4",
20-
"typescript": "^6.0.3"
21+
"typescript": "^6.0.3",
22+
"ws": "^8.18.0"
2123
}
22-
}
24+
}

services/workspace/src/didi.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// didi.ts — the didi.sh identity verify adapter (spec increment 2).
2+
//
3+
// Verifies the `didi_session` cookie presented on the WS upgrade against
4+
// the id.didi.sh JWKS: EdDSA signature + exp + issuer, checked LOCALLY —
5+
// no per-request call to the identity service (the JWKS is fetched once
6+
// and cached by jose; re-fetched on unknown `kid`, which is the key-
7+
// rotation contract).
8+
//
9+
// Modes (DIDI_AUTH env):
10+
// off — adapter inert; legacy continuity tokens only.
11+
// optional — verify when the cookie is present; legacy flow still works.
12+
// (dev default while the shell's access panel is built)
13+
// required — upgrades without a valid didi_session are rejected.
14+
// (the posture once invites exist and operators are didi users)
15+
//
16+
// Spec of record: ai-labs/context-v/specs/Id-Didi-Sh-Identity-Service.md.
17+
// Local dev: run the id service on localhost:4000 (`mix phx.server`) —
18+
// host-only localhost cookies ignore ports, so a cookie set by :4000 rides
19+
// every localhost WS upgrade, the same-host analog of `.didi.sh`.
20+
21+
import { createRemoteJWKSet, jwtVerify } from 'jose';
22+
23+
const JWKS_URL = process.env.ID_JWKS_URL;
24+
const ISSUER = process.env.ID_ISSUER ?? 'https://id.didi.sh';
25+
const MODE = (process.env.DIDI_AUTH ?? 'off') as 'off' | 'optional' | 'required';
26+
27+
export type DidiIdentity = {
28+
didi_id: string;
29+
session_id: string;
30+
};
31+
32+
export function didiMode(): 'off' | 'optional' | 'required' {
33+
// No JWKS endpoint configured → the adapter cannot verify anything;
34+
// fall back to off regardless of the requested mode.
35+
return JWKS_URL ? MODE : 'off';
36+
}
37+
38+
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
39+
40+
/**
41+
* Verify the didi_session cookie from a raw Cookie header.
42+
* Returns the identity on success, null on absent/invalid — the CALLER
43+
* decides whether null is fatal (required mode) or fine (optional).
44+
*/
45+
export async function verifyDidiCookie(
46+
cookieHeader: string | string[] | undefined,
47+
): Promise<DidiIdentity | null> {
48+
if (didiMode() === 'off') return null;
49+
const token = readCookie(cookieHeader, 'didi_session');
50+
if (!token) return null;
51+
52+
try {
53+
jwks ??= createRemoteJWKSet(new URL(JWKS_URL as string));
54+
const { payload } = await jwtVerify(token, jwks, {
55+
issuer: ISSUER,
56+
algorithms: ['EdDSA'],
57+
});
58+
if (typeof payload.sub !== 'string' || typeof payload.sid !== 'string') {
59+
return null;
60+
}
61+
return { didi_id: payload.sub, session_id: payload.sid };
62+
} catch {
63+
return null;
64+
}
65+
}
66+
67+
function readCookie(
68+
header: string | string[] | undefined,
69+
name: string,
70+
): string | null {
71+
if (!header) return null;
72+
const raw = Array.isArray(header) ? header.join('; ') : header;
73+
for (const part of raw.split(';')) {
74+
const eq = part.indexOf('=');
75+
if (eq === -1) continue;
76+
if (part.slice(0, eq).trim() === name) {
77+
return part.slice(eq + 1).trim();
78+
}
79+
}
80+
return null;
81+
}

0 commit comments

Comments
 (0)