Files
ziti/controller/oidc_auth/requests.go
Andrew Martinez 949de99ee4 fixes #3809 support CSR submission during OIDC authentication (#3840)
* fixes #3809 support CSR submission during OIDC authentication

- accepts an optional CSR during OIDC login (all auth methods) and
  signs it into a session-bound certificate with a SPIFFE ID derived
  from the identity and API session
- returns the signed certificate PEM as a top-level "session_cert"
  field in the token endpoint JSON response (CodeExchange, RefreshToken,
  TokenExchange)
- adds cert-binding verification on RefreshToken and TokenExchange:
  if z_cfs is present the peer cert fingerprint must match (strict),
  otherwise falls back to SPIFFE ID verification
- supports cert rotation via csr_pem form parameter on refresh and
  token exchange; replaces the session cert fingerprint while
  preserving the authenticating cert fingerprint
- adds AuthCertFingerprints (z_acfs) claim to track permanent auth
  cert fingerprints separately from rotatable session cert fingerprints
- only the leaf certificate fingerprint is added to z_cfs and z_acfs,
  intermediates are never included
- invalid CSR returns 400 Bad Request in OIDC error format
- propagates updated CustomClaims (including CertFingerprints) from
  access token to renewed refresh token so rotated fingerprints are
  enforced on subsequent refreshes
- adds CertGenerated field to ApiSessionEvent
- advertises OIDC_AUTH_WITH_CSR controller capability when OIDC is
  enabled
- adds unit tests for verifyCertBinding (fingerprint, SPIFFE ID,
  edge cases) and CsrPem field parsing
- adds integration tests for initial CSR auth (updb, cert, ext-jwt),
  cert-binding on refresh/exchange, CSR rotation with cert auth,
  SPIFFE fallback and z_cfs transition, and CSR property forging
  resistance

* address pr concerns

* add z_cfs len tests on junk chain certs

* strip csr subject info, replace w/ santized values

* fix csr rotation rejection/paths during token exchange/refresh
2026-05-07 13:56:48 -04:00

284 lines
8.5 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 oidc_auth
import (
"crypto/sha1"
"crypto/x509"
"fmt"
"time"
"github.com/openziti/edge-api/rest_model"
"github.com/openziti/foundation/v2/stringz"
"github.com/openziti/ziti/v2/common"
"github.com/openziti/ziti/v2/controller/model"
"github.com/zitadel/oidc/v3/pkg/oidc"
)
// AuthRequest represents an OIDC authentication request and implements op.AuthRequest
type AuthRequest struct {
oidc.AuthRequest
Id string
CreationDate time.Time
IdentityId string
AuthTime time.Time
ApiSessionId string
IsTotpEnrolled bool
SecondaryTotpRequired bool
SecondaryExtJwtSigner *model.ExternalJwtSigner
ConfigTypes []string
Amr map[string]struct{}
PeerCerts []*x509.Certificate
RequestedMethod string
BearerTokenDetected bool
SdkInfo *rest_model.SdkInfo
EnvInfo *rest_model.EnvInfo
RemoteAddress string
IsCertExtendable bool
CertAllowExpired bool
IsCertExtendRequested bool
IsCertKeyRollRequested bool
AuthenticatorId string
ImproperClientCertChain bool
CsrPem string
}
// GetID returns an AuthRequest's ID and implements op.AuthRequest
func (a *AuthRequest) GetID() string {
return a.Id
}
// GetACR returns the authentication class reference provided by client and implements oidc.AuthRequest
// All ACRs are currently ignored.
func (a *AuthRequest) GetACR() string {
return ""
}
// GetAMR returns the authentication method references the authentication has undergone and implements op.AuthRequest
func (a *AuthRequest) GetAMR() []string {
result := make([]string, len(a.Amr))
i := 0
for k := range a.Amr {
result[i] = k
i = i + 1
}
return result
}
// HasFullAuth returns true if an authentication request has passed all primary and secondary authentications.
func (a *AuthRequest) HasFullAuth() bool {
return a.HasPrimaryAuth() && a.HasSecondaryAuth()
}
// HasPrimaryAuth returns true if a primary authentication mechanism has been passed.
func (a *AuthRequest) HasPrimaryAuth() bool {
return a.HasAmr(AuthMethodCert) || a.HasAmr(AuthMethodPassword) || a.HasAmr(AuthMethodExtJwt)
}
// HasSecondaryAuth returns true if all applicable secondary authentications have been passed
func (a *AuthRequest) HasSecondaryAuth() bool {
return (!a.SecondaryTotpRequired || a.HasAmr(AuthMethodSecondaryTotp)) &&
(a.SecondaryExtJwtSigner == nil || a.HasAmrExtJwtId(a.SecondaryExtJwtSigner.Id))
}
// HasAmr returns true if the supplied amr is present
func (a *AuthRequest) HasAmr(amr string) bool {
_, found := a.Amr[amr]
return found
}
func (a *AuthRequest) HasAmrExtJwtId(id string) bool {
return a.HasAmr(AuthMethodSecondaryExtJwt + ":" + id)
}
// AddAmrExtJwtId records that the external JWT signer with the given ID has been satisfied
// as a secondary authentication method by adding it to the AMR set in the format
// "<AuthMethodSecondaryExtJwt>:<id>".
func (a *AuthRequest) AddAmrExtJwtId(id string) {
amr := AuthMethodSecondaryExtJwt + ":" + id
a.AddAmr(amr)
}
// AddAmr adds the supplied amr
func (a *AuthRequest) AddAmr(amr string) {
if a.Amr == nil {
a.Amr = map[string]struct{}{}
}
a.Amr[amr] = struct{}{}
}
// GetAudience returns all current audience targets and implements op.AuthRequest
func (a *AuthRequest) GetAudience() []string {
return []string{a.ClientID}
}
// GetAuthTime returns the time at which authentication has occurred and implements op.AuthRequest
func (a *AuthRequest) GetAuthTime() time.Time {
return a.AuthTime
}
// GetClientID returns the client id requested and implements op.AuthRequest
func (a *AuthRequest) GetClientID() string {
return a.ClientID
}
// GetCodeChallenge returns the rp supplied code change and implements op.AuthRequest
func (a *AuthRequest) GetCodeChallenge() *oidc.CodeChallenge {
return &oidc.CodeChallenge{
Challenge: a.CodeChallenge,
Method: a.CodeChallengeMethod,
}
}
// GetNonce returns the rp supplied nonce and implements op.AuthRequest
func (a *AuthRequest) GetNonce() string {
return a.Nonce
}
// GetRedirectURI returns the rp supplied redirect target and implements op.AuthRequest
func (a *AuthRequest) GetRedirectURI() string {
return a.RedirectURI
}
// GetResponseType returns the rp supplied response type and implements op.AuthRequest
func (a *AuthRequest) GetResponseType() oidc.ResponseType {
return a.ResponseType
}
// GetResponseMode is not supported and all tokens are turned via query string and implements op.AuthRequest
func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
return ""
}
// GetScopes returns the current scopes and implements op.AuthRequest
func (a *AuthRequest) GetScopes() []string {
return a.Scopes
}
// GetState returns the rp provided state and implements op.AuthRequest
func (a *AuthRequest) GetState() string {
return a.State
}
// GetSubject returns the target subject and implements op.AuthRequest
func (a *AuthRequest) GetSubject() string {
return a.IdentityId
}
// Done returns true once authentication has been completed and implements op.AuthRequest
func (a *AuthRequest) Done() bool {
return a.HasFullAuth()
}
// GetCertFingerprint returns the SHA-1 fingerprint of the leaf peer certificate,
// or an empty string if no peer certificates are present. Only the leaf is
// returned so that z_cfs binds proof-of-possession to the auth cert and not to
// incidental intermediates the client may have presented on the TLS chain.
func (a *AuthRequest) GetCertFingerprint() string {
if len(a.PeerCerts) == 0 {
return ""
}
return fmt.Sprintf("%x", sha1.Sum(a.PeerCerts[0].Raw))
}
func (a *AuthRequest) NeedsTotp() bool {
return a.SecondaryTotpRequired && !a.HasAmr(AuthMethodSecondaryTotp)
}
func (a *AuthRequest) NeedsSecondaryExtJwt() bool {
return a.SecondaryExtJwtSigner != nil && !a.HasAmrExtJwtId(a.SecondaryExtJwtSigner.Id)
}
func (a *AuthRequest) GetAuthQueries() []*rest_model.AuthQueryDetail {
var authQueries []*rest_model.AuthQueryDetail
if a.NeedsTotp() {
provider := rest_model.MfaProvidersZiti
authQueries = append(authQueries, &rest_model.AuthQueryDetail{
Format: rest_model.MfaFormatsNumeric,
HTTPMethod: "POST",
HTTPURL: "./oidc/login/totp",
MaxLength: 8,
MinLength: 6,
Provider: &provider,
TypeID: rest_model.AuthQueryTypeTOTP,
IsTotpEnrolled: a.IsTotpEnrolled,
})
}
if a.NeedsSecondaryExtJwt() {
provider := rest_model.MfaProvidersURL
authQueries = append(authQueries, &rest_model.AuthQueryDetail{
ClientID: stringz.OrEmpty(a.SecondaryExtJwtSigner.ClientId),
HTTPURL: stringz.OrEmpty(a.SecondaryExtJwtSigner.ExternalAuthUrl),
Scopes: a.SecondaryExtJwtSigner.Scopes,
Provider: &provider,
ID: a.SecondaryExtJwtSigner.Id,
TypeID: rest_model.AuthQueryTypeEXTDashJWT,
})
}
return authQueries
}
// RefreshTokenRequest is a wrapper around RefreshClaims to avoid collisions between go-jwt interface requirements and
// zitadel oidc interface names. Implements zitadel op.RefreshTokenRequest
type RefreshTokenRequest struct {
common.RefreshClaims
}
// GetAMR implements op.RefreshTokenRequest
func (r *RefreshTokenRequest) GetAMR() []string {
return r.AuthenticationMethodsReferences
}
// GetAudience implements op.RefreshTokenRequest
func (r *RefreshTokenRequest) GetAudience() []string {
return r.Audience
}
// GetAuthTime implements op.RefreshTokenRequest
func (r *RefreshTokenRequest) GetAuthTime() time.Time {
return r.AuthTime.AsTime()
}
// GetClientID implements op.RefreshTokenRequest
func (r *RefreshTokenRequest) GetClientID() string {
return r.ClientID
}
// GetScopes implements op.RefreshTokenRequest
func (r *RefreshTokenRequest) GetScopes() []string {
return r.Scopes
}
// GetSubject implements op.RefreshTokenRequest
func (r *RefreshTokenRequest) GetSubject() string {
return r.Subject
}
// SetCurrentScopes implements op.RefreshTokenRequest
func (r *RefreshTokenRequest) SetCurrentScopes(scopes []string) {
r.Scopes = scopes
}
func (r *RefreshTokenRequest) GetCertFingerprints() []string {
return r.CertFingerprints
}