docs: add consent screen design

This commit is contained in:
Johan Lundberg 2026-02-18 13:50:56 +01:00
parent 404fcac4dd
commit 16f3e039d9
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1

View file

@ -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.