Skip to content

feat(socket-auth): authenticated realtime channels (tokens, authorizer, signed publish) - #290

Open
roncodes wants to merge 9 commits into
release/v1.6.69from
feature/socket-auth
Open

roncodes wants to merge 9 commits into
release/v1.6.69from
feature/socket-auth

Conversation

@roncodes

@roncodes roncodes commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

What

Authenticated realtime channels for the API side. Today any socket client can subscribe to any channel and publish to it. With SOCKETCLUSTER_AUTH_KEY set, clients present a short-lived token, the socket server asks the API whether that token may follow a channel, and the API publishes through a signed internal endpoint. Without the key nothing changes.

Tokens and authorization (Fleetbase\Support\SocketCluster)

  • SocketToken: issue(SocketPrincipal, ?int $ttl), verify(string): ?SocketPrincipal, system(), enabled(), plus forTracking() / trackingId() for scoped public tracking tokens. HS256 via lcobucci/jwt. Verification rejects any algorithm other than HS256 (including none and RS256), a wrong key, issuer or audience, and missing or out-of-range iat/nbf/exp. Lifetimes: SOCKETCLUSTER_TOKEN_TTL (default 900 s), tracking 1800 s, system 300 s, capped at 3600 s.

  • SocketPrincipal: an immutable value object (forUser, forApiCredential, system, fromClaims/toClaims).

  • SocketChannelRegistry (singleton): register, registerModel, registerPrincipalResolver, resolve. Extensions use it to register their channel prefixes.

  • ChannelAuthorizer (singleton) checks in this order:

    1. An anonymous connection may only follow fleetbase.install, and only until setup has created a user.
    2. An expired principal is denied.
    3. A principal with scp may follow exactly the listed channels.
    4. The system principal may follow every channel.
    5. A principal may follow its own channels (company, api, user/driver, install/uninstall) without a lookup.
    6. Every other channel goes to the resolver registered for its prefix; unknown prefixes are denied.

    Decisions are cached per token id and channel.

  • Core resolvers: company, api (credential, or a personal access token owned by a company user), user, test, install/uninstall, chat, chat_channel, chat_participant, chat_message and file. Users and API credentials see their own company's records. Drivers and customers only see chats they take part in.

  • SocketSignature: derives a key for each purpose (HMAC(auth key, "fleetbase-socket:{purpose}")). Requests are signed over {timestamp}.{body} and rejected when the timestamp is more than 60 s off or the signature does not match (compared in constant time).

Endpoints

  • POST int/v1/socket/token (fleetbase.protected) returns a user token for the current company. When the console is in sandbox mode, the token is for the sandbox environment.
  • POST v1/socket/token (fleetbase.api) returns an api token for an API credential. For a Sanctum user token it returns the principal a registered resolver claims (FleetOps: driver), or else a user token.
  • POST v1/socket/system-token (fleetbase.platform-api) returns a system token, valid for 300 s. It has its own path because the platform and public API groups share the v1 prefix.
  • POST int/v1/socket/authorize is called by the socket server only. It accepts only requests signed with the socket server's authorize key, takes no session or user token, verifies the token again, and returns {allow, ttl, reason}. It is exempt from the configured-instance check so the install page can follow fleetbase.install before setup finishes.
  • All mint routes return 404 while SOCKETCLUSTER_AUTH_KEY is unset (or shorter than 32 bytes). Clients treat a 404 as "connect anonymously".

Publishing

When the key is set, a broadcast becomes one signed POST {SOCKETCLUSTER_PUBLISH_URL}/publish carrying all of its channels, with short timeouts. SocketClusterService::publish() keeps working and takes the same path. Without the key, the websocket publisher is used as before. Channels ending in . (for example company. from a session read in a queue worker) and names the socket server would reject are dropped first.

Hardening

The admin SocketCluster test (settings/test-socketcluster-config) used to accept any channel from the request. It now always publishes to test.{current user uuid}, ignores the channel input, and returns the channel it used so the console can subscribe to it.

Config

broadcasting.connections.socketcluster gains auth_key (SOCKETCLUSTER_AUTH_KEY), publish_url (SOCKETCLUSTER_PUBLISH_URL, default http://{SOCKETCLUSTER_HOST}:8001) and token_ttl (SOCKETCLUSTER_TOKEN_TTL, default 900). The README has a short section on the feature for extension authors.

Why

Realtime channels carry company data (orders, drivers, chats, users), and today nothing stops a client from subscribing to another company's channels. This gives the socket server something to enforce. It is off by default, so it can be rolled out gradually: off, then log, then enforce on the socket server.

Test plan

Verified by CI. New Pest tests cover:

  • token minting and verification: claims, lifetimes, expiry, wrong key, alg: none, RS256, missing or wrong aud/iss
  • principal factories
  • authorizer local rules, scope-only tokens, registry dispatch and caching
  • cross-company denial for the chat, user, company, api and file resolvers, and driver narrowing
  • the HMAC middleware: good signature, stale timestamp, wrong key, bad signature, disabled
  • the mint and authorize endpoints, including 404 while disabled
  • the route contract
  • the HTTP publish request shape, signature and empty-channel filtering (Http::fake), and the websocket fallback without a key

Related PRs

Part of the authenticated realtime channels rollout (socket auth), one PR per repo:

Auth switch (SOCKETCLUSTER_AUTH_ENABLED)

Socket auth used to turn on as soon as SOCKETCLUSTER_AUTH_KEY was set. Setting the key also moved every broadcast to the socket server's HTTP publish endpoint. That makes it hard to ship this before every socket client (released mobile apps, Navigator, integrations) fetches tokens.

  • New broadcasting.connections.socketcluster.auth_enabled (SOCKETCLUSTER_AUTH_ENABLED), default false. SocketToken::enabled() now requires the switch and a valid key. Every gated path follows it, including fleetbase/storefront's and fleetbase/fleetops's socket code:
    • the token routes, the authorize endpoint and its signature check;
    • HTTP publishing.
  • With the switch off, this release behaves exactly as before for every socket client, even with a key provisioned. The console's socket test also publishes to the requested channel again.
  • README documents the rollout:
    1. Ship clients that fall back on a 404 from the token route.
    2. Switch on with the socket server in log mode.
    3. Then switch to enforce.
  • Tests cover both states (2018 passing locally; the one local failure, UtilsTest reading composer.lock metadata, also fails without this change).

Adds the realtime channel authentication core, switched on by
SOCKETCLUSTER_AUTH_KEY (config auth_key, publish_url, token_ttl):

- SocketToken mints and verifies HS256 socket tokens (iss/aud/iat/nbf/exp/jti
  plus kind, sub, cid, cpid, env, ids, adm, scp, sid); anything not HS256 with
  the configured key, or with a wrong issuer/audience or out-of-range time
  claims, is rejected. Includes the scoped public tracking token.
- SocketSignature derives per-purpose HMAC keys and signs/verifies the
  timestamped requests exchanged with the socket server.
- SocketPrincipal, ChannelDecision, the SocketChannelResolver contract and the
  SocketChannelRegistry extensions register their channel prefixes with.
- ChannelAuthorizer applies install, expiry, scope, system and self rules,
  then the prefix resolver, caching decisions per token and channel.
- Core resolvers for company, api, user, test, install/uninstall, chat,
  chat_channel, chat_participant, chat_message and file channels.
- The registry and authorizer are container singletons.
- POST int/v1/socket/token (console session): a user token for the current
  company, in the sandbox environment when the console is in sandbox mode.
- POST v1/socket/token (public API): an api token for an API credential; for a
  Sanctum user token, the principal a registered resolver claims, else a user
  token.
- POST int/v1/socket/authorize: called by the socket server only, admitted by
  its signature (VerifySocketSignature) rather than a session; re-verifies the
  token and returns {allow, ttl, reason}. Exempt from the configured-instance
  check so an install page can follow the install channel before setup ends.
- Every mint route answers 404 while SOCKETCLUSTER_AUTH_KEY is unset.
When SOCKETCLUSTER_AUTH_KEY is set, the broadcaster and
SocketClusterService::publish()/send() post every channel of a broadcast in a
single request to {SOCKETCLUSTER_PUBLISH_URL}/publish, signed with the derived
publish key and short timeouts. Without the key the websocket publisher is
used as before.

Channels ending in "." (an empty suffix, e.g. a session read in a queue
worker) and names the socket server would reject are dropped before
publishing.
… channel

The SocketCluster settings test ignored nothing: any admin could publish an
arbitrary payload to any channel. It now always publishes to
test.{current user uuid} and returns that channel so the console can
subscribe to it.
POST v1/socket/system-token in the fleetbase.platform-api group mints a
system token. A separate path because the platform and public API groups
share the v1 prefix, where v1/socket/token is the public API mint route.
…annel decision

The install-channel test skipped the users table but still seeded it. The
cross-company and driver tests now compare every channel's decision reason
(and any resolver error logged) in one assertion.
… model

ChatMessage always eager loads its attachments, so authorizing a chat_message
channel queried chat_attachments for nothing (and failed where that table is
absent). Channel lookups now load only the model's own row.
@codecov

codecov Bot commented Oct 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (ab33100) to head (2327045).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##                main      #290    +/-   ##
============================================
  Coverage     100.00%   100.00%            
- Complexity      7931      8137   +206     
============================================
  Files            438       448    +10     
  Lines          25665     26129   +464     
============================================
+ Hits           25665     26129   +464     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@LeftoversTodayAppAdmin

Copy link
Copy Markdown

Related to the websocket publish path this keeps when SOCKETCLUSTER_AUTH_KEY is unset: the PHP client connects without an Origin header, and socketcluster-server treats a missing origin as *. So when SOCKETCLUSTER_OPTIONS restricts origins to the console host, which scripts/docker-install.sh does today, every server broadcast fails with Failed to authorize socket handshake - Invalid origin: * and the console stops getting realtime updates. We see this on v0.7.68 and work around it by adding null:* to the origins list, which lets in any client without an Origin.

phrity/websocket already sends options['headers'] on the handshake, so one config line would cover installs that upgrade without setting the key:

// config/broadcasting.connections.php, socketcluster options
'headers' => array_filter(['Origin' => env('SOCKETCLUSTER_ORIGIN')]),

with the installer writing SOCKETCLUSTER_ORIGIN next to the origins list. Happy to open it as a separate issue or PR if you'd rather keep it out of this one.

@roncodes roncodes mentioned this pull request Oct 7, 2026
@roncodes
roncodes changed the base branch from main to release/v1.6.69 October 7, 2026 05:51
Socket authentication was on as soon as SOCKETCLUSTER_AUTH_KEY was set. That
also moved every broadcast to the socket server's HTTP publish endpoint, so
provisioning the key before every client fetched tokens (or before the new
socket server was deployed) would break existing socket clients.

- New `broadcasting.connections.socketcluster.auth_enabled`
  (SOCKETCLUSTER_AUTH_ENABLED, default false). SocketToken::enabled() now
  needs the switch and a valid key. Every gated path follows: token routes,
  the authorize endpoint and its signature check, and HTTP publishing.
- With the switch off, the console's socket test publishes to the requested
  channel (default `test`) as before. With it on, only to `test.{user}`.
- README: the switch and the rollout order (ship clients that fall back on
  404, switch on with the socket server in log mode, then enforce).
roncodes added a commit to fleetbase/fleetbase.io that referenced this pull request Oct 7, 2026
…t order

Socket authentication is now off until SOCKETCLUSTER_AUTH_ENABLED=true on the
API containers and the socket server (fleetbase/core-api#290). System Setup →
Socket: env table entry, what the switch gates (token routes, authorize
endpoint, signed publishing, socket server mode), enforce requiring the switch
on the API, installer defaults (switch off, log), the rollout order, and two
troubleshooting entries. Migration guide and Socket Events: the switch and the
self-hosted rollout, so self-hosters set it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants