Skip to content

fix(api): revoke a connected app when the credential epoch supersedes it - #1248

Merged
mforce merged 10 commits into
mainfrom
fix/1229-revoke-superseded-app-tokens
Oct 12, 2026
Merged

mforce merged 10 commits into
mainfrom
fix/1229-revoke-superseded-app-tokens

Conversation

@mforce

@mforce mforce commented Oct 11, 2026

Copy link
Copy Markdown
Owner

After a password change, Account › Connected apps kept listing the app as connected, and "last used" kept moving on every refused retry. Now a credential-epoch bump disconnects the person's apps in the same transaction, and only a request that passes the account and credential checks counts as use.

Closes #1229

What changes for the person

  • A password change or reset, an email or role change, and disabling a user now revoke every app connection that person holds. The app leaves Connected apps at once and must go through consent again. Before, the app was refused but stayed listed, and its token row stayed valid for the life of the authorization, so OAuthPurge never pruned it.
  • "Last used" moves only for a request that passes the account and credential checks. Before, OAuthLastUsedStamp ran inside OpenIddict token validation, ahead of CredentialEpochMiddleware, so every refused retry stamped it. A request refused later, for a missing scope or role, or an MCP tool that fails, still counts as use.

Design

The issue offered two designs. This PR uses the second one, plus a small backstop from the first.

Revoke at the bump (issue option b). All five bump sites are in IdentityProvider (role change, email change, disable, password reset and break-glass, own password change). Each one already calls RevokeAllActiveForUserAsync(userId, now, ct) inside its transaction, right after CredentialEpoch++. That overload now also sets the user's valid OpenIddict authorizations and their valid or inactive tokens to revoked, as ConnectedApps.DisconnectAsync does for one app. Each statement also writes a new ConcurrencyToken, and so do Disconnect's two status updates, which had the same gap before this PR. OpenIddict writes a new token row twice, an insert and then a full-row payload update guarded by ConcurrencyToken. Without the rotation, a payload update that loaded the row before the revocation would still match and write valid back. Option (a) alone would leave idle apps listed until they retried, which is the symptom the issue reports.

Failure handling matches refresh tokens. The OpenIddict tables are in the same database and the same transaction. The new statements run inside the existing RunRevocationAsync, which logs Auth.RefreshRevocationFailed and rethrows, so a failed revocation rolls back the reset, as a failed refresh-token revocation already does. A reset cannot succeed and leave the app connected.

Backstop on refusal (from issue option a). For a Superseded verdict, CredentialEpochMiddleware revokes the presented token (oi_tkn_id), with the same ConcurrencyToken rotation. The epoch only moves forward, so that token can never pass again. It revokes only the token, never its authorization. Suspension bumps the epoch without touching OAuth rows, so after reactivation a reconnection reuses the old authorization, and the old token's retry must not disconnect the new one.

The backstop covers tokens the bump missed. One case is a token issued while a bump committed. The other is a token left valid by a bump that ran before this change. Their authorization stays listed until someone disconnects it or a later bump revokes it.

The backstop is best-effort. A failed write is logged and the 401 stands. It runs only for Superseded. Disabled, FarmSuspended and ConnectedAppsOff can be reversed, so those refusals revoke nothing.

Last used. The stamp moved from an OpenIddict validation handler (OAuthLastUsedStamp, deleted) to ConnectedApps.RecordUseAsync. The middleware calls it only for a Current verdict. The 15-minute write throttle is unchanged. ConnectedApps is resolved from request services only when the principal carries an authorization id, so session requests do not construct it. "Last used" follows the account and credential checks. It does not prove the request then passed authorization or that a tool succeeded.

Not fixed here

  • Farm suspension bumps every user's epoch in AccountSuspensionService with its own bulk update. It revokes refresh tokens only. That is a separate code path, so this PR leaves it alone. While the farm is suspended, apps are refused with Auth.FarmSuspended. After reactivation, an app's old token is refused as Superseded and the backstop revokes that token, but its connection stays listed until someone disconnects it or reconnects the app.
  • Disabling a user uses IdentityProvider.DisableUserAsync, one of the five bump sites, so it is fixed here.
  • An app approved in the instant a bump commits stays listed until someone disconnects it. Closing that window would need a row lock on the user in both the consent transaction and the bump. Its tokens are refused on every request either way.

One visible protocol difference

After a password change, the app's next request is refused by OpenIddict's authorization-entry check, because the authorization is now revoked. The 401 carries WWW-Authenticate: Bearer resource_metadata=... without error="invalid_token", which is the response Disconnect already gives. Before, the refusal came from the epoch middleware and carried error="invalid_token" with title Auth.CredentialsSuperseded. The live-run comment shows both responses to a real MCP client.

Tests

New tests are in OAuthFailClosedTests.SupersededApps.cs. RoleChange_RevokesTheToken now also asserts that nothing stays valid.

