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