(w http.ResponseWriter, r *http.Request, rawEmail string)
| 49 | } |
| 50 | |
| 51 | func (h *Handler) serve(w http.ResponseWriter, r *http.Request, rawEmail string) { |
| 52 | // Authenticate via the `Authorization: Bearer <api_key>` handshake header — |
| 53 | // the standard shape for non-browser WebSocket clients (matches OpenAI |
| 54 | // Realtime server-side, Slack Socket Mode, Kubernetes). The credential never |
| 55 | // touches the URL, so it can't leak to access logs / browser history / |
| 56 | // Referer the way a `?token=` query param does. All e2a WS clients (the TS + |
| 57 | // Python SDKs and the CLI) are non-browser and set the header on the |
| 58 | // handshake. (A short-lived connect ticket is the path to add later if an |
| 59 | // in-browser client ever needs to connect — browsers can't set headers.) |
| 60 | token := bearerToken(r) |
| 61 | if token == "" { |
| 62 | w.Header().Set("WWW-Authenticate", `Bearer realm="e2a"`) |
| 63 | http.Error(w, "missing credential — send Authorization: Bearer <api_key>", http.StatusUnauthorized) |
| 64 | return |
| 65 | } |
| 66 | |
| 67 | principal, err := h.store.GetPrincipalByAPIKey(r.Context(), token) |
| 68 | if err != nil || principal == nil || principal.User == nil { |
| 69 | http.Error(w, "invalid token", http.StatusUnauthorized) |
| 70 | return |
| 71 | } |
| 72 | |
| 73 | // Resolve agent and verify ownership. Canonicalize the email so that |
| 74 | // `ws://.../UPPER@x.dev/ws?token=…` resolves identically to the |
| 75 | // lower-case form — matches the REST API's `normalizeEmail` policy. |
| 76 | email := identity.NormalizeEmail(rawEmail) |
| 77 | agent, err := h.store.GetAgentByEmail(r.Context(), email) |
| 78 | if err != nil { |
| 79 | http.Error(w, "agent not found", http.StatusNotFound) |
| 80 | return |
| 81 | } |
| 82 | // Tenant ownership: the agent must belong to the credential's user. |
| 83 | if agent.UserID != principal.User.ID { |
| 84 | http.Error(w, "not authorized for this agent", http.StatusForbidden) |
| 85 | return |
| 86 | } |
| 87 | // Agent-scope confinement (HIGH-1): an agent-scoped credential is pinned to |
| 88 | // its one bound agent — it may not open a different agent's stream even |
| 89 | // within the same account. Mirrors the REST resolveOwnedAgent pin. |
| 90 | if principal.Scope == identity.ScopeAgent && principal.AgentID != agent.ID { |
| 91 | http.Error(w, "not authorized for this agent", http.StatusForbidden) |
| 92 | return |
| 93 | } |
| 94 | |
| 95 | // Upgrade to WebSocket. Origin checks are intentionally disabled because |
| 96 | // authentication is the `Authorization: Bearer` header, not cookies — and a |
| 97 | // browser cannot set that header on a cross-site WebSocket, so there is no |
| 98 | // cross-site-WebSocket-hijacking (CSWSH) vector to defend against here. |
| 99 | // CLI/SDK clients have no Origin to check. (Header auth is strictly safer |
| 100 | // than the old `?token=` query param it replaced, which leaked the key to |
| 101 | // access logs / history / Referer.) |
| 102 | conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ |
| 103 | InsecureSkipVerify: true, |
| 104 | }) |
| 105 | if err != nil { |
| 106 | log.Printf("[ws] upgrade failed for %s: %v", email, err) |
| 107 | return |
| 108 | } |
no test coverage detected