Skip to content

feat: let users and Owners see and disconnect connected apps - #1145

Merged
mforce merged 8 commits into
mainfrom
feat/799-connected-apps
Oct 9, 2026
Merged

mforce merged 8 commits into
mainfrom
feat/799-connected-apps

Conversation

@mforce

@mforce mforce commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Closes #799

What changes for people

  • Account › Connected apps. Every role sees the apps they allowed to act as them. The panel says how many there are ("2 connected apps can act as you"). It names an app unused for 30 days and offers to disconnect it. Each app opens to show what it Can do (in the consent screen's words), when it was Connected, when it was Last used, and Disconnect.
  • Setup › Connected apps is for the Owner only. It uses the plug icon. It lists every app on the farm and has a Person filter. At 1280 it is a table. At 390 it is a stacked list with a full-width Disconnect.
  • Disconnect asks for confirmation first. After it, the panel says the app "stops working on its next request".

The maintainer picked C for both screens. docs/designs/788-oauth-screens/SELECTION.md records the picks.

How it works

  • Access contract. IAccessModule gains three members: ListConnectedAppsAsync, ListFarmConnectedAppsAsync and DisconnectAppAsync. Each returns AppConnection records, one per person and app. They merge every valid authorization the person has for that app.

  • Routes.

    • GET and DELETE /api/v1/me/connected-apps[/{clientId}] are for your own apps, every role.
    • GET /api/v1/users/connected-apps and DELETE /api/v1/users/{id}/connected-apps/{clientId} are Owner-only, through the /users group.
  • Farm isolation. OpenIddict's tables have no AccountId, so every farm-wide read and every Disconnect goes through AspNetUsers.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.AppDisconnected with the human as the actor.

  • Last used. There is a new nullable OpenIddictAuthorizations.LastUsedAtUtc column. An OAuth validation handler runs one conditional UPDATE per request, and it writes at most once every 15 minutes per authorization (N = 15). The database's now() 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.AppConnected for a first approval, or for one that asks for wider scopes;
    • User.AppReconnected for 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. The src/AGENTS.md rule 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. main at e40924b measured 1924.11 KiB, so this PR adds 19.37 KiB.

Tests

OAuthFailClosedTests.ConnectedApps.cs runs against real Postgres and the real OAuth flow:

Rule Test Red mutation (tools/oauth/mutation-check.sh)
User isolation ConnectedApps_ListsOnlyYourOwn own-apps-anyones
Owner-only FarmConnectedApps_AreOwnerOnly farm-apps-not-owner-only
Farm isolation (list and Disconnect) Owner_SeesAndDisconnectsOnlyTheirOwnFarm farm-list-every-farm, disconnect-any-farm
Every authorization and its tokens revoked Disconnect_RevokesEveryAuthorizationOfTheApp_AndItsTokens one-authorization-revoked, tokens-left-valid
Next request fails (self and Owner) DisconnectedApp_IsRefused_OnItsNextRequest disconnect-does-nothing; authorization-alone-refuses (hold) shows the authorization revoke is enough on its own
Disconnect audited, with the human as actor Disconnect_IsAudited_WithThePersonWhoDidIt disconnect-unaudited
Approve audited Approval_IsAudited_AsAConnection connect-unaudited
Widen audited as a connection WiderApproval_IsAudited_AsAConnection widen-as-reconnect
Reconnect audited as a reconnection Reconnect_IsAudited_AsAReconnection reconnect-as-connect
Last used, once per interval LastUsed_IsStampedAtMostOncePerInterval last-used-unstamped, last-used-every-request, last-used-once-ever

The 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 at 0c2c57d3 with all 101 mutants. The baseline was green (115/115), the restore was green, and 100 mutants matched their declared verdict. disconnect-unaudited did not compile, because deleting the write left a parameter unread. 7a1f31d6 changes it to write the wrong action, and a run of that one mutant failed Disconnect_IsAudited_WithThePersonWhoDidIt with "expected one audit row per disconnect, found 0". Every other #799 mutant in the table was killed, and authorization-alone-refuses held.

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 in 9852b5ee: 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.

  • F1. If the Owner had filtered to one person and disconnected that person's last app, the person's option disappeared but the filter still applied. The selector showed Everyone while the list stayed empty. Now the filter falls back to Everyone when its person is gone. The new spec is shows everyone again when the selected person has no app left, and the mutant filter-keeps-gone-person turns it red.
  • F2. Disconnect removed its own row and left keyboard focus on <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 are no-fallback-focus (both last-row specs), previous-row-first (both middle-row specs), no-refocus (all four) and notice-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 first no-refocus run 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)

  • A request that CredentialEpochMiddleware refuses after validation, such as a disabled person's, still stamps last used. This is noted in the decision record.
  • If code issuance fails after the approval commits, the approval and its audit row stay, but the client gets no code. Asking again records a reconnect.
  • No test forces the audit write to fail to prove that the approval rolls back. Atomicity rests on IUnitOfWork.ExecuteInTransactionAsync.

mforce added 6 commits October 9, 2026 16:22
…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
@mforce

mforce commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

Screenshots for #799, at 1:1 and captured at 7a1f31d6. The stack was built from that commit (941-mkstack.sh, isolated project cw799). The "before" frames come from the same recipe at e40924b8 (main).

The sim stack serves plain http, so OpenIddict refuses real protocol requests there. The connections in these frames are rows seeded into OpenIddictApplications and OpenIddictAuthorizations, the same rows a consent approval writes. The real flow is covered by OAuthFailClosedTests.ConnectedApps.cs against Postgres.

Account, before and after (1280). The after frame has the Connected apps panel, the idle nudge, and one row opened.

before 1280 after 1280

Account, before and after (390).

before 390 after 390

The Disconnect confirmation (1280 and 390).

confirm 1280 confirm 390

After disconnecting (1280 and 390).

after disconnect 1280 after disconnect 390

Account with no apps (1280 and 390).

empty 1280 empty 390

Setup › Connected apps, the Owner's page (1280 and 390). This page is new, so there are no before frames.

owner 1280 owner 390

The Owner's page with nothing connected (1280 and 390).

owner empty 1280 owner empty 390

Night mode (1280).

account dark owner dark

Console errors: one 401 per run. That is the sign-in page's session probe on a fresh browser context. There were no others.

mforce added 2 commits October 9, 2026 17:38
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
@mforce

mforce commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

Reply to review round 1 by Codex GPT-6 Astra, at 7a1f31d6. Both P2 findings are fixed in 9dbed054. 8c839ec2 is a one-line cleanup on top.

F1, the Person filter after Disconnect. ConnectedAppsPage now uses the stored person only while that person still has a connection, and otherwise falls back to Everyone. The selector and the rows can no longer disagree. Your probe is now the spec shows everyone again when the selected person has no app left. Mutant filter-keeps-gone-person (filter on the stored person directly) turns it red.

F2, focus after Disconnect. The shared useDisconnect hook records the order of the rows before it asks for confirmation. After the reload, it focuses the next row's control, else the previous row's, else the section. On Account the control is the row's disclosure and the section fallback is the Connected apps panel. On the Owner page the control is the row's Disconnect button and the fallback is the heading. The result message ("Claude Desktop is disconnected. It stops working on its next request.") now sits in a polite role="status" region that is present from the first render. I kept the wording that is already translated in en, es and tl.

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:

  • no-fallback-focus turns both last-row specs red.
  • previous-row-first turns both middle-row specs red.
  • no-refocus turns all four red.
  • notice-not-live turns the live-region assertion red.

On the first no-refocus run, the Account middle-row spec stayed green. Focus had fallen to <body>, and the body's text also contains the next app's name. That spec now asserts that the next app's disclosure has focus.

Verification. tsc -b is clean. 656 tests pass across 26 targeted SPA files. Precache is 1943.48 of 1950 KiB. The rendering did not change, so the screenshots in the earlier comment still match the head.

@mforce

mforce commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

I checked the round-1 fixes (7a1f31d..8c839ec) directly, without another review round:

  • Person filter. It now derives its selection from the people who still have connections, so removing the last app of the selected person shows Everyone.
  • Focus after Disconnect. The shared hook moves focus to the next row, then the previous row, then the section fallback, and the result is announced in a polite live region.
  • Reloads. Both loaders catch their own failures, so awaiting the reload cannot leave the Disconnect button stuck as busy.
  • Proof. The worker's four mutants turned the new specs red.

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 OAuth__Issuer before that release is deployed.

@mforce
mforce merged commit a6729e4 into main Oct 9, 2026
19 checks passed
@mforce
mforce deleted the feat/799-connected-apps branch October 9, 2026 19:30
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.

OAuth slice 5: connected-apps screens — user's own, and the Owner's farm-wide view

1 participant