Test Proves
PasswordChange_DisconnectsTheApp the bump revokes the authorization and the token, Connected apps is empty, and the old token is refused
RoleChange_RevokesTheToken a role change does the same
SupersededToken_IsRevoked_OnItsNextRequest a token the bump missed is refused with Auth.CredentialsSuperseded and revoked on its next request, and its approval stays valid
SupersededToken_LeavesAReconnectionUnderTheSameApprovalWorking after suspend, reactivate and a reconnection that reuses the approval, the old token's refusal leaves the new token working
PasswordChange_FailsAnInFlightTokenWrite an OpenIddict token write that loaded the row before the password change fails with a concurrency error and the row stays revoked
SupersededRefusal_FailsAnInFlightTokenWrite the same for the backstop's revocation
Disconnect_FailsAnInFlightTokenWrite the same around the real Disconnect endpoint
RefusedRequest_DoesNotMoveLastUsed a refused request leaves LastUsedAtUtc unchanged, even past the 15-minute throttle
PasswordChange_RacingTokenUse_LeavesNothingValid 20 concurrent requests with the token during the password change leave nothing valid
PasswordChange_ThenReconnect_Works the same client id connects again after the change and its new token works

The three in-flight-write tests use OpenIddict's own IOpenIddictTokenManager.UpdateAsync from a scope that loaded the row before the revocation. That is the store method OpenIddict's issuance calls for its second write, so the interleaving is deterministic without a barrier inside OpenIddict.

tools/oauth/mutation-check.sh has ten new mutants and six updated ones. tokens-left-valid and authorization-alone-refuses now anchor on Disconnect's client-id predicate, because the backstop added a second match of their old anchor. epoch-exempts-oauth now targets SupersededToken_IsRevoked_OnItsNextRequest. The role-change test no longer reaches the epoch middleware, because OpenIddict refuses the revoked authorization first. The last-used-* mutants point at ConnectedApps. I ran the script on every mutant anchored in a file this branch changes, which is CredentialEpochMiddleware.cs, IdentityProvider.cs, ConnectedApps.cs and OAuthServerRegistration.cs. That is 50 mutants. 47 were killed and 1 held, on a green 487-test baseline, and the restore run was green too. Two came back INCONCLUSIVE because the mutant does not compile, and both do the same on main:

  • shared-check-unchallenged deletes the only CluckworkMcp call in the middleware, which leaves using Cluckwork.Api.Hosting; unused, so IDE0005 fails the build.
  • resource-not-stamped deletes the only OpenIddict.Abstractions use in OAuthServerRegistration.cs, with the same IDE0005 failure.

I applied both mutants to an export of origin/main and the build failed the same way, so neither comes from this PR. rpc-429-as-problem has an anchor that already matches nothing on main. It is outside this PR and outside the filtered run.

At the merged head I ran the OAuth, MCP, credential-epoch, disable, suspension and schema-docs integration tests (514 passed), the tenant-bypass and architecture guards (450 passed), the web i18n and glossary tests (342 passed), and docs-impact.mjs (nothing to flag). Earlier on the branch the step-up and break-glass suites passed too, and so did the whole web suite (2224). PasswordChange_ThenReconnect_Works has no mutant because this diff has no reconnect code to break. It guards against a revocation that would block a new approval.

Docs

  • specs/product/GLOSSARY.md and the Help topic "When an AI app is refused" (en, es, tl) name every change that disconnects an app. The es and tl text uses each locale's own label for Connected apps.
  • The in-app glossary definition of a connected app (en, es, tl) names the changes that disconnect it, matching GLOSSARY.md.
  • docs/decisions/1146-connected-apps-switch.md strikes the accepted cost "a refused retry still moves Last used" and notes that Revoke a connected app's token when the credential epoch supersedes it, and stop stamping last used #1229 removed it. docs/decisions/799-connected-apps.md says where the stamp runs now and what "last used" still counts.
  • FilterFreeSetSites classifies the four new filter-free queries, re-pins Disconnect's token query and drops the deleted stamp's row.

Reading order

  1. IdentityProvider.RevokeAllActiveForUserAsync, the bump-time revocation.
  2. CredentialEpochMiddleware, the verdict gate.
  3. ConnectedApps.RecordUseAsync, RevokeSupersededTokenAsync and the ConcurrencyToken lines in DisconnectAsync.
  4. OAuthServerRegistration, where the validation handler is removed.
  5. The tests, then tools/oauth/mutation-check.sh.

A password, email or role change, a password reset and disabling a user
now revoke the user's OpenIddict authorizations and tokens in the same
transaction that bumps CredentialEpoch, so Connected apps stops listing a
dead connection and OAuthPurge can prune it.

CredentialEpochMiddleware now records "last used" only for a Current
verdict, so a refused retry never moves it. For a Superseded token it
also revokes that token's authorization, which catches a connection
approved while a bump committed and rows left from before this change.

Closes #1229
…OpenIddict writes

