Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,686 changes: 2,490 additions & 1,196 deletions common/pb/edge_cmd_pb/edge_cmd.pb.go

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions common/pb/edge_cmd_pb/edge_cmd.proto
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ message Revocation {
string id = 1;
google.protobuf.Timestamp expiresAt = 2;
map<string, TagValue> tags = 3;
string type = 4;
}

message DeleteRevocationsBatchCommand {
Expand Down
5 changes: 5 additions & 0 deletions controller/db/revocation_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import (

const (
FieldRevocationExpiresAt = "expiresAt"
FieldRevocationType = "type"
)

type Revocation struct {
boltz.BaseExtEntity
ExpiresAt time.Time `json:"expiresAt"`
Type string `json:"type"`
}

func (r Revocation) GetEntityType() string {
Expand All @@ -56,6 +58,7 @@ type revocationStoreImpl struct {
func (store *revocationStoreImpl) initializeLocal() {
store.AddExtEntitySymbols()
store.AddSymbol(FieldRevocationExpiresAt, ast.NodeTypeDatetime)
store.AddSymbol(FieldRevocationType, ast.NodeTypeString)
}

func (store *revocationStoreImpl) initializeLinked() {}
Expand All @@ -67,9 +70,11 @@ func (store *revocationStoreImpl) NewEntity() *Revocation {
func (store *revocationStoreImpl) FillEntity(entity *Revocation, bucket *boltz.TypedBucket) {
entity.LoadBaseValues(bucket)
entity.ExpiresAt = bucket.GetTimeOrError(FieldRevocationExpiresAt)
entity.Type = bucket.GetStringOrError(FieldRevocationType)
}

func (store *revocationStoreImpl) PersistEntity(entity *Revocation, ctx *boltz.PersistContext) {
entity.SetBaseValues(ctx)
ctx.SetTimeP(FieldRevocationExpiresAt, &entity.ExpiresAt)
ctx.SetString(FieldRevocationType, entity.Type)
}
10 changes: 10 additions & 0 deletions controller/env/appenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,16 @@ func (ae *AppEnv) ValidateAccessToken(token string) (*common.AccessClaims, error
return nil, errors.New("access token has been revoked by identity")
}

apiSessionRevocation, err := ae.GetManagers().Revocation.Read(accessClaims.ApiSessionId)

if err != nil && !boltz.IsErrNotFoundErr(err) {
return nil, err
}

if apiSessionRevocation != nil && apiSessionRevocation.CreatedAt.After(accessClaims.IssuedAt.AsTime()) {
return nil, errors.New("access token has been revoked by api session")
}

return accessClaims, nil
}

Expand Down
21 changes: 15 additions & 6 deletions controller/env/security_ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,25 +397,34 @@ func (ctx *SecurityCtx) resolveOidcSession(securityToken *common.SecurityToken)
return
}

// Check if this specific token has been revoked by JWTID.
tokenRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.JWTID)
// Check revocations before accepting the session.
jtiRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.JWTID)
if err != nil && !boltz.IsErrNotFoundErr(err) {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
if tokenRevocation != nil {
if jtiRevocation != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}

// Check if the issuing identity has been terminated via a high-water-mark revocation.
identityRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.Subject)
if err != nil && !boltz.IsErrNotFoundErr(err) {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
if identityRevocation != nil && identityRevocation.CreatedAt.Truncate(time.Second).After(claims.IssuedAt.AsTime()) {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcExpired())
if identityRevocation != nil && identityRevocation.CreatedAt.After(claims.IssuedAt.AsTime()) {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}

apiSessionRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.ApiSessionId)
if err != nil && !boltz.IsErrNotFoundErr(err) {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
if apiSessionRevocation != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}

Expand Down
50 changes: 50 additions & 0 deletions controller/internal/routes/revocation_api_model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
Copyright NetFoundry Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package routes

import (
"github.com/go-openapi/strfmt"
"github.com/openziti/edge-api/rest_model"
"github.com/openziti/ziti/v2/controller/env"
"github.com/openziti/ziti/v2/controller/model"
"github.com/openziti/ziti/v2/controller/response"
)

const EntityNameRevocations = "revocations"

// RevocationLinkFactory is the link factory for revocation entities.
var RevocationLinkFactory = NewBasicLinkFactory(EntityNameRevocations)

// MapRevocationToRestEntity maps a model Revocation to its REST representation for use
// with ListWithHandler and DetailWithHandler.
func MapRevocationToRestEntity(_ *env.AppEnv, _ *response.RequestContext, revocationModel *model.Revocation) (interface{}, error) {
return MapRevocationToRestModel(revocationModel)
}

// MapRevocationToRestModel converts a model Revocation to a rest_model RevocationDetail.
func MapRevocationToRestModel(revocation *model.Revocation) (*rest_model.RevocationDetail, error) {
expiresAt := strfmt.DateTime(revocation.ExpiresAt)
revocationType := rest_model.RevocationTypeEnum(revocation.Type)

ret := &rest_model.RevocationDetail{
BaseEntity: BaseEntityToRestModel(revocation, RevocationLinkFactory),
ExpiresAt: &expiresAt,
Type: &revocationType,
}

return ret, nil
}
135 changes: 135 additions & 0 deletions controller/internal/routes/revocation_router.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
Copyright NetFoundry Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package routes

import (
"net/http"
"time"

"github.com/go-openapi/runtime/middleware"
"github.com/google/uuid"
"github.com/openziti/edge-api/rest_management_api_server/operations/revocation"
"github.com/openziti/edge-api/rest_model"
"github.com/openziti/foundation/v2/errorz"
"github.com/openziti/storage/boltz"
"github.com/openziti/ziti/v2/controller/env"
"github.com/openziti/ziti/v2/controller/model"
"github.com/openziti/ziti/v2/controller/models"
"github.com/openziti/ziti/v2/controller/permissions"
"github.com/openziti/ziti/v2/controller/response"
)

func init() {
r := NewRevocationRouter()
env.AddRouter(r)
}

// RevocationRouter handles Management API routes for revocation management.
type RevocationRouter struct {
BasePath string
}

// NewRevocationRouter creates a new RevocationRouter.
func NewRevocationRouter() *RevocationRouter {
return &RevocationRouter{
BasePath: "/" + EntityNameRevocations,
}
}

func (r *RevocationRouter) Register(ae *env.AppEnv) {
ae.ManagementApi.RevocationListRevocationsHandler = revocation.ListRevocationsHandlerFunc(func(params revocation.ListRevocationsParams, _ interface{}) middleware.Responder {
ae.InitPermissionsContext(params.HTTPRequest, permissions.Management, "revocation", permissions.Read)
return ae.IsAllowed(r.List, params.HTTPRequest, "", "", permissions.DefaultManagementAccess())
})

ae.ManagementApi.RevocationDetailRevocationHandler = revocation.DetailRevocationHandlerFunc(func(params revocation.DetailRevocationParams, _ interface{}) middleware.Responder {
ae.InitPermissionsContext(params.HTTPRequest, permissions.Management, "revocation", permissions.Read)
return ae.IsAllowed(r.Detail, params.HTTPRequest, params.ID, "", permissions.DefaultManagementAccess())
})

ae.ManagementApi.RevocationCreateRevocationHandler = revocation.CreateRevocationHandlerFunc(func(params revocation.CreateRevocationParams, _ interface{}) middleware.Responder {
ae.InitPermissionsContext(params.HTTPRequest, permissions.Management, "revocation", permissions.Create)
return ae.IsAllowed(func(ae *env.AppEnv, rc *response.RequestContext) { r.Create(ae, rc, params) }, params.HTTPRequest, "", "", permissions.DefaultManagementAccess())
})
}

func (r *RevocationRouter) List(ae *env.AppEnv, rc *response.RequestContext) {
ListWithHandler[*model.Revocation](ae, rc, ae.Managers.Revocation, MapRevocationToRestEntity)
}

func (r *RevocationRouter) Detail(ae *env.AppEnv, rc *response.RequestContext) {
DetailWithHandler[*model.Revocation](ae, rc, ae.Managers.Revocation, MapRevocationToRestEntity)
}

// Create handles POST /revocations. It validates the submitted id against the
// revocation type, computes the expiry from the configured refresh token duration,
// and persists the revocation entry.
func (r *RevocationRouter) Create(ae *env.AppEnv, rc *response.RequestContext, params revocation.CreateRevocationParams) {
Create(rc, rc, RevocationLinkFactory, func() (string, error) {
entity, err := mapCreateRevocationToModel(ae, rc.Request, params.Revocation)
if err != nil {
return "", err
}
if err = ae.Managers.Revocation.Create(entity, rc.NewChangeContext()); err != nil {
return "", err
}
return entity.Id, nil
})
}

// mapCreateRevocationToModel converts a RevocationCreate REST model to a model.Revocation,
// validating the id against the revocation type and computing the server-side expiry.
func mapCreateRevocationToModel(ae *env.AppEnv, r *http.Request, create *rest_model.RevocationCreate) (*model.Revocation, error) {
if create == nil {
return nil, errorz.NewUnhandled(nil)
}

id := ""
if create.ID != nil {
id = *create.ID
}

revocationType := rest_model.RevocationTypeEnumJTI
if create.Type != nil {
revocationType = *create.Type
}

switch revocationType {
case rest_model.RevocationTypeEnumIDENTITY:
if _, err := ae.Managers.Identity.Read(id); err != nil {
if boltz.IsErrNotFoundErr(err) {
return nil, errorz.NewNotFound()
}
return nil, err
}
case rest_model.RevocationTypeEnumJTI, rest_model.RevocationTypeEnumAPISESSION:
if _, err := uuid.Parse(id); err != nil {
return nil, errorz.NewFieldError("must be a valid UUID v4", "id", id)
}
}

expiresAt := time.Now().Add(ae.GetConfig().Edge.Oidc.RefreshTokenDuration)

return &model.Revocation{
BaseEntity: models.BaseEntity{
Id: id,
Tags: TagsOrDefault(create.Tags),
},
ExpiresAt: expiresAt,
Type: string(revocationType),
}, nil
}
2 changes: 2 additions & 0 deletions controller/model/revocation_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ func (self *RevocationManager) Marshall(entity *Revocation) ([]byte, error) {
Id: entity.Id,
ExpiresAt: timePtrToPb(&entity.ExpiresAt),
Tags: tags,
Type: entity.Type,
}

return proto.Marshal(msg)
Expand All @@ -139,6 +140,7 @@ func (self *RevocationManager) Unmarshall(bytes []byte) (*Revocation, error) {
Tags: edge_cmd_pb.DecodeTags(msg.Tags),
},
ExpiresAt: *pbTimeToTimePtr(msg.ExpiresAt),
Type: msg.Type,
}, nil
}

Expand Down
3 changes: 3 additions & 0 deletions controller/model/revocation_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
type Revocation struct {
models.BaseEntity
ExpiresAt time.Time
Type string
}

func (entity *Revocation) toBoltEntityForUpdate(tx *bbolt.Tx, env Env, _ boltz.FieldChecker) (*db.Revocation, error) {
Expand All @@ -37,6 +38,7 @@ func (entity *Revocation) toBoltEntityForUpdate(tx *bbolt.Tx, env Env, _ boltz.F
func (entity *Revocation) fillFrom(_ Env, _ *bbolt.Tx, boltRevocation *db.Revocation) error {
entity.FillCommon(boltRevocation)
entity.ExpiresAt = boltRevocation.ExpiresAt
entity.Type = boltRevocation.Type

return nil
}
Expand All @@ -45,6 +47,7 @@ func (entity *Revocation) toBoltEntityForCreate(*bbolt.Tx, Env) (*db.Revocation,
boltEntity := &db.Revocation{
BaseExtEntity: *boltz.NewExtEntity(entity.Id, entity.Tags),
ExpiresAt: entity.ExpiresAt,
Type: entity.Type,
}

return boltEntity, nil
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ require (
github.com/openziti/agent v1.0.33
github.com/openziti/channel/v4 v4.3.9
github.com/openziti/cobra-to-md v1.0.1
github.com/openziti/edge-api v0.27.5
github.com/openziti/edge-api v0.27.6-0.20260408221309-6015c2dee0d4
github.com/openziti/foundation/v2 v2.0.90
github.com/openziti/identity v1.0.128
github.com/openziti/jwks v1.0.6
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,10 @@ github.com/openziti/channel/v4 v4.3.9 h1:uTiaFdS1XyFKv15g8W21Q9EjI3NE876lj5nvg+q
github.com/openziti/channel/v4 v4.3.9/go.mod h1:pznrlRy3xAqBH1aj6EpjV7XfuoXT53nqdrw/pzXlgHg=
github.com/openziti/cobra-to-md v1.0.1 h1:WRinNoIRmwWUSJm+pSNXMjOrtU48oxXDZgeCYQfVXxE=
github.com/openziti/cobra-to-md v1.0.1/go.mod h1:FjCpk/yzHF7/r28oSTNr5P57yN5VolpdAtS/g7KNi2c=
github.com/openziti/edge-api v0.27.5 h1:PkwdTLE6beIngrurVGrjOU+8WuQi+W0ISQwPkjjLtpM=
github.com/openziti/edge-api v0.27.5/go.mod h1:pDtCR6Mq0h5e8ulJhmuhPshEBa39hpQy+yTtLpUSBOs=
github.com/openziti/edge-api v0.27.6-0.20260408211105-2218206e933b h1:fsFu2y13/b2Yb/yRudLqt/NWdduvYpyptivKsFj4zF0=
github.com/openziti/edge-api v0.27.6-0.20260408211105-2218206e933b/go.mod h1:pDtCR6Mq0h5e8ulJhmuhPshEBa39hpQy+yTtLpUSBOs=
github.com/openziti/edge-api v0.27.6-0.20260408221309-6015c2dee0d4 h1:+KXp+8vPAqO13YDPcCQ6NiRSzl5BTOFzFXC7Y5RB+/0=
github.com/openziti/edge-api v0.27.6-0.20260408221309-6015c2dee0d4/go.mod h1:pDtCR6Mq0h5e8ulJhmuhPshEBa39hpQy+yTtLpUSBOs=
github.com/openziti/foundation/v2 v2.0.90 h1:1+Xbug8gQgi656yq+mvKvzROmmX/Lsrgt/s12l1Wz3s=
github.com/openziti/foundation/v2 v2.0.90/go.mod h1:mJT858owwwcM6XvpEawdKM+7UAjc+ftIlpD/6toCTCg=
github.com/openziti/go-term-markdown v1.0.1 h1:9uzMpK4tav6OtvRxRt99WwPTzAzCh+Pj9zWU2FBp3Qg=
Expand Down
27 changes: 27 additions & 0 deletions tests/api_client_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/openziti/identity/certtools"
edgeApis "github.com/openziti/sdk-golang/edge-apis"
"github.com/openziti/sdk-golang/ziti"
"github.com/openziti/ziti/v2/common"
"github.com/zitadel/oidc/v3/pkg/oidc"
)

Expand Down Expand Up @@ -781,3 +782,29 @@ func (s *SingularClientTransportPool) TryTransportsForOp(operation *runtime.Clie
func (s *SingularClientTransportPool) TryTransportForF(cb func(*edgeApis.ApiClientTransport) (any, error)) (any, error) {
return cb(s.ApiClientTransport)
}

// OidcAccessToken authenticates via OIDC with the given credentials and returns
// the raw access token string plus its parsed claims. The caller must set CaPool
// on the credentials before calling if the controller uses a self-signed certificate.
func (helper *ClientHelperClient) OidcAccessToken(credentials edgeApis.Credentials) (string, *common.AccessClaims, error) {
tokens, _, err := helper.RawOidcAuthRequest(credentials)
if err != nil {
return "", nil, err
}
if tokens.AccessToken == "" {
return "", nil, fmt.Errorf("empty access token from OIDC auth")
}
return parseAccessToken(tokens.AccessToken)
}

// parseAccessToken parses an access token string into AccessClaims without
// verifying the signature.
func parseAccessToken(tokenStr string) (string, *common.AccessClaims, error) {
claims := &common.AccessClaims{}
parser := jwt.NewParser()
_, _, err := parser.ParseUnverified(tokenStr, claims)
if err != nil {
return "", nil, err
}
return tokenStr, claims, nil
}
Loading
Loading