Skip to content

"Sign out everywhere" is one AS call, then the local teardown - #1222

Merged
evanrbowers merged 2 commits into
feat/permitteer-loginfrom
feat/sign-out-everywhere
Sep 15, 2026
Merged

evanrbowers merged 2 commits into
feat/permitteer-loginfrom
feat/sign-out-everywhere

Conversation

@evanrbowers

Copy link
Copy Markdown
Contributor

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. On main the same button called the legacy auth.api.remote.it/v1/globalSignout (Cognito AdminUserGlobalSignOut by email), a reach the Permitteer-lane build lost with the Amplify code.

Change

  • globalSignOut makes 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 (permitteer docs/remoteit-desktop-login.md Phase 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/end returned early without a stored id_token.
  • devices.write joins the permitteer_account declaration (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).
  • The agent's background grant is revoked before the call (chat.signOut mints 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.
  • The silent /session/end lane (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

  • auth.test.ts: order (grant → sign-out-all → local), a refused call and an unreachable AS both still sign the app out. vitest 74/74, tsc clean.
  • e2e test:dev against localhost:3003 (this branch) with the new sign-out-everywhere.spec.ts from remoteit/e2e-tests#20: 81 passed, 1 skipped — two browser contexts, one press ends both, the answer on the wire is 200 with ended ≥ 2 and a non-failed pool, the other device boots back to the sign-in door. Live against Permitteer dev at 3203596+ with the IAM half (remoteit/authentication#34); the dev log shows POST /account/api/devices/sign-out-all 200 and 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.write declaration, so it fails against an app.dev that predates this PR.

🤖 Generated with Claude Code

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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-15T04:12:49.445577Z f036ed5 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread frontend/src/models/auth.ts Outdated
Comment on lines +574 to +575
const { signOutEverywhere } = await import('../services/permitteerAccount')
const r = await signOutEverywhere()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: f036ed5a0a

ℹ️ 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".

@evanrbowers
evanrbowers merged commit e4b0023 into feat/permitteer-login Sep 15, 2026
3 checks passed
@evanrbowers
evanrbowers deleted the feat/sign-out-everywhere branch September 15, 2026 04:59
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