Skip to content

Commit dbeb5c9

Browse files
fixes #3673 purge expired revocations, fix revocation bugs (#3679)
- fixes RevokeToken double-write: adds return after JWTID-keyed revocation save, preventing fallthrough write with raw JWT string as unreachable key - fixes TerminateSession key mismatch: stores revocation by identityId alone, matching the Subject-based lookup in ValidateAccessToken - fixes RevocationDelete sync action: passes DataState_Delete instead of DataState_Create so routers evict the entry from their data model - adds RevocationManager.DeleteExpired: batch-deletes expired revocations in batches of 500 until none remain - adds RevocationEnforcer: periodic policy runner (every 1 minute) that calls DeleteExpired and records metrics before test fixes
1 parent 61e4ecc commit dbeb5c9

File tree

12 files changed

+626
-22
lines changed

12 files changed

+626
-22
lines changed

controller/env/appenv.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ func (ae *AppEnv) ValidateAccessToken(token string) (*common.AccessClaims, error
229229
return nil, err
230230
}
231231

232-
if revocation != nil && revocation.CreatedAt.After(accessClaims.IssuedAt.AsTime()) {
232+
if revocation != nil && revocation.CreatedAt.Truncate(time.Second).After(accessClaims.IssuedAt.AsTime()) {
233233
return nil, errors.New("access token has been revoked by identity")
234234
}
235235

controller/env/security_ctx.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
"github.com/openziti/edge-api/rest_model"
2828
"github.com/openziti/foundation/v2/errorz"
29+
"github.com/openziti/storage/boltz"
2930
"github.com/openziti/ziti/v2/common"
3031
"github.com/openziti/ziti/v2/controller/model"
3132
"github.com/openziti/ziti/v2/controller/models"
@@ -396,6 +397,28 @@ func (ctx *SecurityCtx) resolveOidcSession(securityToken *common.SecurityToken)
396397
return
397398
}
398399

400+
// Check if this specific token has been revoked by JWTID.
401+
tokenRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.JWTID)
402+
if err != nil && !boltz.IsErrNotFoundErr(err) {
403+
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
404+
return
405+
}
406+
if tokenRevocation != nil {
407+
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
408+
return
409+
}
410+
411+
// Check if the issuing identity has been terminated via a high-water-mark revocation.
412+
identityRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.Subject)
413+
if err != nil && !boltz.IsErrNotFoundErr(err) {
414+
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
415+
return
416+
}
417+
if identityRevocation != nil && identityRevocation.CreatedAt.Truncate(time.Second).After(claims.IssuedAt.AsTime()) {
418+
ctx.setApiSessionError(errorz.NewUnauthorizedOidcExpired())
419+
return
420+
}
421+
399422
ctx.resolvedIdentity = identity
400423
ctx.resolvedAuthPolicy = authPolicy
401424

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
Copyright NetFoundry Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package policy
18+
19+
import (
20+
"time"
21+
22+
"github.com/michaelquigley/pfxlog"
23+
"github.com/openziti/ziti/v2/common/runner"
24+
"github.com/openziti/ziti/v2/controller/change"
25+
"github.com/openziti/ziti/v2/controller/env"
26+
)
27+
28+
const (
29+
RevocationEnforcerRun = "revocation.enforcer.run"
30+
RevocationEnforcerDelete = "revocation.enforcer.delete"
31+
RevocationEnforcerSource = "revocation.enforcer"
32+
)
33+
34+
// RevocationEnforcer periodically purges expired revocation records from the
35+
// controller database and router data models.
36+
type RevocationEnforcer struct {
37+
appEnv *env.AppEnv
38+
*runner.BaseOperation
39+
}
40+
41+
// NewRevocationEnforcer creates a RevocationEnforcer that runs at the given frequency.
42+
func NewRevocationEnforcer(appEnv *env.AppEnv, frequency time.Duration) *RevocationEnforcer {
43+
return &RevocationEnforcer{
44+
appEnv: appEnv,
45+
BaseOperation: runner.NewBaseOperation("RevocationEnforcer", frequency),
46+
}
47+
}
48+
49+
// Run deletes all expired revocation entries.
50+
func (e *RevocationEnforcer) Run() error {
51+
startTime := time.Now()
52+
53+
defer func() {
54+
e.appEnv.GetMetricsRegistry().Timer(RevocationEnforcerRun).UpdateSince(startTime)
55+
}()
56+
57+
ctx := change.New().SetSourceType(RevocationEnforcerSource).SetChangeAuthorType(change.AuthorTypeController)
58+
59+
total, err := e.appEnv.GetManagers().Revocation.DeleteExpired(ctx)
60+
if err != nil {
61+
pfxlog.Logger().WithError(err).Error("failed to delete expired revocations")
62+
return nil
63+
}
64+
65+
if total > 0 {
66+
pfxlog.Logger().Debugf("removed %d expired revocations", total)
67+
e.appEnv.GetMetricsRegistry().Meter(RevocationEnforcerDelete).Mark(int64(total))
68+
}
69+
70+
return nil
71+
}

controller/model/revocation_manager.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
package model
1818

