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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user