Repository navigation
fix(api): revoke a connected app when the credential epoch supersedes it - #1248
Conversation
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.
Live run with a real MCP clientI ran this on an isolated stack ( Before, on main. The Owner connects the app and calls After, on this branch. I ran the same steps. The client's next call is refused. The authorization and its access token are 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. The client registers a new client id on every A token the bump never reached. I bumped the Owner's Afterwards I took both stacks down with 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. |






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
validfor the life of the authorization, soOAuthPurgenever pruned it.OAuthLastUsedStampran inside OpenIddict token validation, ahead ofCredentialEpochMiddleware, 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 callsRevokeAllActiveForUserAsync(userId, now, ct)inside its transaction, right afterCredentialEpoch++. That overload now also sets the user's valid OpenIddict authorizations and their valid or inactive tokens torevoked, asConnectedApps.DisconnectAsyncdoes for one app. Each statement also writes a newConcurrencyToken, 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 byConcurrencyToken. Without the rotation, a payload update that loaded the row before the revocation would still match and writevalidback. 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 logsAuth.RefreshRevocationFailedand 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
Supersededverdict,CredentialEpochMiddlewarerevokes the presented token (oi_tkn_id), with the sameConcurrencyTokenrotation. 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,FarmSuspendedandConnectedAppsOffcan be reversed, so those refusals revoke nothing.Last used. The stamp moved from an OpenIddict validation handler (
OAuthLastUsedStamp, deleted) toConnectedApps.RecordUseAsync. The middleware calls it only for aCurrentverdict. The 15-minute write throttle is unchanged.ConnectedAppsis 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
AccountSuspensionServicewith 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 withAuth.FarmSuspended. After reactivation, an app's old token is refused asSupersededand the backstop revokes that token, but its connection stays listed until someone disconnects it or reconnects the app.IdentityProvider.DisableUserAsync, one of the five bump sites, so it is fixed here.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=...withouterror="invalid_token", which is the response Disconnect already gives. Before, the refusal came from the epoch middleware and carriederror="invalid_token"with titleAuth.CredentialsSuperseded. The live-run comment shows both responses to a real MCP client.Tests
New tests are in
OAuthFailClosedTests.SupersededApps.cs.RoleChange_RevokesTheTokennow also asserts that nothing stays valid.PasswordChange_DisconnectsTheAppRoleChange_RevokesTheTokenSupersededToken_IsRevoked_OnItsNextRequestAuth.CredentialsSupersededand revoked on its next request, and its approval stays validSupersededToken_LeavesAReconnectionUnderTheSameApprovalWorkingPasswordChange_FailsAnInFlightTokenWriteSupersededRefusal_FailsAnInFlightTokenWriteDisconnect_FailsAnInFlightTokenWriteRefusedRequest_DoesNotMoveLastUsedLastUsedAtUtcunchanged, even past the 15-minute throttlePasswordChange_RacingTokenUse_LeavesNothingValidPasswordChange_ThenReconnect_WorksThe three in-flight-write tests use OpenIddict's own
IOpenIddictTokenManager.UpdateAsyncfrom 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.shhas ten new mutants and six updated ones.tokens-left-validandauthorization-alone-refusesnow anchor on Disconnect's client-id predicate, because the backstop added a second match of their old anchor.epoch-exempts-oauthnow targetsSupersededToken_IsRevoked_OnItsNextRequest. The role-change test no longer reaches the epoch middleware, because OpenIddict refuses the revoked authorization first. Thelast-used-*mutants point atConnectedApps. I ran the script on every mutant anchored in a file this branch changes, which isCredentialEpochMiddleware.cs,IdentityProvider.cs,ConnectedApps.csandOAuthServerRegistration.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-unchallengeddeletes the onlyCluckworkMcpcall in the middleware, which leavesusing Cluckwork.Api.Hosting;unused, so IDE0005 fails the build.resource-not-stampeddeletes the onlyOpenIddict.Abstractionsuse inOAuthServerRegistration.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-problemhas 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_Workshas 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.mdand 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.docs/decisions/1146-connected-apps-switch.mdstrikes 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.mdsays where the stamp runs now and what "last used" still counts.FilterFreeSetSitesclassifies the four new filter-free queries, re-pins Disconnect's token query and drops the deleted stamp's row.Reading order
IdentityProvider.RevokeAllActiveForUserAsync, the bump-time revocation.CredentialEpochMiddleware, the verdict gate.ConnectedApps.RecordUseAsync,RevokeSupersededTokenAsyncand theConcurrencyTokenlines inDisconnectAsync.OAuthServerRegistration, where the validation handler is removed.tools/oauth/mutation-check.sh.