Open History to see the last commit
1# Environment authentication
2
3The environment issues its own sessions and enforces their capabilities. Cloud
4identity and relay credentials belong to a separate trust boundary, described in
5[T3 Connect](./t3-connect.md). A relay token is never an environment login.
6
7## Authority survives transport changes
8
9Pairing delegates a set of scopes. Exchanging a bootstrap credential can narrow
10that grant but cannot widen it. The default grant includes access and relay
11management: `terminal:operate` is a shell as the server's user and
12`orchestration:operate` can start a full-access agent, and either can mint a
13session with every scope through `t3 auth session issue`, so withholding
14management from the default would not be a boundary. A narrower client gets
15explicit scopes. Creating another pairing link requires both `access:write` and
16every scope being delegated. The
17[auth handlers](../../apps/server/src/auth/http.ts) enforce this at issuance;
18client labels and device metadata have no authorization role.
19
20The access read model contains pairing metadata, never recoverable pairing
21secrets. Only the creation response returns the raw credential. Otherwise read
22access to the connections list would become a way to acquire another client's
23authority.
24
25Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session
26model. DPoP binds a token to a client's proof key; an invalid proof must fail
27rather than fall back to bearer authentication. The OAuth token-exchange
28vocabulary gives these grants a familiar meaning.
29
30### Guest links are not sessions
31
32A [guest link](../user/guest-links.md) gives one browser read access to one
33thread and a way to propose messages. Its tokens are HMACs of the thread and
34share id under a server key ([GuestShareService](../../apps/server/src/guest/GuestShareService.ts)),
35so nothing secret is stored. The share in the thread projection is the only
36state: replacing, closing, or archiving it revokes every token, and the
37orchestrator's serial commands make redeeming and sending happen once. Guest
38tokens never enter `SessionStore`, so no RPC, WebSocket, or MCP path can accept
39one. Guest routes also require T3 Connect to still serve the link's hostname;
40the request's Host header is not checked, because a LAN client can forge it.
41
42### MCP clients are a separate audience
43
44Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth
45authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)).
46It accepts loopback redirect URIs for agents on the user's machine and any
47HTTPS redirect for hosted agents (ChatGPT, bots). An HTTPS redirect means a
48link someone else sends the owner can deliver access to that someone, so the
49approval page names the host access goes to and approving stays the owner's
50call. Every client is public and proves itself with PKCE; a client that asks
51for a secret is registered without one. Client registration is stateless, so an unauthenticated caller cannot grow server
52state. Approval spends a one-time pairing code, or uses a browser session with
53`access:write`; proof-bound T3 Connect codes are refused without being spent.
54
55The user grants either read-only access or a runtime-mode ceiling, not a
56scope list: MCP tools are all orchestration, and `orchestration:operate`
57alone would let an agent start a thread in full access and act through it.
58The result is an ordinary session with subject `mcp-client`. A read-only
59grant holds `orchestration:read` alone. On `/mcp` it passes only tools
60declared as reads in [McpToolAccess](../../apps/server/src/mcp/McpToolAccess.ts),
61where every tool must declare who may call it to compile. Any other grant adds
62`orchestration:operate` and a signed ceiling. Only `/mcp` accepts these sessions. Every other HTTP and WebSocket
63path rejects that subject, because the RPC surface would let the agent act
64above its ceiling. Inside MCP the credential sets the limits and tool
65parameters only pick targets; see
66[threadAccess](../../apps/server/src/mcp/threadAccess.ts).
67
68Issuer and resource URLs come from the request's Host and
69`X-Forwarded-Proto`, so one server answers over loopback, Tailscale Serve and a
70T3 Connect tunnel. A proxy that rewrites Host or drops the protocol header
71breaks sign-in.
72
73Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated
74HTTP so long-lived tokens stay out of socket URLs. A ticket names the route it
75opens: the RPC socket, or the media routes (device hub, preview stream), which
76share one ticket across `<img>`, `EventSource` and several sockets. A DPoP
77client's RPC ticket is bound to its proof key and the upgrade carries a fresh
78proof in the `dpop` query parameter, since a WebSocket cannot set headers; a
79ticket captured on a plain HTTP LAN route is useless without the key, and the
80proof's `jti` is consumed like any other. Media tickets stay bearer because each
81media request would need its own proof. Clients that predate this mint unbound
82tickets that open either route. Browser sessions can authenticate the upgrade
83with their cookie. A successful handshake grants no
84extra authority: [every RPC declares a required
85scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC
86group's `RpcScopeAuthorization` middleware checks it before any handler runs.
87
88Scope changes must not prevent older clients from connecting. Token exchange
89intersects recognized requests with the pairing grant; retired and unknown names
90are dropped. A request with no granted scopes fails before consuming the link.
91Stored credentials are never expanded when scopes split.
92
93Auth responses keep `scopes` within the original wire vocabulary and include
94`permissions` for the exact grant. New clients use `permissions` when present,
95even if empty. Older servers omit it, so clients use legacy parent checks for
96features those servers already support. These client checks never change server
97authorization. Permission errors likewise retain a legacy `requiredScope` and
98add the exact `requiredPermission`, so a denied RPC stays decodable by old clients.
99Unknown response permissions are ignored; grant inputs stay strict.
100
101Desktop restarts forget the previous local bearer token, so its reusable
102bootstrap grant replaces earlier sessions for the same subject and method.
103Revocation and insertion share a [database
104transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed
105replacement must leave the old credential usable. Pairing and browser sessions
106do not follow this replacement rule.
107
108### Reusable dev credential
109
110Web development environments can accept one `T3CODE_DEV_AUTH_TOKEN` across
111worktrees and ports on one hostname. The token and startup URLs that contain it
112grant administrative access. Desktop and non-development servers ignore it. See
113the [development runbook](../operations/development.md#reusable-dev-credential)
114for setup.
115
116Each environment hashes the value and seeds its own database record at startup.
117Environments do not share SQLite data, signing keys, environment IDs, session
118records, pairing grants, or revocation state. Local revocation persists after
119restart and does not affect another worktree. Removing or rotating the value
120and restarting invalidates the old credential and its WebSocket tickets.
121
122Normal credentials keep precedence. A rejected normal credential never falls
123back to the reusable credential. OAuth exchanges create ordinary local bearer
124or DPoP children with normal expiry and revocation. The reusable cookie expires
125after 30 days.
126
127## The environment is the filesystem boundary
128
129Projects are organizational boundaries, not filesystem sandboxes.
130`filesystem:read` permits reading files the server account can read, including
131absolute paths outside a project. This lets clients display artifacts that an
132agent writes in a temporary directory. Relative paths and writes still follow
133the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts).
134
135Signed asset URLs are bearer credentials. A URL for media on the host grants
136access to one canonical file and its device/inode identity, not its containing directory.
137[Asset access](../../apps/server/src/assets/AssetAccess.ts) rechecks the opened
138file's identity when serving it, so atomic replacement requires a new URL while
139editing the same file in place does not. An HTML file authorized this way cannot
140load sibling assets; directory-scoped workspace previews are a separate grant.
141Clients should share the authored file reference so they do not disclose the
142temporary URL's credential. `filesystem:read` is checked when the URL is minted,
143not when it is served: a URL issued before the grant was revoked keeps working
144until it expires, and it is not bound to the session that minted it.
145
146Host videos can change in place. Their [HTTP
147responses](../../apps/server/src/http.ts) omit cache validators because file
148metadata cannot prove byte-for-byte identity for `If-Range`. Adding weak
149validators would turn native-player seeks into full downloads.