Skip to content

OAuth slice 4: consent screen, with step-up and return-URL validation #798

Description

@mforce

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

Location A route in the existing SPA. The app ships three locales, and a server-rendered page would need its own i18n, styling and accessibility on the one screen where clarity matters most
Granularity All-or-nothing. Allow or Cancel, no per-permission checkboxes
Approval Requires re-entering the current password, reusing #308's step-up grant
Not signed in Redirect to login, then back to consent
Already approved Skip silently if nothing changed; show again only if the app asks for MORE

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_uri validation, 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.md and 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.

Activity

  1. added
    sliceThin vertical work item
    area:apiAPI/endpoint layer
    epic-788OAuth 2.1 authorization server for MCP (#788)
    priority:tier3Real product weight, real cost
    size:LSeveral days; wide blast radius or unresolved scope
    on Sep 13, 2026
  2. mforce commented on Oct 6, 2026

    @mforce
    OwnerAuthor

    Amendment, 2026-10-06: what #514 and the SPA revamp changed for this slice

    The body above stays as written. Paths were checked against main at 8cb14e67.

    • Step-up on the server. IStepUpGrantService is 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 offers IAccessModule.IssueStepUpGrantAsync, and today only Access's own user-admin operations consume a grant. The consent endpoint therefore needs a new IAccessModule method that consumes a grant for consent. Add it in this slice, with its RealModuleLedger.Adapters row.
    • Screen design. Milestone 9 (SPA revamp) has shipped. Build the consent route in MUI, following docs/designs/822-mui-revamp.md and docs/designs/864-visual-language/. Reuse the existing components named there. The route must also work at the 390 px phone width that the chromium-phone Playwright project covers.
    • Step-up in the SPA. No shared password-prompt component exists. web/src/routes/UsersPage.tsx keeps 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.
  3. mforce commented on Oct 8, 2026

    @mforce
    OwnerAuthor

    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 branch docs/788-oauth-screen-mockups (not pushed yet), in docs/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

    consent A desktop consent A phone

    B, desktop 1280 and phone 390

    consent B desktop consent B phone

    C, desktop 1280 and phone 390

    consent C desktop consent C phone

    Login, continuing to consent

    A, desktop 1280 and phone 390

    login A desktop login A phone

    B, desktop 1280 and phone 390

    login B desktop login B phone

    C, desktop 1280 and phone 390

    login C desktop login C phone

  4. mforce commented on Oct 8, 2026

    @mforce
    OwnerAuthor

    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() in src/Cluckwork.Api/Hosting/CluckworkIdentityServiceCollectionExtensions.cs returns 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:

    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.

  5. mforce commented on Oct 9, 2026

    @mforce
    OwnerAuthor

    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

    consent D desktop consent D phone

    E

    consent E desktop consent E phone

    E with Details open, and E asking for more

    consent E details consent E widened phone

    F

    consent F desktop consent F phone

    Login B on a phone (the "Next" box moves above the form)

    login B phone

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerarea:frontendReact/Vite web clientepic-788OAuth 2.1 authorization server for MCP (#788)priority:tier3Real product weight, real costsize:LSeveral days; wide blast radius or unresolved scopesliceThin vertical work item

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions