Skip to content

feat(server): verified signed-in users through dewee identity session exchange - #6

Open
mrgoonie wants to merge 7 commits into
mainfrom
feat/dewee-identity-session
Open

mrgoonie wants to merge 7 commits into
mainfrom
feat/dewee-identity-session

Conversation

@mrgoonie

@mrgoonie mrgoonie commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Part of nextlevelbuilder/dewee#1450 (Trusted User Context + Dewee Connect). A host app passes one server-side callback, and the widget then chats as the verified user, with private tools available through Dewee. The host no longer mints tokens, stores per-visitor credentials or exposes the gateway token. Anonymous mode is unchanged when the new identity option is absent.

Licensing gate (needs a human decision before merge): the vendored vendor/dewee-identity-0.1.0-beta.0.tgz is licensed CC-BY-NC-4.0 (its package.json license), while this repository and the dewee-webchat package are MIT. The tarball is only a dev dependency used by the tests, and @dewee/identity is an optional peer that users install themselves. Committing a non-commercial-licensed artifact into an MIT repository, and pointing MIT users at a non-commercial SDK, still needs sign-off from a maintainer.

What changed

  • identity handler option (packages/dewee-webchat/src/server/handler.ts). It takes:

    • sdk: deweeIdentitySdk from the new dewee-webchat/server/identity entry;
    • authenticate(req), appId, issuer, an optional audience check;
    • exactly one of assertionSecret or privateKeyJwk;
    • agent, required, rebindEachRequest, sessionTtlSeconds and allowInsecureHttp.

    Options are validated at startup.

  • Separate SDK entry. dewee-webchat/server never imports @dewee/identity, not even lazily, so hosts that serve only anonymous visitors bundle it with webpack or esbuild without the SDK. The only module that imports the SDK is dewee-webchat/server/identity.

  • POST /session calls authenticate. For a signed-in user it exchanges the user for a Dewee identity session, or reuses the one it holds for that user, and wraps it in the visitor token.

    • The ids claim is sealed with AES-256-GCM. The key is derived from the handler secret with HKDF and a distinct info label, and the associated data binds the ciphertext to the visitor and the agent. The browser holds only an opaque value.
    • exp is the earlier of the session lifetime, measured from receipt, and the visitor expiry. expiresIn is returned as well.
    • The visitor id is derived from the principal, so the same user keeps the same conversations on every device.
  • /chat and /history, when the token has ids:

    • The handler connects with identity_session and no user_id.
    • It goes on only when the connect answer names a tuc: user. Otherwise it closes the connection and answers 503 SOURCE_UNAVAILABLE, with nothing run.
    • The bound agent must still be in agents.
    • The connection is checked before streaming, so a refused session is a typed JSON error.
  • Opt-in rebindEachRequest. /chat, /history and /link call authenticate(req) again and compare the result with an HMAC digest of the subject sealed in the token; the raw subject is never stored. Another user or no user gets 401 IDENTITY_REQUIRED, and the widget re-requests /session. A throwing authenticate gets 503 SOURCE_UNAVAILABLE. This is recommended for cookie logins, because a replayed widget token becomes useless under another user's cookie.

  • Typed errors. The seven identity codes are forwarded as {error:{message, code, remediation}}, or as an SSE error event mid-run, and the widget shows localized en and vi copy.

    • On the exchange path, a refusal addressed to the operator or integrator, or an exchange route switched off on the gateway, becomes 500 identity_misconfigured without detail.
    • Link conflicts get their own codes: 409 idempotency_key_reused, 409 link_in_progress and 503 linking_unavailable, each with localized copy.
  • Web-first linking. POST {endpoint}/link calls startLink and returns {proofCode, expiresAt, deepLinks}. chat.startLink() shows the code. Nobody types a sender id.

  • Widget sessions.

    • Signed-in sessions are kept in memory only.
    • The refresh margin is min(60s, lifetime/2), with the lifetime taken from the server's expiresIn and counted from receipt.
    • link() and history() drop a refused signed-in session and renew it once, the same way chat() does.
  • Rate limits, the in-memory defaults:

    • signed-in /session: 30 per user per 10 minutes, applied after authenticate;
    • /link: its own bucket, 5 per user per 10 minutes;
    • a still-valid anonymous token comes back as is, and only minting counts against the existing 10 per IP per 10 minutes.

    One identity session per user and agent is cached, bounded at 1000 entries, until it nears expiry. A session the gateway reports dead is dropped. RateLimiter buckets gain verified and link.

  • Fail closed.

    • A signed-in user whose exchange fails gets no session.
    • A throwing authenticate gets 503 whether or not required is set.
    • A null authenticate gets an anonymous session only when required is not set.
    • Combining identity with BYO credentials is refused at startup. That is the only change to the BYO path.
    • The gateway token, the assertion secret, the private key, the raw identity session and the subject never appear in a browser-facing response.
  • scopes removed from AuthenticatedUser. The docs point to the SDK lifecycle({event: "scopes_changed"}).

  • Docs. "Signed-in users" sections in both READMEs, and the identity sections in docs/http-contract.md. They now cover:

    • exactly what the identity session token can do: chat and history with the bound agent, starting links, listing and removing the user's own bindings;
    • the sealed claim format;
    • rebinding;
    • the rate limits;
    • that IP header trust is pre-existing;
    • that a cross-origin widget cannot use a cookie-based authenticate.

