Skip to content

Add device pairing and authenticated browser sessions for remote access #77

Description

@comfuture

Summary

Add first-class device pairing and authenticated browser sessions before Codori supports Tailscale Funnel, a directly managed relay, or another ingress path outside a trusted tailnet boundary.

This is deferred security groundwork for future public ingress, a directly managed relay, and other deployments that cannot rely on a private tailnet boundary. The initial product model should remain intentionally narrow: one trusted operator may pair multiple browser devices, inspect those devices, and revoke them. This issue does not need to introduce general multi-user accounts or role-based access control.

Priority: P2 — required before public ingress or a Codori-managed relay

Dependencies: None

Required before: Tailscale Funnel, a Codori-managed relay, or any public/non-tailnet exposure. The private, loopback-backed Tailscale Serve launch in #78 does not depend on this issue.

Background

Codori is a self-hosted control plane that serves its Nuxt UI, REST API, and Codex app-server WebSocket proxy from one Fastify origin. This is convenient for private-network use, but the same origin currently has no authentication boundary.

The README explicitly states that Codori does not provide built-in authentication and that anyone who can reach the server may be able to interact with the runtime. That assumption becomes unsafe once Codori adds public ingress, a directly managed relay, or another exposure path outside a trusted tailnet.

Fastify supports request lifecycle hooks that can reject requests before route handlers run, which provides a suitable integration point for protecting static content, REST endpoints, and WebSocket upgrades consistently: https://fastify.dev/docs/latest/Reference/Hooks/

Current implementation facts

Observed in the current repository:

  • packages/server/src/http-server.ts creates one Fastify application, registers multipart, WebSocket, and static-file plugins, and then defines the management API. It does not register an authentication hook.
  • /api/projects/:projectId/rpc and /api/chats/:chatId/rpc accept WebSocket connections and proxy frames to the loopback app-server without authenticating the browser first.
  • packages/server/src/process-manager.ts correctly keeps the underlying Codex app-server on 127.0.0.1; the missing boundary is the browser-facing Codori server.
  • packages/server/src/service.ts can choose a Tailscale address during service setup and otherwise warns when falling back to 0.0.0.0, but it has no paired-client credential lifecycle.
  • README.md documents that Codori v1 has no built-in authentication and recommends a private network boundary.
  • The generated Codex app-server contract contains host-level filesystem and process operations. An authenticated Codori browser must therefore be treated as a trusted operator unless a separate method-level authorization layer is added later.

Problem statement

Network reachability is currently equivalent to authorization. A private tailnet reduces exposure, but it does not provide Codori-owned device enrollment, session expiration, explicit revocation, or a reliable boundary for future public/private tunnel providers.

Private Tailscale Serve can remain bounded by tailnet membership and ACLs, but Funnel, a Codori-managed relay, or other public/non-tailnet ingress must not ship without this application-owned boundary.

Goal

Require a browser device to complete an explicit, short-lived pairing flow before it can load the Codori application or open project/chat RPC WebSockets. Persist only non-reversible credential material on the server, expose paired-device management, and make revocation effective for both HTTP and active WebSocket sessions.

Preserve a practical local bootstrap path so a user cannot accidentally lock themselves out of a newly installed service.

Scope

This issue should include:

  • A server-side authentication/session module with persisted device records under the Codori state directory.
  • A local bootstrap command such as codori pair that creates a short-lived, single-use pairing challenge.
  • A minimal unauthenticated pairing page/API that exchanges the challenge for an authenticated browser session.
  • Device metadata suitable for management, such as id, user-supplied display name, created time, last-seen time, and expiry.
  • codori clients list, codori clients revoke <id>, and revoke-all/recovery commands, plus equivalent management API/UI when practical.
  • Protection for static application content, REST routes, attachment/local-file routes, service-update routes, and WebSocket handshakes.
  • Session renewal and expiry behavior.
  • Origin/Host validation for browser requests and WebSocket upgrades.
  • CSRF protection for state-changing HTTP endpoints.
  • Rate limits, attempt limits, expiry, and replay prevention for pairing.
  • Secure persistence permissions and secret redaction from logs and JSON output.
  • A migration/default policy for existing loopback-only installations.
  • Fail-closed behavior that rejects Funnel, Codori-managed relay activation, and other public/non-tailnet ingress while authentication is disabled or incomplete.

Non-goals

