fix: a self-service password change revokes every OAuth grant, however recently refreshed

ChangePassword fired the access-revoked event with Before = the calling
session's iat, so an OAuth/MCP grant whose client refreshed it after that
instant kept working. Before is now the instant of the request; the stored
tokens_valid_after still keeps the calling session.
This commit is contained in:
2026-10-23 20:48:38 +02:00
parent 38551a4104
commit 30f7a01d3d
3 changed files with 26 additions and 16 deletions

View File

@@ -102,9 +102,11 @@ func wholeSecondsUntruncated(t *testing.T, env *adminEnv, id uint, before time.T
}
// TestAccessRevocation pins plan 08's revocation chain on the user side
// (C-CR-01, P-35): the ban bulk action, ResetPassword, ChangePassword and an
// administrator setting a password each fire one AccessRevokedEvent in the
// write's transaction with the right ids, Before and Reason, then one
// (C-CR-01, RC-CR-01, P-35): the ban bulk action, ResetPassword,
// ChangePassword and an administrator setting a password each fire one
// AccessRevokedEvent in the write's transaction with the right ids, Before
// and Reason (for ChangePassword the instant of the request, never the
// calling session's older iat, while the session itself survives), then one
// revoked subject event per user after the commit; the ban fires only for
// newly banned users; a failing listener rolls a password reset back; and
// a banned linked user loses the backend link until unbanned. Postgres
@@ -153,20 +155,26 @@ func TestAccessRevocation(t *testing.T) {
wholeSecondsUntruncated(t, env, user.ID, got.ev.Before)
})
t.Run("ChangePassword fires with the stored cutoff plus one second", func(t *testing.T) {
t.Run("ChangePassword revokes as of the request and keeps the calling session", func(t *testing.T) {
user := env.seedUser(t, "rv-change", func(u *models.User) { u.HasSelfSetPassword = true })
status, session := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK {
t.Fatalf("login = %d", status)
}
// A session last refreshed twenty minutes ago: an OAuth grant its
// client rotated since then is newer than the session, and must
// still end (RC-CR-01).
iat := time.Now().Add(-20 * time.Minute)
session := craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "rv-change-"+strconv.FormatInt(time.Now().UnixNano(), 10), iat, time.Now().Add(time.Hour))
probe.take()
spy.reset()
started := time.Now()
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session, map[string]string{"current_password": "correct-horse", "password": "rv-changed-password", "password_confirmation": "rv-changed-password"})
got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordChanged)
stored := env.reload(t, user.ID).TokensValidAfter
if stored == nil || !got.ev.Before.Equal(stored.Add(time.Second)) {
t.Fatalf("Before %s, stored cutoff %v: want the cutoff plus one second", got.ev.Before, stored)
if got.ev.Before.Before(started) || got.ev.Before.After(time.Now()) {
t.Fatalf("Before %s is not the instant of the request (started %s): grants refreshed after the session's iat %s would survive", got.ev.Before, started, iat)
}
stored := env.reload(t, user.ID).TokensValidAfter
if want := time.Unix(iat.Unix(), 0).Add(-time.Second); stored == nil || !stored.Equal(want) {
t.Fatalf("stored cutoff %v, want the session's iat second minus one (%s)", stored, want)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
})
t.Run("an administrator setting a password fires with the untruncated instant", func(t *testing.T) {