diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md new file mode 100644 index 0000000000..32e35d7caf --- /dev/null +++ b/.docs/remote-architecture.md @@ -0,0 +1,302 @@ +# Remote Architecture + +This document describes the target architecture for first-class remote environments in T3 Code. + +It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. + +## Goals + +- Treat remote environments as first-class product primitives, not special cases. +- Support multiple ways to reach the same environment. +- Keep the T3 server as the execution boundary. +- Let desktop, mobile, and web all share the same conceptual model. +- Avoid introducing a local control plane unless product pressure proves it is necessary. + +## Non-goals + +- Replacing the existing WebSocket server boundary with a custom transport protocol. +- Making SSH the only remote story. +- Syncing provider auth across machines. +- Shipping every access method in the first iteration. + +## High-level architecture + +T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. + +Remote support should preserve that boundary. + +```text +┌──────────────────────────────────────────────┐ +│ Client (desktop / mobile / web) │ +│ │ +│ - known environments │ +│ - connection manager │ +│ - environment-aware routing │ +└───────────────┬──────────────────────────────┘ + │ + │ resolves one access endpoint + │ +┌───────────────▼──────────────────────────────┐ +│ Access method │ +│ │ +│ - direct ws / wss │ +│ - tunneled ws / wss │ +│ - desktop-managed ssh bootstrap + forward │ +└───────────────┬──────────────────────────────┘ + │ + │ connects to one T3 server + │ +┌───────────────▼──────────────────────────────┐ +│ Execution environment = one T3 server │ +│ │ +│ - environment identity │ +│ - provider state │ +│ - projects / threads / terminals │ +│ - git / filesystem / process runtime │ +└──────────────────────────────────────────────┘ +``` + +The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself. + +## Domain model + +### ExecutionEnvironment + +An `ExecutionEnvironment` is one running T3 server instance. + +It is the unit that owns: + +- provider availability and auth state +- model availability +- projects and threads +- terminal processes +- filesystem access +- git operations +- server settings + +It is identified by a stable `environmentId`. + +This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here. + +### KnownEnvironment + +A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. + +It is not server-authored. It is local to a device or client profile. + +Examples: + +- a saved LAN URL +- a saved public `wss://` endpoint +- a desktop-managed SSH host entry +- a saved tunneled environment + +A known environment may or may not know the target `environmentId` before first successful connect. + +### AccessEndpoint + +An `AccessEndpoint` is one concrete way to reach a known environment. + +This is the key abstraction that keeps SSH from taking over the model. + +A single environment may have many endpoints: + +- `wss://t3.example.com` +- `ws://10.0.0.25:3773` +- a tunneled relay URL +- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL + +The environment stays the same. Only the access path changes. + +### RepositoryIdentity + +`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. + +It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. + +### Workspace / Project + +The current `Project` model remains environment-local. + +That means: + +- a local clone and a remote clone are different projects +- they may share a `RepositoryIdentity` +- threads still bind to one project in one environment + +## Access methods + +Access methods answer one question: + +How does the client speak WebSocket to a T3 server? + +They do not answer: + +- how the server got started +- who manages the server process +- whether the environment is local or remote + +### 1. Direct WebSocket access + +Examples: + +- `ws://10.0.0.15:3773` +- `wss://t3.example.com` + +This is the base model and should be the first-class default. + +Benefits: + +- works for desktop, mobile, and web +- no client-specific process management required +- best fit for hosted or self-managed remote T3 deployments + +### 2. Tunneled WebSocket access + +Examples: + +- public relay URLs +- private network relay URLs +- local tunnel products such as pipenet + +This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. + +For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. + +This is especially useful when: + +- the host is behind NAT +- inbound ports are unavailable +- mobile must reach a desktop-hosted environment +- a machine should be reachable without exposing raw LAN or public ports + +### 3. Desktop-managed SSH access + +SSH is an access and launch helper, not a separate environment type. + +The desktop main process can use SSH to: + +- reach a machine +- probe it +- launch or reuse a remote T3 server +- establish a local port forward + +After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. + +This keeps the renderer transport model consistent with every other access method. + +## Launch methods + +Launch methods answer a different question: + +How does a T3 server come to exist on the target machine? + +Launch and access should stay separate in the design. + +### 1. Pre-existing server + +The simplest launch method is no launch at all. + +The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. + +This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. + +### 2. Desktop-managed remote launch over SSH + +This is the main place where Zed is a useful reference. + +Useful ideas to borrow from Zed: + +- remote probing +- platform detection +- session directories with pid/log metadata +- reconnect-friendly launcher behavior +- desktop-owned connection UX + +What should be different in T3: + +- no custom stdio/socket proxy protocol between renderer and remote runtime +- no attempt to make the remote runtime look like an editor transport +- keep the final client-to-server connection as WebSocket + +The recommended T3 flow is: + +1. Desktop connects over SSH. +2. Desktop probes the remote machine and verifies T3 availability. +3. Desktop launches or reuses a remote T3 server. +4. Desktop establishes local port forwarding. +5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. + +### 3. Client-managed local publish + +This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. + +This is useful for: + +- exposing a desktop-hosted environment to mobile +- temporary remote access without changing router or firewall settings + +This is still a launch concern, not a new environment kind. + +## Why access and launch must stay separate + +These concerns are easy to conflate, but separating them prevents architectural drift. + +Examples: + +- A manually hosted T3 server might be reached through direct `wss`. +- The same server might also be reachable through a tunnel. +- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. +- A local desktop server might be published through a tunnel for mobile. + +In all of those cases, the `ExecutionEnvironment` is the same kind of thing. + +Only the launch and access paths differ. + +## Security model + +Remote support must assume that some environments will be reachable over untrusted networks. + +That means: + +- remote-capable environments should require explicit authentication +- tunnel exposure should not rely on obscurity +- client-saved endpoints should carry enough auth metadata to reconnect safely + +T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. + +For publicly reachable environments, authenticated access should be treated as required. + +## Relationship to Zed + +Zed is a useful reference implementation for managed remote launch and reconnect behavior. + +The relevant lessons are: + +- remote bootstrap should be explicit +- reconnect should be first-class +- connection UX belongs in the client shell +- runtime ownership should stay clearly on the remote host + +The important mismatch is transport shape. + +Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. + +T3 should not copy that part. + +T3 already has the right runtime boundary: + +- one T3 server per environment +- ordinary HTTP/WebSocket between client and environment + +So T3 should borrow Zed's launch discipline, not its transport protocol. + +## Recommended rollout + +1. First-class known environments and access endpoints. +2. Direct `ws` / `wss` remote environments. +3. Authenticated tunnel-backed environments. +4. Desktop-managed SSH launch and forwarding. +5. Multi-environment UI improvements after the base runtime path is proven. + +This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. diff --git a/.plans/18-server-auth-model.md b/.plans/18-server-auth-model.md new file mode 100644 index 0000000000..9f8ba8a05d --- /dev/null +++ b/.plans/18-server-auth-model.md @@ -0,0 +1,823 @@ +# Server Auth Model Plan + +## Purpose + +Define the long-term server auth architecture for T3 Code before first-class remote environments ship. + +This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. + +This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. + +## Primary goals + +- Make auth server-wide, not WebSocket-only. +- Make insecure exposure hard to do accidentally. +- Preserve zero-login local desktop UX for desktop-managed environments. +- Support browser-native pairing and session auth. +- Leave room for native/mobile credentials later without rewriting the server boundary. +- Keep auth separate from transport and launch method. + +## Non-goals + +- Full multi-user authorization and RBAC. +- OAuth / SSO / enterprise identity. +- Passkeys or biometric UX in v1. +- Syncing auth state across environments. +- Designing the full remote environment product in this document. + +## Core decisions + +### 1. Auth is a server concern + +Every privileged surface of the T3 server must go through the same auth policy engine: + +- HTTP routes +- WebSocket upgrades +- RPC methods reached through WebSocket + +The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. + +### 2. Pairing and session are different things + +The system should distinguish: + +- bootstrap credentials +- session credentials + +Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. + +Session credentials are the durable credentials used after pairing. + +Bootstrap should never become the long-lived request credential. + +### 3. Auth and transport are separate + +Auth must not be defined by how the client reached the server. + +Examples: + +- local desktop-managed server +- LAN `ws://` +- public `wss://` +- tunneled `wss://` +- SSH-forwarded `ws://127.0.0.1:` + +All of these should feed into the same auth model. + +### 4. Exposure level changes defaults + +The more exposed an environment is, the narrower the safe default should be. + +Safe default expectations: + +- local desktop-managed: auto-pair allowed +- loopback browser access: explicit bootstrap allowed +- non-loopback bind: auth required +- tunnel/public endpoint: auth required, explicit enablement required + +### 5. Browser and native clients may use different session credentials + +The auth model should support more than one session credential type even if only one ships first. + +Examples: + +- browser session cookie +- native bearer/device token + +This should be represented in the model now, even if browser cookies are the first implementation. + +## Target auth domain + +### Route classes + +Every route or transport entrypoint should be classified as one of: + +1. `public` +2. `bootstrap` +3. `authenticated` + +#### `public` + +Unauthenticated by definition. + +Should be extremely small. Examples: + +- static shell needed to render the pairing/login UI +- favicon/assets required for the pairing screen +- a minimal server health/version endpoint if needed + +#### `bootstrap` + +Used only to exchange a bootstrap credential for a session. + +Examples: + +- Initial bootstrap envelope over file descriptor at startup +- `POST /api/auth/bootstrap` +- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX + +#### `authenticated` + +Everything that reveals machine state or mutates it. + +Examples: + +- WebSocket upgrade +- orchestration snapshot and events +- terminal open/write/close +- project search and file writes +- git routes +- attachments +- project favicon lookup +- server settings + +The default stance should be: if it touches the machine, it is authenticated. + +## Credential model + +### Bootstrap credentials + +Initial credential types to model: + +- `desktop-bootstrap` +- `one-time-token` + +Possible future credential types: + +- `device-code` +- `passkey-assertion` +- `external-identity` + +#### `desktop-bootstrap` + +Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. + +Properties: + +- launcher-provided +- short-lived +- one-time or bounded-use +- never shown to the user as a reusable password + +#### `one-time-token` + +Used for explicit browser/mobile pairing flows. + +Properties: + +- short TTL +- one-time use +- safe to embed in a pairing URL fragment +- exchanged for a session credential + +### Session credentials + +Initial credential types to model: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `browser-session-cookie` + +Primary browser credential. + +Properties: + +- signed +- `HttpOnly` +- bounded lifetime +- revocable by server key rotation or session invalidation + +#### `bearer-session-token` + +Reserved for native/mobile or non-browser clients. + +Properties: + +- opaque token, not a bootstrap secret +- long enough lifetime to survive reconnects +- stored in secure client storage when available + +## Auth policy model + +Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. + +### Policy examples + +#### `DesktopManagedLocalPolicy` + +Default for desktop-managed local server. + +Allowed bootstrap methods: + +- `desktop-bootstrap` + +Allowed session methods: + +- `browser-session-cookie` + +Disabled by default: + +- `one-time-token` +- `bearer-session-token` +- password login +- public pairing + +#### `LoopbackBrowserPolicy` + +Used for browser access on localhost without desktop-managed bootstrap. + +Allowed bootstrap methods: + +- `one-time-token` + +Allowed session methods: + +- `browser-session-cookie` + +#### `RemoteReachablePolicy` + +Used when binding non-loopback or using an explicit remote/tunnel workflow. + +Allowed bootstrap methods: + +- `one-time-token` +- possibly `desktop-bootstrap` when a desktop shell is brokering access + +Allowed session methods: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `UnsafeNoAuthPolicy` + +Should exist only as an explicit escape hatch. + +Requirements: + +- explicit opt-in flag +- loud startup warnings +- never defaulted automatically + +## Effect-native service model + +### `ServerAuth` + +The main auth facade used by HTTP routes and WebSocket upgrade handling. + +Responsibilities: + +- classify requests +- authenticate requests +- authorize bootstrap attempts +- create sessions from bootstrap credentials +- enforce policy by environment mode + +Sketch: + +```ts +export interface ServerAuthShape { + readonly getCapabilities: Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + routeClass: RouteAuthClass, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + input: BootstrapExchangeInput, + ) => Effect.Effect; +} + +export class ServerAuth extends ServiceMap.Service()( + "t3/ServerAuth", +) {} +``` + +### `BootstrapCredentialService` + +Owns issuance, storage, validation, and consumption of bootstrap credentials. + +Responsibilities: + +- issue desktop bootstrap grants +- issue one-time pairing tokens +- validate TTL and single-use semantics +- consume bootstrap grants atomically + +Sketch: + +```ts +export interface BootstrapCredentialServiceShape { + readonly issueDesktopBootstrap: ( + input: IssueDesktopBootstrapInput, + ) => Effect.Effect; + readonly issueOneTimeToken: ( + input: IssueOneTimeTokenInput, + ) => Effect.Effect; + readonly consume: ( + presented: PresentedBootstrapCredential, + ) => Effect.Effect; +} +``` + +### `SessionCredentialService` + +Owns creation and validation of authenticated sessions. + +Responsibilities: + +- mint cookie sessions +- mint bearer sessions +- validate active session credentials +- revoke sessions if needed later + +Sketch: + +```ts +export interface SessionCredentialServiceShape { + readonly createBrowserSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly createBearerSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly authenticateCookie: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateBearer: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; +} +``` + +### `ServerAuthPolicy` + +Pure policy/config service that decides which credential types are allowed. + +Responsibilities: + +- map runtime mode and bind/exposure settings to allowed auth methods +- answer whether a route can be public +- answer whether remote exposure requires auth + +This should stay mostly pure and cheap to test. + +### `ServerSecretStore` + +Owns long-lived server signing keys and secrets. + +Responsibilities: + +- get or create signing key +- rotate signing key +- abstract secure OS-backed storage vs filesystem fallback + +Important: + +- prefer platform secure storage when available +- support hardened filesystem fallback for headless/server-only environments + +### `BrowserSessionCookieCodec` + +Focused utility service for cookie encode/decode/signing behavior. + +This should not own policy. It should only own the cookie format. + +### `AuthRouteGuards` + +Thin helper layer used by routes to enforce auth consistently. + +Responsibilities: + +- require auth for HTTP route handlers +- classify route auth mode +- convert auth failures into `401` / `403` + +This prevents every route from re-implementing the same pattern. + +Integrates with `HttpRouter.middleware` to enforce auth consistently. + +## Suggested layer graph + +```text +ServerSecretStore + ├─> BootstrapCredentialService + ├─> BrowserSessionCookieCodec + └─> SessionCredentialService + +ServerAuthPolicy + ├─> BootstrapCredentialService + ├─> SessionCredentialService + └─> ServerAuth + +ServerAuth + └─> AuthRouteGuards +``` + +Layer naming should follow existing repo style: + +- `ServerSecretStoreLive` +- `BootstrapCredentialServiceLive` +- `SessionCredentialServiceLive` +- `ServerAuthPolicyLive` +- `ServerAuthLive` +- `AuthRouteGuardsLive` + +## High-level implementation examples + +### Example: WebSocket upgrade auth + +Current state: + +- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) + +Target shape: + +```ts +const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateWebSocketUpgrade(request); + return yield* httpApp; + }), +); +``` + +Then the `/ws` route becomes: + +```ts +export const websocketRpcRouteLayer = HttpRouter.add( + "GET", + "/ws", + rpcWebSocketHttpEffect.pipe( + websocketUpgradeAuth, + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +This keeps the route itself declarative and makes auth compose like normal HTTP middleware. + +### Example: authenticated HTTP route + +For routes like attachments or project favicon: + +```ts +const authenticatedRoute = (routeClass: RouteAuthClass) => + HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request, routeClass); + return yield* httpApp; + }), + ); +``` + +Then: + +```ts +export const attachmentsRouteLayer = HttpRouter.add( + "GET", + `${ATTACHMENTS_ROUTE_PREFIX}/*`, + serveAttachment.pipe( + authenticatedRoute(RouteAuthClass.Authenticated), + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +### Example: desktop bootstrap exchange + +The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. + +That grant is then exchanged for a browser cookie session when the renderer loads. + +Sketch: + +```ts +const pairDesktopRenderer = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + const credential = yield* bootstrapService.issueDesktopBootstrap({ + audience: "desktop-renderer", + ttlMs: 30_000, + }); + return credential; +}); +``` + +The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. + +### Example: one-time pairing URL + +For browser-driven pairing: + +```ts +const createPairingToken = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + return yield* bootstrapService.issueOneTimeToken({ + ttlMs: 5 * 60_000, + audience: "browser", + }); +}); +``` + +The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. + +## Sequence diagrams + +These flows are meant to anchor the auth model in concrete user journeys. + +The important invariant across all of them is: + +- access method is not the auth method +- launch method is not the auth method +- bootstrap credential is not the session credential + +### Normal desktop user + +This is the default desktop-managed local flow. + +The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. + +```text +Participants: + DesktopMain = Electron main + SecretStore = secure local secret backend + T3Server = local backend child process + Frontend = desktop renderer + +DesktopMain -> SecretStore : getOrCreate("server-signing-key") +SecretStore --> DesktopMain : signing key available + +DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) +DesktopMain -> T3Server : send desktop bootstrap envelope +note over T3Server : policy = DesktopManagedLocalPolicy +note over T3Server : allowed pairing = desktop-bootstrap only + +Frontend -> DesktopMain : request local bootstrap grant +DesktopMain --> Frontend : short-lived desktop bootstrap grant + +Frontend -> T3Server : POST /api/auth/bootstrap +T3Server -> T3Server : validate desktop bootstrap grant +T3Server -> T3Server : create browser session +T3Server --> Frontend : Set-Cookie: session=... + +Frontend -> T3Server : GET /ws + authenticated cookie +T3Server -> T3Server : validate cookie session +T3Server --> Frontend : websocket accepted +``` + +### `npx t3` user + +This is the standalone local server flow. + +There is no trusted desktop shell here, so pairing should be explicit. + +```text +Participants: + UserShell = npx t3 launcher + T3Server = standalone local server + Browser = browser tab + +UserShell -> T3Server : start server +T3Server -> T3Server : getOrCreate("server-signing-key") +note over T3Server : policy = LoopbackBrowserPolicy + +UserShell -> T3Server : issue one-time pairing token +T3Server --> UserShell : pairing URL or pairing token + +UserShell --> Browser : open /pair?token=... + +Browser -> T3Server : GET /pair?token=... +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create browser session +T3Server --> Browser : Set-Cookie: session=... +T3Server --> Browser : redirect to app + +Browser -> T3Server : GET /ws + authenticated cookie +T3Server --> Browser : websocket accepted +``` + +### Phone user with tunneled host + +This is the explicit remote access flow for a browser on another device. + +The tunnel only provides reachability. It must not imply trust. + +Recommended UX: + +- desktop shows a QR code +- desktop also shows a copyable pairing URL +- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access + +```text +Participants: + DesktopUser = user at the host machine + DesktopMain = desktop app + Tunnel = tunnel provider + T3Server = T3 server + PhoneBrowser = mobile browser + +DesktopUser -> DesktopMain : enable remote access via tunnel +DesktopMain -> T3Server : switch policy to RemoteReachablePolicy +DesktopMain -> Tunnel : publish local T3 endpoint +Tunnel --> DesktopMain : public https/wss URL + +DesktopMain -> T3Server : issue one-time pairing token +T3Server --> DesktopMain : pairing token +DesktopMain -> DesktopUser : show QR code / shareable URL + +DesktopUser -> PhoneBrowser : scan QR / open URL +PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... +Tunnel -> T3Server : forward request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> Tunnel : GET /ws + authenticated cookie +Tunnel -> T3Server : forward websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Phone user with private network + +This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. + +The auth flow should stay the same. + +```text +Participants: + DesktopUser = user at the host machine + T3Server = T3 server + PrivateNet = tailscale / private LAN + PhoneBrowser = mobile browser + +DesktopUser -> T3Server : enable private-network access +T3Server -> T3Server : switch policy to RemoteReachablePolicy +DesktopUser -> T3Server : issue one-time pairing token +T3Server --> DesktopUser : pairing URL / QR + +DesktopUser -> PhoneBrowser : open private-network URL +PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... +PrivateNet -> T3Server : route request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie +PrivateNet -> T3Server : websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Desktop user adding new SSH hosts + +SSH should be treated as launch and reachability plumbing, not as the long-term auth model. + +The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. + +```text +Participants: + DesktopUser = local desktop user + DesktopMain = desktop app + SSH = ssh transport/session + RemoteHost = remote machine + RemoteT3 = remote T3 server + Frontend = desktop renderer + +DesktopUser -> DesktopMain : add SSH host +DesktopMain -> SSH : connect to remote host +SSH -> RemoteHost : probe environment / verify t3 availability +DesktopMain -> SSH : run remote launch command +SSH -> RemoteHost : t3 remote launch --json +RemoteHost -> RemoteT3 : start or reuse server +RemoteT3 --> RemoteHost : port + environment metadata +RemoteHost --> SSH : launch result JSON +SSH --> DesktopMain : remote server details + +DesktopMain -> SSH : establish local port forward +SSH --> DesktopMain : localhost:FORWARDED_PORT ready + +note over RemoteT3 : policy = RemoteReachablePolicy +note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here + +Frontend -> DesktopMain : request bootstrap for selected environment +DesktopMain --> Frontend : short-lived bootstrap grant + +Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port +RemoteT3 -> RemoteT3 : validate bootstrap grant +RemoteT3 -> RemoteT3 : create browser session +RemoteT3 --> Frontend : Set-Cookie: session=... + +Frontend -> RemoteT3 : GET /ws + authenticated cookie +RemoteT3 --> Frontend : websocket accepted +``` + +## Storage decisions + +### Server secrets + +Use a `ServerSecretStore` abstraction. + +Preferred order (use a layer for each, resolve on startup): + +1. OS secure storage if available +2. hardened filesystem fallback if not + +The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. + +### Client credentials + +Client-side credential persistence should prefer secure storage when available: + +- desktop: OS keychain / secure store +- mobile: platform secure storage +- browser: cookie session for browser auth + +This concern should stay in the client shell/runtime layer, not the server auth layer. + +## What to build now + +These are the parts worth building before remote environments ship: + +1. `ServerAuth` service boundary. +2. route classification and route guards. +3. `ServerSecretStore` abstraction. +4. bootstrap vs session credential split. +5. browser session cookie codec as one session method. +6. explicit auth capabilities/config surfaced in contracts. + +Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. + +## What to add as part of first remote-capable auth + +1. Browser pairing flow using one-time bootstrap token and cookie session. +2. Desktop-managed auto-bootstrap for the local desktop-managed environment. +3. Auth-required defaults for any non-loopback or explicitly published server. +4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. + +## What to defer + +- passkeys / WebAuthn +- iCloud Keychain / Face ID-specific UX +- multi-user permissions +- collaboration roles +- OAuth / SSO +- polished session management UI +- complex device approval flows + +These can all sit on top of the same bootstrap/session/service split. + +## Relationship to future remote environments + +Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. + +Keep the design focused on: + +- one T3 server +- one auth policy +- multiple credential types +- multiple future access methods + +That keeps the server auth model stable even as access methods expand later. + +## Recommended implementation order + +### Phase 1 + +- Introduce route auth classes. +- Add `ServerAuth` and `AuthRouteGuards`. +- Move existing `authToken` check behind `ServerAuth`. +- Require auth for all privileged HTTP routes as well as WebSocket. + +### Phase 2 + +- Add `ServerSecretStore` service with platform-specific layer implementations. + - `layerOSXKeychain`, `layer +- Add bootstrap/session split. +- Add browser session cookie support. +- Add one-time bootstrap exchange endpoint. + +### Phase 3 + +- Add desktop bootstrap flow on top of the same services. +- Make desktop-managed local environments default to bootstrap-only pairing. +- Surface auth capabilities in shared contracts and renderer bootstrap. + +### Phase 4 + +- Add non-browser bearer session support if mobile/native needs it. +- Add richer policy modes for remote-reachable environments. + +## Acceptance criteria + +- No privileged HTTP or WebSocket path bypasses auth policy. +- Local desktop-managed flows still avoid a visible login screen. +- Non-loopback or published environments require explicit authenticated pairing by default. +- Bootstrap and session credentials are distinct in code and in behavior. +- Auth logic is centralized in Effect services/layers rather than route-local branching. diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 5244d51dbf..7c0d55ac9a 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -5,8 +5,17 @@ import { join } from "node:path"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; -const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); -const devServerUrl = `http://localhost:${port}`; +const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); +if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required for desktop development."); +} + +const devServer = new URL(devServerUrl); +const port = Number.parseInt(devServer.port, 10); +if (!Number.isInteger(port) || port <= 0) { + throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`); +} + const requiredFiles = [ "dist-electron/main.js", "dist-electron/preload.js", @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; await waitForResources({ baseDir: desktopDir, files: requiredFiles, + tcpHost: devServer.hostname, tcpPort: port, }); @@ -62,10 +72,7 @@ function startApp() { [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], { cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, + env: childEnv, stdio: "inherit", }, ); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index de327d0ff8..14eb2e3d5b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -83,6 +83,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -92,7 +93,8 @@ type LinuxDesktopNamedApp = Electron.App & { let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; -let backendAuthToken = ""; +let backendBootstrapToken = ""; +let backendHttpUrl = ""; let backendWsUrl = ""; let restartAttempt = 0; let restartTimer: ReturnType | null = null; @@ -141,10 +143,31 @@ function readPersistedBackendObservabilitySettings(): { } } +function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { + if (!rawPort) { + return undefined; + } + + const parsedPort = Number.parseInt(rawPort, 10); + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { + return undefined; + } + + return parsedPort; +} + +function resolveDesktopDevServerUrl(): string { + const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); + if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); + } + + return devServerUrl; +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; - delete env.T3CODE_AUTH_TOKEN; delete env.T3CODE_MODE; delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; @@ -199,6 +222,29 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +async function waitForBackendHttpReady(baseUrl: string): Promise { + const deadline = Date.now() + 10_000; + + for (;;) { + try { + const response = await fetch(`${baseUrl}/api/auth/session`, { + redirect: "manual", + }); + if (response.ok) { + return; + } + } catch { + // Retry until the backend becomes reachable or the deadline expires. + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -543,10 +589,7 @@ function dispatchMenuAction(action: string): void { const send = () => { if (targetWindow.isDestroyed()) return; targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - if (!targetWindow.isVisible()) { - targetWindow.show(); - } - targetWindow.focus(); + revealWindow(targetWindow); }; if (targetWindow.webContents.isLoadingMainFrame()) { @@ -767,6 +810,26 @@ function clearUpdatePollTimer(): void { } } +function revealWindow(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + if (process.platform === "darwin") { + app.focus({ steal: true }); + } + + window.focus(); +} + function emitUpdateState(): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue; @@ -1036,7 +1099,8 @@ function startBackend(): void { noBrowser: true, port: backendPort, t3Home: BASE_DIR, - authToken: backendAuthToken, + host: DESKTOP_LOOPBACK_HOST, + desktopBootstrapToken: backendBootstrapToken, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), @@ -1178,6 +1242,7 @@ function registerIpcHandlers(): void { event.returnValue = { label: "Local environment", wsUrl: backendWsUrl || null, + bootstrapToken: backendBootstrapToken || undefined, } as const; }); @@ -1346,14 +1411,19 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function getInitialWindowBackgroundColor(): string { + return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, height: 780, minWidth: 840, minHeight: 620, - show: false, + show: isDevelopment, autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, titleBarStyle: "hiddenInset", @@ -1410,15 +1480,20 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); - window.once("ready-to-show", () => { - window.show(); - }); + if (!isDevelopment) { + window.once("ready-to-show", () => { + revealWindow(window); + }); + } if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); + void window.loadURL(resolveDesktopDevServerUrl()); window.webContents.openDevTools({ mode: "detach" }); + setImmediate(() => { + revealWindow(window); + }); } else { - void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); + void window.loadURL(resolveDesktopWindowUrl()); } window.on("closed", () => { @@ -1430,6 +1505,14 @@ function createWindow(): BrowserWindow { return window; } +function resolveDesktopWindowUrl(): string { + if (backendHttpUrl) { + return backendHttpUrl; + } + + return `${DESKTOP_SCHEME}://app`; +} + // Override Electron's userData path before the `ready` event so that // Chromium session data uses a filesystem-friendly directory name. // Must be called synchronously at the top level — before `app.whenReady()`. @@ -1439,21 +1522,48 @@ configureAppIdentity(); async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); - backendPort = await Effect.service(NetService).pipe( - Effect.flatMap((net) => net.reserveLoopbackPort()), - Effect.provide(NetService.layer), - Effect.runPromise, + const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); + if (isDevelopment && configuredBackendPort === undefined) { + throw new Error("T3CODE_PORT is required in desktop development."); + } + + backendPort = + configuredBackendPort ?? + (await Effect.service(NetService).pipe( + Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)), + Effect.provide(NetService.layer), + Effect.runPromise, + )); + writeDesktopLogHeader( + configuredBackendPort === undefined + ? `reserved backend port via NetService port=${backendPort}` + : `using configured backend port port=${backendPort}`, ); - writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); - backendAuthToken = Crypto.randomBytes(24).toString("hex"); - const baseUrl = `ws://127.0.0.1:${backendPort}`; - backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; - writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); + backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); + backendHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${backendPort}`; + backendWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${backendPort}`; + writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); startBackend(); writeDesktopLogHeader("bootstrap backend start requested"); + + if (isDevelopment) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + void waitForBackendHttpReady(backendHttpUrl) + .then(() => { + writeDesktopLogHeader("bootstrap backend ready"); + }) + .catch((error) => { + handleFatalStartupError("bootstrap", error); + }); + return; + } + + await waitForBackendHttpReady(backendHttpUrl); + writeDesktopLogHeader("bootstrap backend ready"); mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); } diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts new file mode 100644 index 0000000000..1ff9ef5564 --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -0,0 +1,83 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), + ); + +const makeBootstrapCredentialLayer = ( + overrides?: Partial>, +) => BootstrapCredentialServiceLive.pipe(Layer.provide(makeServerConfigLayer(overrides))); + +it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { + it.effect("issues one-time bootstrap tokens that can only be consumed once", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const token = yield* bootstrapCredentials.issueOneTimeToken(); + const first = yield* bootstrapCredentials.consume(token); + const second = yield* Effect.flip(bootstrapCredentials.consume(token)); + + expect(first.method).toBe("one-time-token"); + expect(second._tag).toBe("BootstrapCredentialError"); + expect(second.message).toContain("Unknown bootstrap credential"); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("atomically consumes a one-time token when multiple requests race", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const token = yield* bootstrapCredentials.issueOneTimeToken(); + const results = yield* Effect.all( + Array.from({ length: 8 }, () => Effect.result(bootstrapCredentials.consume(token))), + { + concurrency: "unbounded", + }, + ); + + const successes = results.filter((result) => result._tag === "Success"); + const failures = results.filter((result) => result._tag === "Failure"); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(7); + for (const failure of failures) { + expect(failure.failure._tag).toBe("BootstrapCredentialError"); + expect(failure.failure.message).toContain("Unknown bootstrap credential"); + } + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("seeds the desktop bootstrap credential as a one-time grant", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); + const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); + + expect(first.method).toBe("desktop-bootstrap"); + expect(second._tag).toBe("BootstrapCredentialError"); + }).pipe( + Effect.provide( + makeBootstrapCredentialLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); +}); diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts new file mode 100644 index 0000000000..4423a000cc --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -0,0 +1,136 @@ +import { Effect, Layer, Ref, DateTime, Duration } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { + BootstrapCredentialError, + BootstrapCredentialService, + type BootstrapCredentialServiceShape, + type BootstrapGrant, +} from "../Services/BootstrapCredentialService.ts"; + +interface StoredBootstrapGrant extends BootstrapGrant { + readonly remainingUses: number | "unbounded"; +} + +type ConsumeResult = + | { + readonly _tag: "error"; + readonly error: BootstrapCredentialError; + } + | { + readonly _tag: "success"; + readonly grant: BootstrapGrant; + }; + +const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); + +export const makeBootstrapCredentialService = Effect.gen(function* () { + const config = yield* ServerConfig; + const grantsRef = yield* Ref.make(new Map()); + + const seedGrant = (credential: string, grant: StoredBootstrapGrant) => + Ref.update(grantsRef, (current) => { + const next = new Map(current); + next.set(credential, grant); + return next; + }); + + if (config.desktopBootstrapToken) { + const now = yield* DateTime.now; + yield* seedGrant(config.desktopBootstrapToken, { + method: "desktop-bootstrap", + expiresAt: DateTime.add(now, { + milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), + }), + remainingUses: 1, + }); + } + + const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => + Effect.gen(function* () { + const credential = crypto.randomUUID(); + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const now = yield* DateTime.now; + yield* seedGrant(credential, { + method: "one-time-token", + expiresAt: DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }), + remainingUses: 1, + }); + return credential; + }); + + const consume: BootstrapCredentialServiceShape["consume"] = (credential) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const result: ConsumeResult = yield* Ref.modify( + grantsRef, + (current): readonly [ConsumeResult, Map] => { + const grant = current.get(credential); + if (!grant) { + return [ + { + _tag: "error", + error: new BootstrapCredentialError({ + message: "Unknown bootstrap credential.", + }), + }, + current, + ]; + } + + const next = new Map(current); + if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { + next.delete(credential); + return [ + { + _tag: "error", + error: new BootstrapCredentialError({ + message: "Bootstrap credential expired.", + }), + }, + next, + ]; + } + + const remainingUses = grant.remainingUses; + if (typeof remainingUses === "number") { + if (remainingUses <= 1) { + next.delete(credential); + } else { + next.set(credential, { + ...grant, + remainingUses: remainingUses - 1, + }); + } + } + + return [ + { + _tag: "success", + grant: { + method: grant.method, + expiresAt: grant.expiresAt, + } satisfies BootstrapGrant, + }, + next, + ]; + }, + ); + + if (result._tag === "error") { + return yield* result.error; + } + + return result.grant; + }); + + return { + issueOneTimeToken, + consume, + } satisfies BootstrapCredentialServiceShape; +}); + +export const BootstrapCredentialServiceLive = Layer.effect( + BootstrapCredentialService, + makeBootstrapCredentialService, +); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts new file mode 100644 index 0000000000..a8fc430bb4 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -0,0 +1,147 @@ +import { type AuthBootstrapResult, type AuthSessionState } from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Option } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import { HttpServerRequest as HttpServerRequestModule } from "effect/unstable/http"; + +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; +import { + ServerAuth, + type AuthenticatedSession, + AuthError, + type ServerAuthShape, +} from "../Services/ServerAuth.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; + +const AUTHORIZATION_PREFIX = "Bearer "; + +function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { + const header = request.headers["authorization"]; + if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { + return null; + } + const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); + return token.length > 0 ? token : null; +} + +function parseQueryToken(request: HttpServerRequest.HttpServerRequest): string | null { + const url = HttpServerRequestModule.toURL(request); + if (Option.isNone(url)) { + return null; + } + const token = url.value.searchParams.get("token"); + return token && token.length > 0 ? token : null; +} + +export const makeServerAuth = Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const bootstrapCredentials = yield* BootstrapCredentialService; + const sessions = yield* SessionCredentialService; + const descriptor = yield* policy.getDescriptor(); + + const authenticateToken = (token: string): Effect.Effect => + sessions.verify(token).pipe( + Effect.map((session) => ({ + subject: session.subject, + method: session.method, + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), + })), + Effect.mapError( + (cause) => + new AuthError({ + message: "Unauthorized request.", + cause, + }), + ), + ); + + const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const cookieToken = request.cookies[sessions.cookieName]; + const bearerToken = parseBearerToken(request); + const queryToken = parseQueryToken(request); + const credential = cookieToken ?? bearerToken ?? queryToken; + if (!credential) { + return Effect.fail( + new AuthError({ + message: "Authentication required.", + }), + ); + } + return authenticateToken(credential); + }; + + const getSessionState: ServerAuthShape["getSessionState"] = (request) => + authenticateRequest(request).pipe( + Effect.map( + (session) => + ({ + authenticated: true, + auth: descriptor, + sessionMethod: session.method, + ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), + }) satisfies AuthSessionState, + ), + Effect.catchTag("AuthError", () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + ), + ); + + const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = ( + credential, + ) => + bootstrapCredentials.consume(credential).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap credential.", + cause, + }), + ), + Effect.flatMap((grant) => + sessions.issue({ + method: "browser-session-cookie", + subject: grant.method, + }), + ), + Effect.map( + (session) => + ({ + authenticated: true, + sessionMethod: session.method, + sessionToken: session.token, + expiresAt: DateTime.toUtc(session.expiresAt), + }) satisfies AuthBootstrapResult, + ), + ); + + const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => + bootstrapCredentials.issueOneTimeToken().pipe( + Effect.map((credential) => { + const url = new URL(baseUrl); + url.pathname = "/pair"; + url.searchParams.set("token", credential); + return url.toString(); + }), + ); + + return { + getDescriptor: () => Effect.succeed(descriptor), + getSessionState, + exchangeBootstrapCredential, + authenticateHttpRequest: authenticateRequest, + authenticateWebSocketUpgrade: authenticateRequest, + issueStartupPairingUrl, + } satisfies ServerAuthShape; +}); + +export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe( + Layer.provideMerge(ServerAuthPolicyLive), + Layer.provideMerge(BootstrapCredentialServiceLive), + Layer.provideMerge(SessionCredentialServiceLive), +); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts new file mode 100644 index 0000000000..bb718ae4d1 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -0,0 +1,32 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; + +const SESSION_COOKIE_NAME = "t3_session"; + +export const makeServerAuthPolicy = Effect.gen(function* () { + const config = yield* ServerConfig; + + const descriptor: ServerAuthDescriptor = + config.mode === "desktop" + ? { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: SESSION_COOKIE_NAME, + } + : { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: SESSION_COOKIE_NAME, + }; + + return { + getDescriptor: () => Effect.succeed(descriptor), + } satisfies ServerAuthPolicyShape; +}); + +export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts new file mode 100644 index 0000000000..b331e476a2 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -0,0 +1,159 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; + +const makeServerConfigLayer = () => + ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); + +const makeServerSecretStoreLayer = () => + ServerSecretStoreLive.pipe(Layer.provide(makeServerConfigLayer())); + +const PermissionDeniedFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + readFile: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: path, + description: "Permission denied while reading secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makePermissionDeniedSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provide(PermissionDeniedFileSystemLayer), + ); + +const RenameFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + rename: (from, to) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: `${String(from)} -> ${String(to)}`, + description: "Permission denied while persisting secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRenameFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provide(RenameFailureFileSystemLayer), + ); + +const RemoveFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + remove: (path, options) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: String(path), + description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRemoveFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provide(RemoveFailureFileSystemLayer), + ); + +it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { + it.effect("returns null when a secret file does not exist", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const secret = yield* secretStore.get("missing-secret"); + + expect(secret).toBeNull(); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("reuses an existing secret instead of regenerating it", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + + expect(Array.from(second)).toEqual(Array.from(first)); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("propagates read failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to read secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), + ); + + it.effect("propagates write failures instead of treating them as success", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip( + secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), + ); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to persist secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), + ); + + it.effect("propagates remove failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.remove("session-signing-key")); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to remove secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts new file mode 100644 index 0000000000..033d84c5fe --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -0,0 +1,97 @@ +import * as Crypto from "node:crypto"; + +import { Effect, FileSystem, Layer, Path } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { + SecretStoreError, + ServerSecretStore, + type ServerSecretStoreShape, +} from "../Services/ServerSecretStore.ts"; + +export const makeServerSecretStore = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); + + const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + + const isMissingSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => + cause instanceof PlatformError.PlatformError && cause.reason._tag === "NotFound"; + + const get: ServerSecretStoreShape["get"] = (name) => + fileSystem.readFile(resolveSecretPath(name)).pipe( + Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.catch((cause) => + isMissingSecretFileError(cause) + ? Effect.succeed(null) + : Effect.fail( + new SecretStoreError({ + message: `Failed to read secret ${name}.`, + cause, + }), + ), + ), + ); + + const set: ServerSecretStoreShape["set"] = (name, value) => { + const secretPath = resolveSecretPath(name); + const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; + return Effect.gen(function* () { + yield* fileSystem.writeFile(tempPath, value); + yield* fileSystem.rename(tempPath, secretPath); + }).pipe( + Effect.catch((cause) => + fileSystem.remove(tempPath).pipe( + Effect.ignore, + Effect.flatMap(() => + Effect.fail( + new SecretStoreError({ + message: `Failed to persist secret ${name}.`, + cause, + }), + ), + ), + ), + ), + ); + }; + + const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + get(name).pipe( + Effect.flatMap((existing) => { + if (existing) { + return Effect.succeed(existing); + } + + const generated = Crypto.randomBytes(bytes); + return set(name, generated).pipe(Effect.as(Uint8Array.from(generated))); + }), + ); + + const remove: ServerSecretStoreShape["remove"] = (name) => + fileSystem.remove(resolveSecretPath(name)).pipe( + Effect.catch((cause) => + isMissingSecretFileError(cause) + ? Effect.void + : Effect.fail( + new SecretStoreError({ + message: `Failed to remove secret ${name}.`, + cause, + }), + ), + ), + ); + + return { + get, + set, + getOrCreateRandom, + remove, + } satisfies ServerSecretStoreShape; +}); + +export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts new file mode 100644 index 0000000000..166a0e4626 --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -0,0 +1,70 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); + +const makeSessionCredentialLayer = ( + overrides?: Partial>, +) => + SessionCredentialServiceLive.pipe( + Layer.provide(ServerSecretStoreLive), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { + it.effect("issues and verifies signed browser session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + subject: "desktop-bootstrap", + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("browser-session-cookie"); + expect(verified.subject).toBe("desktop-bootstrap"); + expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + it.effect("rejects malformed session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const error = yield* Effect.flip(sessions.verify("not-a-session-token")); + + expect(error._tag).toBe("SessionCredentialError"); + expect(error.message).toContain("Malformed session token"); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + it.effect("verifies session tokens against the Effect clock", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + method: "bearer-session-token", + subject: "test-clock", + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("bearer-session-token"); + expect(verified.subject).toBe("test-clock"); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); +}); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts new file mode 100644 index 0000000000..ad14f3e9f6 --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -0,0 +1,108 @@ +import { Clock, DateTime, Duration, Effect, Layer, Schema } from "effect"; + +import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { + SessionCredentialError, + SessionCredentialService, + type IssuedSession, + type SessionCredentialServiceShape, + type VerifiedSession, +} from "../Services/SessionCredentialService.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../tokenCodec.ts"; + +const SIGNING_SECRET_NAME = "server-signing-key"; +const DEFAULT_SESSION_TTL = Duration.days(30); + +const SessionClaims = Schema.Struct({ + v: Schema.Literal(1), + kind: Schema.Literal("session"), + sub: Schema.String, + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + iat: Schema.Number, + exp: Schema.Number, +}); +type SessionClaims = typeof SessionClaims.Type; + +export const makeSessionCredentialService = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); + + const issue: SessionCredentialServiceShape["issue"] = Effect.fn("issue")(function* (input) { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), + }); + const claims: SessionClaims = { + v: 1, + kind: "session", + sub: input?.subject ?? "browser", + method: input?.method ?? "browser-session-cookie", + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = signPayload(encodedPayload, signingSecret); + + return { + token: `${encodedPayload}.${signature}`, + method: claims.method, + expiresAt: expiresAt, + } satisfies IssuedSession; + }); + + const verify: SessionCredentialServiceShape["verify"] = Effect.fn("verify")(function* (token) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new SessionCredentialError({ + message: "Malformed session token.", + }); + } + + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new SessionCredentialError({ + message: "Invalid session token signature.", + }); + } + + const claims = yield* Effect.try({ + try: () => + Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), + catch: (cause) => + new SessionCredentialError({ + message: "Invalid session token payload.", + cause, + }), + }); + + const now = yield* Clock.currentTimeMillis; + if (claims.exp <= now) { + return yield* new SessionCredentialError({ + message: "Session token expired.", + }); + } + + return { + token, + method: claims.method, + expiresAt: DateTime.makeUnsafe(claims.exp), + subject: claims.sub, + } satisfies VerifiedSession; + }); + + return { + cookieName: "t3_session", + issue, + verify, + } satisfies SessionCredentialServiceShape; +}); + +export const SessionCredentialServiceLive = Layer.effect( + SessionCredentialService, + makeSessionCredentialService, +); diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts new file mode 100644 index 0000000000..dd4083a77a --- /dev/null +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -0,0 +1,25 @@ +import type { ServerAuthBootstrapMethod } from "@t3tools/contracts"; +import { Data, DateTime, Duration, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface BootstrapGrant { + readonly method: ServerAuthBootstrapMethod; + readonly expiresAt: DateTime.DateTime; +} + +export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface BootstrapCredentialServiceShape { + readonly issueOneTimeToken: (input?: { + readonly ttl?: Duration.Duration; + }) => Effect.Effect; + readonly consume: (credential: string) => Effect.Effect; +} + +export class BootstrapCredentialService extends ServiceMap.Service< + BootstrapCredentialService, + BootstrapCredentialServiceShape +>()("t3/auth/Services/BootstrapCredentialService") {} diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts new file mode 100644 index 0000000000..005d7b9f9f --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -0,0 +1,41 @@ +import type { + AuthBootstrapResult, + AuthSessionState, + ServerAuthDescriptor, + ServerAuthSessionMethod, +} from "@t3tools/contracts"; +import { Data, DateTime, ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; + +export interface AuthenticatedSession { + readonly subject: string; + readonly method: ServerAuthSessionMethod; + readonly expiresAt?: DateTime.DateTime; +} + +export class AuthError extends Data.TaggedError("AuthError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface ServerAuthShape { + readonly getDescriptor: () => Effect.Effect; + readonly getSessionState: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + credential: string, + ) => Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect; +} + +export class ServerAuth extends ServiceMap.Service()( + "t3/auth/Services/ServerAuth", +) {} diff --git a/apps/server/src/auth/Services/ServerAuthPolicy.ts b/apps/server/src/auth/Services/ServerAuthPolicy.ts new file mode 100644 index 0000000000..43dae6ca69 --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuthPolicy.ts @@ -0,0 +1,11 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerAuthPolicyShape { + readonly getDescriptor: () => Effect.Effect; +} + +export class ServerAuthPolicy extends ServiceMap.Service()( + "t3/auth/Services/ServerAuthPolicy", +) {} diff --git a/apps/server/src/auth/Services/ServerSecretStore.ts b/apps/server/src/auth/Services/ServerSecretStore.ts new file mode 100644 index 0000000000..376527aea3 --- /dev/null +++ b/apps/server/src/auth/Services/ServerSecretStore.ts @@ -0,0 +1,22 @@ +import { Data, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface ServerSecretStoreShape { + readonly get: (name: string) => Effect.Effect; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; +} + +export class ServerSecretStore extends ServiceMap.Service< + ServerSecretStore, + ServerSecretStoreShape +>()("t3/auth/Services/ServerSecretStore") {} diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts new file mode 100644 index 0000000000..dabf03c816 --- /dev/null +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -0,0 +1,36 @@ +import type { ServerAuthSessionMethod } from "@t3tools/contracts"; +import { Data, DateTime, Duration, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface IssuedSession { + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly expiresAt: DateTime.DateTime; +} + +export interface VerifiedSession { + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly expiresAt?: DateTime.DateTime; + readonly subject: string; +} + +export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface SessionCredentialServiceShape { + readonly cookieName: string; + readonly issue: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly method?: ServerAuthSessionMethod; + }) => Effect.Effect; + readonly verify: (token: string) => Effect.Effect; +} + +export class SessionCredentialService extends ServiceMap.Service< + SessionCredentialService, + SessionCredentialServiceShape +>()("t3/auth/Services/SessionCredentialService") {} diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts new file mode 100644 index 0000000000..f8efef7f0b --- /dev/null +++ b/apps/server/src/auth/http.ts @@ -0,0 +1,52 @@ +import { AuthBootstrapInput } from "@t3tools/contracts"; +import { DateTime, Effect } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; + +export const toUnauthorizedResponse = (error: AuthError) => + HttpServerResponse.jsonUnsafe( + { + error: error.message, + }, + { status: 401 }, + ); + +export const authSessionRouteLayer = HttpRouter.add( + "GET", + "/api/auth/session", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.getSessionState(request); + return HttpServerResponse.jsonUnsafe(session, { status: 200 }); + }), +); + +export const authBootstrapRouteLayer = HttpRouter.add( + "POST", + "/api/auth/bootstrap", + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + const descriptor = yield* serverAuth.getDescriptor(); + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap payload.", + cause, + }), + ), + ); + const result = yield* serverAuth.exchangeBootstrapCredential(payload.credential); + + return yield* HttpServerResponse.jsonUnsafe(result, { status: 200 }).pipe( + HttpServerResponse.setCookie(descriptor.sessionCookieName, result.sessionToken, { + expires: DateTime.toDate(result.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ); + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), +); diff --git a/apps/server/src/auth/tokenCodec.ts b/apps/server/src/auth/tokenCodec.ts new file mode 100644 index 0000000000..9345f334b0 --- /dev/null +++ b/apps/server/src/auth/tokenCodec.ts @@ -0,0 +1,23 @@ +import * as Crypto from "node:crypto"; + +export function base64UrlEncode(input: string | Uint8Array): string { + const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); + return buffer.toString("base64url"); +} + +export function base64UrlDecodeUtf8(input: string): string { + return Buffer.from(input, "base64url").toString("utf8"); +} + +export function signPayload(payload: string, secret: Uint8Array): string { + return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); +} + +export function timingSafeEqualBase64Url(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "base64url"); + const rightBuffer = Buffer.from(right, "base64url"); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return Crypto.timingSafeEqual(leftBuffer, rightBuffer); +} diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index ef2f9f55d8..63b3974658 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "true", - T3CODE_AUTH_TOKEN: "env-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", }, @@ -85,7 +83,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "env-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -106,7 +103,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.some(true), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), @@ -125,7 +121,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: join(os.tmpdir(), "ignored-base"), VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "false", - T3CODE_AUTH_TOKEN: "ignored-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "false", }, @@ -148,7 +143,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -166,7 +160,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: baseDir, devUrl: "http://127.0.0.1:5173", noBrowser: true, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -183,7 +176,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -218,7 +210,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -242,7 +233,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.some(customCwd), devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -285,7 +275,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: "/tmp/t3-bootstrap-home", devUrl: "http://127.0.0.1:5173", noBrowser: false, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); @@ -300,7 +289,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.none(), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -338,7 +326,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -371,7 +358,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -402,7 +388,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: resolved.staticDir, devUrl: undefined, noBrowser: true, - authToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 9ece02a0d3..7d29e99c17 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -25,7 +25,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ t3Home: Schema.optional(Schema.String), devUrl: Schema.optional(Schema.URLFromString), noBrowser: Schema.optional(Schema.Boolean), - authToken: Schema.optional(Schema.String), + desktopBootstrapToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), otlpTracesUrl: Schema.optional(Schema.String), @@ -58,11 +58,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Disable automatic browser opening."), Flag.optional, ); -const authTokenFlag = Flag.string("auth-token").pipe( - Flag.withDescription("Auth token required for WebSocket connections."), - Flag.withAlias("token"), - Flag.optional, -); const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( Flag.withSchema(Schema.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), @@ -117,10 +112,6 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), - authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -143,7 +134,6 @@ interface CliServerFlags { readonly cwd: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; - readonly authToken: Option.Option; readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; @@ -248,13 +238,9 @@ export const resolveServerConfig = ( () => mode === "desktop", ), ); - const authToken = Option.getOrUndefined( - resolveOptionPrecedence( - flags.authToken, - Option.fromUndefinedOr(env.authToken), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.authToken), - ), + const desktopBootstrapToken = Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.desktopBootstrapToken), ), ); const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -327,7 +313,7 @@ export const resolveServerConfig = ( staticDir, devUrl, noBrowser, - authToken, + desktopBootstrapToken, autoBootstrapProjectFromCwd, logWebSocketEvents, }; @@ -348,7 +334,6 @@ const commandFlags = { ), devUrl: devUrlFlag, noBrowser: noBrowserFlag, - authToken: authTokenFlag, bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9ceea4c13c..6fd52f1d93 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { readonly providerEventLogPath: string; readonly terminalLogsDir: string; readonly anonymousIdPath: string; + readonly secretsDir: string; } /** @@ -54,7 +55,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; - readonly authToken: string | undefined; + readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; } @@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( providerEventLogPath: join(providerLogsDir, "events.log"), terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), + secretsDir: join(stateDir, "secrets"), }; }); @@ -145,7 +147,7 @@ export class ServerConfig extends ServiceMap.Service`; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; +const requireAuthenticatedRequest = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request); +}); + class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ readonly cause: unknown; readonly bodyJson: OtlpTracer.TraceData; @@ -35,6 +43,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; @@ -76,7 +85,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), ).pipe( Layer.provide( HttpRouter.cors({ @@ -91,6 +100,7 @@ export const attachmentsRouteLayer = HttpRouter.add( "GET", `${ATTACHMENTS_ROUTE_PREFIX}/*`, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -139,13 +149,14 @@ export const attachmentsRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), ); export const projectFaviconRouteLayer = HttpRouter.add( "GET", "/api/project-favicon", Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -179,7 +190,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), ); export const staticAndDevRouteLayer = HttpRouter.add( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5ea6cb8e6d..35a3e5db38 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -86,9 +86,12 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; const defaultProjectId = ProjectId.makeUnsafe("project-default"); const defaultThreadId = ThreadId.makeUnsafe("thread-default"); +const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", @@ -105,6 +108,7 @@ const testEnvironmentDescriptor = { repositoryIdentity: true, }, }; +let cachedDefaultSessionToken: string | null = null; const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); @@ -164,6 +168,8 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); +const authTestLayer = ServerAuthLive.pipe(Layer.provide(ServerSecretStoreLive)); + const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { const collector = yield* Effect.acquireRelease( @@ -287,6 +293,7 @@ const buildAppUnderTest = (options?: { }; }) => Effect.gen(function* () { + cachedDefaultSessionToken = null; const fileSystem = yield* FileSystem.FileSystem; const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); const baseDir = options?.config?.baseDir ?? tempBaseDir; @@ -303,7 +310,7 @@ const buildAppUnderTest = (options?: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", - mode: "web", + mode: "desktop", port: 0, host: "127.0.0.1", cwd: process.cwd(), @@ -312,7 +319,7 @@ const buildAppUnderTest = (options?: { staticDir: undefined, devUrl, noBrowser: true, - authToken: undefined, + desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, @@ -325,6 +332,10 @@ const buildAppUnderTest = (options?: { }).pipe( Layer.provide( Layer.mock(Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), streamChanges: Stream.empty, ...options?.layers?.keybindings, }), @@ -442,6 +453,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), + Layer.provideMerge(authTestLayer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -466,6 +478,13 @@ const withWsRpcClient = ( f: (client: WsRpcClient) => Effect.Effect, ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); +const appendSessionTokenToUrl = (url: string, sessionToken: string) => { + const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); + const next = new URL(url, "http://localhost"); + next.searchParams.set("token", sessionToken); + return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; +}; + const getHttpServerUrl = (pathname = "") => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; @@ -473,11 +492,68 @@ const getHttpServerUrl = (pathname = "") => return `http://127.0.0.1:${address.port}${pathname}`; }); -const getWsServerUrl = (pathname = "") => +const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap"); + const response = yield* Effect.promise(() => + fetch(bootstrapUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + credential, + }), + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly sessionMethod: string; + readonly sessionToken: string; + readonly expiresAt: string; + }; + return { + response, + body, + cookie: response.headers.get("set-cookie"), + }; + }); + +const getAuthenticatedSessionToken = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + if (credential === defaultDesktopBootstrapToken && cachedDefaultSessionToken) { + return cachedDefaultSessionToken; + } + + const { response, body } = yield* bootstrapBrowserSession(credential); + if (!response.ok) { + return yield* Effect.fail( + new Error(`Expected bootstrap session response to succeed, got ${response.status}`), + ); + } + + if (credential === defaultDesktopBootstrapToken) { + cachedDefaultSessionToken = body.sessionToken; + } + + return body.sessionToken; + }); + +const getWsServerUrl = ( + pathname = "", + options?: { authenticated?: boolean; sessionToken?: string; credential?: string }, +) => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; const address = server.address as HttpServer.TcpAddress; - return `ws://127.0.0.1:${address.port}${pathname}`; + const baseUrl = `ws://127.0.0.1:${address.port}${pathname}`; + if (options?.authenticated === false) { + return baseUrl; + } + return appendSessionTokenToUrl( + baseUrl, + options?.sessionToken ?? (yield* getAuthenticatedSessionToken(options?.credential)), + ); }); it.layer(NodeServices.layer)("server router seam", (it) => { @@ -528,7 +604,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + appendSessionTokenToUrl( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 200); @@ -548,7 +627,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + appendSessionTokenToUrl( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 200); @@ -556,6 +638,105 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports unauthenticated session state without requiring auth", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const url = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* Effect.promise(() => fetch(url)); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly auth: { + readonly policy: string; + readonly bootstrapMethods: ReadonlyArray; + readonly sessionMethods: ReadonlyArray; + readonly sessionCookieName: string; + }; + }; + + assert.equal(response.status, 200); + assert.equal(body.authenticated, false); + assert.equal(body.auth.policy, "desktop-managed-local"); + assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); + assert.deepEqual(body.auth.sessionMethods, [ + "browser-session-cookie", + "bearer-session-token", + ]); + assert.equal(body.auth.sessionCookieName, "t3_session"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("bootstraps a browser session and authenticates the session endpoint via cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { + response: bootstrapResponse, + body: bootstrapBody, + cookie: setCookie, + } = yield* bootstrapBrowserSession(); + + assert.equal(bootstrapResponse.status, 200); + assert.equal(bootstrapBody.authenticated, true); + assert.equal(bootstrapBody.sessionMethod, "browser-session-cookie"); + assert.isDefined(setCookie); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* Effect.promise(() => + fetch(sessionUrl, { + headers: { + cookie: setCookie?.split(";")[0] ?? "", + }, + }), + ); + const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { + readonly authenticated: boolean; + readonly sessionMethod?: string; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "browser-session-cookie"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const first = yield* bootstrapBrowserSession(); + const second = yield* bootstrapBrowserSession(); + + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 401); + assert.equal( + (second.body as { readonly error?: string }).error, + "Invalid bootstrap credential.", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("accepts websocket rpc handshake with a bootstrapped session token", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBrowserSession(); + + assert.equal(bootstrapResponse.status, 200); + + const wsUrl = appendSessionTokenToUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + bootstrapBody.sessionToken, + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(response.auth.policy, "desktop-managed-local"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves attachment files from state dir", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -572,7 +753,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - const response = yield* HttpClient.get(`/attachments/${attachmentId}`); + const response = yield* HttpClient.get( + appendSessionTokenToUrl( + `/attachments/${attachmentId}`, + yield* getAuthenticatedSessionToken(), + ), + ); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-ok"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -594,7 +780,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); const response = yield* HttpClient.get( - "/attachments/thread%20folder/message%20folder/file%20name.png", + appendSessionTokenToUrl( + "/attachments/thread%20folder/message%20folder/file%20name.png", + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-encoded-ok"); @@ -731,6 +920,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + authorization: `Bearer ${yield* getAuthenticatedSessionToken()}`, "content-type": "application/json", origin: "http://localhost:5733", }, @@ -839,6 +1029,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + authorization: `Bearer ${yield* getAuthenticatedSessionToken()}`, "content-type": "application/json", }, body: HttpBody.text(JSON.stringify(payload), "application/json"), @@ -885,7 +1076,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const response = yield* HttpClient.get( - "/attachments/missing-11111111-1111-4111-8111-111111111111", + appendSessionTokenToUrl( + "/attachments/missing-11111111-1111-4111-8111-111111111111", + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 404); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -927,7 +1121,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("rejects websocket rpc handshake when auth token is missing", () => + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -937,13 +1131,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "export const needle = 1;", ); - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); + yield* buildAppUnderTest(); - const wsUrl = yield* getWsServerUrl("/ws"); + const wsUrl = yield* getWsServerUrl("/ws", { authenticated: false }); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.projectsSearchEntries]({ @@ -959,38 +1149,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("accepts websocket rpc handshake when auth token is provided", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" }); - yield* fs.writeFileString( - path.join(workspaceDir, "needle-file.ts"), - "export const needle = 1;", - ); - - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws?token=secret-token"); - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.projectsSearchEntries]({ - cwd: workspaceDir, - query: "needle", - limit: 10, - }), - ), - ); - - assert.isAtLeast(response.entries.length, 1); - assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { const providers = [] as const; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f202397165..f2d4e4eace 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,6 +50,9 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +import { authBootstrapRouteLayer, authSessionRouteLayer } from "./auth/http"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -186,6 +189,8 @@ const WorkspaceLayerLive = Layer.mergeAll( ), ); +const AuthLayerLive = ServerAuthLive.pipe(Layer.provide(ServerSecretStoreLive)); + const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), @@ -201,6 +206,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), + Layer.provideMerge(AuthLayerLive), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), @@ -213,6 +219,8 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( ); export const makeRoutesLayer = Layer.mergeAll( + authBootstrapRouteLayer, + authSessionRouteLayer, attachmentsRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e94c322225..ab139c2adc 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -29,6 +29,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerSettingsService } from "./serverSettings"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { ServerAuth } from "./auth/Services/ServerAuth"; const isWildcardHost = (host: string | undefined): boolean => host === "0.0.0.0" || host === "::" || host === "[::]"; @@ -229,28 +230,39 @@ const autoBootstrapWelcome = Effect.gen(function* () { } as const; }); -const maybeOpenBrowser = Effect.gen(function* () { +const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig; - if (serverConfig.noBrowser) { - return; - } - const { openBrowser } = yield* Open; + const serverAuth = yield* ServerAuth; const localUrl = `http://localhost:${serverConfig.port}`; const bindUrl = serverConfig.host && !isWildcardHost(serverConfig.host) ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; - const target = serverConfig.devUrl?.toString() ?? bindUrl; - - yield* openBrowser(target).pipe( - Effect.catch(() => - Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, - }), + const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; + return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( + Effect.flatMap((target) => + target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), ), ); }); +const maybeOpenBrowser = (target: string) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const { openBrowser } = yield* Open; + + yield* openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + }); + const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( Effect.annotateSpans({ "startup.phase": phase }), @@ -371,7 +383,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; yield* Effect.logDebug("startup phase: browser open check"); - yield* runStartupPhase("browser.open", maybeOpenBrowser); + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( + Effect.annotateLogs({ pairingUrl: startupBrowserTarget }), + ); + } + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); yield* Effect.logDebug("startup phase: complete"); }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f1096eb4c..e60114ba6d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; import { CommandId, EventId, @@ -20,7 +20,7 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { clamp } from "effect/Number"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; @@ -48,6 +48,8 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; +import { ServerAuth } from "./auth/Services/ServerAuth"; +import { toUnauthorizedResponse } from "./auth/http"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -69,6 +71,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; + const serverAuth = yield* ServerAuth; const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -376,9 +379,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( const providers = yield* providerRegistry.getProviders; const settings = yield* serverSettings.getSettings; const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); return { environment, + auth, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, @@ -787,19 +792,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( "/ws", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const config = yield* ServerConfig; - if (config.authToken) { - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); - } - const token = url.value.searchParams.get("token"); - if (token !== config.authToken) { - return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); - } - } + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateWebSocketUpgrade(request); return yield* rpcWebSocketHttpEffect; - }), + }).pipe( + Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error))), + ), ); }), ); diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d019..a83e403ecd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,72 @@ + + T3 Code (Alpha) -
+
+
+
+ +
+
+
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts new file mode 100644 index 0000000000..509ce34edb --- /dev/null +++ b/apps/web/src/authBootstrap.test.ts @@ -0,0 +1,235 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json", + }, + status: 200, + ...init, + }); +} + +type TestWindow = { + location: URL; + history: { + replaceState: (_data: unknown, _unused: string, url: string) => void; + }; + desktopBridge?: DesktopBridge; +}; + +function installTestBrowser(url: string) { + const testWindow: TestWindow = { + location: new URL(url), + history: { + replaceState: (_data, _unused, nextUrl) => { + testWindow.location = new URL(nextUrl, testWindow.location.href); + }, + }, + }; + + vi.stubGlobal("window", testWindow); + vi.stubGlobal("document", { title: "T3 Code" }); + + return testWindow; +} + +describe("resolveInitialServerAuthGateState", () => { + beforeEach(() => { + vi.restoreAllMocks(); + installTestBrowser("http://localhost/"); + }); + + afterEach(async () => { + const { __resetServerAuthBootstrapForTests } = await import("./authBootstrap"); + __resetServerAuthBootstrapForTests(); + vi.restoreAllMocks(); + }); + + it("reuses an in-flight silent bootstrap attempt", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + sessionToken: "session-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + wsUrl: "ws://localhost:3773/ws", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost/api/auth/session"); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost/api/auth/bootstrap"); + }); + + it("uses https fetch urls when the primary environment uses wss", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_WS_URL", "wss://remote.example.com/ws"); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", { + credentials: "include", + }); + }); + + it("uses the current origin as an auth proxy base for local dev environments", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3773/ws"); + installTestBrowser("http://localhost:5735/"); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", { + credentials: "include", + }); + }); + + it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + }); + + it("takes a pairing token from the location and strips it immediately", async () => { + const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); + const { takePairingTokenFromUrl } = await import("./authBootstrap"); + + expect(takePairingTokenFromUrl()).toBe("pairing-token"); + expect(testWindow.location.searchParams.get("token")).toBeNull(); + }); + + it("allows manual token submission after the initial auth check requires pairing", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + sessionToken: "session-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + installTestBrowser("http://localhost/"); + + const { resolveInitialServerAuthGateState, submitServerAuthCredential } = + await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts new file mode 100644 index 0000000000..d4d4059d87 --- /dev/null +++ b/apps/web/src/authBootstrap.ts @@ -0,0 +1,130 @@ +import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts"; +import { resolveServerHttpUrl } from "./lib/utils"; + +export type ServerAuthGateState = + | { status: "authenticated" } + | { + status: "requires-auth"; + auth: AuthSessionState["auth"]; + errorMessage?: string; + }; + +let bootstrapPromise: Promise | null = null; + +export function peekPairingTokenFromUrl(): string | null { + const url = new URL(window.location.href); + const token = url.searchParams.get("token"); + return token && token.length > 0 ? token : null; +} + +export function stripPairingTokenFromUrl() { + const url = new URL(window.location.href); + if (!url.searchParams.has("token")) { + return; + } + url.searchParams.delete("token"); + window.history.replaceState({}, document.title, url.toString()); +} + +export function takePairingTokenFromUrl(): string | null { + const token = peekPairingTokenFromUrl(); + if (!token) { + return null; + } + stripPairingTokenFromUrl(); + return token; +} + +function getBootstrapCredential(): string | null { + return getDesktopBootstrapCredential(); +} + +function getDesktopBootstrapCredential(): string | null { + const bootstrap = window.desktopBridge?.getLocalEnvironmentBootstrap(); + return typeof bootstrap?.bootstrapToken === "string" && bootstrap.bootstrapToken.length > 0 + ? bootstrap.bootstrapToken + : null; +} + +async function fetchSessionState(): Promise { + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/session" }), { + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Failed to load auth session state (${response.status}).`); + } + return (await response.json()) as AuthSessionState; +} + +async function exchangeBootstrapCredential(credential: string): Promise { + const payload: AuthBootstrapInput = { credential }; + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/bootstrap" }), { + body: JSON.stringify(payload), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Failed to bootstrap auth session (${response.status}).`); + } + + return (await response.json()) as AuthBootstrapResult; +} + +async function bootstrapServerAuth(): Promise { + const bootstrapCredential = getBootstrapCredential(); + const currentSession = await fetchSessionState(); + if (currentSession.authenticated) { + return { status: "authenticated" }; + } + + if (!bootstrapCredential) { + return { + status: "requires-auth", + auth: currentSession.auth, + }; + } + + try { + await exchangeBootstrapCredential(bootstrapCredential); + return { status: "authenticated" }; + } catch (error) { + return { + status: "requires-auth", + auth: currentSession.auth, + errorMessage: error instanceof Error ? error.message : "Authentication failed.", + }; + } +} + +export async function submitServerAuthCredential(credential: string): Promise { + const trimmedCredential = credential.trim(); + if (!trimmedCredential) { + throw new Error("Enter a pairing token to continue."); + } + + await exchangeBootstrapCredential(trimmedCredential); + bootstrapPromise = Promise.resolve({ status: "authenticated" } satisfies ServerAuthGateState); + stripPairingTokenFromUrl(); +} + +export function resolveInitialServerAuthGateState(): Promise { + if (bootstrapPromise) { + return bootstrapPromise; + } + + bootstrapPromise = bootstrapServerAuth().catch((error) => { + bootstrapPromise = null; + throw error; + }); + + return bootstrapPromise; +} + +export function __resetServerAuthBootstrapForTests() { + bootstrapPromise = null; +} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index da7f9cb92e..b3ec23d8b4 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -35,6 +35,7 @@ import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -127,6 +128,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -724,6 +731,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 9f7b60b996..1122acc987 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -22,6 +22,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; const THREAD_ID = "thread-kb-toast-test" as ThreadId; @@ -48,6 +49,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -214,6 +221,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 58426f50ba..093739d6c5 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,12 +1,11 @@ import { FolderIcon } from "lucide-react"; import { useState } from "react"; -import { resolveServerUrl } from "~/lib/utils"; +import { resolveServerHttpUrl } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Set(); export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { - const src = resolveServerUrl({ - protocol: "http", + const src = resolveServerHttpUrl({ pathname: "/api/project-favicon", searchParams: { cwd }, }); diff --git a/apps/web/src/components/SplashScreen.tsx b/apps/web/src/components/SplashScreen.tsx new file mode 100644 index 0000000000..a0b593a950 --- /dev/null +++ b/apps/web/src/components/SplashScreen.tsx @@ -0,0 +1,9 @@ +export function SplashScreen() { + return ( +
+
+ T3 Code +
+
+ ); +} diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 1855046a62..c07f59bb9d 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -1,9 +1,6 @@ -import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react"; import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "../branding"; import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { useServerConfig } from "../rpc/serverState"; import { exhaustWsReconnectIfStillWaiting, getWsConnectionStatus, @@ -14,7 +11,6 @@ import { useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; -import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { getWsRpcClient } from "~/wsRpcClient"; @@ -58,11 +54,7 @@ function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } -function buildReconnectTitle(status: WsConnectionStatus): string { - if (status.nextRetryAt === null) { - return "Disconnected from T3 Server"; - } - +function buildReconnectTitle(_status: WsConnectionStatus): string { return "Disconnected from T3 Server"; } @@ -113,155 +105,6 @@ export function shouldAutoReconnect( ); } -function buildBlockingCopy( - uiState: WsConnectionUiState, - status: WsConnectionStatus, -): { - readonly description: string; - readonly eyebrow: string; - readonly title: string; -} { - if (uiState === "connecting") { - return { - description: `Opening the WebSocket connection to the ${APP_DISPLAY_NAME} server and waiting for the initial config snapshot.`, - eyebrow: "Starting Session", - title: `Connecting to ${APP_DISPLAY_NAME}`, - }; - } - - if (uiState === "offline") { - return { - description: - "Your browser is offline, so the web client cannot reach the T3 server. Reconnect to the network and the app will retry automatically.", - eyebrow: "Offline", - title: "WebSocket connection unavailable", - }; - } - - if (status.lastError?.trim()) { - return { - description: `${status.lastError} Verify that the T3 server is running and reachable, then reload the app if needed.`, - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; - } - - return { - description: - "The web client could not complete its initial WebSocket connection to the T3 server. It will keep retrying in the background.", - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; -} - -function buildConnectionDetails(status: WsConnectionStatus, uiState: WsConnectionUiState): string { - const details = [ - `state: ${uiState}`, - `online: ${status.online ? "yes" : "no"}`, - `attempts: ${status.attemptCount}`, - ]; - - if (status.socketUrl) { - details.push(`socket: ${status.socketUrl}`); - } - if (status.connectedAt) { - details.push(`connectedAt: ${status.connectedAt}`); - } - if (status.disconnectedAt) { - details.push(`disconnectedAt: ${status.disconnectedAt}`); - } - if (status.lastErrorAt) { - details.push(`lastErrorAt: ${status.lastErrorAt}`); - } - if (status.lastError) { - details.push(`lastError: ${status.lastError}`); - } - if (status.closeCode !== null) { - details.push(`closeCode: ${status.closeCode}`); - } - if (status.closeReason) { - details.push(`closeReason: ${status.closeReason}`); - } - - return details.join("\n"); -} - -function WebSocketBlockingState({ - status, - uiState, -}: { - readonly status: WsConnectionStatus; - readonly uiState: WsConnectionUiState; -}) { - const copy = buildBlockingCopy(uiState, status); - const disconnectedAt = formatConnectionMoment(status.disconnectedAt ?? status.lastErrorAt); - const Icon = - uiState === "connecting" ? LoaderCircle : uiState === "offline" ? CloudOff : AlertTriangle; - - return ( -
-
-
-
-
- -
-
-
-

