3.8 KiB
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).
openidis 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_deniedto 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-appclient 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):
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:
- If
client_id == settings.manage_client_id, skip consent — proceed directly to_complete_authorization(). - Load stored consent from
user_consentsfor this user+client. - If stored scopes cover all requested scopes, skip consent.
- Otherwise store the parsed OIDC request in the session and redirect to
GET /consent. - The consent page shows the RP name (client_id), requested scopes with
checkboxes, and Allow / Deny buttons.
openidis checked and disabled. - On "Allow": save approved scopes to
user_consents(upsert), filter the OIDC request scopes to the approved set, proceed to_complete_authorization(). - 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 | Noneset_consent(userid, client_id, scopes) -> None— upsertdelete_consent(userid, client_id) -> None— for revocationlist_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.