Skip to content

fix(auth): invalidate JWT tokens on password change or reset - #2199

Merged
yohamta0 merged 4 commits into
mainfrom
fix/password-change-invalidates-tokens
May 24, 2026
Merged

yohamta0 merged 4 commits into
mainfrom
fix/password-change-invalidates-tokens

Conversation

@yohamta0

@yohamta0 yohamta0 commented May 24, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds PasswordChangedAt *time.Time to the User struct (and UserForStorage for persistence), stored with second-level precision
  • Embeds the timestamp as a pwd_changed_at Unix-second claim in every newly issued JWT
  • GetUserFromToken rejects any token whose pwd_changed_at claim predates the user's recorded PasswordChangedAt — closing the 24-hour window where a stolen token stays valid after the victim changes their password
  • ChangePassword, ResetPassword, and the password-update path in UpdateUser all stamp PasswordChangedAt

Backward compatibility

  • Field is a pointer with omitempty — existing stored user records round-trip without change
  • Old tokens (no pwd_changed_at claim) decode to 0 (epoch), which is treated as "before any real password change" — they are rejected the first time the user changes their password, and remain valid indefinitely for users who never change their password

Test plan

  • TestService_GetUserFromToken_PasswordChangeInvalidatesToken — old token rejected after ChangePassword
  • TestService_GetUserFromToken_NewTokenValidAfterPasswordChange — token issued after password change is accepted
  • TestService_GetUserFromToken_ResetPasswordInvalidatesToken — old token rejected after admin ResetPassword
  • TestService_GetUserFromToken_NoPasswordChangeTokenStillValid — token stays valid when user never changed password
  • All existing auth service tests still pass

Summary by CodeRabbit

  • New Features

    • Password changes now invalidate previously issued authentication tokens.
    • Password resets invalidate tokens issued before the reset.
    • User accounts now track password-change timestamps to enforce token invalidation.
  • Tests

    • Added comprehensive tests verifying token acceptance/rejection across password changes and admin resets, ensuring expected authentication behavior.

Review Change Stack

Add PasswordChangedAt field to User and embed it as a Unix-second claim
in JWT tokens. GetUserFromToken rejects any token issued before the
user's last password change, closing the window where an attacker with a
stolen token retains access after the victim resets their password.

Old tokens (no pwd_changed_at claim) map to epoch and are rejected once
the user changes their password for the first time. Tokens issued after
a password change remain valid. Users who never change their password
are unaffected.
@coderabbitai

coderabbitai Bot commented May 24, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f2ac7553-6572-41eb-b019-b71f0faad4db

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements password-change-based token invalidation. The User model gains a PasswordChangedAt timestamp field that flows through storage serialization. JWTs now include this timestamp in claims. GetUserFromToken compares the token's password-change time against the current user's value and rejects stale tokens. All password update methods (UpdateUser, ChangePassword, ResetPassword) consistently set the new timestamp. Four test cases validate token rejection after password changes and token validity in unchanged scenarios.

Changes

Password-change-based token invalidation

Layer / File(s) Summary
Data model: PasswordChangedAt field and persistence
internal/auth/user.go
User struct gains PasswordChangedAt *time.Time field; UserForStorage and its conversion methods (ToStorage(), ToUser()) are updated to persist and restore the timestamp.
JWT claims and token generation
internal/service/auth/service.go
Claims struct adds pwd_changed_at_ns Unix-nanosecond timestamp claim; GenerateToken computes and includes the password-change time in the JWT.
Token validation: password-change freshness check
internal/service/auth/service.go
GetUserFromToken compares the token's pwd_changed_at_ns against the current user's PasswordChangedAt.UnixNano() and rejects the token if it was issued before the password change.
Password update operations: consistent timestamp setting
internal/service/auth/service.go
UpdateUser, ChangePassword, and ResetPassword capture a single UTC now and set PasswordChangedAt and UpdatedAt when a password is updated.
Test coverage: token invalidation and validity scenarios
internal/service/auth/service_test.go
Four test cases validate that tokens are rejected after ChangePassword or ResetPassword, that new tokens work after password changes, and that tokens remain valid when no password change occurs.

Sequence Diagram

sequenceDiagram
  participant Client
  participant GenerateToken
  participant GetUserFromToken
  participant UserStore
  Client->>GenerateToken: request token
  GenerateToken->>UserStore: fetch user
  GenerateToken-->>Client: token with pwd_changed_at_ns (maybe zero)
  Client->>Client: user password changed
  Client->>GetUserFromToken: validate token
  GetUserFromToken->>UserStore: fetch current user
  UserStore-->>GetUserFromToken: user with PasswordChangedAt set
  GetUserFromToken-->>Client: ErrInvalidToken (if token's pwd_changed_at_ns < user's PasswordChangedAt.UnixNano())
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: token invalidation on password change or reset, which aligns with the primary security objective of the PR.
Description check ✅ Passed The description covers all template sections with detailed information about changes, backward compatibility, and test plans, making the PR objectives fully clear.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/password-change-invalidates-tokens

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/service/auth/service_test.go (1)