Review fixes in this update (84d2ba1, 8421b88, 1589398)

Finding Fix
H1: fail-open when the gateway ignores identity_session The connect answer's user_id must start with tuc:. Otherwise the connection is closed and the request gets 503 SOURCE_UNAVAILABLE. A fake-gateway test ignores the session and asserts that nothing ran.
H2: webpack can't resolve @dewee/identity New dewee-webchat/server/identity entry passed as identity.sdk. scripts/bundler-smoke.mjs (CI step pnpm test:bundlers) bundles the anonymous server with webpack and esbuild with the SDK made unresolvable and runs the bundles. Against the previous build it reproduced the failure (Module not found: Can't resolve '@dewee/identity' at dist/server.js:375).
M1: /session and /link share the per-IP limiter Per-subject verified bucket after authenticate, valid anonymous tokens reused, separate link bucket, limits documented
M2: re-exchange on every request with short TTLs Widget refresh margin min(60s, ttl/2), measured from receipt using expiresIn; the server cache uses the same margin
M3: session token readable in the browser ids sealed with AES-256-GCM (HKDF from the handler secret); docs describe the token's exact powers
M4: one exchange per signed-in /session A bounded in-memory cache per subject and agent, with concurrent exchanges coalesced; per-subject limit; IP header trust noted as pre-existing
M5: scopes silently ignored Removed from AuthenticatedUser; the docs point to lifecycle scopes_changed
L1: exchange-path errors Non-end_user remediation and NOT_FOUND map to 500 identity_misconfigured; the contract table matches
L2: throwing authenticate 503 SOURCE_UNAVAILABLE always; README fixed
L3: link() and revoked sessions link() (and history()) drop and renew once; tests cover the SSE renew path and the link renew
L4: link error mapping IDEMPOTENCY_KEY_REUSED and FAILED_PRECONDITION map to 409 with their own codes; NOT_FOUND maps to 503 linking_unavailable
L5: agent allowlist Identity tokens are checked against the current agents on every request
Added: per-request rebinding identity.rebindEachRequest with a sealed subject digest; tests below

Linking code host apps no longer need

What a host that knows its user (Campus) had to build before this PR, and what replaces it:

Before (host-owned) Now
Its own learner/visitor token minting and signing so the widget could act as the user identity.authenticate(req) => { subject }; the handler does the exchange
A per-learner signing key to stop a token being replayed under another login identity.rebindEachRequest: true
A per-visitor credential push to the gateway (per-user MCP credentials for each visitor) None: the gateway derives the verified principal from the identity session, and principal-scoped MCP tools receive a signed trusted-user token
Passing a client-chosen user_id for "logged-in" visitors None: there is no user_id with ids, so the client cannot choose the user
Custom account-linking flow (collecting a Telegram/Discord id from the user) chat.startLink() shows a Dewee proof code; Dewee checks both sides
Custom error copy for expired or revoked access Typed codes with remediation and localized widget messages

