Skip to content

feat(socket-auth): authenticate the realtime socket with session socket tokens - #97

Merged
roncodes merged 1 commit into
release/v0.3.26from
feature/socket-auth
Oct 8, 2026
Merged

roncodes merged 1 commit into
release/v0.3.26from
feature/socket-auth

Conversation

@roncodes

@roncodes roncodes commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

What

The console's realtime socket now authenticates with short-lived socket tokens minted from the console session. The socket service gives the SocketCluster client its own in-memory auth engine.

  • Handshake. loadToken() runs on every connect and reconnect. While the session is authenticated it calls POST int/v1/socket/token and gets { token, expires_in, expires_at }. A cached token is reused while it has more than 60 s left, and concurrent loads share one request. saveToken and removeToken keep the token in memory only. It is never written to localStorage or put in the URL. Subscriptions queued before the handshake go out authenticated, so existing callers (listen(), instance().subscribe()) need no change.
  • Anonymous fallback. With no session, a 404 (older server, or socket auth turned off), or any other mint failure, loadToken resolves null and the client connects anonymously as it does today.
  • Refresh. A native timer (not the run loop, which would keep settled() waiting) fires at expires_in - 60 s (minimum 5 s). It mints a new token and calls socket.authenticate(). While disconnected, the token waits for the next handshake.
  • Recovery.
    • Triggers: deauthenticate, a kickOut with a token reason, or a subscribeFail with an AuthError whose reason is no_token, token_expired, token_changed, deauthenticated or identity_changed.
    • What happens: the service gets a fresh token, authenticates, and resubscribes the lost channels by name. SocketCluster sends data by channel name, so existing for await loops on raw instance().subscribe() channels start receiving again.
    • Loop guards: each channel is retried once per token, there are at most 3 recoveries a minute, and deauthenticate caused by the service's own authenticate call is ignored. A token the server rejects is dropped.
    • Refusals for other reasons (the user may not see the channel) are not retried. They are left to the subscriber's subscribeFail handling.
  • Session lifecycle. The service listens on the universe bus:
    • session.authenticated (login) and user.organization_switched re-key the socket with a new token.
    • user.loaded authenticates a socket that connected anonymously before the session was restored. An anonymous connect while signed in does the same.
    • user.deauthenticated (logout) clears the token, stops timers and retries, and disconnects. The next login reconnects.
  • Client tag. The handshake query carries client=console/<app version>. It is not a secret; the server uses it to label its logs.

Why

Today any client can subscribe to any channel name. This is the console's part of the socket-auth work: the socket server can now check each subscription against the user's session (see the contract in the related PRs). This PR does not change channel names.

Notes for extension authors

The docs/ guides moved to fleetbase.io, so these notes are here for now:

  • Keep subscribing the way you do now. You don't need to pass a token or call authenticate yourself.
  • Once a server enforces socket auth, each subscription is checked on the server. A user can subscribe only to channels for records their organization owns, plus their own user.* and company.* channels.
  • If a channel is refused for a reason other than its token, the service does not retry it. Listen for channel.listener('subscribeFail'): error.name === 'AuthError', and error.reason is a short snake_case code. Show or log the refusal instead of waiting.

Tests

  • New tests/unit/services/socket-auth-test.js covers:
    • anonymous handshakes when signed out
    • minting, reuse and leeway, a shared in-flight request, and the 404 or malformed-response fallback
    • nothing stored outside memory, and a token arriving after logout being discarded
    • refresh timing and the minimum delay; refresh while connected and while disconnected
    • rejected or failed authentication
    • re-authentication on login, restore and organization switch; logout teardown
    • recovery from deauthenticate, kickOut and subscribeFail: resubscribe, batching, the follow-up run, the once-per-token rule and the per-minute cap
    • client event wiring and listener teardown
  • tests/helpers/stub-socketcluster.js gains inertClientMethods(), createEventStream() and createRecordingClient(). The suite-wide stub, socket-test.js and socket-listen-test.js now provide the client methods the service calls.

Test plan

Verified by CI (lint, full suite, 100% coverage gate). No local builds or test runs.

Related PRs

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

…et tokens

The socket service now hands the SocketCluster client an in-memory auth
engine that mints a short-lived socket token from the console session
(POST int/v1/socket/token) for every handshake, so subscriptions queued
before the handshake go out authenticated and existing callers need no
change. Without a session, or when the server cannot mint tokens (404),
the client connects anonymously as before. Tokens never touch
localStorage or the URL.

- refresh 60s before expiry via socket.authenticate()
- on deauthenticate, or a kickOut/subscribeFail with a token reason,
  mint a fresh token, authenticate, and resubscribe the lost channels by
  name (restores delivery to raw instance().subscribe loops); retries are
  once per channel per token and capped per minute
- re-key on login, session restore and organization switch; on logout
  clear the token, stop timers and disconnect
- send query client=console/<version>
- extension-author notes live in the PR (docs moved to fleetbase.io)
@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 (56af192) to head (4180647).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##              main       #97    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          168       168            
  Lines         5027      5229   +202     
  Branches      1362      1395    +33     
==========================================
+ Hits          5027      5229   +202     
Flag Coverage Δ
ember-core 100.00% <100.00%> (ø)

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@roncodes
roncodes changed the base branch from main to release/v0.3.26 October 8, 2026 04:48
@roncodes roncodes mentioned this pull request Oct 8, 2026
@roncodes
roncodes merged commit 9629f62 into release/v0.3.26 Oct 8, 2026
8 checks passed
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.

1 participant