This issue should not include:

  • Tailscale Serve, Cloudflare Tunnel, or a Codori-hosted relay.
  • General username/password accounts, invitations, teams, SSO, OAuth providers, or multi-tenant RBAC.
  • Project-specific authorization for different users.
  • Replacing the Codex app-server protocol or its loopback transport.
  • Exposing the raw app-server port to browsers.
  • Storing a reusable client secret in localStorage.

Suggested direction

A plausible first implementation:

  1. Add an AuthManager sibling to RuntimeManager, initialized from startHttpServer() and closed through Fastify lifecycle hooks.
  2. Generate a cryptographically random, short-lived pairing challenge from codori pair. Store only a digest plus expiry and attempt state.
  3. Exchange the one-time challenge for a random device/session credential. Store only a slow or keyed digest server-side; never return persisted secret material from list/status APIs.
  4. Deliver the browser session through an HttpOnly, SameSite=Strict cookie. Set Secure whenever the request is served over HTTPS, and document the loopback bootstrap behavior deliberately.
  5. Register an early Fastify hook before protected routes. Keep only health/bootstrap/pairing resources unauthenticated.
  6. Validate the same session during WebSocket upgrade and associate active sockets with the paired device id so revocation can close them immediately.
  7. Store device and challenge state in a 0600 file or a small local database under ~/.codori; write updates atomically.
  8. Keep pairing responses and CLI output free of tokens once pairing completes. Never place credentials in service arguments or service.json.

Open design questions that should be resolved during implementation:

  • Whether loopback-only access remains unauthenticated by default for one compatibility release, or whether initial pairing becomes mandatory everywhere.
  • Whether the initial browser session is long-lived with rolling expiry or renewed through a separate rotating token.
  • Whether revocation should invalidate only one device or optionally all devices.

Acceptance criteria

  • A fresh remote browser cannot load protected Codori UI/API content or open an RPC WebSocket before pairing.
  • A user can run a documented local CLI command to create a short-lived, one-time pairing challenge.
  • A browser can complete pairing and receive an authenticated session without persisting a reusable secret in localStorage.
  • Pairing challenges expire, cannot be replayed, have a bounded failure count, and are rate-limited.
  • Static UI content, REST endpoints, uploads/previews, service update endpoints, and both project/chat WebSocket routes use the same authentication policy.
  • State-changing HTTP requests reject invalid Origin/CSRF context.
  • Paired devices can be listed without exposing credential hashes or secrets.
  • Revoking a device rejects future requests and closes its active WebSocket connections.
  • The user has a documented local CLI recovery path when every browser device has been revoked or expired.
  • Credential and device state is persisted with restrictive filesystem permissions and survives a Codori service restart.
  • Codori refuses Funnel, Codori-managed relay activation, and other public/non-tailnet ingress while authentication is disabled or initial setup is incomplete.
  • Secrets do not appear in logs, process arguments, service metadata, status responses, or test snapshots.
  • Existing project/chat runtime behavior and loopback app-server binding remain unchanged.
  • The compatibility policy for loopback-only installations is documented and covered by tests.
  • pnpm lint, pnpm typecheck, and pnpm test pass.

Suggested validation

Automated coverage should include:

  • pairing creation, expiry, failure limits, single-use behavior, and replay rejection
  • cookie/session validation and renewal
  • CSRF and Origin rejection
  • authenticated and unauthenticated REST requests
  • authenticated and unauthenticated WebSocket upgrades
  • device revoke behavior for open sockets
  • persistence and restart behavior
  • permission/secret-redaction checks where portable

Manual smoke test:

  1. Start Codori on loopback and follow the documented initial pairing flow.
  2. Confirm a paired browser can use projects, chats, uploads, file previews, and streaming RPC.
  3. Open a second unpaired browser profile and confirm it cannot access protected content.
  4. Pair the second profile, revoke it from the first, and confirm its active RPC socket closes and reconnect is rejected.
  5. Restart the Codori service and confirm the non-revoked device remains usable according to the documented expiry policy.

Documentation follow-up

Update README, server package documentation, service installation output, and the PRD to describe:

  • the trusted-device security model
  • initial pairing and device revocation commands
  • loopback compatibility behavior
  • why application authentication becomes mandatory for Funnel, Codori-managed relays, and other public/non-tailnet ingress
  • recovery steps when all devices have been revoked

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions