Files
ziti/controller/env/security_ctx.go
T
Andrew Martinez 8297a817b7 fixes #3734 enforce client certificate proof-of-possession for OIDC sessions (#3805)
* fixes #3734 enforce client certificate proof-of-possession for OIDC sessions

- adds verifyCertProofOfPossession() in resolveOidcSession() to require
  TLS client cert matching a z_cfs fingerprint or SPIFFE ID when the
  OIDC token was issued with cert bindings
- adds z_cae (CertAllowExpired) claim from auth policy, propagated
  through token issuance and refresh
- adds TrustCache.VerifyClientCert() with tiered pool matching
  (first-party roots, trust anchors, third-party) and TTL cache
- adds WrapIdentityWithCertValidation() on the router to verify client
  certs at the TLS level against RDM PublicKeys
- adds IsFirstPartyCert() on the router to gate SPIFFE ID matching by
  checking whether the cert chains to the controller root CA
- adds VerifySpiffeId() in common/spiffehlp with SpiffeMatchApiSession,
  SpiffeMatchIdentity, and SpiffeMatchNone return types
- adds SPIFFE IDs to OTT and token enrollment certs (/identity/<id>)
- enforces cert expiry by match type: API session certs must be valid,
  fingerprint-matched certs respect z_cae, legacy sessions skip checks
- shallow-copies leaf certs before overriding time fields in all cert
  verification paths to avoid races on shared x509.Certificate pointers
- fixes controllerRootCache setting inited=true before the ctrl channel
  is available, which permanently cached the failure
2026-04-23 12:00:46 -04:00

583 lines
20 KiB
Go

/*
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 env
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/edge-api/rest_model"
"github.com/openziti/foundation/v2/errorz"
"github.com/openziti/ziti/v2/controller/storage/boltz"
"github.com/openziti/ziti/v2/common"
"github.com/openziti/ziti/v2/common/spiffehlp"
"github.com/openziti/ziti/v2/controller/model"
"github.com/openziti/ziti/v2/controller/models"
"github.com/openziti/ziti/v2/controller/permissions"
)
// SecurityCtx resolves and caches the full authentication context for a single HTTP request.
// Starting from the raw token data in a SecurityTokenCtx, it looks up the associated API
// session, identity, auth policy, MFA state, and permission set — each at most once.
// It also supports administrator identity masquerading for privileged operations.
type SecurityCtx struct {
securityTokenCtx *common.SecurityTokenCtx
env model.Env
resolveApiSessionOnce sync.Once
resolvedApiSessionError error
resolveMfaOnce sync.Once
resolvedMfaError error
resolvedMfaAuthQueries []*rest_model.AuthQueryDetail
resolvePermissionsOnce sync.Once
resolvedPermissions map[string]struct{}
resolveVerifiedApiSessionToken *common.SecurityToken
resolvedApiSession *model.ApiSession
resolvedIdentity *model.Identity
resolvedAuthPolicy *model.AuthPolicy
masqueradeIdentity *model.Identity
resolvedTotp *model.Mfa
resolvedTotpError error
}
// NewSecurityCtx creates a SecurityCtx that will resolve authentication details from
// securityTokenCtx using the managers and stores available through env.
func NewSecurityCtx(securityTokenCtx *common.SecurityTokenCtx, env model.Env) *SecurityCtx {
return &SecurityCtx{securityTokenCtx: securityTokenCtx, env: env, resolvedPermissions: map[string]struct{}{}}
}
// GetError returns the error encountered while resolving the API session, or nil if the
// session resolved successfully.
func (ctx *SecurityCtx) GetError() error {
return ctx.resolvedApiSessionError
}
// GetSecurityTokenCtx returns the underlying token context that holds the raw bearer tokens
// and their pre-parsed issuer associations.
func (ctx *SecurityCtx) GetSecurityTokenCtx() *common.SecurityTokenCtx {
return ctx.securityTokenCtx
}
// GetIdentity triggers full resolution of the authentication context and returns the
// identity associated with the session. When an administrator has called MasqueradeAsIdentity,
// the masquerade identity is returned instead of the session's own identity.
func (ctx *SecurityCtx) GetIdentity() (*model.Identity, error) {
ctx.resolve()
if ctx.masqueradeIdentity != nil {
return ctx.masqueradeIdentity, nil
}
return ctx.resolvedIdentity, ctx.resolvedApiSessionError
}
// GetAuthPolicy triggers resolution and returns the auth policy governing the session's identity.
func (ctx *SecurityCtx) GetAuthPolicy() (*model.AuthPolicy, error) {
ctx.resolve()
return ctx.resolvedAuthPolicy, ctx.resolvedApiSessionError
}
// GetApiSession triggers resolution and returns the API session for the request.
func (ctx *SecurityCtx) GetApiSession() (*model.ApiSession, error) {
ctx.resolve()
return ctx.resolvedApiSession, ctx.resolvedApiSessionError
}
// GetTotp triggers resolution and returns the TOTP MFA configuration for the session's identity.
func (ctx *SecurityCtx) GetTotp() (*model.Mfa, error) {
ctx.resolve()
return ctx.resolvedTotp, ctx.resolvedTotpError
}
// GetApiSessionWithoutResolve returns the API session if it has already been resolved,
// without triggering resolution. Useful for response header helpers that run after
// the primary handler has already resolved the session.
func (ctx *SecurityCtx) GetApiSessionWithoutResolve() (*model.ApiSession, error) {
return ctx.resolvedApiSession, ctx.resolvedApiSessionError
}
// GetMfaAuthQueriesWithoutResolve returns any outstanding MFA auth queries without triggering
// resolution.
func (ctx *SecurityCtx) GetMfaAuthQueriesWithoutResolve() []*rest_model.AuthQueryDetail {
return ctx.resolvedMfaAuthQueries
}
// GetMfaErrorWithoutResolve returns the MFA error if secondary authentication checks have
// already run, without triggering resolution.
func (ctx *SecurityCtx) GetMfaErrorWithoutResolve() error {
return ctx.resolvedMfaError
}
// GetVerifiedApiSessionToken triggers resolution and returns the verified primary security token
// (either a legacy zt-session or an OIDC bearer token) along with any session-level error.
func (ctx *SecurityCtx) GetVerifiedApiSessionToken() (*common.SecurityToken, error) {
ctx.resolve()
return ctx.resolveVerifiedApiSessionToken, ctx.resolvedApiSessionError
}
// GetMfaAuthQueries triggers resolution and returns the list of outstanding MFA challenges
// that the identity must complete before gaining full access.
func (ctx *SecurityCtx) GetMfaAuthQueries() []*rest_model.AuthQueryDetail {
ctx.resolve()
return ctx.resolvedMfaAuthQueries
}
// GetMfaError triggers resolution and returns any error encountered while evaluating
// secondary MFA requirements (e.g., a missing or expired ext-JWT secondary token).
func (ctx *SecurityCtx) GetMfaError() error {
ctx.resolve()
return ctx.resolvedMfaError
}
// MasqueradeAsIdentity allows an authenticated administrator to act as another identity
// for the duration of the request. Subsequent calls to GetIdentity will return the given
// identity rather than the one derived from the session token. Returns an error if the
// caller is not authenticated or does not hold admin privileges.
func (ctx *SecurityCtx) MasqueradeAsIdentity(identity *model.Identity) error {
ctx.resolve()
originalIdentity := ctx.resolvedIdentity
if originalIdentity == nil {
return errors.New("cannot masquerade as identity when not authenticated")
}
if !originalIdentity.IsAdmin && !originalIdentity.IsDefaultAdmin {
return errors.New("only administrators can masquerade as other identities")
}
if !ctx.isFullyAuthed() {
return errors.New("cannot masquerade as identity until fully authenticated")
}
ctx.masqueradeIdentity = identity
return nil
}
// EndMasquerade clears any active identity masquerade, restoring GetIdentity to return
// the identity associated with the session token.
func (ctx *SecurityCtx) EndMasquerade() {
ctx.masqueradeIdentity = nil
}
// IsPartiallyAuthed returns true when the primary authentication (session token) succeeded
// but at least one secondary factor (TOTP or ext-JWT) is still outstanding.
func (ctx *SecurityCtx) IsPartiallyAuthed() bool {
ctx.resolve()
return ctx.isPartiallyAuthed()
}
func (ctx *SecurityCtx) isPartiallyAuthed() bool {
primaryAuthOk := ctx.resolvedApiSession != nil && ctx.resolvedApiSessionError == nil
secondaryAuthOk := len(ctx.resolvedMfaAuthQueries) == 0 && ctx.resolvedMfaError == nil
return primaryAuthOk && !secondaryAuthOk
}
// IsFullyAuthed returns true when both primary and all secondary authentication factors
// have been satisfied.
func (ctx *SecurityCtx) IsFullyAuthed() bool {
ctx.resolve()
return ctx.isFullyAuthed()
}
func (ctx *SecurityCtx) isFullyAuthed() bool {
primaryAuthOk := ctx.resolvedApiSession != nil && ctx.resolvedApiSessionError == nil
secondaryAuthOk := len(ctx.resolvedMfaAuthQueries) == 0 && ctx.resolvedMfaError == nil
return primaryAuthOk && secondaryAuthOk
}
func (ctx *SecurityCtx) setApiSessionError(err error) {
ctx.resolvedApiSessionError = err
}
func (ctx *SecurityCtx) resolveMfa() {
if ctx.securityTokenCtx == nil {
return
}
ctx.resolveMfaOnce.Do(func() {
if ctx.resolvedApiSessionError != nil {
return
}
if ctx.resolvedAuthPolicy == nil {
return
}
totpRequired := ctx.resolvedApiSession.TotpRequired || ctx.resolvedAuthPolicy.Secondary.RequireTotp
if totpRequired {
if !ctx.resolvedApiSession.TotpComplete {
totpAuthQuery := NewAuthQueryZitiTotp()
if ctx.resolvedIdentity != nil {
ctx.resolvedTotp, ctx.resolvedTotpError = ctx.env.GetManagers().Mfa.ReadOneByIdentityId(ctx.resolvedIdentity.Id)
if ctx.resolvedTotp != nil && ctx.resolvedTotp.IsVerified {
totpAuthQuery.IsTotpEnrolled = true
}
}
ctx.resolvedMfaAuthQueries = append(ctx.resolvedMfaAuthQueries, totpAuthQuery)
}
}
if ctx.resolvedAuthPolicy.Secondary.RequiredExtJwtSigner != nil {
requireExtJwtSigner, err := ctx.env.GetManagers().ExternalJwtSigner.Read(*ctx.resolvedAuthPolicy.Secondary.RequiredExtJwtSigner)
if err != nil {
ctx.resolvedMfaError = fmt.Errorf("error reading required external JWT signer: %w", err)
return
}
if requireExtJwtSigner == nil {
ctx.resolvedMfaError = fmt.Errorf("required external JWT signer id %s not found", *ctx.resolvedAuthPolicy.Secondary.RequiredExtJwtSigner)
return
}
verifiedExternalToken := ctx.securityTokenCtx.GetExternalTokenForExtJwtSigner(requireExtJwtSigner.Id)
if verifiedExternalToken == nil {
ctx.resolvedMfaError = errorz.NewUnauthorizedSecondaryExtTokenMissing([]string{requireExtJwtSigner.Id}, []string{*requireExtJwtSigner.Issuer})
ctx.resolvedMfaAuthQueries = append(ctx.resolvedMfaAuthQueries, NewAuthQueryExtJwt(requireExtJwtSigner))
} else if !verifiedExternalToken.IsValid() {
ctx.resolvedMfaAuthQueries = append(ctx.resolvedMfaAuthQueries, NewAuthQueryExtJwt(requireExtJwtSigner))
if verifiedExternalToken.TokenVerificationResult.TokenIsExpired() {
ctx.resolvedMfaError = errorz.NewUnauthorizedSecondaryExtTokenExpired([]string{requireExtJwtSigner.Id}, []string{*requireExtJwtSigner.Issuer})
} else {
ctx.resolvedMfaError = errorz.NewUnauthorizedSecondaryExtTokenInvalid([]string{requireExtJwtSigner.Id}, []string{*requireExtJwtSigner.Issuer})
}
}
}
})
}
func (ctx *SecurityCtx) resolve() {
if ctx.securityTokenCtx == nil {
return
}
ctx.resolveApiSessionOnce.Do(func() {
verifiedApiSessionToken, err := ctx.securityTokenCtx.GetVerifiedApiSessionToken()
if err != nil {
ctx.setApiSessionError(err)
return
}
if verifiedApiSessionToken == nil {
ctx.setApiSessionError(errorz.NewUnauthorizedTokensMissing())
return
}
ctx.resolveVerifiedApiSessionToken = verifiedApiSessionToken
if verifiedApiSessionToken.IsLegacy {
ctx.resolveZtSession(verifiedApiSessionToken)
} else {
ctx.resolveOidcSession(verifiedApiSessionToken)
}
if ctx.resolvedApiSessionError != nil {
ctx.setApiSessionError(ctx.resolvedApiSessionError)
return
}
if ctx.resolvedApiSession == nil {
return
}
ctx.resolveMfa()
ctx.resolvePermissions()
})
}
func (ctx *SecurityCtx) resolveZtSession(securityToken *common.SecurityToken) {
if ctx.securityTokenCtx == nil {
return
}
apiSession, err := ctx.env.GetManagers().ApiSession.ReadByToken(securityToken.ZtSession)
if err != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedZtSessionInvalid())
return
}
if apiSession == nil {
ctx.setApiSessionError(errorz.NewUnauthorizedZtSessionInvalid())
return
}
if apiSession.IdentityId == "" {
ctx.setApiSessionError(errorz.NewUnauthorizedZtSessionInvalid())
return
}
identity, err := ctx.env.GetManagers().Identity.Read(apiSession.IdentityId)
if err != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedZtSessionInvalid())
return
}
if identity == nil {
ctx.setApiSessionError(errorz.NewUnauthorizedZtSessionInvalid())
return
}
authPolicy, err := ctx.env.GetManagers().AuthPolicy.Read(identity.AuthPolicyId)
if err != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedZtSessionInvalid())
return
}
if authPolicy == nil {
ctx.setApiSessionError(errorz.NewUnauthorizedZtSessionInvalid())
return
}
ctx.resolvedIdentity = identity
ctx.resolvedAuthPolicy = authPolicy
ctx.resolvedApiSession = apiSession
}
func (ctx *SecurityCtx) resolveOidcSession(securityToken *common.SecurityToken) {
if securityToken == nil || securityToken.OidcToken == nil || securityToken.OidcToken.AccessClaims == nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
claims := securityToken.OidcToken.AccessClaims
identity, err := ctx.env.GetManagers().Identity.Read(claims.Subject)
if err != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
if identity == nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
authPolicy, err := ctx.env.GetManagers().AuthPolicy.Read(identity.AuthPolicyId)
if err != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
if authPolicy == nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
// 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 jtiRevocation != nil {
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return
}
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).Before(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
}
// Enforce certificate proof-of-possession when the token was issued with cert bindings.
if len(claims.CertFingerprints) > 0 {
if !ctx.verifyCertProofOfPossession(securityToken, claims) {
return
}
}
ctx.resolvedIdentity = identity
ctx.resolvedAuthPolicy = authPolicy
configTypes := map[string]struct{}{}
for _, configType := range claims.ConfigTypes {
configTypes[configType] = struct{}{}
}
ctx.resolvedApiSession = &model.ApiSession{
BaseEntity: models.BaseEntity{
Id: claims.ApiSessionId,
CreatedAt: claims.IssuedAt.AsTime(),
UpdatedAt: claims.IssuedAt.AsTime(),
IsSystem: false,
},
Token: ctx.resolveVerifiedApiSessionToken.OidcToken.Raw,
IdentityId: claims.Subject,
Identity: identity,
IPAddress: securityToken.Request.RemoteAddr,
ConfigTypes: configTypes,
TotpComplete: claims.TotpComplete(),
TotpRequired: false,
ExpiresAt: claims.Expiration.AsTime(),
ExpirationDuration: time.Until(claims.Expiration.AsTime()),
LastActivityAt: time.Now(),
AuthenticatorId: claims.AuthenticatorId,
IsCertExtendable: claims.IsCertExtendable,
IsCertExtendRequested: claims.IsCertExtendRequested,
IsCertKeyRollRequested: claims.IsCertKeyRollRequested,
ImproperClientCertChain: claims.ImproperClientCertChain,
}
}
// verifyCertProofOfPossession validates that the TLS client certificate presented on this request
// matches one of the certificate fingerprints bound to the OIDC token (z_cfs claim), and that
// the certificate was issued by a trusted CA. SPIFFE ID matching is only allowed for certificates
// issued by the internal (first-party) CA.
func (ctx *SecurityCtx) verifyCertProofOfPossession(securityToken *common.SecurityToken, claims *common.AccessClaims) bool {
if securityToken.Request == nil || securityToken.Request.TLS == nil || len(securityToken.Request.TLS.PeerCertificates) == 0 {
pfxlog.Logger().Warn("OIDC cert PoP failed: no client certificate presented")
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return false
}
peerCerts := securityToken.Request.TLS.PeerCertificates
leafCert := peerCerts[0]
fpg := ctx.env.GetFingerprintGenerator()
fingerprint := fpg.FromCert(leafCert)
// Chain verification bypasses time checks, expiry is enforced below based on match type
// and the z_cae (cert allow expired) claim from the auth policy.
trustCache := ctx.env.GetManagers().Ca.GetTrustCache()
origin := trustCache.VerifyClientCertCached(fingerprint, peerCerts, true)
if origin == model.CertOriginUntrusted {
pfxlog.Logger().WithField("fingerprint", fingerprint).Warn("OIDC cert PoP failed: client certificate not issued by a trusted CA")
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return false
}
now := time.Now()
certExpired := now.Before(leafCert.NotBefore) || now.After(leafCert.NotAfter)
// For first-party certs, check the SPIFFE ID to determine the match type.
if origin == model.CertOriginFirstParty {
spiffeId, err := spiffehlp.GetSpiffeIdFromCert(leafCert)
if err == nil && spiffeId != nil {
match := spiffehlp.VerifySpiffeId(spiffeId, claims.Subject, claims.ApiSessionId)
switch match {
case spiffehlp.SpiffeMatchApiSession:
// Full API session cert. Must not be expired.
if certExpired {
pfxlog.Logger().WithField("fingerprint", fingerprint).Warn("OIDC cert PoP failed: API session certificate is expired")
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return false
}
return true
case spiffehlp.SpiffeMatchIdentity:
// Identity enrollment cert. The SPIFFE ID confirms the cert belongs to
// this identity, but we still need to verify the fingerprint is in z_cfs.
break
}
}
}
// Fingerprint matching against z_cfs (required for identity-only SPIFFE matches and
// for third-party certs). Expired certs are allowed only if the auth policy permits
// it (z_cae claim).
for _, boundFingerprint := range claims.CertFingerprints {
if fingerprint == boundFingerprint {
if certExpired && !claims.CertAllowExpired {
pfxlog.Logger().WithField("fingerprint", fingerprint).Warn("OIDC cert PoP failed: client certificate is expired and auth policy does not allow expired certs")
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return false
}
return true
}
}
pfxlog.Logger().WithField("fingerprint", fingerprint).Warn("OIDC cert PoP failed: client certificate does not match any bound fingerprint or SPIFFE ID")
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
return false
}
func (ctx *SecurityCtx) resolvePermissions() {
if ctx.resolvedApiSession == nil || ctx.resolvedIdentity == nil {
return
}
ctx.resolvePermissionsOnce.Do(func() {
if ctx.isFullyAuthed() {
ctx.resolvedPermissions[permissions.AuthenticatedPermission] = struct{}{}
if ctx.resolvedIdentity.IsAdmin || ctx.resolvedIdentity.IsDefaultAdmin {
ctx.resolvedPermissions[permissions.AdminPermission] = struct{}{}
}
} else if ctx.isPartiallyAuthed() {
ctx.resolvedPermissions[permissions.PartiallyAuthenticatePermission] = struct{}{}
}
for _, permission := range ctx.resolvedIdentity.Permissions {
ctx.resolvedPermissions[permission] = struct{}{}
}
})
}
// GetPermissions returns the set of permission strings granted to the session, such as
// "authenticated", "partiallyAuthenticated", and "admin". The map is populated during
// resolution and is safe to read after any of the Get* methods have been called.
func (ctx *SecurityCtx) GetPermissions() map[string]struct{} {
ctx.resolve()
return ctx.resolvedPermissions
}
// AddToRequest stores this SecurityCtx in the request's context under common.SecurityCtxKey
// so that route handlers can retrieve it without needing to re-resolve authentication.
func (ctx *SecurityCtx) AddToRequest(r *http.Request) {
*r = *r.WithContext(context.WithValue(r.Context(), common.SecurityCtxKey, ctx))
}