Skip to content

Add external auth mode that trusts the gateway actor header - #482

Open
alex-clickhouse wants to merge 2 commits into
alex/hosted-channel-invoke-triggerfrom
alex/external-mode-01-auth
Open

alex-clickhouse wants to merge 2 commits into
alex/hosted-channel-invoke-triggerfrom
alex/external-mode-01-auth

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

What

This adds an external authentication mode. With NERVE_AUTH_MODE=external, a gateway in front of Nerve signs people in. The gateway names the person behind each request in the X-Nerve-Actor-Context header, and Nerve acts as that person. Local mode (the default) keeps its behavior.

Stacked on #467. The hosted channel in #465 already verifies gateway-signed tokens (nerve/channels/hosted/auth.py). A later signature check for the actor header can reuse that key handling. This PR also changes files that the stack changes: server.py, the nerve doctor checks, the MCP HTTP endpoint and docs/config.md.

How

Mode

  • create_app() reads NERVE_AUTH_MODE once and pins it. A configuration reload does not change the mode. The values are local (also used when the variable is not set) and external. Any other value stops the server and nerve start with an error.
  • There is no YAML key. Lockdown drops config.yaml keys and a reload replaces the configuration object, so the environment is the only source.

Requests

  • decode_actor_context() splits the compact JWS and decodes its payload. It reads principal_id (a UUID, stored as a lower-case string) and profile.display_name. The signature is not checked. REST requests and the WebSocket upgrade both use this one function, so a later verifier replaces only this function.
  • When the header is present, Nerve ignores Authorization, the nerve_token cookie and ?token=. A header that Nerve cannot read gives 401.
  • Session tokens and legacy tokens give 401 on REST, /ws, the MCP endpoint and the worker-token route. System and MCP tokens still give the system actor.

Data

  • AccountStore.upsert_external_actor() adds the actor_refs row on first sight and writes the display name when it changes. This happens before the request writes rows that refer to the actor through foreign keys. An in-process map skips the statement when the name is the same, so a normal request does not take the SQLite write lock.
  • A header that names the system actor makes the system_actor_cannot_be_replaced trigger abort the insert. Nerve gives 401 for this, not 500.

Routes

  • The account and setup routes are not registered.
  • POST /api/auth/login gives 404.
  • GET /api/auth/status adds mode. In external mode it does not read login state, and login keeps the fail-closed value.

Startup

  • In external mode, startup creates no account. It keeps the JWT signing secret, which system and MCP tokens use. It deletes a setup token left from local mode.
  • nerve doctor does not warn about a missing local account.
  • Startup logs a warning that the header is trusted without a signature check.

Why

Hosted agents sign people in at the gateway, so local accounts and session tokens do not apply. Nerve must know which person sends each message so that it can attribute the message.

Limits

Do not deploy this mode yet. Nerve trusts the header without a signature check, so any caller that can reach Nerve can act as any person. Before external mode is used outside a local stack, either only the gateway must be able to reach Nerve, or Nerve must verify the signature. docs/config.md states this.

Testing

  • .venv/bin/pytest tests/ -q: 4800 passed, 37 skipped, 0 failed.
  • New tests/test_external_auth.py covers:
    • mode parsing, the pin, reloads and startup errors;
    • header decoding: malformed values, values that are not UUIDs, upper-case UUIDs, non-UTF-8 input and JSON that is nested too deep;
    • actor rows: first sight, a name change, and the system actor ID;
    • two principals in concurrent requests, with distinct sessions.created_by_actor_id and messages.actor_id;
    • session tokens refused on REST, /ws, the MCP endpoint and worker-token, while system and MCP tokens work;
    • the removed routes and the mode field;
    • WebSocket admission, a fixed actor per connection, and message attribution;
    • startup, nerve init, nerve start and nerve doctor in external mode.
  • Existing tests: only exact-equality checks of GET /api/auth/status changed, to add "mode": "local".

🤖 Generated with Claude Code

@alex-clickhouse
alex-clickhouse added this pull request to stack #407 October 5, 2026 07:30
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review October 5, 2026 09:21
alex-clickhouse and others added 2 commits October 5, 2026 11:25
A hosted Nerve runs behind a gateway that signs people in and names the
person behind each request in the X-Nerve-Actor-Context header. Local
accounts, login and session tokens do not apply there.

NERVE_AUTH_MODE selects the mode: local (default) or external.
create_app() reads it once and pins it, so a configuration reload cannot
change it. An unknown value stops the server and `nerve start`.

In external mode:
- require_auth and the WebSocket upgrade decode the header payload in
  one function, decode_actor_context(), and act as the person it names.
  The signature is not checked. A later change can replace that one
  function and keep its callers.
- The actor_refs row is added on first sight, and the display name is
  written when it changes. An in-process map skips the write when the
  name is the same, so a normal request takes no write lock. A header
  that names the system actor gives 401.
- Session tokens are refused on REST, /ws, the MCP endpoint and the
  worker-token route. System and MCP tokens give the system actor.
- The account and setup routes are not registered, login answers 404,
  and GET /api/auth/status reports the mode without login state.
- Startup creates no account, keeps the signing secret and deletes a
  setup token from local mode. nerve doctor does not warn about a
  missing local account.

Local mode keeps its behavior. Its status responses add the mode field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the external mode found these gaps:

- A display name with a lone UTF-16 surrogate is valid JSON, but SQLite
  cannot store it. The upsert raised UnicodeEncodeError, so REST gave
  500 and /ws did not get its 4001 close. decode_actor_context() now
  refuses such a name, so both paths give the normal refusal.
- A request with the actor context header twice used the first value.
  REST and the WebSocket upgrade now read every value and refuse a
  request that has more than one.
- nerve restart stopped the running daemon before the new daemon read
  NERVE_AUTH_MODE. It now checks the value first, as nerve start does.
- nerve doctor showed the mode only in external mode. It now always
  shows the mode, and says that it reads NERVE_AUTH_MODE in its own
  shell, not from the running server.

The setup-token startup test seeds an unclaimed local account, so that
it fails without the external-mode guard. New tests cover the surrogate
name and repeated headers on REST and /ws, the header alone on /ws in
local mode, and nerve restart with an unknown mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex/external-mode-01-auth branch from 548e7db to 2309c11 Compare October 5, 2026 09:31
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