porchlight/docs/plans/2026-02-18-consent-screen-design.md
2026-02-18 13:50:56 +01:00

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).
  • 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):

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.

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.