# Consent Screen Design **Goal:** Add a consent screen to the OIDC authorization flow so users can approve (or partially approve) the scopes an RP requests before tokens are issued. Consent decisions are persisted per user+client and only shown again when the requested scopes change. ## Requirements - Consent is shown at scope level (not individual claims). - `openid` is always granted (mandatory per OIDC spec). - Users may uncheck optional scopes (`profile`, `email`, `phone`) for partial consent — the flow proceeds with only the approved scopes. - A "Deny" button returns `access_denied` to the RP. - Consent is remembered per user+client. If the stored scopes cover all requested scopes, consent is skipped silently. - If the RP requests new scopes not previously approved, the consent screen is shown again. - The built-in `manage-app` client bypasses consent entirely (trusted first-party). - No other clients can be marked as trusted — all configured RP clients require consent. - Saved consents can be revoked from the (future) profile page. ## Data Model New SQLite table in a new migration (`002_user_consents.sql`): ```sql CREATE TABLE user_consents ( userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, client_id TEXT NOT NULL, scopes TEXT NOT NULL, -- JSON array, e.g. '["openid", "profile"]' created_at TEXT NOT NULL, -- ISO 8601 updated_at TEXT NOT NULL, -- ISO 8601 PRIMARY KEY (userid, client_id) ); ``` **Change detection:** load stored consent for user+client. If the stored scopes are a superset of (or equal to) the requested scopes, skip consent. Otherwise show the consent screen. On approval the stored scopes are replaced entirely with whatever the user checked. ## Authorization Flow Current: ``` /authorization → login → _complete_authorization() → redirect with code ``` New: ``` /authorization → login → check consent → [consent screen] → _complete_authorization() → redirect with code ``` Detailed steps after authentication: 1. If `client_id == settings.manage_client_id`, skip consent — proceed directly to `_complete_authorization()`. 2. Load stored consent from `user_consents` for this user+client. 3. If stored scopes cover all requested scopes, skip consent. 4. Otherwise store the parsed OIDC request in the session and redirect to `GET /consent`. 5. The consent page shows the RP name (client_id), requested scopes with checkboxes, and Allow / Deny buttons. `openid` is checked and disabled. 6. On "Allow": save approved scopes to `user_consents` (upsert), filter the OIDC request scopes to the approved set, proceed to `_complete_authorization()`. 7. On "Deny": redirect to the RP with `error=access_denied`. ## Consent Page New template `consent.html` extending `base.html`. Uses a standard HTML form (no HTMX needed — this is a full-page navigation step). Scope descriptions shown to the user: | Scope | Label | |---|---| | `openid` | Sign you in (required) | | `profile` | Your name and profile information | | `email` | Your email address | | `phone` | Your phone number | ## Repository Layer New `ConsentRepository` protocol and `SQLiteConsentRepository`, following the existing repository pattern. Methods: - `get_consent(userid, client_id) -> Consent | None` - `set_consent(userid, client_id, scopes) -> None` — upsert - `delete_consent(userid, client_id) -> None` — for revocation - `list_consents(userid) -> list[Consent]` — for profile page listing `Consent` is a Pydantic model with `userid`, `client_id`, `scopes: list[str]`, `created_at`, `updated_at`. ## Scope Filtering When the user grants partial consent (e.g. approves `openid` + `profile` but not `email`), the authorization request's scope list must be narrowed before passing to idpyoidc. This ensures the issued tokens only contain claims for the approved scopes.