Isolation test

packages/dewee-webchat/test/identity.test.ts has 34 tests. They run against a fake gateway that serves the identity HTTP routes (verifying HS256 and ES256 assertions) and the WS v3 protocol on one port. They cover:

Isolation

  • Users A and B get different identity sessions.
  • Connect params are exactly {token, identity_session}, with no user_id even when the client sends one.
  • By default, A's token replayed with B's cookie still chats as A.
  • Tampered and foreign-signed tokens get 401.
  • The bound agent is refused once it leaves agents.

Rebinding

  • A's token with B's cookie, or with no cookie, gets 401 IDENTITY_REQUIRED on chat, history and link.
  • A's token with A's cookie works.
  • A throwing authenticate gets 503.

Sealed token

  • Neither the raw session nor the subject appears in the token.
  • Moving the ciphertext into another visitor's claims fails.

Fail closed

  • Misconfiguration is refused at startup, including a missing sdk.
  • A throwing authenticate gets 503 whether or not required is set.
  • With required, no session is issued.
  • Exchange refusals issue no token, and an operator-audience refusal gets 500 identity_misconfigured.
  • A gateway that ignores identity_session gets 503, and nothing is sent.

Reuse and limits

  • One exchange per user, and a re-exchange after the gateway reports LINK_EXPIRED.
  • A 60 s session is reused at 20 s and re-exchanged at 35 s.
  • Limiter keys and buckets per route.
  • The anonymous token is reused without minting.
  • 429 after 30 signed-in sessions.

Typed errors and linking

  • LINK_EXPIRED comes back as 401 for chat and history, and a mid-run LINK_REVOKED arrives as an SSE event.
  • History is read as the session user.
  • /link returns the proof code, refuses anonymous visitors, and maps reused, in-progress and switched-off.

Widget

  • Renewal on a 401 from chat.
  • Renewal after an SSE LINK_REVOKED.
  • Renewal for link.
  • expiresIn beats a skewed expiresAt.
  • Localized copy for every identity and link code.

Secret hygiene

  • The gateway token, assertion secret, visitor secret, private JWK d and every raw identity session issued in the file are absent from all browser-facing bodies and headers.

Test results (Windows, Node 24.11, pnpm 10.18), head 1589398

pnpm install --frozen-lockfile   up to date
pnpm typecheck                   tsc --noEmit: clean
pnpm test                        Test Files 6 passed | 1 skipped (7); Tests 82 passed | 1 skipped (83)
                                 (identity 34, server 13, page-actions 19, byo-hosts 8, widget-page-actions 4,
                                  markdown 4; php-parity skipped locally: no php binary, runs in CI)
pnpm build                       ESM + IIFE + DTS build success (new dist/server-identity.js)
pnpm test:bundlers               ok   dist/server.js does not import the SDK
                                 ok   esbuild bundles the anonymous server without the SDK, and the bundle runs
                                 ok   webpack bundles the anonymous server without the SDK, and the bundle runs
                                 ok   control: esbuild bundles dewee-webchat/server/identity with the SDK installed
                                 ok   control: esbuild refuses dewee-webchat/server/identity without the SDK

