---
id: w4/m16/t001
title: "Consent route: unknown client + live session ⇒ render a consent page instead of the flat 403"
worker: worker4
status: done
estimate: 40m
depends_on: []
---

## Objective

Turn the headless consent acceptor's unknown-client dead end into a real consent page. Today `handleConsent` (`dashboard/src/common/server-fn/hydra-consent.ts`) returns a bare 403 (`"consent required: client … is not a trusted first-party client"`) for any client that isn't Hydra-skippable or in `OAUTH_TRUSTED_CLIENTS`. Instead, when the browser carries a live Kratos session, render a consent page showing the client's name (`client_name`, falling back to `client_id`), the requested scopes, and the requested access-token audience, with Approve/Deny buttons. The trusted/skippable auto-accept path — and the PKCE-required 400 — must stay byte-identical.

## Context

- `dashboard/src/routes/auth.consent.ts` is currently a server-only route handler (no component) delegating to `handleConsent`. Rendering UI means promoting it to a page route (`auth.consent.tsx`) whose loader fetches the consent request via a server function, while the auto-accept/deny logic stays server-side.
- Hydra guarantees consent redirects arrive **after** login (Kratos's native `oauth2_provider` integration accepted the login challenge), so a session should exist; the no-session case is t003's problem (login-first redirect).
- The consent lookup (`getOAuth2ConsentRequest`) uses `HYDRA_ADMIN_URL` — server-only, never `VITE_`-prefixed, never in the client bundle (`docs/ADR012-auth.md` §7).
- UI conventions: shadcn `Card` primitives, every user-visible string through `useTranslations()` with `auth.*` keys (en + zh), page shell consistent with `features/auth/` pages (`dashboard/CLAUDE.md`).

## Steps

1. Add a server function (in `hydra-consent.ts` or a sibling) that, given a `consent_challenge`, returns the safe-to-render subset: client_id, client_name, logo_uri (optional), requested_scope, requested_access_token_audience — or a directive (`auto-accepted` redirect / PKCE 400 / stale-challenge home redirect) when the existing headless paths fire.
2. Convert `routes/auth.consent.ts` to a page route: loader calls the server function; trusted/skippable and error paths still redirect/return exactly as today; only the unknown-client + session case falls through to rendering.
3. Build the consent page component under `features/auth/` (client identity, scope list with human-readable descriptions where known, Approve/Deny buttons — wired in t002, can be inert this task).
4. Locale keys (en/zh) for all strings.

## Files

- `dashboard/src/routes/auth.consent.ts` → `auth.consent.tsx`
- `dashboard/src/common/server-fn/hydra-consent.ts`
- `dashboard/src/features/auth/` (new consent page component)
- `dashboard/src/features/auth/locales/{en,zh}.ts`

## Acceptance criteria

- [ ] An unknown-client consent challenge with a live session renders the consent page (client name + scopes visible), not a 403.
- [ ] A trusted/skippable client still auto-accepts headlessly (no UI flash) — behavior byte-identical, existing tests pass.
- [ ] A missing `code_challenge` still 400s; a stale/missing challenge still degrades to home.
- [ ] `HYDRA_ADMIN_URL` and raw Hydra responses never reach the client bundle — only the safe-to-render subset crosses the wire.
- [ ] `yarn typecheck` + `yarn lint` clean.

## Out of scope

- Approve/deny actions actually calling Hydra (t002).
- CSRF/subject-binding/no-session handling (t003).