1919
import (
20+
"fmt"
21+
"time"
22+
2023
"github.com/openziti/storage/boltz"
2124
"github.com/openziti/ziti/v2/common/pb/edge_cmd_pb"
2225
"github.com/openziti/ziti/v2/controller/change"
@@ -59,6 +62,41 @@ func (self *RevocationManager) NewModelEntity() *Revocation {
5962
return &Revocation{}
6063
}
6164

65+
const revocationDeleteBatchSize = 500
66+
67+
// DeleteExpired deletes all revocations whose ExpiresAt is in the past, working
68+
// in batches of revocationDeleteBatchSize until no expired entries remain.
69+
// Returns the total number of entries deleted.
70+
func (self *RevocationManager) DeleteExpired(ctx *change.Context) (int, error) {
71+
query := fmt.Sprintf(`expiresAt < datetime(%s) limit %d`, time.Now().UTC().Format(time.RFC3339), revocationDeleteBatchSize)
72+
total := 0
73+
for {
74+
result, err := self.BaseList(query)
75+
if err != nil {
76+
return total, err
77+
}
78+
79+
ids := make([]string, 0, len(result.GetEntities()))
80+
for _, entity := range result.GetEntities() {
81+
ids = append(ids, entity.GetId())
82+
}
83+
84+
if len(ids) == 0 {
85+
break
86+
}
87+
88+
if err = self.deleteEntityBatch(ids, ctx); err != nil {
89+
return total, err
90+
}
91+
total += len(ids)
92+
93+
if len(ids) < revocationDeleteBatchSize {
94+
break
95+
}
96+
}
97+
return total, nil
98+
}
99+
62100
func (self *RevocationManager) Read(id string) (*Revocation, error) {
63101
modelEntity := &Revocation{}
64102
if err := self.readEntity(id, modelEntity); err != nil {

controller/oidc_auth/storage.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,7 @@ func (s *HybridStorage) TokenRequestByRefreshToken(_ context.Context, refreshTok
830830
// TerminateSession implements the op.Storage interface
831831
func (s *HybridStorage) TerminateSession(_ context.Context, identityId string, clientID string) error {
832832
now := time.Now()
833-
return s.saveRevocation(NewRevocation(identityId+","+clientID, now.Add(s.config.MaxTokenDuration())))
833+
return s.saveRevocation(NewRevocation(identityId, now.Add(s.config.MaxTokenDuration())))
834834
}
835835

836836
// GetRefreshTokenInfo implements the op.Storage interface
@@ -856,6 +856,7 @@ func (s *HybridStorage) RevokeToken(_ context.Context, tokenIDOrToken string, _
856856
return oidc.ErrServerError()
857857
}
858858

859+
return nil
859860
}
860861

861862
revocation := NewRevocation(tokenIDOrToken, time.Now().Add(s.config.MaxTokenDuration()))

controller/server/controller.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ type Controller struct {
4747
}
4848

4949
const (
50-
policyMinFreq = 1 * time.Second
51-
policyMaxFreq = 1 * time.Hour
52-
policyAppWanFreq = 1 * time.Second
53-
policySessionFreq = 5 * time.Second
50+
policyMinFreq = 1 * time.Second
51+
policyMaxFreq = 1 * time.Hour
52+
policyAppWanFreq = 1 * time.Second
53+
policySessionFreq = 5 * time.Second
54+
policyRevocationFreq = 1 * time.Minute
5455
)
5556

5657
func NewController(host env.HostController) (*Controller, error) {
@@ -202,6 +203,14 @@ func (c *Controller) Initialize() {
202203

203204
}
204205

206+
revocationEnforcer := policy.NewRevocationEnforcer(c.AppEnv, policyRevocationFreq)
207+
if err := c.policyEngine.AddOperation(revocationEnforcer); err != nil {
208+
log.WithField("cause", err).
209+
WithField("enforcerName", revocationEnforcer.GetName()).
210+
WithField("enforcerId", revocationEnforcer.GetId()).
211+
Errorf("could not add revocation enforcer")
212+
}
213+
205214
if err := c.AppEnv.GetStores().EventualEventer.Start(c.AppEnv.GetHostController().GetCloseNotifyChannel()); err != nil {
206215
log.WithError(err).Panic("could not start EventualEventer")
207216
}

controller/sync_strats/sync_instant.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1837,7 +1837,7 @@ func (strategy *InstantStrategy) RevocationUpdate(index uint64, revocation *db.R
18371837
}
18381838

18391839
func (strategy *InstantStrategy) RevocationDelete(index uint64, revocation *db.Revocation) {
1840-
strategy.handleRevocation(index, edge_ctrl_pb.DataState_Create, revocation)
1840+
strategy.handleRevocation(index, edge_ctrl_pb.DataState_Delete, revocation)
18411841
}
18421842

18431843
func (strategy *InstantStrategy) handlePublicKey(index uint64, action edge_ctrl_pb.DataState_Action, publicKey *edge_ctrl_pb.DataState_PublicKey) {

tests/api_client_management.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,22 @@ func (helper *ManagementHelperClient) PatchIdentity(id string, patch *rest_model
597597
return helper.GetIdentity(id)
598598
}
599599

600+
// AuthenticateOidc sets the client to use OIDC, authenticates with the given
601+
// credentials, and returns the OIDC API session. Returns an error if
602+
// authentication fails or the session is not an OIDC session.
603+
func (helper *ManagementHelperClient) AuthenticateOidc(creds edgeApis.Credentials) (*edgeApis.ApiSessionOidc, error) {
604+
helper.SetUseOidc(true)
605+
apiSession, err := helper.Authenticate(creds, nil)
606+
if err != nil {
607+
return nil, err
608+
}
609+
oidcSession, ok := apiSession.(*edgeApis.ApiSessionOidc)
610+
if !ok {
611+
return nil, fmt.Errorf("expected *edge_apis.ApiSessionOidc, got %T", apiSession)
612+
}
613+
return oidcSession, nil
614+
}
615+
600616
func (helper *ManagementHelperClient) GetCurrentApiSessionDetail() (*rest_model.CurrentAPISessionDetail, error) {
601617
resp, err := helper.GetCurrentApiSessionDetailResponse()
602618

0 commit comments

Comments
 (0)