fix(cabana): enforce tokens_valid_after and is_activated on admin refresh

Fixes review finding CR-01 (quick 260927-q23): POST {prefix}/api/v1/auth/refresh
minted a new token without loading the admin, so a session kept alive by the
SPA's refresh-on-401 survived admin:reset-password, deactivation and deletion.
This broke Phase 9 truth T-09-04.

- bouncer: extract the JWT guard's subject lookup into subjectPrincipal and
  issuedBeforeCutoff (same order and messages), add ErrSubjectRejected
- bouncer: add RefreshAudienceFor, which runs the guard's subject checks
  after the token-only checks and before minting; Refresh and
  RefreshAudience are unchanged (nil hook)
- cabana: share one lazyBackendUsers provider between the backend guard and
  refresh; a cookie refresh refused for its subject expires summer_admin
- test: TestAdminRefreshRevocation (Postgres, real admin:reset-password)
This commit is contained in:
Jakub Zych
2026-09-27 18:58:15 +02:00
parent 815cb903c6
commit be4a923f36
6 changed files with 246 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ package cabana
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
@@ -22,6 +23,20 @@ import (
"gorm.io/gorm"
)
// refreshSubjects is a map-backed bouncer.UserProvider for refresh tests
// that run without a database; err, when set, is returned for every lookup.
type refreshSubjects struct {
byID map[uint]*bouncer.Principal
err error
}
func (p refreshSubjects) FindByID(_ context.Context, id uint) (*bouncer.Principal, error) {
if p.err != nil {
return nil, p.err
}
return p.byID[id], nil
}
// emptyOptionsRow is a filter source whose scope has no choices yet;
// literalOptionsRow's choice label is a literal rather than a phrase key.
type emptyOptionsRow struct {
@@ -281,6 +296,7 @@ func TestPhase10Coverage(t *testing.T) {
refreshTTL: 2 * time.Hour,
issuer: "https://app.test" + DefaultAdminPrefix,
bl: bouncer.NewMemoryBlacklist(),
users: refreshSubjects{byID: map[uint]*bouncer.Principal{5: {ID: 5, Backend: true}}},
}
sign := func(iat, exp time.Time, jti string) string {
t.Helper()