Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@ an `access_token` query parameter (browsers, which cannot set headers on a
WebSocket). The handshake fails with `401`/`400`/`403`/`503` — the same statuses
as the REST middleware — before the connection is upgraded.

An EdDSA-authenticated owner may act as one of their granted identities by
supplying either the `X-FMSG-Act-As` header or an `act_as` query parameter
(again for browsers, which cannot set headers on a WebSocket); the header wins
when both are present, and validation is identical to the REST routes.

**Events:** every frame is a JSON envelope with a `type` discriminator so new
event types can be added without breaking clients:

Expand Down
18 changes: 15 additions & 3 deletions internal/handlers/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,21 @@ func NewWSHandler(verifier *middleware.Verifier, hub *Hub, allowedOrigins []stri
}
}

// actAsParam returns the requested act-as address: the X-FMSG-Act-As header
// when present, else the act_as query parameter. The query parameter exists
// because browsers cannot set custom headers on a WebSocket upgrade; it is
// validated identically to the header.
func actAsParam(c *gin.Context) string {
if v := c.GetHeader("X-FMSG-Act-As"); v != "" {
return v
}
return c.Query("act_as")
}

// Connect handles GET /fmsg/ws. It authenticates the request (via the
// access_token query parameter or an Authorization header), upgrades the
// connection, and runs it until either side closes.
// access_token query parameter or an Authorization header, optionally acting
// as a sub-account via the X-FMSG-Act-As header or act_as query parameter),
// upgrades the connection, and runs it until either side closes.
func (h *WSHandler) Connect(c *gin.Context) {
token := c.Query("access_token")
if token == "" {
Expand All @@ -82,7 +94,7 @@ func (h *WSHandler) Connect(c *gin.Context) {
return
}

res, status, msg := h.verifier.AuthenticateRequest(c.Request.Context(), token, c.ClientIP(), c.GetHeader("X-FMSG-Act-As"))
res, status, msg := h.verifier.AuthenticateRequest(c.Request.Context(), token, c.ClientIP(), actAsParam(c))
if status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
Expand Down
39 changes: 39 additions & 0 deletions internal/handlers/ws_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package handlers

import (
"net/http/httptest"
"net/url"
"testing"

"github.com/gin-gonic/gin"
)

func TestActAsParam(t *testing.T) {
tests := []struct {
name string
header string
query string
want string
}{
{name: "neither set", want: ""},
{name: "header only", header: "@[email protected]", want: "@[email protected]"},
{name: "query only", query: "@[email protected]", want: "@[email protected]"},
{name: "header wins over query", header: "@[email protected]", query: "@[email protected]", want: "@[email protected]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
target := "/fmsg/ws"
if tt.query != "" {
target += "?act_as=" + url.QueryEscape(tt.query)
}
c.Request = httptest.NewRequest("GET", target, nil)
if tt.header != "" {
c.Request.Header.Set("X-FMSG-Act-As", tt.header)
}
if got := actAsParam(c); got != tt.want {
t.Errorf("actAsParam() = %q, want %q", got, tt.want)
}
})
}
}
8 changes: 4 additions & 4 deletions internal/middleware/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ type CORSConfig struct {
}

// DefaultCORSConfig returns a CORSConfig populated with values appropriate for
// this API: GET/POST/PUT/DELETE/OPTIONS plus Authorization and Content-Type
// request headers, with a 10 minute preflight cache. Callers must still set
// AllowedOrigins.
// this API: GET/POST/PUT/PATCH/DELETE/OPTIONS plus Authorization and
// Content-Type request headers, with a 10 minute preflight cache. Callers must
// still set AllowedOrigins.
func DefaultCORSConfig() CORSConfig {
return CORSConfig{
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-FMSG-Act-As"},
MaxAge: 10 * time.Minute,
}
Expand Down
Loading