Repository navigation
feat: let users and Owners see and disconnect connected apps - #1145
Conversation
…as last used Users list and disconnect their own apps under /me/connected-apps; an Owner lists and disconnects any on the farm under /users. Disconnect revokes every valid authorization of the user for the app and their tokens, in one transaction with a User.AppDisconnected audit row. OAuth validation stamps LastUsedAtUtc at most once per 15 minutes per authorization. Refs #799
…page Account shows each app allowed to act as you, with what it can do, when it was connected and last used, an idle nudge, and Disconnect. Owners get Setup › Connected apps with a Person filter. Help, the glossary and all three locales name each control by its label. Refs #799
Approving an app on the consent screen writes User.AppConnected when it creates an authorization (a first or wider approval) and User.AppReconnected when it reuses one, in the same transaction that creates the authorization, after the step-up grant is spent. Adds the decision record, the src/AGENTS.md rule and the mutants. Refs #799
…icon The server sorts scopes by name, which put Record daily entries before Read farm data. The table's Disconnect icon touched its label, and the idle nudge's button wrapped at 1280. Found in the PR's screenshots. Refs #799
…he write Deleting the write left ConnectedApps' audit parameter unread, which does not compile, so the mutant was inconclusive. Writing the wrong action keeps it building and still turns the audit test red. Refs #799
|
Screenshots for #799, at 1:1 and captured at The sim stack serves plain http, so OpenIddict refuses real protocol requests there. The connections in these frames are rows seeded into Account, before and after (1280). The after frame has the Connected apps panel, the idle nudge, and one row opened. Account, before and after (390). The Disconnect confirmation (1280 and 390). After disconnecting (1280 and 390). Account with no apps (1280 and 390). Setup › Connected apps, the Owner's page (1280 and 390). This page is new, so there are no before frames. The Owner's page with nothing connected (1280 and 390). Night mode (1280). Console errors: one |
Disconnect removes its own row, so focus now moves to the next row's control, else the previous one's, else the section, and the result sits in a polite live region. When the selected person's last app goes, the filter shows everyone instead of an empty list behind "Everyone". Codex GPT-6 Astra's round-1 F1 and F2. Refs #799
|
Reply to review round 1 by Codex GPT-6 Astra, at F1, the Person filter after Disconnect. F2, focus after Disconnect. The shared There are four new specs, for the last row and a middle row on each screen. Each one focuses the real trigger, confirms, and waits for the dialog to close. The red mutants:
On the first Verification. |
|
I checked the round-1 fixes (7a1f31d..8c839ec) directly, without another review round:
The review loop for this PR is complete. Release note: this PR ships in the same release as #798 (PR #1144), and the deployment repo must set |
















