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:
- Add an
AuthManager sibling to RuntimeManager, initialized from startHttpServer() and closed through Fastify lifecycle hooks.
- Generate a cryptographically random, short-lived pairing challenge from
codori pair. Store only a digest plus expiry and attempt state.
- 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.
- 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.
- Register an early Fastify hook before protected routes. Keep only health/bootstrap/pairing resources unauthenticated.
- Validate the same session during WebSocket upgrade and associate active sockets with the paired device id so revocation can close them immediately.
- Store device and challenge state in a
0600 file or a small local database under ~/.codori; write updates atomically.
- 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:
- Start Codori on loopback and follow the documented initial pairing flow.
- Confirm a paired browser can use projects, chats, uploads, file previews, and streaming RPC.
- Open a second unpaired browser profile and confirm it cannot access protected content.
- Pair the second profile, revoke it from the first, and confirm its active RPC socket closes and reconnect is rejected.
- 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
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.tscreates 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/rpcand/api/chats/:chatId/rpcaccept WebSocket connections and proxy frames to the loopback app-server without authenticating the browser first.packages/server/src/process-manager.tscorrectly keeps the underlying Codex app-server on127.0.0.1; the missing boundary is the browser-facing Codori server.packages/server/src/service.tscan choose a Tailscale address during service setup and otherwise warns when falling back to0.0.0.0, but it has no paired-client credential lifecycle.README.mddocuments that Codori v1 has no built-in authentication and recommends a private network boundary.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:
codori pairthat creates a short-lived, single-use pairing challenge.codori clients list,codori clients revoke <id>, and revoke-all/recovery commands, plus equivalent management API/UI when practical.Non-goals
This issue should not include:
localStorage.Suggested direction
A plausible first implementation:
AuthManagersibling toRuntimeManager, initialized fromstartHttpServer()and closed through Fastify lifecycle hooks.codori pair. Store only a digest plus expiry and attempt state.HttpOnly,SameSite=Strictcookie. SetSecurewhenever the request is served over HTTPS, and document the loopback bootstrap behavior deliberately.0600file or a small local database under~/.codori; write updates atomically.service.json.Open design questions that should be resolved during implementation:
Acceptance criteria
localStorage.pnpm lint,pnpm typecheck, andpnpm testpass.Suggested validation
Automated coverage should include:
Manual smoke test:
Documentation follow-up
Update README, server package documentation, service installation output, and the PRD to describe: