Repository navigation
OAuth slice 4: consent screen, with step-up and return-URL validation #798
Description
Activity
- addedsliceThin vertical work itemThin vertical work itemarea:apiAPI/endpoint layerAPI/endpoint layerarea:frontendReact/Vite web clientReact/Vite web clientepic-788OAuth 2.1 authorization server for MCP (#788)OAuth 2.1 authorization server for MCP (#788)priority:tier3Real product weight, real costReal product weight, real costsize:LSeveral days; wide blast radius or unresolved scopeSeveral days; wide blast radius or unresolved scope
on Sep 13, 2026 Amendment, 2026-10-06: what #514 and the SPA revamp changed for this slice
The body above stays as written. Paths were checked against
mainat8cb14e67.- Step-up on the server.
IStepUpGrantServiceis internal to Access ([C] #514 slice 15: Access contract — security hold point, runs last #857). An adapter that takes it fails the ownership walk. The contract offersIAccessModule.IssueStepUpGrantAsync, and today only Access's own user-admin operations consume a grant. The consent endpoint therefore needs a newIAccessModulemethod that consumes a grant for consent. Add it in this slice, with itsRealModuleLedger.Adaptersrow. - Screen design. Milestone 9 (SPA revamp) has shipped. Build the consent route in MUI, following
docs/designs/822-mui-revamp.mdanddocs/designs/864-visual-language/. Reuse the existing components named there. The route must also work at the 390 px phone width that thechromium-phonePlaywright project covers. - Step-up in the SPA. No shared password-prompt component exists.
web/src/routes/UsersPage.tsxkeeps three separate inline step-up password states, one per action. Extract one shared component for consent and the Users page rather than adding a fourth copy.
- Step-up on the server.
Mockups for selection, 2026-10-08
Refreshed 2026-10-08: every label now says "Connected apps", the maintainer's chosen term. OAuth calls these "clients"; the UI does not.
Design directions for this slice, drawn by a mockup agent inside the app's current visual language (
docs/designs/822-mui-revamp.md,864-visual-language). Nothing here is chosen yet; the maintainer picks a direction per screen. The interactive lab and the README with trade-offs are on branchdocs/788-oauth-screen-mockups(not pushed yet), indocs/designs/788-oauth-screens/.The agent recommends consent B, because it is the only direction that opens with the "acting as you" sentence, and login A, because B disappears on a phone and C promises a step 2 that a skipped re-approval removes.
Consent screen
A, desktop 1280 and phone 390
B, desktop 1280 and phone 390
C, desktop 1280 and phone 390
Login, continuing to consent
A, desktop 1280 and phone 390
B, desktop 1280 and phone 390
C, desktop 1280 and phone 390
Amendment, 2026-10-08: this slice turns the authorization server on in Production
The body above stays as written. This adds one item to its scope.
#795 (PR #1135) ships OpenIddict inside the API but keeps it off in Production.
OAuthIssuer()insrc/Cluckwork.Api/Hosting/CluckworkIdentityServiceCollectionExtensions.csreturns nothing in Production, so the server and its/api/v1/oauth/endpoints are never registered there. Its comment names #797 and #798 as the reason. No slice owned removing that gate, so this one does, because it completes the last prerequisite.Before starting this item: #796 (fail-closed checks and rate limits) and #797 (client registration) must be merged.
In this slice:
- Remove the Production condition from
OAuthIssuer(). A Production serving process then runs the authorization server. - Make
OAuth:Issuerrequired for a Production serving process, and fail the boot when it is missing or not an absolutehttpsURL. Scope the guard by process role (refactor(api): make process role explicit for boot guards #347), so one-shot verbs still run without it. Add theProcessRoleGuardTestsandServingGuardCoverageTestsrows. - Teach the new required key to the sim harness in the same PR (Sim harness (#243) rotted silently: 4 breakages, no CI ever ran it #370):
tools/simulation/bootstrap.sh,docker-compose.sim.ymlandverify-harness.sh. The AppHost (Developer experience: add an Aspire AppHost for local orchestration and observability #565) runs Development, where the key stays optional; confirm that it still starts. - Add the key to
deploy/.env.examplewith a placeholder. Keep it host-agnostic: no provider names. - Decide whether discovery's endpoint URLs should derive from the issuer rather than from the request host. PR feat(api): stand up OpenIddict as an OAuth 2.1 authorization server #1135 records that today they follow the host the client called.
- State in the PR body that the deployment repo must supply
OAuth:Issuerbefore any release containing this slice is deployed. Without it, the serving container will not start.
Done when, in addition to the body's criteria: a Production-mode host with the issuer set serves the full connect flow with consent and step-up, and one without it refuses to start.
- Remove the Production condition from
Consent mockups, round 2: less text (2026-10-09)
The maintainer chose Login B, and asked for consent screens with far less text. Directions A–C ran 145–161 words, so these replace them. Each one fits the required facts (app name, the two permissions, acts as you, where to undo, password, Allow/Cancel, the unverified name, where the browser returns) in about 40 visible words or fewer, with longer explanations behind a closed Details section.
Visible words Summary D · Compact card 39 A narrow card: headline, "Unverified app" badge, two icon rows, a "Returns to this computer" line, and a role chip E · Login card (agent's pick) 40 The same content inside the Login B card; the brand panel shows the farm and the signed-in email F · Ledger 29 Three labelled rows: Can, Acts as, Returns to Reconnecting an already-approved app will show only a password prompt (decided on PR #1144), so these screens appear only for a new app or a wider request.
D
E
E with Details open, and E asking for more
F
Login B on a phone (the "Next" box moves above the form)
- added 16 commits that reference this issue
on Oct 9, 2026





















Slice 4 of #788. Depends on #795 and #797.
The Allow/Deny page is the only screen a human sees in the whole flow, and the only moment a person actually makes a security decision. Everything else happens without a person.
Decided in #788
What the screen must communicate
Beyond the app name and permissions, it has to say "acting as you", meaning the assistant can never do more than the signed-in user can. A farmer's mental model of "an AI has access to my farm" is likely scarier than the reality, where a Worker's assistant gets a Worker's view. That sentence makes the difference between informed consent and a dialog people dismiss.
It should also say where to undo the approval, so the user learns how to revoke access at the moment they grant it.
The login-to-consent redirect is an open-redirect security hazard
The app must validate the return URL against an allow-list of internal paths. If the app takes the return URL from a query parameter and follows it unchecked, an attacker can send a link that logs someone into Cluckwork and then bounces them to a lookalike site that asks for their password. The user's experience is "I clicked a Cluckwork link, logged into Cluckwork, then Cluckwork asked me something". The redirect is invisible.
This is separate from OAuth's own
redirect_urivalidation, which OpenIddict performs against the registered client. The hazard is the login-to-consent hop inside the app, which OpenIddict knows nothing about.Guard it with a stated red mutation. Point the return parameter at an absolute external URL and assert that the app refuses it. Also assert that it refuses protocol-relative URLs (
//evil.example). They look relative but are not, and naive checks let this variant through.Test against a built SPA
#778 records that this repo's service worker swallows server-issued redirects, so
?farm=never reaches the login form. The login-to-consent hop goes through the same machinery. Test with the service worker active, not only in dev.Also
This slice changes what a user sees, so under the standing rule the PR carries before/after screenshots captured from a stack rebuilt at the head under review. The screen is net-new, so after-only screenshots are acceptable. The same PR updates
specs/product/GLOSSARY.mdand the SPA Help page.Done when
A user can complete a full connect flow in a browser, the flow enforces the password step, the app skips re-approval when nothing changed, and the redirect guard refuses external and protocol-relative return URLs.