- {copy.eyebrow} -

-

{copy.title}

-
-
- -
-
- -

{copy.description}

- -
-
-

- Connection -

-

- {uiState === "connecting" - ? "Opening WebSocket" - : uiState === "offline" - ? "Waiting for network" - : "Retrying server connection"} -

-
-
-

- Latest Event -

-

{disconnectedAt ?? "Pending"}

-
-
- -
- -
- -
- - Show connection details - Hide connection details - -
-            {buildConnectionDetails(status, uiState)}
-          
-
-
-
- ); -} - export function WebSocketConnectionCoordinator() { const status = useWsConnectionStatus(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -525,20 +368,3 @@ export function SlowRpcAckToastCoordinator() { return null; } - -export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - const serverConfig = useServerConfig(); - const status = useWsConnectionStatus(); - - if (serverConfig === null) { - const uiState = getWsConnectionUiState(status); - return ( - - ); - } - - return children; -} diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx new file mode 100644 index 0000000000..f404ae9e53 --- /dev/null +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -0,0 +1,195 @@ +import type { AuthSessionState } from "@t3tools/contracts"; +import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; + +import { APP_DISPLAY_NAME } from "../../branding"; +import { + peekPairingTokenFromUrl, + stripPairingTokenFromUrl, + submitServerAuthCredential, +} from "../../authBootstrap"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +export function PairingPendingSurface() { + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ Pairing with this environment +

+

+ Validating the pairing link and preparing your session. +

+
+
+ ); +} + +export function PairingRouteSurface({ + auth, + initialErrorMessage, + onAuthenticated, +}: { + auth: AuthSessionState["auth"]; + initialErrorMessage?: string; + onAuthenticated: () => void; +}) { + const autoPairTokenRef = useRef(peekPairingTokenFromUrl()); + const [credential, setCredential] = useState(() => autoPairTokenRef.current ?? ""); + const [errorMessage, setErrorMessage] = useState(initialErrorMessage ?? ""); + const [isSubmitting, setIsSubmitting] = useState(false); + const autoSubmitAttemptedRef = useRef(false); + + const submitCredential = useCallback( + async (nextCredential: string) => { + setIsSubmitting(true); + setErrorMessage(""); + + const submitError = await submitServerAuthCredential(nextCredential).then( + () => null, + (error) => errorMessageFromUnknown(error), + ); + + setIsSubmitting(false); + + if (submitError) { + setErrorMessage(submitError); + return; + } + + startTransition(() => { + onAuthenticated(); + }); + }, + [onAuthenticated], + ); + + const handleSubmit = useCallback( + async (event?: React.SubmitEvent) => { + event?.preventDefault(); + await submitCredential(credential); + }, + [submitCredential, credential], + ); + + useEffect(() => { + const token = autoPairTokenRef.current; + if (!token || autoSubmitAttemptedRef.current) { + return; + } + + autoSubmitAttemptedRef.current = true; + stripPairingTokenFromUrl(); + void submitCredential(token); + }, [submitCredential]); + + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ Pair with this environment +

+

+ {describeAuthGate(auth.bootstrapMethods)} +

+ +
void handleSubmit(event)}> +
+ + setCredential(event.currentTarget.value)} + placeholder="Paste a one-time token or pairing secret" + spellCheck={false} + value={credential} + /> +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + +
+
+ +
+ {describeSupportedMethods(auth.bootstrapMethods)} +
+
+
+ ); +} + +function errorMessageFromUnknown(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + return "Authentication failed."; +} + +function describeAuthGate(bootstrapMethods: ReadonlyArray): string { + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment expects a trusted pairing credential before the app can connect."; + } + + return "Enter a pairing token to start a session with this environment."; +} + +function describeSupportedMethods(bootstrapMethods: ReadonlyArray): string { + if ( + bootstrapMethods.includes("desktop-bootstrap") && + bootstrapMethods.includes("one-time-token") + ) { + return "Desktop-managed pairing and one-time pairing tokens are both accepted for this environment."; + } + + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment is desktop-managed. Open it from the desktop app or paste a bootstrap credential if one was issued explicitly."; + } + + return "This environment accepts one-time pairing tokens. Pairing links can open this page directly, or you can paste the token here."; +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 2fd0fb3187..c3aba0efcf 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -24,6 +24,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 017b6bee07..41f00d58f9 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,6 +1,6 @@ -import { assert, describe, it } from "vitest"; +import { afterEach, beforeEach, describe, assert, it, vi } from "vitest"; -import { isWindowsPlatform } from "./utils"; +import { isWindowsPlatform, resolveServerHttpUrl, resolveServerUrl } from "./utils"; describe("isWindowsPlatform", () => { it("matches Windows platform identifiers", () => { @@ -13,3 +13,52 @@ describe("isWindowsPlatform", () => { assert.isFalse(isWindowsPlatform("darwin")); }); }); + +const originalWindow = globalThis.window; + +beforeEach(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + origin: "http://localhost:5735", + hostname: "localhost", + port: "5735", + protocol: "http:", + }, + }, + }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); +}); + +describe("resolveServerHttpUrl", () => { + it("uses the Vite dev origin for local HTTP requests automatically", () => { + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3775/ws"); + + assert.equal( + resolveServerHttpUrl({ pathname: "/api/observability/v1/traces" }), + "http://localhost:5735/api/observability/v1/traces", + ); + }); +}); + +describe("resolveServerUrl", () => { + it("keeps the backend origin for websocket requests", () => { + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3775/ws"); + + assert.equal( + resolveServerUrl({ + protocol: "ws", + pathname: "/ws", + }), + "ws://127.0.0.1:3775/ws", + ); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 5b0bcec4bd..2ce617b204 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,14 +53,8 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString( - options?.url, - resolvePrimaryEnvironmentBootstrapUrl(), - import.meta.env.VITE_WS_URL, - window.location.origin, - ); - - const parsedUrl = new URL(rawUrl); + const rawUrl = resolveBaseServerUrl(options?.url); + const parsedUrl = resolveServerBaseUrl(rawUrl, options?.protocol); if (options?.protocol) { parsedUrl.protocol = options.protocol; } @@ -74,3 +68,77 @@ export const resolveServerUrl = (options?: { } return parsedUrl.toString(); }; + +export const resolveServerHttpUrl = (options?: { + url?: string | undefined; + pathname?: string | undefined; + searchParams?: Record | undefined; +}): string => { + const rawUrl = resolveBaseServerUrl(options?.url); + return resolveServerUrl({ + ...options, + url: rawUrl, + protocol: inferHttpProtocol(rawUrl), + }); +}; + +function resolveBaseServerUrl(url?: string | undefined): string { + return firstNonEmptyString( + url, + resolvePrimaryEnvironmentBootstrapUrl(), + import.meta.env.VITE_WS_URL, + window.location.origin, + ); +} + +function resolveServerBaseUrl( + rawUrl: string, + requestedProtocol: "http" | "https" | "ws" | "wss" | undefined, +): URL { + const currentUrl = new URL(window.location.origin); + const targetUrl = new URL(rawUrl, currentUrl); + + if (shouldUseSameOriginForLocalHttp(currentUrl, targetUrl, requestedProtocol)) { + return new URL(currentUrl); + } + + return targetUrl; +} + +function shouldUseSameOriginForLocalHttp( + currentUrl: URL, + targetUrl: URL, + requestedProtocol: "http" | "https" | "ws" | "wss" | undefined, +): boolean { + const protocol = requestedProtocol ?? targetUrl.protocol.slice(0, -1); + if (protocol !== "http" && protocol !== "https") { + return false; + } + + try { + return ( + isLocalHostname(currentUrl.hostname) && + isLocalHostname(targetUrl.hostname) && + currentUrl.origin !== targetUrl.origin + ); + } catch { + return false; + } +} + +function inferHttpProtocol(rawUrl: string): "http" | "https" { + try { + const url = new URL(rawUrl, window.location.origin); + if (url.protocol === "wss:" || url.protocol === "https:") { + return "https"; + } + } catch { + // Fall back to http for malformed values. + } + + return "http"; +} + +function isLocalHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index aa9d50c114..c631292f0e 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -3,7 +3,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { isElectron } from "../env"; -import { resolveServerUrl } from "../lib/utils"; +import { resolveServerHttpUrl } from "../lib/utils"; import { APP_VERSION } from "~/branding"; const DEFAULT_EXPORT_INTERVAL_MS = 1_000; @@ -51,8 +51,7 @@ export function configureClientTracing(config: ClientTracingConfig = {}): Promis } async function applyClientTracingConfig(config: ClientTracingConfig): Promise { - const otlpTracesUrl = resolveServerUrl({ - protocol: window.location.protocol === "https:" ? "https" : "http", + const otlpTracesUrl = resolveServerHttpUrl({ pathname: "/api/observability/v1/traces", }); const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 77b1b15842..2cb474ac98 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as PairRouteImport } from './routes/pair' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -21,6 +22,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const PairRoute = PairRouteImport.update({ + id: '/pair', + path: '/pair', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/_chat', getParentRoute: () => rootRouteImport, @@ -48,12 +54,14 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute + '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute } export interface FileRoutesByTo { + '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute @@ -63,6 +71,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren + '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/_chat/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute @@ -73,12 +82,14 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/pair' | '/settings' | '/$threadId' | '/settings/archived' | '/settings/general' fileRoutesByTo: FileRoutesByTo to: + | '/pair' | '/settings' | '/$threadId' | '/settings/archived' @@ -87,6 +98,7 @@ export interface FileRouteTypes { id: | '__root__' | '/_chat' + | '/pair' | '/settings' | '/_chat/$threadId' | '/settings/archived' @@ -96,6 +108,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren } @@ -108,6 +121,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/pair': { + id: '/pair' + path: '/pair' + fullPath: '/pair' + preLoaderRoute: typeof PairRouteImport + parentRoute: typeof rootRouteImport + } '/_chat': { id: '/_chat' path: '' @@ -174,6 +194,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 71c100a3a6..f5c4f387f0 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -7,8 +7,8 @@ import { Outlet, createRootRouteWithContext, type ErrorComponentProps, - useNavigate, useLocation, + useNavigate, } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; @@ -16,10 +16,10 @@ import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { SplashScreen } from "../components/SplashScreen"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, - WebSocketConnectionSurface, } from "../components/WebSocketConnectionSurface"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; @@ -48,6 +48,7 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; import { deriveReplayRetryDecision } from "../orchestrationRecovery"; +import { configureClientTracing } from "../observability/clientTracing"; import { getWsRpcClient } from "~/wsRpcClient"; export const Route = createRootRouteWithContext<{ @@ -61,30 +62,28 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { - if (!readNativeApi()) { - return ( -
-
-

- Connecting to {APP_DISPLAY_NAME} server... -

-
-
- ); + const pathname = useLocation({ select: (location) => location.pathname }); + const bootstrapComplete = useStore((store) => store.bootstrapComplete); + + if (pathname === "/pair") { + return ; } return ( + - + {bootstrapComplete ? ( - + ) : ( + + )} ); @@ -207,6 +206,14 @@ function ServerStateBootstrap() { return null; } +function AuthenticatedTracingBootstrap() { + useEffect(() => { + void configureClientTracing(); + }, []); + + return null; +} + function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 1ce840a01a..1b5ff29a2f 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,6 +1,7 @@ -import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; +import { resolveInitialServerAuthGateState } from "../authBootstrap"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; @@ -96,5 +97,11 @@ function ChatRouteLayout() { } export const Route = createFileRoute("/_chat")({ + beforeLoad: async () => { + const authGateState = await resolveInitialServerAuthGateState(); + if (authGateState.status !== "authenticated") { + throw redirect({ to: "/pair", replace: true }); + } + }, component: ChatRouteLayout, }); diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx new file mode 100644 index 0000000000..7cc1ce7762 --- /dev/null +++ b/apps/web/src/routes/pair.tsx @@ -0,0 +1,41 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; + +import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; +import { resolveInitialServerAuthGateState } from "../authBootstrap"; + +export const Route = createFileRoute("/pair")({ + beforeLoad: async () => { + const authGateState = await resolveInitialServerAuthGateState(); + if (authGateState.status === "authenticated") { + throw redirect({ to: "/", replace: true }); + } + return { + authGateState, + }; + }, + component: PairRouteView, + pendingComponent: PairRoutePendingView, +}); + +function PairRouteView() { + const { authGateState } = Route.useRouteContext(); + const navigate = useNavigate(); + + if (!authGateState) { + return null; + } + + return ( + { + void navigate({ to: "/", replace: true }); + }} + {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})} + /> + ); +} + +function PairRoutePendingView() { + return ; +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 45096fd6d6..01404c7e37 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -2,6 +2,7 @@ import { RotateCcwIcon } from "lucide-react"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { resolveInitialServerAuthGateState } from "../authBootstrap"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; @@ -83,7 +84,12 @@ function SettingsRouteLayout() { } export const Route = createFileRoute("/settings")({ - beforeLoad: ({ location }) => { + beforeLoad: async ({ location }) => { + const authGateState = await resolveInitialServerAuthGateState(); + if (authGateState.status !== "authenticated") { + throw redirect({ to: "/pair", replace: true }); + } + if (location.pathname === "/settings") { throw redirect({ to: "/settings/general", replace: true }); } diff --git a/apps/web/src/rpc/client.ts b/apps/web/src/rpc/client.ts index e4f9a6bbdd..2b0e8feb5c 100644 --- a/apps/web/src/rpc/client.ts +++ b/apps/web/src/rpc/client.ts @@ -2,11 +2,7 @@ import { WsRpcGroup } from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime } from "effect"; import { AtomRpc } from "effect/unstable/reactivity"; -import { - __resetClientTracingForTests, - ClientTracingLive, - configureClientTracing, -} from "../observability/clientTracing"; +import { __resetClientTracingForTests, ClientTracingLive } from "../observability/clientTracing"; import { createWsRpcProtocolLayer } from "./protocol"; export class WsRpcAtomClient extends AtomRpc.Service()("WsRpcAtomClient", { @@ -28,10 +24,8 @@ function getRuntime() { export function runRpc( execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect, ): Promise { - return configureClientTracing().then(() => { - const runtime = getRuntime(); - return runtime.runPromise(WsRpcAtomClient.use(execute)); - }); + const runtime = getRuntime(); + return runtime.runPromise(WsRpcAtomClient.use(execute)); } export async function __resetWsRpcAtomClientForTests() { diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 4eb198324d..5ee6d6807f 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -66,6 +66,12 @@ const baseEnvironment = { const baseServerConfig: ServerConfig = { environment: baseEnvironment, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 1eeae03e08..c21d31ebd9 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -169,6 +169,12 @@ const baseEnvironment = { const baseServerConfig: ServerConfig = { environment: baseEnvironment, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 3e435ee167..4af7835b81 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -11,7 +11,7 @@ import { } from "effect"; import { RpcClient } from "effect/unstable/rpc"; -import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing"; +import { ClientTracingLive } from "./observability/clientTracing"; import { createWsRpcProtocolLayer, makeWsRpcProtocolClient, @@ -44,7 +44,6 @@ function formatErrorMessage(error: unknown): string { } export class WsTransport { - private readonly tracingReady: Promise; private readonly url: string | undefined; private disposed = false; private reconnectChain: Promise = Promise.resolve(); @@ -52,7 +51,6 @@ export class WsTransport { constructor(url?: string) { this.url = url; - this.tracingReady = configureClientTracing(); this.session = this.createSession(); } @@ -64,7 +62,6 @@ export class WsTransport { throw new Error("Transport disposed"); } - await this.tracingReady; const session = this.session; const client = await session.clientPromise; return await session.runtime.runPromise(Effect.suspend(() => execute(client))); @@ -78,7 +75,6 @@ export class WsTransport { throw new Error("Transport disposed"); } - await this.tracingReady; const session = this.session; const client = await session.clientPromise; await session.runtime.runPromise( @@ -220,8 +216,7 @@ export class WsTransport { rejectCompleted = reject; }); const cancel = session.runtime.runCallback( - Effect.promise(() => this.tracingReady).pipe( - Effect.flatMap(() => Effect.promise(() => session.clientPromise)), + Effect.promise(() => session.clientPromise).pipe( Effect.flatMap((client) => Stream.runForEach(connect(client), (value) => Effect.sync(() => { diff --git a/apps/web/test/authHttpHandlers.ts b/apps/web/test/authHttpHandlers.ts new file mode 100644 index 0000000000..d45dc57409 --- /dev/null +++ b/apps/web/test/authHttpHandlers.ts @@ -0,0 +1,26 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { HttpResponse, http } from "msw"; + +const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z"; +const TEST_SESSION_TOKEN = "browser-test-session-token"; + +export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { + return [ + http.get("*/api/auth/session", () => + HttpResponse.json({ + authenticated: true, + auth: getAuthDescriptor(), + sessionMethod: "browser-session-cookie", + expiresAt: TEST_SESSION_EXPIRES_AT, + }), + ), + http.post("*/api/auth/bootstrap", () => + HttpResponse.json({ + authenticated: true, + sessionMethod: "browser-session-cookie", + sessionToken: TEST_SESSION_TOKEN, + expiresAt: TEST_SESSION_EXPIRES_AT, + }), + ), + ] as const; +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 56b138d331..a9d47df60e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,8 @@ import { defineConfig } from "vite"; import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); +const host = process.env.HOST?.trim() || "localhost"; +const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); const buildSourcemap = @@ -15,6 +17,29 @@ const buildSourcemap = ? "hidden" : true; +function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { + if (!wsUrl) { + return undefined; + } + + try { + const url = new URL(wsUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return undefined; + } +} + +const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); + export default defineConfig({ plugins: [ tanstackRouter(), @@ -34,21 +59,36 @@ export default defineConfig({ }, define: { // In dev mode, tell the web app where the WebSocket server lives - "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""), + "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), }, resolve: { tsconfigPaths: true, }, server: { + host, port, strictPort: true, + ...(devProxyTarget + ? { + proxy: { + "/api": { + target: devProxyTarget, + changeOrigin: true, + }, + "/attachments": { + target: devProxyTarget, + changeOrigin: true, + }, + }, + } + : {}), hmr: { // Explicit config so Vite's HMR WebSocket connects reliably // inside Electron's BrowserWindow. Vite 8 uses console.debug for // connection logs — enable "Verbose" in DevTools to see them. protocol: "ws", - host: "localhost", + host, }, }, build: { diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts index 70cf7996a0..5aaf459c58 100644 --- a/packages/client-runtime/src/knownEnvironment.test.ts +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -1,7 +1,10 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; +import { + createKnownEnvironmentFromWsUrl, + getKnownEnvironmentHttpBaseUrl, +} from "./knownEnvironment"; import { scopedRefKey, scopeProjectRef, scopeThreadRef } from "./scoped"; describe("known environment bootstrap helpers", () => { @@ -21,6 +24,26 @@ describe("known environment bootstrap helpers", () => { }, }); }); + + it("converts websocket base urls into fetchable http origins", () => { + expect( + getKnownEnvironmentHttpBaseUrl( + createKnownEnvironmentFromWsUrl({ + label: "Local environment", + wsUrl: "ws://localhost:3773/ws", + }), + ), + ).toBe("http://localhost:3773/ws"); + + expect( + getKnownEnvironmentHttpBaseUrl( + createKnownEnvironmentFromWsUrl({ + label: "Remote environment", + wsUrl: "wss://remote.example.com/api/ws", + }), + ), + ).toBe("https://remote.example.com/api/ws"); + }); }); describe("scoped refs", () => { diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts index 40c9054f17..a2d11485af 100644 --- a/packages/client-runtime/src/knownEnvironment.ts +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -38,6 +38,27 @@ export function getKnownEnvironmentBaseUrl( return environment?.target.wsUrl ?? null; } +export function getKnownEnvironmentHttpBaseUrl( + environment: KnownEnvironment | null | undefined, +): string | null { + const baseUrl = getKnownEnvironmentBaseUrl(environment); + if (!baseUrl) { + return null; + } + + try { + const url = new URL(baseUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + return url.toString(); + } catch { + return baseUrl; + } +} + export function attachEnvironmentDescriptor( environment: KnownEnvironment, descriptor: ExecutionEnvironmentDescriptor, diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts new file mode 100644 index 0000000000..83d7345618 --- /dev/null +++ b/packages/contracts/src/auth.ts @@ -0,0 +1,119 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +/** + * Declares the server's overall authentication posture. + * + * This is a high-level policy label that tells clients how the environment is + * expected to be accessed, not a transport detail and not an exhaustive list + * of every accepted credential. + * + * Typical usage: + * - rendered in auth/pairing UI so the user understands what kind of + * environment they are connecting to + * - used by clients to decide whether silent desktop bootstrap is expected or + * whether an explicit pairing flow should be shown + * + * Meanings: + * - `desktop-managed-local`: local desktop-managed environment with narrow + * trusted bootstrap, intended to avoid login prompts on the same machine + * - `loopback-browser`: standalone local server intended for browser pairing on + * the same machine + * - `remote-reachable`: environment intended to be reached from other devices + * or networks, where explicit pairing/auth is expected + * - `unsafe-no-auth`: intentionally unauthenticated mode; this is an explicit + * unsafe escape hatch, not a normal deployment mode + */ +export const ServerAuthPolicy = Schema.Literals([ + "desktop-managed-local", + "loopback-browser", + "remote-reachable", + "unsafe-no-auth", +]); +export type ServerAuthPolicy = typeof ServerAuthPolicy.Type; + +/** + * A credential type that can be exchanged for a real authenticated session. + * + * Bootstrap methods are for establishing trust at the start of a connection or + * pairing flow. They are not the long-lived credential used for ordinary + * authenticated HTTP / WebSocket traffic after pairing succeeds. + * + * Current methods: + * - `desktop-bootstrap`: a trusted local desktop handoff, used so the desktop + * shell can pair the renderer without a login screen + * - `one-time-token`: a short-lived pairing token, suitable for manual pairing + * flows such as `/pair?token=...` + */ +export const ServerAuthBootstrapMethod = Schema.Literals(["desktop-bootstrap", "one-time-token"]); +export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; + +/** + * A credential type accepted for steady-state authenticated requests after a + * client has already paired. + * + * These methods are used by the server-wide auth layer for privileged HTTP and + * WebSocket access. They are distinct from bootstrap methods so clients can + * reason clearly about "pair first, then use session auth". + * + * Current methods: + * - `browser-session-cookie`: cookie-backed browser session, used by the web + * app after bootstrap/pairing + * - `bearer-session-token`: token-based session suitable for non-cookie or + * non-browser clients + */ +export const ServerAuthSessionMethod = Schema.Literals([ + "browser-session-cookie", + "bearer-session-token", +]); +export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; + +/** + * Server-advertised auth capabilities for a specific execution environment. + * + * Clients should treat this as the authoritative description of how that + * environment expects to be paired and how authenticated requests should be + * made afterward. + * + * Field meanings: + * - `policy`: high-level auth posture for the environment + * - `bootstrapMethods`: pairing/bootstrap methods the server is currently + * willing to accept + * - `sessionMethods`: authenticated request/session methods the server supports + * once pairing is complete + * - `sessionCookieName`: cookie name clients should expect when + * `browser-session-cookie` is in use + * + * This descriptor is intentionally capability-oriented. It lets clients choose + * the right UX without embedding server-specific auth logic or assuming a + * single access method. + */ +export const ServerAuthDescriptor = Schema.Struct({ + policy: ServerAuthPolicy, + bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod), + sessionMethods: Schema.Array(ServerAuthSessionMethod), + sessionCookieName: TrimmedNonEmptyString, +}); +export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; + +export const AuthBootstrapInput = Schema.Struct({ + credential: TrimmedNonEmptyString, +}); +export type AuthBootstrapInput = typeof AuthBootstrapInput.Type; + +export const AuthBootstrapResult = Schema.Struct({ + authenticated: Schema.Literal(true), + sessionMethod: ServerAuthSessionMethod, + sessionToken: TrimmedNonEmptyString, + expiresAt: Schema.DateTimeUtc, +}); +export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; + +export const AuthSessionState = Schema.Struct({ + authenticated: Schema.Boolean, + auth: ServerAuthDescriptor, + sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), + expiresAt: Schema.optionalKey(Schema.DateTimeUtc), +}); +export type AuthSessionState = typeof AuthSessionState.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d2f84eda9d..f12cf80d57 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./auth"; export * from "./environment"; export * from "./ipc"; export * from "./terminal"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8ed0d08d13..ecd6beb1ad 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -106,6 +106,7 @@ export interface DesktopUpdateCheckResult { export interface DesktopEnvironmentBootstrap { label: string; wsUrl: string | null; + bootstrapToken?: string; } export interface DesktopBridge { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9227f4d8c9..a4e33c990b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { ExecutionEnvironmentDescriptor } from "./environment"; +import { ServerAuthDescriptor } from "./auth"; import { IsoDateTime, NonNegativeInt, @@ -85,6 +86,7 @@ export type ServerObservability = typeof ServerObservability.Type; export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, + auth: ServerAuthDescriptor, cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index d3e19e55c2..fc4a7b5733 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -54,7 +54,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -75,7 +74,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/custom-t3", - authToken: "secret", noBrowser: true, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, @@ -105,7 +103,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -127,7 +124,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: false, @@ -148,7 +144,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/my-t3", - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -161,13 +156,12 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - it.effect("does not export backend bootstrap env for dev:desktop", () => + it.effect("pins desktop dev to a stable backend port and websocket url", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ mode: "dev:desktop", baseEnv: { T3CODE_PORT: "3773", - T3CODE_AUTH_TOKEN: "stale-token", T3CODE_MODE: "web", T3CODE_NO_BROWSER: "0", T3CODE_HOST: "0.0.0.0", @@ -176,7 +170,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/my-t3", - authToken: "fresh-token", noBrowser: true, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -187,14 +180,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); assert.equal(env.PORT, "5733"); - assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); - assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); - assert.equal(env.T3CODE_PORT, undefined); - assert.equal(env.T3CODE_AUTH_TOKEN, undefined); + assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); + assert.equal(env.HOST, "127.0.0.1"); + assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.T3CODE_MODE, undefined); assert.equal(env.T3CODE_NO_BROWSER, undefined); assert.equal(env.T3CODE_HOST, undefined); - assert.equal(env.VITE_WS_URL, undefined); + assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 852f21ad01..b50c8237f6 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -13,6 +13,7 @@ const BASE_SERVER_PORT = 3773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; +const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(homedir(), ".t3"), @@ -120,7 +121,6 @@ interface CreateDevRunnerEnvInput { readonly serverOffset: number; readonly webOffset: number; readonly t3Home: string | undefined; - readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; @@ -135,7 +135,6 @@ export function createDevRunnerEnv({ serverOffset, webOffset, t3Home, - authToken, noBrowser, autoBootstrapProjectFromCwd, logWebSocketEvents, @@ -152,8 +151,9 @@ export function createDevRunnerEnv({ const output: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(webPort), - ELECTRON_RENDERER_PORT: String(webPort), - VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, + VITE_DEV_SERVER_URL: + devUrl?.toString() ?? + `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, T3CODE_HOME: resolvedBaseDir, }; @@ -161,9 +161,8 @@ export function createDevRunnerEnv({ output.T3CODE_PORT = String(serverPort); output.VITE_WS_URL = `ws://localhost:${serverPort}`; } else { - delete output.T3CODE_PORT; - delete output.VITE_WS_URL; - delete output.T3CODE_AUTH_TOKEN; + output.T3CODE_PORT = String(serverPort); + output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; @@ -173,12 +172,6 @@ export function createDevRunnerEnv({ output.T3CODE_HOST = host; } - if (!isDesktopMode && authToken !== undefined) { - output.T3CODE_AUTH_TOKEN = authToken; - } else if (!isDesktopMode) { - delete output.T3CODE_AUTH_TOKEN; - } - if (!isDesktopMode && noBrowser !== undefined) { output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; } else if (!isDesktopMode) { @@ -208,6 +201,7 @@ export function createDevRunnerEnv({ } if (isDesktopMode) { + output.HOST = DESKTOP_DEV_LOOPBACK_HOST; delete output.T3CODE_DESKTOP_WS_URL; } @@ -350,7 +344,6 @@ export function resolveModePortOffsets({ interface DevRunnerCliInput { readonly mode: DevMode; readonly t3Home: string | undefined; - readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; @@ -430,7 +423,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { serverOffset, webOffset, t3Home: input.t3Home, - authToken: input.authToken, noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( input.autoBootstrapProjectFromCwd, @@ -503,11 +495,6 @@ const devRunnerCli = Command.make("dev-runner", { Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), ), - authToken: Flag.string("auth-token").pipe( - Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), - Flag.withAlias("token"), - Flag.withFallbackConfig(optionalStringConfig("T3CODE_AUTH_TOKEN")), - ), noBrowser: Flag.boolean("no-browser").pipe( Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), diff --git a/turbo.json b/turbo.json index 47d7091385..d4d58c15e6 100644 --- a/turbo.json +++ b/turbo.json @@ -1,10 +1,10 @@ { "$schema": "https://turbo.build/schema.json", "globalEnv": [ + "HOST", "PORT", "VITE_WS_URL", "VITE_DEV_SERVER_URL", - "ELECTRON_RENDERER_PORT", "T3CODE_LOG_WS_EVENTS", "T3CODE_MODE", "T3CODE_PORT",