collective / collective/pas.plugins.oidc

Persist/expose OIDC tokens (or support prompt=none) for delegated calls on behalf of the user

Open
#99 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
6
Forks
16
PR merge metrics
No merged PRs in 30d

Description

## Context

Most OIDC relying-party implementations retain the token set obtained at login (`access_token`, `refresh_token`, `expires_in`, and the `id_token`) for the lifetime of the user's session, not just for the duration of the login callback. `pas.plugins.oidc` currently discards it entirely once the callback completes. Aligning with the common pattern would unlock several use cases that today require reimplementing OIDC token handling from scratch outside the plugin:

- Calling the provider's own user-facing API on behalf of the logged-in user (self-service profile/password/MFA/session management, where the provider's admin UI isn't suitable or brandable for end users).
- Calling other resource servers/APIs that trust the same IdP, presenting the user's own access token (a common "the RP is also an OAuth2 client acting on behalf of the user against other services" pattern).
- Proper logout/revocation: calling the provider's revocation endpoint with the actual token instead of only clearing local Plone session state.
- Silent session renewal via the refresh token, extending the user's session without a full interactive re-login.
- General auditing/debugging: knowing token expiry to proactively warn about/handle an about-to-expire session.

## Current behavior

In the current implementation, the OIDC access/refresh token does not survive past the login callback:

- `utils.get_user_info()` calls `client.do_access_token_request(...)`, which returns an `AccessTokenResponse` containing `access_token`, `refresh_token` (if requested), and `expires_in`. The function only extracts `id_token`/userinfo claims from it and returns those; the token values themselves are discarded.
- `plugins.OIDCPlugin.get_oauth2_client()` builds a fresh `pyoidc` `Client` on every call (`LoginView`, `CallbackView`, `LogoutView` each call it independently) - so even the `oic` library's own in-memory token cache (`client.grant[state]`) doesn't survive across requests.
- `CallbackView.__call__` calls `rememberIdentity(user_info)` with only the userinfo dict; the token is never passed to `Session`, a cookie, or a member property anywhere in the plugin.

So today there is no way for an integrator to call the provider's API on behalf of the logged-in user after the login flow completes.

Related gap: `authorization_flow_args()` doesn't support a `prompt` parameter, so there's no way to request a silent re-authentication (`prompt=none`) either - which would be a reasonable alternative/complement to token persistence for short-lived, on-demand delegated calls. If added, this also needs explicit handling of `error=login_required`/`interaction_required` responses in `CallbackView`, which today just falls through to a generic `raise Unauthorized()`, indistinguishable from a tampered/invalid request.

## Proposal

1. New opt-in plugin properties, default `False`: `store_tokens`, `store_refresh_token`. Off by default given the security implications below.
2. Extract `access_token` / `refresh_token` / `expires_in` from the existing `AccessTokenResponse` in `get_user_info()` (or a sibling function) and pass them through to `CallbackView` alongside `user_info`.
3. **Storage should be a pluggable adapter, not a hardcoded cookie**: the existing `Session` class (`session.py`) stores its dict in a cookie encoded with base64 only, which is both insecure (not encrypted) and size-limited (cookies commonly cap around 4KB, and overall request-header limits apply too) - encryption alone doesn't fix the size problem, since it only adds overhead (IV, auth tag, base64). In a distributed/clustered deployment (multiple Zope/WSGI workers), the cookie also isn't the right place for this regardless of size, since any worker needs to reach the same session state. This points toward a small storage interface (e.g. `ITokenStore`, with `set(key, tokens, ttl)` / `get(key)` / `delete(key)`), resolved as a named adapter/utility so integrators can plug in what fits their infrastructure:
- A Redis/Valkey-backed adapter as the realistic option for anything beyond a single-instance, low-traffic install: no cookie-size limit (only an opaque key travels with the request), shared naturally across workers, no ZODB involvement.
- A ZODB-backed adapter shipped only as the zero-extra-infrastructure default, explicitly documented as demo-only / suitable for very low-traffic single-instance installs, not a production recommendation.
4. `scope` currently defaults to `("profile", "email", "phone")` - no explicit `openid`, no `offline_access`. Without `offline_access` (or the provider's equivalent), most providers won't issue a `refresh_token` at all, so this needs to be configurable/documented for anyone wanting `store_refresh_token`.
5. `prompt=none` support in `authorization_flow_args()` plus explicit `login_required` / `interaction_required` handling in `CallbackView`, so a silent-renew attempt can cleanly fall back to an interactive login instead of a bare `Unauthorized()`.
6. A small helper, e.g. `plugin.get_current_access_token(request)`, that returns a live token for the current user - refreshing via `client.do_access_token_refresh()` if expired and a refresh token is stored - so integrators have one supported entry point instead of reimplementing token lifecycle handling themselves. This should stay a **server-side Python API only, not a REST service**: any Volto-facing capability should follow a BFF (Backend-For-Frontend) approach - action-specific REST endpoints (e.g. "change my password", "list my sessions") that use the stored token internally and refresh it transparently, never a generic endpoint that hands the raw token to the browser. Exposing the token to JS would reintroduce the token-theft-via-XSS exposure that server-side-only storage is meant to avoid in the first place.

Would love feedback on this shape (especially the storage approach in point 3) before starting a PR.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by reading utils.get_user_info(), plugins.OIDCPlugin.get_oauth2_client(), CallbackView.__call__, session.py, and authorization_flow_args(). The issue spans token extraction, storage, refresh, silent authentication, and callback errors; done requires an agreed, security-reviewed scope and an explicit storage and lifecycle design before implementation.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
authentication, backend-api-design, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.