Closes #799
What changes for people
The maintainer picked C for both screens.
docs/designs/788-oauth-screens/SELECTION.mdrecords the picks.How it works
Access contract.
IAccessModulegains three members:ListConnectedAppsAsync,ListFarmConnectedAppsAsyncandDisconnectAppAsync. Each returnsAppConnectionrecords, one per person and app. They merge every valid authorization the person has for that app.Routes.
GETandDELETE /api/v1/me/connected-apps[/{clientId}]are for your own apps, every role.GET /api/v1/users/connected-appsandDELETE /api/v1/users/{id}/connected-apps/{clientId}are Owner-only, through the/usersgroup.Farm isolation. OpenIddict's tables have no
AccountId, so every farm-wide read and every Disconnect goes throughAspNetUsers.AccountId. If an Owner names another farm's person, the answer is 404.Disconnect runs in one transaction. It revokes every valid authorization of the person for that client id. A wider approval or a race can leave several (OAuth slice 4: consent screen, with step-up and return-URL validation #798). It also revokes their valid tokens and writes
User.AppDisconnectedwith the human as the actor.Last used. There is a new nullable
OpenIddictAuthorizations.LastUsedAtUtccolumn. An OAuth validation handler runs one conditionalUPDATEper request, and it writes at most once every 15 minutes per authorization (N = 15). The database'snow()is the clock. If the stamp fails, it is logged and the request continues.Audit for connecting. This is the maintainer's 2026-10-09 decision. The consent approval writes an event in the same transaction that creates the authorization, after the step-up grant is spent:
User.AppConnectedfor a first approval, or for one that asks for wider scopes;User.AppReconnectedfor an approval that reuses an existing authorization.The details carry the client id, the app's name at write time, and the granted scopes. The OAuth slice 6: audit provenance — record which application acted, alongside the human #800 connected-app columns stay empty, because those columns mean "acted through this app". So these rows do not appear under Only actions through connected apps.
Without the OAuth server. The screens use
AppDbContext, not OpenIddict's managers, because the managers exist only when the server runs.Rationale:
docs/decisions/799-connected-apps.md. Thesrc/AGENTS.mdrule links to it.Release note
#798 (#1144, already merged) and this PR ship in the same release. The consent screen sends people to Account › Connected apps, which this PR builds. The deployment repo must set
OAuth__Issuer, because #798 makes it required in Production.Precache
1943.48 KiB of the 1950 KiB ceiling at
8c839ec2, with 6.52 KiB of headroom.mainat e40924b measured 1924.11 KiB, so this PR adds 19.37 KiB.Tests
OAuthFailClosedTests.ConnectedApps.csruns against real Postgres and the real OAuth flow:tools/oauth/mutation-check.sh)ConnectedApps_ListsOnlyYourOwnown-apps-anyonesFarmConnectedApps_AreOwnerOnlyfarm-apps-not-owner-onlyOwner_SeesAndDisconnectsOnlyTheirOwnFarmfarm-list-every-farm,disconnect-any-farmDisconnect_RevokesEveryAuthorizationOfTheApp_AndItsTokensone-authorization-revoked,tokens-left-validDisconnectedApp_IsRefused_OnItsNextRequestdisconnect-does-nothing;authorization-alone-refuses(hold) shows the authorization revoke is enough on its ownDisconnect_IsAudited_WithThePersonWhoDidItdisconnect-unauditedApproval_IsAudited_AsAConnectionconnect-unauditedWiderApproval_IsAudited_AsAConnectionwiden-as-reconnectReconnect_IsAudited_AsAReconnectionreconnect-as-connectLastUsed_IsStampedAtMostOncePerIntervallast-used-unstamped,last-used-every-request,last-used-once-everThe SPA side is
web/src/routes/ConnectedApps.test.tsx, plus the nav, empty-state and i18n parity guards.Mutation run.
sg docker -c 'bash tools/oauth/mutation-check.sh'ran at0c2c57d3with all 101 mutants. The baseline was green (115/115), the restore was green, and 100 mutants matched their declared verdict.disconnect-unauditeddid not compile, because deleting the write left a parameter unread.7a1f31d6changes it to write the wrong action, and a run of that one mutant failedDisconnect_IsAudited_WithThePersonWhoDidItwith "expected one audit row per disconnect, found 0". Every other #799 mutant in the table was killed, andauthorization-alone-refusesheld.Screenshots. #1145 (comment) has before and after frames at 1280 and 390, the confirmation, the state after disconnecting, the empty states and night mode, all from a stack built at
7a1f31d6. The connections in them are seeded rows, because the sim stack's plain http blocks a real OAuth flow. The first frames showed three defects, fixed in9852b5ee: the permissions were listed in the server's alphabetical order instead of consent's, the table's Disconnect icon touched its label, and the idle nudge's button wrapped at 1280.Review round 1 (Codex GPT-6 Astra, at
7a1f31d6)The review found two P2 defects, both fixed in
9dbed054. It found nothing in security, correctness or code quality.shows everyone again when the selected person has no app left, and the mutantfilter-keeps-gone-personturns it red.<body>. Both screens share the fix. Focus moves to the next row's control, else the previous row's, else the section. On Account that is the panel; on the Owner page it is the heading. The result is shown in a polite live region that exists from the first render. There are four new specs, for the last and a middle row on each screen. Their red mutants areno-fallback-focus(both last-row specs),previous-row-first(both middle-row specs),no-refocus(all four) andnotice-not-live(the live-region assertion).I ran these mutants by hand against
web/src/routes/ConnectedApps.test.tsx, and each turned its specs red. The firstno-refocusrun left the Account middle-row spec green, because focus on<body>also "contains" the next app's name. That spec now asserts that the next app's disclosure itself has focus. The rendering did not change, so the posted screenshots still match the head.For the coordinator's batch (no issues filed)
CredentialEpochMiddlewarerefuses after validation, such as a disabled person's, still stamps last used. This is noted in the decision record.IUnitOfWork.ExecuteInTransactionAsync.