Skip to content

Endpoint token lifecycle: first-class expired state with one-click recovery#5636

Merged
norman-abramovitz merged 9 commits into
cloudfoundry:developfrom
nabramovitz:norm/feature/endpoint-token-lifecycle
Jul 13, 2026
Merged

Endpoint token lifecycle: first-class expired state with one-click recovery#5636
norman-abramovitz merged 9 commits into
cloudfoundry:developfrom
nabramovitz:norm/feature/endpoint-token-lifecycle

Conversation

@nabramovitz

@nabramovitz nabramovitz commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Makes "expired" a first-class endpoint connection state, detected from data already on the wire, recorded in existing DB columns, reported at login and at jetstream boot, and recovered via the connect dialog. No schema migration and no background token refreshing.

Backend (jetstream)

  • When UAA rejects a refresh token, jetstream disposes of it in place: the stored refresh token is cleared and the expiry floored on the existing row, so read-time state computes as expired everywhere. The classifier inspects the UAA error body, not just the status: a 400 disposes only on invalid_grant/invalid_token, a 401 disposes unless the body names invalid_client. A rotated or misconfigured client secret therefore cannot wipe still-valid user tokens, and the disposal write is hard-gated to OAuth2 tokens (basic-auth rows store the username in the refresh column and must never be cleared this way).
  • When a successful UAA refresh response omits a refresh token (permitted by RFC 6749 section 4.3.3), the previously stored refresh token is kept instead of being overwritten with empty.
  • UpdateTokenAuth now always encrypts the refresh token, empty included; an existing guard silently rejected empty-refresh writes (and a conditional-encrypt path would have produced unreadable rows), which would have made the disposal a no-op.
  • Boot report for operators: one log line counting stored connections that need user re-authentication (counts only, never token material). Skipped on the upgrade-lock boot path, which runs without a store.

Frontend

  • 'expired' joins the endpointConnectionStatus union, computed at read time by a pure computeConnectionStatus from wire fields the info payload already carries (user, token_expiry, token_renewable). Never persisted. Every existing connectionStatus consumer was swept; request fan-out treats expired as not-connected so dead endpoints are not queried.
  • Endpoints list: expired endpoints show an amber Expired pill (table and card views) and keep their connected-endpoint actions - Reconnect opens the connect dialog with the username prefilled from the live connection, and Disconnect stays available. A session-scoped overlay also marks endpoints whose 401s the interceptor witnessed before the next info refetch.
  • Login arrival report: once per session, after the endpoints map first hydrates, a persistent snackbar names the endpoint that needs re-authentication (or counts them, with a View action to the endpoints page). Reconnecting is token upsert - no disconnect bounce, so nothing else about the connection is disturbed.

Verification

  • TDD throughout; new Go tests capture the actual ciphertext written to the DB and decrypt it back, and the must-not-dispose cases (5xx, transport failure, invalid_client, basic-auth rows) are covered alongside the must-dispose ones.
  • Full check gate green (frontend build + tests, backend build + tests, lint).
  • Live-verified against a real CF/UAA: staged a dead token in the exact disposed shape, observed the boot report count it, the arrival snackbar name it, the Expired pill and kebab actions render, and one-click reconnect restore a fresh refresh token with the pill returning to Connected in both views.

Closes #3311, closes #3256, closes #2972, closes #3968.

- isTokenRejectedErr now inspects the UAA response body, not just
  status: a 401 disposes unless the body names invalid_client, a 400
  disposes only when it names invalid_grant/invalid_token. Prevents a
  rotated/misconfigured client secret from wiping every user's still-
  valid refresh token for an endpoint.
- Gate the disposal write on the token's AuthType being OAuth2, making
  the "OAuth tokens only" invariant true by construction instead of
  relying on basic-auth endpoints happening to lack a TokenEndpoint.
- Floor TokenExpiry to now on disposal whenever it's 0 or in the
  future, not just when it's already in the future, so a disposed row
  with no prior expiry can't read back as healthy forever.
- Add a token_expiry===0 case to computeConnectionStatus's spec
  documenting the 0-as-no-expiry 'connected' behavior.
Live verification found the card view rendered a blank Status for an
endpoint whose connectionStatus is 'expired' - the status cell only had
branches for connected/disconnected/checking. Add the expired branch
(warning-toned label + reused disconnected glyph; no new icon font
entry) and keep the card undimmed so it stays as noticeable as a
connected one.

@norman-abramovitz norman-abramovitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@norman-abramovitz norman-abramovitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM - cover the bump dev

@norman-abramovitz norman-abramovitz merged commit c0dfffd into cloudfoundry:develop Jul 13, 2026
21 checks passed
@nabramovitz nabramovitz deleted the norm/feature/endpoint-token-lifecycle branch July 13, 2026 04:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants