Skip to content

fix: add missing phase field to _TokenData TypedDict#36261

Merged
asukaminato0721 merged 1 commit into
langgenius:mainfrom
xxiaoxiong:fix/token-data-missing-phase-field-36116
May 18, 2026
Merged

fix: add missing phase field to _TokenData TypedDict#36261
asukaminato0721 merged 1 commit into
langgenius:mainfrom
xxiaoxiong:fix/token-data-missing-phase-field-36116

Conversation

@xxiaoxiong
Copy link
Copy Markdown
Contributor

Description

Fixes #36116

Problem

  • Password reset always returns 400 invalid_or_expired_token in Dify 1.14.0 and 1.14.1
  • The token is valid in Redis with the correct phase field and long TTL
  • But the handler reads phase == "" and rejects the request
  • Password reset is 100% broken in 1.14.x

Root Cause

Two PRs that shipped together in 1.14.0 interact badly:

  1. PR fix(auth): enforce phase-bound change-email token flow (GHSA-4q3w-q5mc-45rq) #35425 (security fix GHSA-4q3w-q5mc-45rq) added a phase field to reset and change-email tokens, and added a validation gate:

    if data.get("phase", "") != "reset":
        raise InvalidTokenError()
  2. PR refactor(api): replace json.loads with Pydantic validation in security and tools layers #34380 (refactor) changed TokenManager.get_token_data from json.loads(...) to Pydantic TypeAdapter validation:

    dict(_token_data_adapter.validate_json(...))

The problem: _TokenData TypedDict declares these fields:

  • account_id, email, token_type, code, old_email
  • BUT NOT phase

So the TypeAdapter silently strips the phase field during validation.

Net effect: Every reset token reaches the controller with phase already gone, the gate fails, password reset is broken.

Reproduction

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>'))
"
# Before fix:
# -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...'}
# Notice the missing 'phase' key.

# After fix:
# -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...', 'phase': 'reset'}
# phase field is now preserved.

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

Solution

Add phase: str to the _TokenData TypedDict.

Since total=False, it stays optional — no callers need to change.

Changes

  • Modified: api/libs/helper.py
    • Added phase: str field to _TokenData TypedDict (line 42)

Impact

  • Fixes password reset functionality in 1.14.x
  • ✅ Preserves phase field during token validation
  • No breaking changes: total=False makes all fields optional
  • ✅ Backward compatible: existing code continues to work
  • ✅ Also fixes change-email flow which uses the same token structure
  • ✅ Minimal code change: 1 line added

Testing

Manual Test Flow

  1. From the sign-in page, click Forgot password, enter a valid email
  2. Receive the reset email and click the link
  3. Enter the 6-digit code shown in the email
  4. Enter a new password (≥8 chars, letters + digits) and confirm
  5. Verify password reset succeeds with 200 OK { "result": "success" }
  6. Verify you can sign in with the new password

Before Fix (1.14.0, 1.14.1)

POST /console/api/forgot-password/resets
{
  "token": "<token>",
  "new_password": "NewPass1234",
  "password_confirm": "NewPass1234"
}

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

After Fix

POST /console/api/forgot-password/resets
{
  "token": "<token>",
  "new_password": "NewPass1234",
  "password_confirm": "NewPass1234"
}

Response: HTTP 200
{
  "result": "success"
}

Verification

Before fix:

AccountService.get_reset_password_data(token)
# -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...'}
# Missing 'phase' key

After fix:

AccountService.get_reset_password_data(token)
# -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...', 'phase': 'reset'}
# phase field preserved ✅

Related Code

Token validation gate (api/controllers/console/auth/forgot_password.py:174):

if data.get("phase", "") != "reset":
    raise InvalidTokenError()

This gate was added in PR #35425 for security, but the phase field was being stripped by the TypeAdapter before reaching this check.

TypeAdapter usage (api/libs/helper.py:473):

def get_token_data(self, key: str, token: str) -> dict[str, Any]:
    data = self.redis_client.get(f"{key}:{token}")
    if data is None:
        return {}
    return dict(_token_data_adapter.validate_json(data))

The TypeAdapter now correctly preserves the phase field.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas (N/A - simple one-line addition)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works (manual testing)
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Fixes langgenius#36116

Problem:
- Password reset always returns 400 'invalid_or_expired_token' in 1.14.x
- The token is valid in Redis with correct phase field, but handler reads phase == ""
- Password reset is 100% broken in 1.14.0 and 1.14.1

Root Cause:
Two PRs that shipped together in 1.14.0 interact badly:
- PR langgenius#35425 added 'phase' field to reset/change-email tokens for security
- PR langgenius#34380 changed TokenManager.get_token_data to use Pydantic TypeAdapter
- _TokenData TypedDict lists account_id, email, token_type, code, old_email
- BUT it does NOT list 'phase', so TypeAdapter silently strips it
- Every reset token reaches the controller with phase already gone
- The phase gate at forgot_password.py:174 fails, password reset breaks

Solution:
- Add 'phase: str' to _TokenData TypedDict
- Since total=False, it stays optional - no callers need to change
- TypeAdapter now preserves the phase field during validation

Changes:
- Modified: api/libs/helper.py
  - Added phase: str field to _TokenData TypedDict (line 42)

Impact:
- ✅ Fixes password reset functionality in 1.14.x
- ✅ Preserves phase field during token validation
- ✅ No breaking changes: total=False makes all fields optional
- ✅ Backward compatible: existing code continues to work
- ✅ Also fixes change-email flow which uses the same token structure

Testing:
1. Request password reset from sign-in page
2. Receive reset email and click the link
3. Enter the 6-digit code from email
4. Enter new password and confirm
5. Verify password reset succeeds with 200 OK
6. Verify phase field is preserved in token data

Verification:
Before fix:
  AccountService.get_reset_password_data(token)
  -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...'}
  (missing 'phase' key)

After fix:
  AccountService.get_reset_password_data(token)
  -> {'account_id': None, 'email': '...', 'token_type': 'reset_password', 'code': '...', 'phase': 'reset'}
  (phase field preserved)
@xxiaoxiong xxiaoxiong requested a review from QuantumGhost as a code owner May 17, 2026 05:06
@dosubot dosubot Bot added the size:XS This PR changes 0-9 lines, ignoring generated files. label May 17, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Pyrefly Type Coverage

Metric Base PR Delta
Type coverage 0.00% 43.70% +43.70%
Strict coverage 0.00% 43.22% +43.22%
Typed symbols 0 22,132 +22,132
Untyped symbols 0 28,831 +28,831
Modules 0 2557 +2,557

@asukaminato0721 asukaminato0721 enabled auto-merge May 18, 2026 02:08
@asukaminato0721 asukaminato0721 added this pull request to the merge queue May 18, 2026
@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label May 18, 2026
Merged via the queue into langgenius:main with commit b79fc5d May 18, 2026
27 checks passed
zhangtaodemama added a commit to zhangtaodemama/langgenius-dify-bfaadcb0c706 that referenced this pull request May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm This PR has been approved by a maintainer size:XS This PR changes 0-9 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Password reset always returns 400 invalid_or_expired_token in 1.14.x — _TokenData TypedDict strips the phase field

2 participants