dify - ✅(Solved) Fix Password reset always returns 400 `invalid_or_expired_token` in 1.14.x — `_TokenData` TypedDict strips the `phase` field [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
langgenius/dify#36116Fetched 2026-05-14 03:46:40
View on GitHub
Comments
0
Participants
1
Timeline
2
Reactions
1
Author
Participants
Timeline (top)
cross-referenced ×1labeled ×1

Root Cause

Two PRs that shipped together in 1.14.0 interact badly:

  • #35425 fix(auth): enforce phase-bound change-email token flow (GHSA-4q3w-q5mc-45rq) added a phase field to reset and change-email tokens, and a if data.get("phase","") != "reset": raise InvalidTokenError() gate at api/controllers/console/auth/forgot_password.py:174.
  • #34380 refactor(api): replace json.loads with Pydantic validation in security and tools layers changed TokenManager.get_token_data (api/libs/helper.py:473) from json.loads(...) to dict(_token_data_adapter.validate_json(...)). The _TokenData TypedDict declared on api/libs/helper.py:36 lists account_id, email, token_type, code, old_email — but not phase. So the TypeAdapter silently strips it.

Net effect: every reset token reaches the controller with phase already gone, the gate fails, the password reset is 100% broken in 1.14.x.

Quick repro inside the running api container:

docker exec dify-api python -c "
from app_factory import create_app
_, app = create_app()
with app.app_context():
    from services.account_service import AccountService
    print(AccountService.get_reset_password_data('<a real token uuid in redis>'))
"
# -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...'}
# Notice the missing 'phase' key.

Raw value in redis (via redis-cli GET reset_password:token:<uuid>) does contain "phase": "reset".

Fix Action

Fix

Add phase: str to the _TokenData TypedDict. Since total=False, it stays optional — no callers need to change. PR #XXX (this issue) includes the one-line fix plus a regression test that round-trips both the reset_password and change_email payloads through the TypeAdapter.

PR fix notes

PR #36117: fix(auth): preserve phase field in _TokenData so reset-password / change-email phase-bound checks don't 400 (#36116)

Description (problem / solution / changelog)

Fixes #36116

Summary

POST /console/api/forgot-password/resets (and equivalent change-email flows) always return 400 invalid_or_expired_token on 1.14.0, 1.14.1, and current main — even with a fresh, valid token that's still in redis. Two PRs that shipped together in 1.14.0 interact badly:

  • #35425 (fix(auth): enforce phase-bound change-email token flow, GHSA-4q3w-q5mc-45rq) added a phase field to reset / change-email tokens and a if data.get("phase", "") != "reset" gate in controllers/console/auth/forgot_password.py:174.
  • #34380 (refactor(api): replace json.loads with Pydantic validation in security and tools layers) replaced json.loads(...) in TokenManager.get_token_data (libs/helper.py:473) with dict(_token_data_adapter.validate_json(...)). The _TokenData TypedDict on libs/helper.py:36 lists account_id, email, token_type, code, old_email — but not phase, so the TypeAdapter silently strips it.

Net effect: the gate from #35425 always sees phase == "" and the reset flow is 100% broken. Issue #36116 has the full repro.

This PR adds phase: str to _TokenData. Since the TypedDict is declared total=False, every field is already optional — no callers need to change.

I also added api/tests/unit_tests/libs/test_token_manager.py which round-trips both the reset_password and change_email payloads through the _token_data_adapter. Without the one-line fix, both tests fail with the same KeyError/None symptom seen in production; with the fix, both pass.

Verified end-to-end on a 1.14.0 deployment after hot-patching the same line: /forgot-password/resets returns 200 {"result":"success"} and the password is updated.

Screenshots

N/A — backend-only change. The user-visible symptom is the 400 alert on the password-reset form; after this fix the form succeeds and routes to the sign-in page as before 1.14.0.

Checklist

  • This change requires a documentation update — N/A, internal contract.
  • I understand that this PR may be closed in case there was no previous discussion or issues. Issue #36116 was filed first; this PR links to it.
  • I've added a test for each change that was introduced (api/tests/unit_tests/libs/test_token_manager.py) and tried to keep it a single atomic change.
  • I've updated the documentation accordingly. (No docs touched the field list.)
  • I ran ruff check + ruff format --check on the changed files locally (using ruff 0.15.12, matching api/pyproject.toml's pin); both clean.

From Claude Code

Changed files

  • api/libs/helper.py (modified, +1/-0)
  • api/tests/unit_tests/libs/test_token_manager.py (added, +58/-0)

Code Example

{
  "token": "<token from step 3>",
  "new_password": "NewPass1234",
  "password_confirm": "NewPass1234"
}

---

HTTP 400
{ "code": "invalid_or_expired_token",
  "message": "The token is invalid or has expired.",
  "status": 400 }

---

docker exec dify-api python -c "
from app_factory import create_app
_, app = create_app()
with app.app_context():
    from services.account_service import AccountService
    print(AccountService.get_reset_password_data('<a real token uuid in redis>'))
"
# -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...'}
# Notice the missing 'phase' key.
RAW_BUFFERClick to expand / collapse

Self Checks

  • I have read the Contributing Guide and Language Policy.
  • This is only for bug report.
  • I have searched for existing issues, including closed ones — no existing report.
  • I confirm that I am using English to submit this report.
  • (Non-English checkbox acknowledged.)
  • Template fields all filled.

Dify version

1.14.0, 1.14.1, and current main (5edc682c4a) — all affected. The regression was introduced in 1.14.0.

Cloud or Self Hosted

Self Hosted (Docker)

Steps to reproduce

  1. From the sign-in page, click Forgot password, enter the email of a valid account.
  2. Receive the reset email; click the link.
  3. Enter the 6-digit code shown in the email. /console/api/forgot-password/validity returns 200 and yields a token.
  4. Enter a new password (e.g. NewPass1234, ≥8 chars, has letters + digits) and confirm it.
  5. Frontend POSTs to /console/api/forgot-password/resets:
{
  "token": "<token from step 3>",
  "new_password": "NewPass1234",
  "password_confirm": "NewPass1234"
}

✔️ Expected Behavior

200 OK { "result": "success" } and the password is updated. (This is how it worked in 1.13.x.)

❌ Actual Behavior

HTTP 400
{ "code": "invalid_or_expired_token",
  "message": "The token is invalid or has expired.",
  "status": 400 }

The token is still present in redis with the correct phase: "reset" field and a long TTL — but the handler reads phase == "" and 400s.

Root cause

Two PRs that shipped together in 1.14.0 interact badly:

  • #35425 fix(auth): enforce phase-bound change-email token flow (GHSA-4q3w-q5mc-45rq) added a phase field to reset and change-email tokens, and a if data.get("phase","") != "reset": raise InvalidTokenError() gate at api/controllers/console/auth/forgot_password.py:174.
  • #34380 refactor(api): replace json.loads with Pydantic validation in security and tools layers changed TokenManager.get_token_data (api/libs/helper.py:473) from json.loads(...) to dict(_token_data_adapter.validate_json(...)). The _TokenData TypedDict declared on api/libs/helper.py:36 lists account_id, email, token_type, code, old_email — but not phase. So the TypeAdapter silently strips it.

Net effect: every reset token reaches the controller with phase already gone, the gate fails, the password reset is 100% broken in 1.14.x.

Quick repro inside the running api container:

docker exec dify-api python -c "
from app_factory import create_app
_, app = create_app()
with app.app_context():
    from services.account_service import AccountService
    print(AccountService.get_reset_password_data('<a real token uuid in redis>'))
"
# -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...'}
# Notice the missing 'phase' key.

Raw value in redis (via redis-cli GET reset_password:token:<uuid>) does contain "phase": "reset".

Fix

Add phase: str to the _TokenData TypedDict. Since total=False, it stays optional — no callers need to change. PR #XXX (this issue) includes the one-line fix plus a regression test that round-trips both the reset_password and change_email payloads through the TypeAdapter.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

dify - ✅(Solved) Fix Password reset always returns 400 `invalid_or_expired_token` in 1.14.x — `_TokenData` TypedDict strips the `phase` field [1 pull requests, 1 participants]