diff --git a/docs/plans/2026-02-18-consent-screen-design.md b/docs/plans/2026-02-18-consent-screen-design.md new file mode 100644 index 0000000..beb6768 --- /dev/null +++ b/docs/plans/2026-02-18-consent-screen-design.md @@ -0,0 +1,108 @@ +# 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.