"Sign out everywhere" is one AS call, then the local teardown - #1222
Conversation
The Security page's button ended ONE session — this app's, through /session/end — with a
comment promising the every-device half "with Phase 2b", which never carried it. It now
makes the one call permitteer#19 added, POST {issuer}/account/api/devices/sign-out-all:
every session of the account, this one included, each refresh family swept and the
resource servers told, and on a bridged stage the legacy pool's tokens revoked too
(permitteer docs/remoteit-desktop-login.md Phase 4e). Self-terminating by design — the
two-step alternative left a window in which the second step failed with the person half
signed out — and it needs only the access token the app already holds where /session/end
returned early without a stored id_token.
devices.write joins the permitteer_account declaration; the fingerprint changes, so the
existing heal path re-authorizes silently on the next boot and the first-party grant
widens without a screen. The agent's background grant is revoked BEFORE the call (it
mints from the very session the call ends); the local sign-out always follows, whatever
the AS answered — a miss is logged, never fatal. The silent /session/end lane goes: its
only caller was this button.
Pinned: order (grant → sign-out-all → local), a refused call and an unreachable AS both
still sign the app out. 74/74.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 953f9e6295
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // service with a token minted from THIS session, and once the AS has ended the session no | ||
| // token can be minted for that call. signedOut() runs chat.signOut again on the far side | ||
| // — a bounded no-op then, with nothing left to mint. | ||
| await dispatch.chat.signOut() |
There was a problem hiding this comment.
Avoid revoking the chat grant twice
Every global sign-out now awaits chat.signOut() here, then dispatch.auth.signOut() eventually reaches signedOut(), which unconditionally awaits chat.signOut() again at auth.ts:502. Since chat.signOut() always starts backgroundDisable() and waits up to three seconds (chat.ts:524-541), the second invocation is not a no-op: healthy sign-outs issue a duplicate enrollment DELETE and broadcast, while a slow or unavailable agent can delay local teardown by another three seconds after the AS call. Skip the second revocation on this path or make the chat teardown actually idempotent.
Useful? React with 👍 / 👎.
| // service with a token minted from THIS session, and once the AS has ended the session no | ||
| // token can be minted for that call. signedOut() runs chat.signOut again on the far side | ||
| // — a bounded no-op then, with nothing left to mint. | ||
| await dispatch.chat.signOut() |
There was a problem hiding this comment.
Prevent the preliminary grant revoke from blocking global logout
When the agent audience has no cached token and its token refresh stalls, this awaited chat.signOut() returns after its three-second race but leaves the refresh running. oidcAccessToken() serializes all later audience mints through the same minting promise (oidc.ts:492-506), so signOutEverywhere() then waits forever behind that abandoned agent mint and dispatch.auth.signOut() is never reached. This makes the security panic button leave the current app signed in during a half-open token-service failure; the preliminary revoke must be cancellable or isolated so the account-wide request and local teardown remain bounded.
Useful? React with 👍 / 👎.
| const { signOutEverywhere } = await import('../services/permitteerAccount') | ||
| const r = await signOutEverywhere() |
There was a problem hiding this comment.
Preserve AS termination for support sessions
When an operator uses this still-visible control from a support session, signOutEverywhere() cannot authenticate: support sessions have no refresh token and oidcAccessToken() deliberately returns their access token only for the GraphQL resource (oidc.ts:492-497), so the account-API helper returns 401 before making any request. The subsequent local teardown clears the tab, but unlike the removed oidcEndSessionSilently() path it leaves the server-side support session alive until expiry. Route acted sessions through an AS session-ending operation or hide this control for them rather than presenting a global logout that only clears local state.
Useful? React with 👍 / 👎.
… and a support session skips it (Codex review) - chat.signOut revoked the background grant on every call, and "sign out everywhere" calls it twice — once before the AS ends the session, once inside the local teardown: a second enrollment DELETE and another bounded wait on a slow agent. It now revokes ONCE per identity (keyed by user id, so a different account is never skipped); reset(), which every completed teardown ends with, re-arms it. - Audience mints serialize through one shared promise, so a mint the revoke abandoned mid-stall would have queued sign-out-all behind it for good and left the person signed in here. The AS call is bounded (SIGN_OUT_EVERYWHERE_TIMEOUT, 10 s); past it the local sign-out proceeds and the miss is logged. - A support session holds no refresh token and the AS refuses its writes: the control is hidden for it (the id_token says `act`), and globalSignOut backstops with the local teardown alone — ending the support session is the operator's console or the person's account page, never this button. Pinned: once-per-identity (same id skipped, different id not, reset re-arms), the bound (a call that never settles still signs out locally), the support backstop. 78/78. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Problem
The Security page's "Sign Out Everywhere" ended one session — this app's, through
/session/end— and the comment beside it promised the every-device half "with Phase 2b", which shipped the credential surface and never this. Onmainthe same button called the legacyauth.api.remote.it/v1/globalSignout(CognitoAdminUserGlobalSignOutby email), a reach the Permitteer-lane build lost with the Amplify code.Change
globalSignOutmakes the one call ebbtech/permitteer#19 added —POST {issuer}/account/api/devices/sign-out-all: every session of the account, this one included, each refresh family swept and the resource servers told, and on a bridged stage the legacy pool's tokens revoked too (permitteerdocs/remoteit-desktop-login.mdPhase 4e). Then the local teardown. Self-terminating by design: the two-step alternative left a window in which the second step failed with the person half signed out, and it needs only the access token the app already holds where/session/endreturned early without a storedid_token.devices.writejoins thepermitteer_accountdeclaration (oidc.ts). The fingerprint changes, so the existing heal path re-authorizes silently on the next boot and the first-party grant widens without a screen — no registry change (the account type is flat).chat.signOutmints from the very session the call ends;signedOut()runs it again on the far side as a bounded no-op). The local sign-out always follows, whatever the AS answered — a miss is logged, never fatal./session/endlane (oidcEndSessionSilently) goes: its only caller was this button. The menu sign-out stays local-only.Not touched, by design: Passport's, Google's and an org's IdP sessions (never the app's to end); access keys (a credential, not a session); password change (not a sign-out).
Tests
vitest74/74,tscclean.test:devagainst localhost:3003 (this branch) with the newsign-out-everywhere.spec.tsfrom remoteit/e2e-tests#20: 81 passed, 1 skipped — two browser contexts, one press ends both, the answer on the wire is 200 withended ≥ 2and a non-failedpool, the other device boots back to the sign-in door. Live against Permitteer dev at3203596+ with the IAM half (remoteit/authentication#34); the dev log showsPOST /account/api/devices/sign-out-all 200and the session-revoked pushes reaching the resource servers.Landing order
permitteer#19 (deployed to dev already, at its branch build) → this PR; app.dev picks it up on the branch's auto-build. e2e-tests#20 lands after this one — its spec needs the
devices.writedeclaration, so it fails against an app.dev that predates this PR.🤖 Generated with Claude Code