Skip to content

refactor(client): port Meteor auth/oauth packages to @rocket.chat/ddp-client#40413

Draft
ggazzo wants to merge 27 commits intodevelopfrom
port-meteor-auth-to-sdk
Draft

refactor(client): port Meteor auth/oauth packages to @rocket.chat/ddp-client#40413
ggazzo wants to merge 27 commits intodevelopfrom
port-meteor-auth-to-sdk

Conversation

@ggazzo
Copy link
Copy Markdown
Member

@ggazzo ggazzo commented May 5, 2026

Summary

Substitui imports from 'meteor/<pkg>' no client por uso do @rocket.chat/ddp-client SDK, em ordem topológica (folhas primeiro). Os pacotes Meteor seguem em .meteor/packages (server depende — fora do escopo); o que muda é que o bundle client deixa de tocar neles.

Base: worktree-sdk-over-ddp-client (#40301). Esse PR depende daquele para a infra do getDdpSdk()/adoptAccountFromMeteorLoginResult.

Status

  • Fase 1 — provider-oauth: google-oauth, facebook-oauth, twitter-oauth, meteor-developer-oauth — cada provider vira um IOAuthProvider local.
  • Fase 2oauth core: namespace oauth em @rocket.chat/ddp-client (launchLogin/stateParam/resolveLoginStyle/showPopup); client/lib/oauth/redirectUri.ts substitui OAuth._redirectUri + oauthRedirectUri.ts.
  • Fase 3 (parcial)accounts-base (12 de 22 imports removidos):
    • 3a: Accounts.onLogin/onLogoutsdk.account.onLogin/onLogout. LoginCancelledError na SDK. adoptAccountFromMeteorLoginResult agora emite uid.
    • 3b: Accounts._storedLoginToken/Accounts.ConfigError → leitura direta de localStorage + OAuthConfigError local.
    • 3c: Accounts._unstoreLoginToken()clearStoredCredentials() em client/lib/sdk/storedCredentials.ts.
    • 3d: SAML — type augments órfãs deletadas; storageLocation+USER_ID_KEYlocalStorage/'Meteor.userId'.
    • 3e: Accounts.callLoginMethodsdk.account.callLoginMethod na SDK; meteorBackedSdk delega. Accounts._hashPassword@rocket.chat/sha256 inline. Accounts.LoginCancelledErrorLoginCancelledError da SDK em meteor/login/oauth.ts.
    • 3f: Accounts.ConfigError em CustomOAuthOAuthConfigError.
    • 3g: Accounts.storageLocation em e2ee/rocketchat.e2e.ts (11 chamadas) → localStorage direto.
    • 3h: Accounts._unstoreLoginToken monkey-patch em AuthenticationProvideronBeforeClearCredentials emitter (subscription-based, race-free); override unstoreLoginToken.ts agora delega a clearStoredCredentials().

Imports from 'meteor/<pkg>' em apps/meteor/client/

Pacote Antes Depois
google-oauth/facebook-oauth/twitter-oauth/meteor-developer-oauth 4 0
oauth 8 1 (só meteor/login/oauth.ts — popup callback)
accounts-base 22 10

10 imports restantes em accounts-base

Todos com motivo concreto que exige trabalho fora deste PR:

Arquivo Uso restante Bloqueio
meteor/overrides/unstoreLoginToken.ts Accounts._unstoreLoginToken monkey-patch é o monkey-patch em si
meteor/overrides/killMeteorStream.ts Accounts._setLoggingIn Meteor reactive var sem substituto SDK
meteor/overrides/userAndUsers.ts Accounts.connection.userId() reactive bridge para Zustand
meteor/overrides/stubMeteorStream.ts Accounts._lastLoginTokenWhenPolled Meteor poll-stored-token internals
meteor/login/oauth.ts Accounts.oauth.credentialRequestCompleteHandler, onPageLoadLogin popup callback Meteor server-side
meteor/login/google.ts Accounts._options.restrictCreationByEmailDomain server config Meteor; sem RC equivalente
meteor/startup/accounts.ts Accounts.onEmailVerificationLink, Accounts.verifyEmail URL handlers Meteor-routed
providers/AuthenticationProvider/AuthenticationProvider.tsx Accounts.loggingIn() Tracker reactive
lib/customOAuth/CustomOAuth.ts Accounts.oauth.{registerService,credentialRequestCompleteHandler,serviceNames} popup callback coordenação
lib/sdk/meteorBackedSdk.ts Accounts.onLogin/onLogout/callLoginMethod pass-through proxy intencional

Escopo

Apenas apps/meteor/client/. Server (apps/meteor/server, apps/meteor/app/*/server, ee/) continua usando os pacotes Meteor — fora deste PR.

Test plan

  • Lint OK em todos os arquivos modificados
  • tsc --noEmit limpo nos arquivos modificados
  • grep -rn "from 'meteor/{google,facebook,twitter,meteor-developer}-oauth'" apps/meteor/client → 0
  • grep -rn "from 'meteor/oauth'" apps/meteor/client → 1 (esperado)
  • grep -rn "from 'meteor/accounts-base'" apps/meteor/client → 10 (de 22)
  • Smoke manual: senha + Google + custom OAuth em monolith (flag OFF e ON)
  • OAuth popup-style fecha quando provider retorna com ?close
  • OAuth redirect-style recupera credentialToken via sessionStorage após reload
  • 2FA OAuth challenge ainda funciona (onPageLoadLogin handler)
  • 2FA password challenge ainda funciona (TOTP via callLoginMethod)
  • Force-logout do segundo dispositivo (useForceLogout) limpa creds + router cai pra /login
  • Login → logout → login (testa onLogout/onLogin listeners)
  • loginWithIframe / loginWithTokenRoute (AuthenticationProvider) ainda fazem o login completo
  • iframe login auth refresh (useIframeLoginListener + onBeforeClearCredentials) ainda dispara em logout
  • E2EE encrypted room: criar par de chaves, salvar passphrase, recarregar, decifrar mensagens (testa storage migration de storageLocationlocalStorage)
  • e2e suíte completa em CI

ggazzo and others added 12 commits May 4, 2026 14:59
DDPDispatcher's pushItem path runs for every payload that goes through
dispatch — including connect, sub, unsub, and ping/pong. When the head
of the queue is a wait block (e.g. a login), pushItem deliberately
returns without sending so the next method waits. Applied to a non-
method payload, that means the connect frame ws.onopen emits gets
dropped on the floor: the socket sits open, the DDP handshake never
happens, and any caller waiting on `connected` hangs forever.

Visible failure mode: token-resume on page reload dispatched the wait
login while the socket was still connecting; when ws.onopen later
fired the connect frame, it was queued behind that login and never
sent. Status stuck at "connecting".

Fix: short-circuit dispatch for any payload whose msg is not 'method'
— emit it straight away. Wait/queue serialization is a property of
method calls, not protocol frames.
Previously both rejected with `Error('Connection in progress')` when
called against an already-connecting/connected state. That's hostile to
the only callers we have:

- The retry timer scheduled by `ws.onclose` fires `void this.reconnect()`
  with no `.catch`. If an external `connect()` won the race, the timer
  rejected and the unhandled rejection surfaced as a pageError on every
  reconnect cycle.
- Bootstrap paths in SDK consumers (e.g. apps/meteor/client/lib/sdk/ddpSdk.ts)
  already wrap in `.catch(() => {})` purely to silence the same noise.

Make both methods resolve with the current status when the connection is
already in flight or established, and have them clear the pending retry
timer on entry. The retry timer's callback also re-checks the status
before calling `reconnect()` so a connection re-established in the
meantime doesn't trigger a redundant reset cycle.

Also short-circuit `ws.onclose` when the closed socket isn't `this.ws`
anymore — a stale close from a replaced socket otherwise flipped
`status` back to `disconnected`, bumped `retryCount`, and scheduled a
fresh timer while a newer socket was healthy.

Tests cover all four shapes:
  - reconnect/connect when already connected → resolves, doesn't throw
  - retry timer firing after an external connect won the race → no
    unhandled rejection, status stays connected
  - stale ws.onclose after socket replacement → status and retryCount
    untouched, no extra timer scheduled

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to the 'idempotent connect/reconnect' fix in 7742877.
retryCount was incremented on every disconnect but never zeroed on
successful (re)connect. With the default budget of 1, a single
force-logout cycle that chains back-to-back ws.close events (server
broadcasts force_logout → SDK reconnects → app calls Meteor.logout()
on top → server's logout handler closes the WS again) drained the
budget. The next disconnect's onclose handler bailed at
`retryCount >= retryOptions.retryCount`, leaving the SDK permanently
disconnected. Method frames already in the dispatcher queue stayed
queued forever — observed in EE microservices CI as
e2ee-passphrase-management.spec.ts:87 (and :76) where the final
loginByUserState's login frame never reached the server because the
SDK socket never reconnected.

Stack trace pointing at the offending second logout:
   Accounts.logout → standardLogout (saml.ts) → logout (UserProvider)
     → useResetE2EPasswordMutation.onSuccess

The reset E2E password mutation calls `Meteor.logout()` on success by
design (to force re-login with the new keys). In monolith CE that
went via Meteor's own connection (separate retry policy); in EE
microservices it now hits the SDK socket via stubMeteorStream and
exposes the budget exhaustion.

Verified locally with BASE_URL=http://localhost:4000 IS_EE=true:
  - e2ee-passphrase-management.spec.ts:76 ✓
  - e2ee-passphrase-management.spec.ts:87 ✓
  - e2ee-key-reset.spec.ts ✓

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stand up a singleton DDPSDK instance (apps/meteor/client/lib/sdk/ddpSdk.ts)
pointing at the current origin and keep its connection in sync with the
authenticated session via a userIdStore subscription:

- ensureConnectedAndAuthenticated() drives DDPSDK.connect() + login-with-token
  and is awaited from runUserDataSync(uid) so subscribe-on-resume races
  resolve in the right order. Recognized auth-rejection reasons trigger
  Meteor.logout() so a server-side force-logout cleanly drains client state;
  a token-stable guard avoids that path firing on transient 401s where a
  parallel flow has already swapped the stored token.
- adoptAccountFromMeteorLoginResult() syncs DDPSDK.account from a Meteor
  login result so a downstream ensureConnectedAndAuthenticated() doesn't
  fire a second redundant login on the same socket.
- onLoggedIn now bridges off Accounts.onLogin AND userIdStore so the
  callback fires reliably when Meteor's autorun chain is wedged
  (logout → fresh login over the SDK socket).
- CachedStore version bumped (18 → 19) to invalidate caches written
  before the DDP wire encoding switched from JSON to EJSON, since
  fields like Date were stringified incorrectly in the JSON window.

Also wires the SDK module into client/main.ts and bumps
@rocket.chat/ddp-client into apps/meteor's manifest. The streamerAdapter
file gets the symbol the override layer (next commit) will consume.
The previous routing in ddpOverREST shipped methods over the DDPSDK
WebSocket whenever the SDK socket was up — including cached-store gets
that fire immediately after re-login, where the SDK session was briefly
unauthenticated and the server returned [] for everything. The earlier
mitigation (auth-gating) added complexity without resolving the deeper
mismatch with develop's transport choice. Realign on develop's logic
and only use DDPSDK for what it's specialised for:

- All non-bypassed DDP methods now route to REST (`method.call` /
  `method.callAnon`), matching develop. `bypassMethods` is restored to
  ['setUserStatus', 'logout'] and `UserPresence:*` / `stream-*` keep
  bypassing.
- `login` (resume + password) routes through the DDPSDK socket when
  it's connected so the same login authenticates Meteor's session AND
  the SDK socket in one hop. `adoptAccountFromMeteorLoginResult` syncs
  `sdk.account` so a downstream `ensureConnectedAndAuthenticated`
  short-circuits instead of firing a redundant second login.
- `sdk.call` (used by cached stores) now goes via `Meteor.callAsync`,
  same as develop, so methods that previously bypassed ddpOverREST
  (`permissions/get`, `subscriptions/get`, `private-settings/get`)
  now hit REST too.

Two sub-fixes that fall out of this:

- ddpOverREST's REST error path was rethrowing the API middleware's
  parsed JSON body (a plain string `error.message`) into
  `processResult`. Meteor's stream handler couldn't parse it as a DDP
  result frame, so the resume invoker never saw the rejection, the
  stale token stayed in localStorage, and the user wedged on /home with
  no main UI. Re-encode it as a proper DDP error result.
- `ensureConnectedAndAuthenticated` now drops the local credentials on
  an authentication error (`Accounts._unstoreLoginToken` +
  `Meteor.connection.setUserId(null)`) when the stored token didn't
  change mid-flight. Keeps the dead-WS path off limits — the previous
  fix using `Meteor.logout()` flaked in CI's parallel-shard runs by
  racing fresh registration / re-auth.

Reverts `killMeteorStream` to leave Meteor's WS connected: the
permanent-disconnect path broke
`MethodInvoker.sendMessage()`'s `if (this.connection._stream._connected)
{ _send(...) }` gate, leaving every method invoker queued behind a
connection that never returned and ddpOverREST's `_send` wrapper never
firing.

Verified locally:
- Reload with valid token: `/home` renders, 18 REST method calls, 2 WS
  frames (login resume on DDPSDK + the SDK login from
  ensureConnectedAndAuthenticated).
- Reload with invalid token: localStorage cleared, userId null, login
  form rendered.
- administration-settings.spec.ts:26 passes locally in 8.3s.
Replace Meteor.connection._stream with a stub that pretends to be
connected and forwards outbound DDP frames through the DDPSDK socket.
Goal: one WebSocket per page (the DDPSDK one), eliminating the second
auth roundtrip that was inflating boot time by ~1.5s in EE microservices.

- stubMeteorStream: disconnects Meteor's real stream, swaps in a stub
  with currentStatus.connected=true, routes method/sub/unsub frames via
  sdk.client.ddp.emit('send', ...) using Meteor's id namespace, and
  answers heartbeat pings locally with synthetic pongs. Carries the
  message/reset/disconnect listeners that Meteor registered before the
  swap onto the stub. Synthesizes a `connected` frame after a microtask
  if Meteor's WS hadn't finished its DDP handshake yet.
- ddpSdkCollectionBridge: also forwards `result` and `updated` frames so
  bypassed methods routed through the SDK socket reach Meteor's
  _methodInvokers (SDK-internal ids never collide with Meteor's numeric
  ids, so the bridge is a no-op for SDK's own callers).
- overrides/index: import order now guarantees _send override and the
  inbound bridge are wired before the stream swap.
When ensureConnectedAndAuthenticated runs at boot (from the userIdStore
subscriber) it can race Meteor's own resume login that's routed through
stubMeteorStream. Both end up as `login` method frames on the SDK socket
within the same tick. ddp-streamer's Account.login has no dedup, so each
fires Accounts.onLogin → Presence.newConnection → a duplicate row in
usersSessions for the same session id.

The duplicate stays ONLINE while the active connection flips to AWAY on
idle. processConnectionStatus prefers ONLINE over AWAY in the aggregate,
so the user.status update is a no-op (modifiedCount=0) and the
`presence.status` broadcast never fires — the navbar badge stays online
even after `UserPresence:away` succeeds server-side.

Fixes:
- Drop the boot-time `ensureConnectedAndAuthenticated` call. The Meteor
  login resume going through stubMeteorStream (with
  adoptAccountFromMeteorLoginResult populating sdk.account on the result)
  is the only auth path needed at boot.
- Gate the userIdStore-subscriber path on `Accounts.loggingIn()` and
  `sdk.account.uid`: if Meteor's login is in flight (or has just
  finished and the adopt callback set sdk.account.uid), short-circuit
  instead of issuing a redundant loginWithToken.
- Single-flight `inflightLogin` so concurrent calls share one promise.

Verified: tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts
"should be set to the queue" passes locally in 7.2s with IS_EE=true.
…nnect

DDPSDK auto-fires loginWithToken on every `connected` event using the
in-memory account.user.token (DDPSDK.create line 115-122). When the
server force-logs the user out (resetUserE2EKey,
account-manage-devices logout, admin device management, etc.), the
flow on the server is:

1. broadcast 'user.forceLogout' → meteor.service listener closes the
   user's WebSocket sessions
2. Users.unsetLoginTokens(uid) wipes services.resume.loginTokens

The client sees the WS close, the SDK reconnects, and on the new
`connected` it auto-retries loginWithToken with the now-dead token.
DDPSDK calls this with `void` so the rejection is swallowed —
`account.user` stays populated, `Meteor.userId()` stays set, the
router never falls back to Login, and the navbar continues to render
Home with a stale session.

Wrap account.loginWithToken to observe rejections from this auto-retry
path: on auth error (and only when the token in localStorage is still
the same one we tried — guard against a concurrent fresh login that
already rotated it), drop local credentials so the router goes to
Login. Mirrors what ensureConnectedAndAuthenticated already does for
its own loginWithToken call.

Verified: e2ee-key-reset and e2ee-passphrase-management now pass.
The microservices ddp-streamer was using ws.terminate() (TCP RST) for
broadcast force-logouts. The monolith path uses session.connectionHandle.close()
which is graceful and flushes the WS buffer first — letting the
`notify-user/<uid>/force_logout` stream message (queued by
apps/meteor/server/modules/listeners/listeners.module.ts:49) reach the
client before the socket goes down.

In EE that stream message races with the terminate, terminate wins, the
client's useForceLogout hook never fires, and tests like
e2e-encryption/e2ee-passphrase-management.spec.ts:87 are left with
stale localStorage credentials and a Login button that never hides.

Switch to ws.close() with a 5s setTimeout fallback to terminate() for
unresponsive sockets — matches the graceful-close semantics the
monolith already relies on without losing the safety net for zombies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the `Use_RC_SDK` admin setting (General → Use Rocket.Chat SDK) and
matching client-side opt-in helper. The migration ships dormant — legacy
Meteor DDP transport remains the default. Three sources resolve the flag,
URL > localStorage > meta tag:

  - `?sdk_transport=on|off` URL parameter (per-tab)
  - `rc-config-sdk_transport` localStorage entry (per-user)
  - `<meta name="rc-sdk-transport-enabled">` injected from the
    `Use_RC_SDK` setting (workspace-wide opt-in / kill-switch)

The flag is read once at module init and used to dispatch the SDK code
paths back to their Meteor equivalents when off:

  - The 5 SDK-transport overrides (`ddpOverREST`, `ddpSdkCollectionBridge`,
    `subscribeViaSDK`, `stubMeteorStream`, `killMeteorStream`) wrap their
    bodies in `if (isSdkTransportEnabled())` — `Meteor.connection._send`,
    `_subscribe`, and `_stream` stay un-wrapped when off.
  - `apps/meteor/app/utils/client/lib/SDKClient.ts` keeps both
    `createNewMeteorStream` and `createNewDdpSdkStream`. With the flag off
    streams use Meteor's subscribe + the original
    `Meteor.connection._stream.on('message')` bridge; with the flag on the
    SDK stream is used. `publish` similarly dispatches between
    `Meteor.call` and `getDdpSdk().client.callAsync`.
  - `apps/meteor/client/providers/ServerProvider.tsx` only subscribes to
    DDPSDK connection events, combines DDPSDK status into `getStatus`,
    and calls `ensureConnectedAndAuthenticated` from `reconnect()` /
    `getDdpSdk().connection.close()` from `disconnect()` when the flag is
    on. With the flag off, `getStatus` returns `Meteor.status()` directly.
  - The boot-time side-effect block in `ddpSdk.ts` (loginWithToken wrap,
    userIdStore subscriber, reset-on-reconnect listener) is gated as well
    — keeping the SDK socket "anonymous" when off so it doesn't create
    duplicate Presence sessions.

E2e default stays off, matching CI's dormant baseline; the SDK-transport
path is reachable via URL/localStorage for manual verification or a
dedicated future job.
@dionisio-bot
Copy link
Copy Markdown
Contributor

dionisio-bot Bot commented May 5, 2026

Looks like this PR is not ready to merge, because of the following issues:

  • This PR is missing the 'stat: QA assured' label
  • This PR is missing the required milestone or project

Please fix the issues and try again

If you have any trouble, please check the PR guidelines

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: eae0056

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 42 packages
Name Type
@rocket.chat/ddp-client Minor
@rocket.chat/meteor Patch
@rocket.chat/livechat Patch
@rocket.chat/mock-providers Patch
@rocket.chat/ui-contexts Major
@rocket.chat/ddp-streamer Patch
@rocket.chat/federation-matrix Patch
@rocket.chat/uikit-playground Patch
@rocket.chat/fuselage-ui-kit Major
@rocket.chat/gazzodown Major
@rocket.chat/ui-avatar Major
@rocket.chat/ui-client Major
@rocket.chat/ui-video-conf Major
@rocket.chat/ui-voip Major
@rocket.chat/web-ui-registration Major
@rocket.chat/ui-composer Major
@rocket.chat/core-typings Patch
@rocket.chat/rest-typings Patch
@rocket.chat/api-client Patch
@rocket.chat/apps Patch
@rocket.chat/core-services Patch
@rocket.chat/cron Patch
@rocket.chat/http-router Patch
@rocket.chat/model-typings Patch
@rocket.chat/models Patch
@rocket.chat/account-service Patch
@rocket.chat/authorization-service Patch
@rocket.chat/omnichannel-transcript Patch
@rocket.chat/presence-service Patch
@rocket.chat/queue-worker Patch
@rocket.chat/abac Patch
@rocket.chat/license Patch
@rocket.chat/media-calls Patch
@rocket.chat/omnichannel-services Patch
@rocket.chat/pdf-worker Patch
@rocket.chat/presence Patch
rocketchat-services Patch
@rocket.chat/network-broker Patch
@rocket.chat/omni-core-ee Patch
@rocket.chat/instance-status Patch
@rocket.chat/omni-core Patch
@rocket.chat/server-fetch Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 340b12fe-adb8-4673-8700-76763e915169

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 30.15742% with 843 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.58%. Comparing base (6cb6b27) to head (45cc7fb).
⚠️ Report is 16 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop   #40413      +/-   ##
===========================================
- Coverage    70.00%   69.58%   -0.43%     
===========================================
  Files         3301     3314      +13     
  Lines       120462   121567    +1105     
  Branches     21566    21753     +187     
===========================================
+ Hits         84330    84588     +258     
- Misses       32834    33665     +831     
- Partials      3298     3314      +16     
Flag Coverage Δ
e2e 58.92% <23.94%> (-0.70%) ⬇️
e2e-api 46.22% <100.00%> (-0.04%) ⬇️
unit 70.45% <29.33%> (-0.42%) ⬇️

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.

Comment thread packages/ddp-client/src/oauth/launchLogin.ts Fixed
@ggazzo ggazzo changed the base branch from develop to worktree-sdk-over-ddp-client May 5, 2026 23:07
@ggazzo ggazzo force-pushed the port-meteor-auth-to-sdk branch from 4ccfed2 to b96e1d4 Compare May 5, 2026 23:07
ggazzo and others added 8 commits May 5, 2026 20:45
Replace `import { Google/Facebook/Twitter/MeteorDeveloperAccounts } from
'meteor/<provider>-oauth'` with local IOAuthProvider objects that wire the
existing requestCredential implementation into createOAuthTotpLoginMethod.
The provider globals were only used to (1) attach requestCredential and (2)
hand a typed `provider` to the TOTP factory — both fully covered locally.

Constants pulled inline:
- twitter: `TWITTER_VALID_PARAMS_AUTHENTICATE = ['force_login', 'screen_name']`
- meteor-developer: `MDG_SERVER = 'https://www.meteor.com'`

Cordova `Google.signIn` is left undefined; Rocket.Chat web doesn't bundle
the `plugins.googleplus` cordova plugin so the existing isCordova check
becomes a no-op, matching today's web behaviour.

Phase 1 of the plan to remove client-side coupling to Meteor accounts/oauth
packages — the four provider-oauth packages remain in .meteor/packages for
the server, but `apps/meteor/client/` no longer references them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…th to SDK

Move launchLogin / stateParam / loginStyle / showPopup from meteor/oauth
into @rocket.chat/ddp-client as a tree-shakeable `oauth` namespace, and
adapt 7 of the 8 client call sites. The 8th (meteor/login/oauth.ts) keeps
its meteor/oauth import because it's coupled to accounts-base
(_retrieveCredentialSecret + _storageTokenPrefix override) and is handled
in Phase 3.

Also drops `client/meteor/overrides/oauthRedirectUri.ts` — its legacy
`?close` patch is folded into the new `client/lib/oauth/redirectUri.ts`,
which builds on `client/lib/absoluteUrl.ts` instead of Meteor.absoluteUrl.

What lives where:

  packages/ddp-client/src/oauth/  (browser-pure, no Meteor deps)
    launchLogin    popup or redirect, sessionStorage for redirect data
    stateParam     base64 state JSON (loginStyle + credentialToken + flags)
    loginStyle     popup vs redirect, cordova/sessionStorage fallbacks
    showPopup      centered popup + 100ms close polling
    types          LoginStyle / LaunchLoginOptions / etc.

  apps/meteor/client/lib/oauth/redirectUri.ts
    `_oauth/<service>` URL with optional ?close back-compat — has to live
    next to the call site because it depends on RC's absoluteUrl.

Why not _retrieveCredentialSecret yet:
  The Meteor oauth package exposes a popup-window API
  (OAuth._handleCredentialSecret) that is called from the popup callback
  page served by Meteor's server. Reimplementing it in the SDK without
  also moving the popup callback would split the credentialSecrets map
  across two universes. Phase 3 will fix the override in
  client/meteor/login/oauth.ts together with accounts-base.
…count

Phase 3a — replace `Accounts.onLogin`/`Accounts.onLogout` call sites with
`getDdpSdk().account.onLogin`/`onLogout` and back the SDK's emitter with
Meteor in pass-through mode (flag OFF) and with the existing `uid` event
in SDK transport mode (flag ON). Also add `LoginCancelledError` to the
SDK so `meteor/login/oauth.ts` can drop its `Accounts.LoginCancelledError`
reliance in a follow-up.

SDK side (`packages/ddp-client/src/`):
- `types/Account.ts` adds `onLogin(fn)` / `onLogout(fn)` that subscribe to
  the existing `uid` Emitter event and only fire on falsy↔truthy
  transitions, so any code path that ends with `account.uid` populated
  triggers the right callback (no new events to instrument across
  loginWithToken / loginWithPassword / external sync).
- `types/LoginCancelledError.ts` mirrors `Accounts.LoginCancelledError`
  including the magic `numericError = 0x8acdc2f` so existing `instanceof`
  + `.error === numericError` checks keep working over the wire.
- `index.ts` re-exports `LoginCancelledError`.

Adopt sync (`apps/meteor/client/lib/sdk/ddpSdk.ts`):
- `adoptAccountFromMeteorLoginResult` now emits `uid` after assignment so
  subscribers of `account.onLogin` see the Meteor-side login. Skipped on
  same-uid token-refreshes.

Pass-through (`apps/meteor/client/lib/sdk/meteorBackedSdk.ts`):
- `account.onLogin/onLogout` delegate to `Accounts.onLogin/onLogout`
  when the SDK transport is OFF (Meteor still owns auth lifecycle).

Migrated call sites (5 files, 8 hooks):
- client/lib/loggedIn.ts                  — drops the flag-on/off split,
                                            single SDK subscription.
- client/lib/cachedStores/CachedStore.ts  — also replaces
                                            `Accounts._storedLoginToken()`
                                            with direct localStorage read.
- client/lib/e2ee/rocketchat.e2e.ts       — onLogout (storageLocation use
                                            stays — separate phase).
- client/providers/UserProvider/UserProvider.tsx
- client/startup/startup.ts               — both onLogout calls.

Out of scope for this commit:
- `Accounts.callLoginMethod` (oauth.ts, password.ts, AuthenticationProvider)
- `Accounts._unstoreLoginToken`, `Accounts.LoginCancelledError`,
  `Accounts.onPageLoadLogin`, `Accounts.oauth.*`, `Accounts.storageLocation`
- `client/meteor/overrides/killMeteorStream.ts` keeps `Accounts._setLoggingIn`
Phase 3b — three small leaf files that only touched accounts-base for token
storage / config-error reporting:

- client/views/root/hooks/loggedIn/useStoreCookiesOnLogin.ts
  Replace Accounts._storedLoginToken() with direct localStorage read
  ('Meteor.loginToken' is the same key Meteor uses internally; the
  override in client/meteor/overrides/ddpOverREST.ts already reads from
  this exact key, so no compatibility risk).

- client/views/oauth/components/AuthorizationFormPage.tsx
  Same: Accounts.storageLocation.getItem(Accounts.LOGIN_TOKEN_KEY)
  → window.localStorage.getItem('Meteor.loginToken').

- client/lib/wrapRequestCredentialFn.ts
  Drop Accounts.ConfigError; introduce client/lib/oauth/errors.ts with
  OAuthConfigError mirroring the same shape (`name`, `error`) that the
  callback consumers check via duck typing on `error.error`.

`grep -r "from 'meteor/accounts-base'" apps/meteor/client/` now down to 16
(from 22 at branch start, 19 after Phase 3a).
Phase 3c — extract clearStoredCredentials() into
client/lib/sdk/storedCredentials.ts and use it in place of
Accounts._unstoreLoginToken() at the two SDK-aware call sites:

  client/lib/sdk/ddpSdk.ts                      (auth error recovery
                                                 inside ensureConnectedAndAuthenticated)
  client/views/root/hooks/loggedIn/useForceLogout.ts (force-logout broadcast)

The new helper does the same job as the override in
client/meteor/overrides/unstoreLoginToken.ts: drops the standard Meteor
keys (`Meteor.loginToken`, `Meteor.loginTokenExpires`, `Meteor.userId`)
and runs CachedStoresManager.clearAllCachesOnLogout() so per-user caches
don't leak across accounts.

The override itself stays in place for now — anything in the codebase
still calling `Accounts._unstoreLoginToken()` (server-injected paths,
Meteor's own internal logout) keeps the same behaviour. Removing the
override is a later cleanup once we've audited those callers.

Imports remaining: 14 (was 16).
Three changes:
- The `if (!Accounts.saml) Accounts.saml = {};` namespace stub plus the
  matching `declare module 'meteor/accounts-base'` augmentation were dead
  code — `Accounts.saml.{credentialToken,credentialSecret}` is never read
  anywhere in the codebase. Drop both.
- Same for the `function onLogout(...)` augmentation in saml.ts: nothing
  in saml.ts calls `Accounts.onLogout`, and the existing call sites that
  do already migrated to `getDdpSdk().account.onLogout` in the previous
  commit, so the augmentation has no remaining consumers.
- `Accounts.storageLocation.removeItem(Accounts.USER_ID_KEY)` →
  `window.localStorage.removeItem('Meteor.userId')`. `storageLocation` is
  always `localStorage` on the web client and `USER_ID_KEY` is the
  literal `'Meteor.userId'` (Meteor source: accounts-client.js).

Imports remaining: 13 (was 14).
@ggazzo ggazzo force-pushed the port-meteor-auth-to-sdk branch from 26be7e7 to d49077a Compare May 5, 2026 23:46
ggazzo added 2 commits May 5, 2026 20:58
…ount

Phase 3e — add `sdk.account.callLoginMethod()` and `LoginCancelledError` to
the SDK so client-side login dispatchers no longer need accounts-base.

SDK side (`packages/ddp-client/src/`):
- `types/Account.ts` adds `callLoginMethod({ methodName?, methodArguments?,
  userCallback? })` mirroring `Accounts.callLoginMethod`. Defaults to
  `methodName: 'login'`. Dispatches via `client.callAsyncWithOptions(...,
  { wait: true })` and adopts the resulting `id`/`token`/`tokenExpires`
  into `account.uid`/`user`/`token` so the existing `uid` event path
  (which feeds `onLogin`) fires consistently with the standalone
  `loginWithPassword`/`loginWithToken` methods.
- `index.ts` re-exports `CallLoginMethodOptions` and `LoginCallback` types.

Pass-through (`apps/meteor/client/lib/sdk/meteorBackedSdk.ts`):
- `account.callLoginMethod` delegates to the real
  `Accounts.callLoginMethod` so flag-OFF mode keeps Meteor's resume /
  loggingIn / onLogin bookkeeping intact.

Migrated call sites:
- client/lib/2fa/overrideLoginMethod.ts  — `callLoginMethod` Promise wrapper
                                          now hits the SDK; no Accounts import.
- client/meteor/login/oauth.ts           — `tryLoginAfterPopupClosed`
                                          dispatch + `LoginCancelledError`
                                          (now from @rocket.chat/ddp-client).
- client/meteor/login/password.ts        — TOTP login dispatch +
                                          `Accounts._hashPassword(password)`
                                          replaced by an inline
                                          `hashPassword()` using
                                          `@rocket.chat/sha256`.
- client/providers/AuthenticationProvider/AuthenticationProvider.tsx
                                          — `loginWithIframe` /
                                          `loginWithTokenRoute` dispatchers.

Out of scope (kept on Meteor for now): `Accounts.loggingIn`,
`Accounts._unstoreLoginToken` (used by AuthenticationProvider for the
unstoreLoginToken callback registry), `Accounts.oauth.*` (popup callback
coordination — Meteor server-served page), `Accounts.onPageLoadLogin`
(2FA OAuth challenge bridge).

Imports remaining: 11 (was 13).
…cal helper

Use OAuthConfigError (already introduced in 962ab9e for
wrapRequestCredentialFn) so both OAuth provider entry points throw the
same shape.

CustomOAuth.ts still imports Accounts for the popup-callback coordination
APIs (`Accounts.oauth.registerService` /
`Accounts.oauth.credentialRequestCompleteHandler` /
`Accounts.oauth.serviceNames`) — those bridge to Meteor's server-served
OAuth callback page and need a separate plan to migrate.
`Accounts.storageLocation` is always `localStorage` on the web client
(meteor/accounts-base sets it to `Meteor._localStorage`, which equals
`window.localStorage` outside of test/SSR), and all 11 call sites here
read/write E2EE-specific keys (`public_key`, `private_key`,
`e2e.randomPassword`) — none of them are coordinated with the rest of
the accounts-base storage namespace. Replace with direct `localStorage`
reads/writes, no behaviour change.

Imports remaining: 10 (was 11).
if (Accounts.storageLocation.getItem('e2e.randomPassword')) {
Accounts.storageLocation.setItem('e2e.randomPassword', newPassword);
if (localStorage.getItem('e2e.randomPassword')) {
localStorage.setItem('e2e.randomPassword', newPassword);
async createRandomPassword(): Promise<string> {
const randomPassword = await generatePassphrase();
Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword);
localStorage.setItem('e2e.randomPassword', randomPassword);
ggazzo added 2 commits May 5, 2026 21:08
…icationProvider

Add an `onBeforeClearCredentials` Emitter to
`client/lib/sdk/storedCredentials.ts` so consumers can react to a
credential clear without monkey-patching `Accounts._unstoreLoginToken`
on the fly.

The `client/meteor/overrides/unstoreLoginToken.ts` override now bridges
Meteor's internal `_unstoreLoginToken` (called by Meteor's logout flow
and token-rotation paths) into our local `clearStoredCredentials()` so
the same emitter fires regardless of who triggered the drop — the
override existed before to flush per-user caches, this just consolidates
both responsibilities behind the same helper.

`AuthenticationProvider.unstoreLoginToken` now subscribes to that
emitter instead of swapping `Accounts._unstoreLoginToken` for a closure
that wraps the previous reference and unwraps in the cleanup. The old
swap pattern was racey on multiple subscribers (last writer wins) and
leaked references across re-renders if the cleanup didn't fire.

Single consumer of `useUnstoreLoginToken` is
`client/views/root/hooks/useIframeLoginListener.ts`, which keeps the
same sub/unsub semantics — verified by reading the call site. The
`Accounts` import in AuthenticationProvider stays for `Accounts.loggingIn()`
which is a Meteor Tracker reactive var; that's a separate plan.
…lizer

Pure mechanical fixes — prettier formatting (multi-line argument lists
collapsed) plus the nested-ternary in `Account.callLoginMethod`'s
`tokenExpires` handling pulled out to a `normalizeTokenExpires` helper
to satisfy `no-nested-ternary`. Also `${window.location}` →
`window.location.toString()` in `stateParam.ts` to drop the
`restrict-template-expressions` warning.

No behavioural changes.

const saveDataForRedirect = (loginService: string, credentialToken: string): void => {
try {
sessionStorage.setItem(REDIRECT_DATA_KEY, JSON.stringify({ loginService, credentialToken }));
ggazzo added 2 commits May 5, 2026 23:22
Replace every direct `localStorage.{get,set,remove}Item('Meteor.loginToken' /
'Meteor.loginTokenExpires' / 'Meteor.userId')` in the client with a
typed `CredentialStorage` interface exposed by `sdk.account.storage`.

Why: client code was reading/writing Meteor's localStorage keys in 6+
places, each making implicit assumptions about the storage backend
(window.localStorage being available, the exact key strings, the
cleanup semantics). Pulling that into a single SDK abstraction:

- single source of truth for the key names + persistence backend, so
  swapping to sessionStorage / a secure cookie / a no-op (tests) is one
  change instead of grepping the codebase
- typed API: `getToken()` / `getUserId()` / `getTokenExpires()` /
  `setCredentials()` / `clearUserId()` / `clear()` instead of raw
  string keys and `parseInt` for the expiry timestamp
- SSR safe by construction (the LocalStorage impl guards `typeof window`
  internally, so callers don't have to)

SDK side (`packages/ddp-client/src/`):
- `types/CredentialStorage.ts` defines the interface, plus
  `LocalStorageCredentialStorage` (default — keeps the Meteor key names
  for back-compat with `client/meteor/overrides/ddpOverREST.ts` and
  existing user sessions) and `MemoryCredentialStorage` (no-op for
  Node/SSR test envs).
- `Account` interface gains `storage: CredentialStorage`. `AccountImpl`
  takes it as a constructor arg, defaulting to `LocalStorageCredentialStorage`.
- `index.ts` re-exports the interface and both impls.

Pass-through (`apps/meteor/client/lib/sdk/meteorBackedSdk.ts`):
- `account.storage` is a `LocalStorageCredentialStorage` so flag-OFF
  reads and writes hit the same persistent slot Meteor uses.

Migrated call sites (5 files):
- client/lib/sdk/ddpSdk.ts                                     `readStoredLoginToken()`
- client/lib/sdk/storedCredentials.ts                          `clearStoredCredentials()` body
- client/lib/cachedStores/CachedStore.ts                       `getToken()` (PrivateCachedStore)
- client/views/root/hooks/loggedIn/useStoreCookiesOnLogin.ts   `rc_token` cookie
- client/views/oauth/components/AuthorizationFormPage.tsx      access_token form input
- client/startup/startup.ts                                    session-resume failure detection
- client/meteor/login/saml.ts                                  SAML SLO `clearUserId()`
`storedCredentials.ts` was importing `getDdpSdk` from `./ddpSdk` while
`ddpSdk.ts` imports `clearStoredCredentials` from `./storedCredentials`.
The cycle didn't crash on initialisation (every reference was deferred
to call-time inside functions), but it's a code smell — bundlers
disagree on live-binding semantics for circular ESM, and a future
top-level use of either binding would break in subtle ways.

Pull a single shared `LocalStorageCredentialStorage` instance into
`apps/meteor/client/lib/sdk/credentialStorage.ts` and have every
consumer (storedCredentials, ddpSdk, meteorBackedSdk, CachedStore,
useStoreCookiesOnLogin, AuthorizationFormPage, saml, startup) import
that module directly. The `Account` / `AccountImpl` SDK-side abstraction
stays unchanged: when the real DDPSDK path is taken, we monkey-assign
`sdk.account.storage = credentialStorage` so flag-ON and flag-OFF
modes share the same localStorage slot.

While here, drop the `Meteor.loginToken` literal that startup.ts was
still reading — now goes through `credentialStorage.getToken()` like
everything else.
Base automatically changed from worktree-sdk-over-ddp-client to develop May 6, 2026 20:19
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