php -l php/dewee-webchat.php runs in CI because PHP is not installed locally. php/** is untouched.

Vendored SDK tarball (temporary)

@dewee/identity is not published to npm yet. vendor/dewee-identity-0.1.0-beta.0.tgz was packed with pnpm --filter @dewee/identity pack from nextlevelbuilder/dewee dev; the SDK is unchanged through c6e1fc3b8. It is a file: dev dependency for the tests. For users, @dewee/identity is an optional peer dependency used only by dewee-webchat/server/identity. Once the SDK is published, the dev dependency switches to npm and vendor/ is deleted. See the licensing gate above: the tarball is CC-BY-NC-4.0 inside an MIT repository.

Notes for review

  • audience is accepted only as a consistency check (dewee:<appId>), because the SDK derives the audience from the application id.
  • With identity set, /config turns the agent picker off and reports the bound agent as the default, because a Dewee session is bound to one agent.
  • The gateway token must be a tenant API key without an owner (documented).
  • @dewee/identity returns only an absolute expiresAt (the exchange answer has no expires_in). The handler therefore measures the lifetime on receipt, trusts it only while it fits the requested TTL (otherwise it falls back to the requested or default TTL and logs clock skew), and sends that to the widget as expiresIn.
  • authenticate runs before any limiter on /session. That is needed to key the per-user bucket, so the cost of the host's own login check is the host's concern.
  • A gateway-side fail-closed fix for the ignored identity_session case is being done separately in dewee.

PHP parity

Surface parity: php/dewee-webchat.php is N/A in this PR because the identity exchange needs JWS signing and AES-GCM sealing, which the PHP adapter does not have yet. The follow-up is #5.

Refs nextlevelbuilder/dewee#1450 · PHP follow-up: #5

🤖 Generated with Claude Code

mrgoonie and others added 7 commits October 20, 2026 17:21
The Dewee identity server SDK is not on npm yet. Pack 0.1.0-beta.0 from the
dewee repository (packages/dewee-identity at nextlevelbuilder/dewee dev) into
vendor/ and use it as a file: dev dependency for the tests. For users it is an
optional peer dependency, needed only when the handler signs users in, so the
published package never points at a local file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… exchange

A host passes identity.authenticate(req). On /session the handler exchanges
the signed-in user for a Dewee identity session (one agent, chat only,
short-lived) and embeds it in the visitor token (ids, ida), with exp capped at
the session expiry. /chat and /history then connect with identity_session and
no user_id, so the gateway derives the user; a token stays bound to its user
whatever cookie a later request carries.

Fail closed: an exchange failure issues no session, a null or throwing
authenticate falls back to anonymous only when identity is not required, and
identity cannot be combined with client credentials. Gateway identity errors
are forwarded as {error:{code, remediation}} (JSON before streaming, SSE
during a run); the widget shows localized copy and re-requests /session after
LINK_EXPIRED or LINK_REVOKED. POST /link starts a web-first link and the
widget shows the proof code with the chat accounts to send it to.

Anonymous mode is unchanged when the identity option is absent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README "Signed-in users" (setup, options, fail-closed rules, linking) and the
HTTP contract for the identity additions to /config, /session, /chat,
/history, the typed identity errors and POST /link. The PHP backend does not
support signed-in users yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gned-in sessions

- Refuse a connect whose answer does not name a tuc: user, so a gateway that
  ignored identity_session never runs the turn as the operator token's user
  (503 SOURCE_UNAVAILABLE).
- Move the @dewee/identity calls to a separate dewee-webchat/server/identity
  entry passed as identity.sdk; dewee-webchat/server no longer references the
  SDK, so anonymous-only hosts bundle it with webpack without the SDK.
- Seal the identity session in the visitor token with AES-256-GCM (HKDF key
  from the handler secret, bound to visitor and agent).
- Reuse one identity session per user until it nears expiry, drop it when the
  gateway reports it dead, and measure its lifetime from receipt; return
  expiresIn so the widget refreshes min(60s, lifetime/2) before the end.
- Rate-limit signed-in sessions per user and links in their own bucket; hand a
  valid anonymous token back without minting.
- Add opt-in rebindEachRequest: chat, history and link re-run authenticate and
  compare a sealed subject digest.
- A throwing authenticate is 503, exchange refusals for operators are 500
  identity_misconfigured, link conflicts get their own codes, the bound agent
  is checked against the current allowlist, and scopes is removed from
  AuthenticatedUser.
- The widget renews a refused signed-in session once for link and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e identity SDK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed claim size

- Compare the rebinding subject digest with a constant-time byte compare.
- Cap host authenticate calls per IP (new preauth bucket, default 120 per
  minute) before authenticate runs on /session and on rebinding; document
  that authenticate should be cheap or cached.
- Size the sealed identity claim cap from the real plaintext layout
  ({"t":...,"h":...} with worst-case JSON escaping and the subject digest),
  so a 512-character session token always seals and opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant