goauthentik / goauthentik/authentik
Enrollment flow resumes stale FlowPlan across different invitation tokens (itoken silently ignored)
- Dominant language
- Python
- Stars
- 25.6k
- Forks
- 2k
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 659
Description
## Description
`enrollment-invitation`-style flows (any enrollment flow reached via `?itoken=`) resume a cached, incomplete `FlowPlan` already stored in the browser's session for that flow **slug**, even when the request supplies a *different* `itoken` than the one the cached plan was built from. The invitation token in the query string is silently ignored; the executor continues the stale plan instead of starting a fresh one.
This means: opening a brand-new invitation link in a browser that previously started (but never finished/cancelled) an enrollment flow for the same flow slug returns form data belonging to the **old, unrelated attempt** — not the new invitation.
## To Reproduce
1. Create an enrollment flow with an Invitation stage (e.g. the stock `enrollment-invitation` flow, invitation-based, `designation: enrollment`), followed by a Prompt stage collecting username/name/etc.
2. Create Invitation A (`itoken=A`, e.g. `fixed_data.email: a@example.com`). Open `/if/flow//?itoken=A` in a browser. Get as far as the Prompt stage (form is displayed), but do **not** submit / complete the flow.
3. Without submitting, create a second, unrelated Invitation B (`itoken=B`, `fixed_data.email: b@example.com`).
4. In the **same browser session** (cookies intact from step 2), navigate to `/if/flow//?itoken=B`.
5. Inspect the response from `GET /api/v3/flows/executor//?query=itoken=B`.
**Expected:** the Prompt stage's `initial_value` for `email` (and any other invitation-sourced fields) reflects Invitation B's `fixed_data`.
**Actual:** the response still reflects Invitation A's stale, in-progress plan context — Invitation B's `itoken` is silently ignored. Confirmed via direct inspection of the JSON response body (not a client-side caching artifact — verified server round-trip returned `200` with stale `fields[].initial_value`).
Once the browser's session is invalidated (logout, confirmed via `GET /api/v3/core/users/me/` returning `403`) and the same URL for Invitation B is requested again, the correct data appears immediately. This isolates the cause to server-side session/plan state, not client caching.
## Why this matters beyond a UX glitch
We also observed genuine `PermissionDenied: CSRF Failed: CSRF cookie not set` 500s (via DRF's `SessionAuthentication.enforce_csrf`, `rest_framework/authentication.py:148`) on this same flow, exclusively in sessions that were resuming a stale plan of this kind — never reproducible via a clean/anonymous session (checked over 72h of production logs: zero CSRF failures on any other flow, only 2 total, both on this flow, both during stale-plan-resume testing). Our working theory is that resuming a plan built under different context leaves the session in an inconsistent authenticated/unauthenticated state relative to what the executor expects for CSRF enforcement on the next unsafe-method request, though we haven't fully isolated a minimal repro for the CSRF failure specifically — the plan-resume-across-different-itoken behavior itself is the part we've concretely nailed down and can reproduce reliably.
## Environment
- authentik version: `2026.8.0` (`ghcr.io/goauthentik/server:2026.8.0`)
- Deployment: Docker Compose, Postgres + Redis, behind Traefik (TLS via Cloudflare cert resolver)
- Flow: enrollment-designation flow with Invitation stage → Identification/Password (policy-skipped when invitee email not already registered) → Prompt stage → User Write stage → User Login stage
## Suggested fix direction
The flow planner should key a cached/resumed `FlowPlan` to the specific `itoken` (or more generally, the specific query-string context) it was built from — or simply force a fresh plan whenever the request's query params differ from what produced the cached plan — rather than keying resumption purely off the flow slug.
Happy to provide full JSON request/response captures and server-side log excerpts on request.
Contributor guide
Research direction
Start at GET /api/v3/flows/executor//?query=itoken=B and trace how the flow planner resumes a session-stored FlowPlan; the report also points to rest_framework/authentication.py:148 for the related CSRF path. Reproduce the Invitation A/B sequence in one browser session and verify that changing itoken starts a plan with Invitation B's data rather than stale fields from A.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, postgresql, python, redis
- Domain
- api, authentication, backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100