The fallback for a Superseded verdict revoked the token's whole
authorization. Suspension leaves OAuth rows valid, so a reconnection after
reactivation reuses that authorization, and the old token's retry then
disconnected the new token too. The fallback now revokes only the presented
token (oi_tkn_id). Authorization-wide revocation stays at the epoch bump.

Both bulk revocations now also rotate ConcurrencyToken. OpenIddict writes a
new token row twice, and its second full-row write would otherwise match
the unchanged token and put the revoked row back to valid.
The in-app glossary definition of a connected app now names the changes
that disconnect it, matching GLOSSARY.md in en, es and tl. Last used is
described as following the account and credential checks, and decision
#799 records that the stamp moved out of OpenIddict validation.
Disconnect revoked its authorizations and tokens with a status-only bulk
update, so an in-flight OpenIddict payload write could put a disconnected
app's token back to valid. Both updates now rotate ConcurrencyToken, as
the epoch-bump and superseded-token paths do.
@mforce

mforce commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner Author

Live run with a real MCP client

I ran this on an isolated stack (cw1229) behind the TLS proxy at https://localhost:8498, built from 0904f25. The later merge of origin/main (0f945a2) changes no file in this PR's diff. The client is tools/verify/mcp/client.ts, which uses the official TypeScript MCP SDK. It connected through consent with the password step-up. The password changes went through the SPA's Account page in Chromium. I read the OpenIddict rows with psql in the stack's database container. For the before frames I ran the same steps on a stack built from origin/main 15a2676, this branch's base.

Before, on main. The Owner connects the app and calls cluckwork_read_stock. I backdate "last used" by 20 minutes, so any stamp would move it, and change the password in the SPA. The app is refused, but it stays listed and its row stays valid. The refused call also moves "last used" from 20:48:20 to 21:08:24.

=== 4. Change the Owner's password through the SPA
Connected apps rows for "Cluckwork live verifier": 1
=== 5. The client's next call with its old token
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://localhost:8498/.well-known/oauth-protected-resource/mcp"
{"title":"Auth.CredentialsSuperseded","status":401,...}
valid | 2026-10-11 21:08:24.854772+00 | ...authorization_code:redeemed, ...access_token:valid

After, on this branch. I ran the same steps.

=== 3. Backdate last used 20 minutes
valid   | 2026-10-11 22:00:12.655587+00 | ...authorization_code:redeemed, ...access_token:valid
=== 4. Change the Owner's password through the SPA
account page: "Password changed. Any other devices have been signed out."
Connected apps rows for "Cluckwork live verifier": 0
=== 5. The client's next call with its old token
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://localhost:8498/.well-known/oauth-protected-resource/mcp"
ERROR code=401 Streamable HTTP error: Error POSTing to endpoint:
revoked | 2026-10-11 22:00:12.655587+00 | ...authorization_code:redeemed, ...access_token:revoked

The client's next call is refused. The authorization and its access token are revoked, and Connected apps is empty. "Last used" stays at the backdated 22:00:12.

Reconnecting works. I set the password back through the SPA, because the client signs in with the cast password. Then the client connected again through consent. Its new token reads stock, and Connected apps lists the app once.

=== 7. Reconnect
{"rows":1}
Connected apps rows for "Cluckwork live verifier": 1
revoked | 2026-10-11 22:00:12.655587+00 | ... (the old connection)
valid   | 2026-10-11 22:20:23.929589+00 | ...access_token:valid

The client registers a new client id on every connect. PasswordChange_ThenReconnect_Works covers reconnecting with the same client id.

A token the bump never reached. I bumped the Owner's CredentialEpoch with SQL only, which leaves the new connection valid. That is the state a token issued during a bump, or one from before this change, is left in. The next call is refused by the epoch check, and that refusal revokes the presented token. The approval stays valid and listed, because a newer token may share it. "Last used" stays at 22:00:27.

valid   | 2026-10-11 22:00:27.657812+00 | ...access_token:valid
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://localhost:8498/.well-known/oauth-protected-resource/mcp"
{"title":"Auth.CredentialsSuperseded","status":401,...}
valid   | 2026-10-11 22:00:27.657812+00 | ...access_token:revoked
Connected apps rows for "Cluckwork live verifier": 1

Afterwards I took both stacks down with down -v, removed their images, stopped the proxy and restored .env.sim.

The frames below show Account › Connected apps at 1:1, in a 1280-wide viewport, captured from the stacks named above. They are, in order: main before the password change, main after it, this branch before it, this branch after it, after reconnecting, and after the superseded retry.

main: Connected apps lists the app before the password change

main: the app is still listed after the password change

This branch: Connected apps lists the app before the password change

This branch: no apps are connected after the password change

This branch: the app is listed once after reconnecting

This branch: the approval stays listed after the superseded token is revoked

@mforce
mforce merged commit f32a7ec into main Oct 12, 2026
21 checks passed
@mforce
mforce deleted the fix/1229-revoke-superseded-app-tokens branch October 12, 2026 00:27
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.

Revoke a connected app's token when the credential epoch supersedes it, and stop stamping last used

1 participant