640-744: ⚡ Quick win

Add token-invalidation coverage for the UpdateUser password path.

These new cases are great, but they don’t cover the third password mutation flow (UpdateUser with Password) that now sets PasswordChangedAt in service logic. A focused test here would close the remaining invalidation gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/auth/service_test.go` around lines 640 - 744, Add a test
that covers the UpdateUser password mutation path: create a user via CreateUser,
issue a token with GenerateToken, call svc.UpdateUser(...) to set a new Password
(the path that should set PasswordChangedAt), then assert the old token is
rejected by GetUserFromToken (ErrInvalidToken) and that a freshly generated
token for the updated user (fetch with GetUser, then GenerateToken) is accepted;
reference UpdateUser, GenerateToken, GetUserFromToken and GetUser to find where
to add the test modeled after the existing password-change/reset tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/service/auth/service.go`:
- Around line 185-188: The code currently truncates PasswordChangedAt to second
precision (pwdChangedAt = user.PasswordChangedAt.Unix() and
time.Now().UTC().Truncate(time.Second)) which allows same-second password
changes to collide; change the token claim and comparisons to use nanosecond
precision (UnixNano) instead: populate pwdChangedAt with
user.PasswordChangedAt.UnixNano(), stop truncating now to seconds, decode
claims.PasswordChangedAt as nanoseconds via time.Unix(0,
claims.PasswordChangedAt), and perform the invalidation check using the
high-precision times (e.g., tokenPwdChangedAt.Before(user.PasswordChangedAt)
with nanosecond resolution) so two changes within the same second cannot produce
equal timestamps; update any variables/names referencing pwdChangedAt,
claims.PasswordChangedAt, time.Now().UTC().Truncate, and time.Unix(...)
accordingly.

---

Nitpick comments:
In `@internal/service/auth/service_test.go`:
- Around line 640-744: Add a test that covers the UpdateUser password mutation
path: create a user via CreateUser, issue a token with GenerateToken, call
svc.UpdateUser(...) to set a new Password (the path that should set
PasswordChangedAt), then assert the old token is rejected by GetUserFromToken
(ErrInvalidToken) and that a freshly generated token for the updated user (fetch
with GetUser, then GenerateToken) is accepted; reference UpdateUser,
GenerateToken, GetUserFromToken and GetUser to find where to add the test
modeled after the existing password-change/reset tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 29e0c716-57bd-4199-8cf6-1bc281aa1306

📥 Commits

Reviewing files that changed from the base of the PR and between 5fae9ac and 56cd720.

📒 Files selected for processing (3)
  • internal/auth/user.go
  • internal/service/auth/service.go
  • internal/service/auth/service_test.go

Comment thread internal/service/auth/service.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56cd720c2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/service/auth/service.go Outdated
return fmt.Errorf("failed to hash password: %w", err)
}

now := time.Now().UTC().Truncate(time.Second)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep password-change version monotonic across rapid resets

PasswordChangedAt is truncated to whole seconds, so two password changes for the same user within one second can produce identical values; in that case a token minted after the first change is still accepted after the second change because GetUserFromToken only rejects when tokenPwdChangedAt.Before(*user.PasswordChangedAt). This leaves a real revocation gap for rapid consecutive ChangePassword/ResetPassword operations (for example automated admin resets).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b09565a. Switched to UnixNano() for both storage and JWT claim (renamed to pwd_changed_at_ns), removed Truncate(time.Second) from all three password-update paths, and replaced the Before() comparison with direct int64 comparison. Two password changes within the same nanosecond are now the only remaining collision scenario (effectively impossible in practice).

Switch PasswordChangedAt claim from Unix seconds to UnixNano to close
a collision window where two password changes within the same second
produce equal timestamps, allowing a token issued between them to bypass
invalidation. Rename claim key from pwd_changed_at to pwd_changed_at_ns
to make the unit explicit. Remove Truncate(time.Second) from all three
password-update paths.
@yohamta0

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

After password reset, the old JWT is correctly rejected (401) because
GetUserFromToken now enforces PasswordChangedAt. The test must obtain
a fresh token with the new credentials before testing RBAC gating (403).
@yohamta0
yohamta0 merged commit 9069f1b into main May 24, 2026
10 checks passed
@yohamta0
yohamta0 deleted the fix/password-change-invalidates-tokens branch May 24, 2026 11:29
@coderabbitai coderabbitai Bot mentioned this pull request Jul 20, 2026
5 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant