From ef5c4d2d7bf0dad081ff2d57c6d4154cfbed766a Mon Sep 17 00:00:00 2001 From: dovholuknf <46322585+dovholuknf@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:51:20 -0400 Subject: [PATCH 1/2] add support for using ext-jwt-signers with verify traffic --- .../ops/verify/ext-jwt-signer/oidc/oidc.go | 183 +++++++------ ziti/cmd/ops/verify/ops_verify_traffic.go | 247 +++++++++++++++++- 2 files changed, 339 insertions(+), 91 deletions(-) diff --git a/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go b/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go index c2b79f0c0..4caeb4d54 100644 --- a/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go +++ b/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go @@ -298,90 +298,12 @@ func NewOidcVerificationCmd(out io.Writer, errOut io.Writer, initialContext cont } internal.ConfigureLogFormat(logLvl) - config, _, cfgErr := util.LoadRestClientConfig() - if cfgErr == nil { - // any error indicates there are probably no saved credentials. - id := config.GetIdentity() - if defaultId := config.EdgeIdentities[id]; defaultId != nil && !opts.IgnoreConfig { - opts.Token = defaultId.Token - } - } - - if opts.ControllerUrl != "" && !strings.HasPrefix(opts.ControllerUrl, "http") { - opts.ControllerUrl = "https://" + opts.ControllerUrl - } - ctrlUrl, urlParseErr := url.Parse(opts.ControllerUrl) - if urlParseErr != nil { - return fmt.Errorf("invalid controller URL: %w", urlParseErr) - } - - if err := opts.ConfigureCerts(opts.ControllerUrl, ctrlUrl); err != nil { - log.WithError(err).Warn("failed to configure certificates") - return err - } - - m, merr := opts.NewClientApiClient() - if merr != nil { - log.WithError(merr).Fatal("error creating mgmt") - } - - s := client.ExternalJWTSignerFromFilter(m, `name="`+args[0]+`"`) - if s == nil { - return errors.New("no external JWT signer found with name") - } - - if opts.RedirectURL == "" { - opts.RedirectURL = "http://localhost:20314/auth/callback" - log.Infof("using default redirect url: %s", opts.RedirectURL) - } else { - log.Infof("using supplied redirect url: %s", opts.RedirectURL) - } - - if s.Audience != nil { - opts.AdditionalLoginParams = append(opts.AdditionalLoginParams, fmt.Sprintf("audience=%s", *s.Audience)) - } - - if s.Scopes != nil { - opts.additionalScopes = append(opts.additionalScopes, s.Scopes...) - } - - log.Infof("found external JWT signer") - if s.ExternalAuthURL == nil { - return errors.New("external JWT signer has no externalAuthURL configured") - } - if s.ClientID == nil { - return errors.New("external JWT signer has no clientId configured") - } - opts.Issuer = *s.ExternalAuthURL - opts.ClientID = *s.ClientID - log.Infof(" - issuer: %s", safeValue(s.ExternalAuthURL)) - log.Infof(" - clientId: %s", safeValue(s.ClientID)) - ctx, cancel := context.WithTimeout(initialContext, Timeout) defer cancel() - relyingParty, rpErr := opts.NewRelyingParty() - if rpErr != nil { - return fmt.Errorf("error creating relying party %w", rpErr) - } - - if opts.Issuer != "" { - if opts.Issuer == relyingParty.Issuer() { - log.Infof("supplied issuer matches discovered issuer: %s", opts.Issuer) - } else { - log.Infof("discovered issuer [%s] overridden: %s", relyingParty.Issuer(), opts.Issuer) - } - } else { - log.Infof("issuer discovered as: %s", relyingParty.Issuer()) - } - - log.Info("attempting to authenticate to external provider") - log.Debugf("auth url: %s", opts.OIDCConfig.AuthUrl(relyingParty)) - time.Sleep(100 * time.Millisecond) //allow the logger to log before starting the wait spinner - - tokens, oidcErr := GetTokens(ctx, opts.OIDCConfig, relyingParty) - if oidcErr != nil { - return fmt.Errorf("error performing OIDC flow: %w", oidcErr) + tokens, s, err := opts.AuthenticateWithSigner(ctx, args[0]) + if err != nil { + return err } log.Tracef("authentication succeeded") @@ -472,6 +394,105 @@ func NewOidcVerificationCmd(out io.Writer, errOut io.Writer, initialContext cont return cmd } +// AuthenticateWithSigner runs the OIDC auth-code/PKCE flow for the named ext-jwt-signer +// using the configured login options, returning the obtained tokens and the signer +// record. Shared by the `oidc` verify command and `ops verify traffic`'s +// --client-ext-jwt-signer path so both drive the exact same OIDC flow. +func (opts *OidcVerificationConfig) AuthenticateWithSigner(ctx context.Context, signerName string) (*OIDCResponse, *rest_model.ClientExternalJWTSignerDetail, error) { + log := pfxlog.Logger() + + config, _, cfgErr := util.LoadRestClientConfig() + if cfgErr == nil { + // any error indicates there are probably no saved credentials. + id := config.GetIdentity() + if defaultId := config.EdgeIdentities[id]; defaultId != nil && !opts.IgnoreConfig { + opts.Token = defaultId.Token + } + } + + if opts.ControllerUrl != "" && !strings.HasPrefix(opts.ControllerUrl, "http") { + opts.ControllerUrl = "https://" + opts.ControllerUrl + } + ctrlUrl, urlParseErr := url.Parse(opts.ControllerUrl) + if urlParseErr != nil { + return nil, nil, fmt.Errorf("invalid controller URL: %w", urlParseErr) + } + + if err := opts.ConfigureCerts(opts.ControllerUrl, ctrlUrl); err != nil { + return nil, nil, fmt.Errorf("failed to configure certificates: %w", err) + } + + m, merr := opts.NewClientApiClient() + if merr != nil { + return nil, nil, fmt.Errorf("error creating client api: %w", merr) + } + + s := client.ExternalJWTSignerFromFilter(m, `name="`+signerName+`"`) + if s == nil { + return nil, nil, errors.New("no external JWT signer found with name") + } + + if opts.RedirectURL == "" { + opts.RedirectURL = "http://localhost:20314/auth/callback" + log.Infof("using default redirect url: %s", opts.RedirectURL) + } else { + log.Infof("using supplied redirect url: %s", opts.RedirectURL) + } + + if s.Audience != nil { + opts.AdditionalLoginParams = append(opts.AdditionalLoginParams, fmt.Sprintf("audience=%s", *s.Audience)) + } + if s.Scopes != nil { + opts.additionalScopes = append(opts.additionalScopes, s.Scopes...) + } + + log.Infof("found external JWT signer") + if s.ExternalAuthURL == nil { + return nil, nil, errors.New("external JWT signer has no externalAuthURL configured") + } + if s.ClientID == nil { + return nil, nil, errors.New("external JWT signer has no clientId configured") + } + opts.Issuer = *s.ExternalAuthURL + opts.ClientID = *s.ClientID + log.Infof(" - issuer: %s", safeValue(s.ExternalAuthURL)) + log.Infof(" - clientId: %s", safeValue(s.ClientID)) + + relyingParty, rpErr := opts.NewRelyingParty() + if rpErr != nil { + return nil, nil, fmt.Errorf("error creating relying party %w", rpErr) + } + + if opts.Issuer != "" { + if opts.Issuer == relyingParty.Issuer() { + log.Infof("supplied issuer matches discovered issuer: %s", opts.Issuer) + } else { + log.Infof("discovered issuer [%s] overridden: %s", relyingParty.Issuer(), opts.Issuer) + } + } else { + log.Infof("issuer discovered as: %s", relyingParty.Issuer()) + } + + log.Info("attempting to authenticate to external provider") + log.Debugf("auth url: %s", opts.OIDCConfig.AuthUrl(relyingParty)) + time.Sleep(100 * time.Millisecond) //allow the logger to log before starting the wait spinner + + tokens, oidcErr := GetTokens(ctx, opts.OIDCConfig, relyingParty) + if oidcErr != nil { + return nil, nil, fmt.Errorf("error performing OIDC flow: %w", oidcErr) + } + return tokens, s, nil +} + +// TokenForSigner returns the token the controller expects for the signer's configured +// targetToken (access token by default, id token when targetToken is ID). +func TokenForSigner(tokens *OIDCResponse, s *rest_model.ClientExternalJWTSignerDetail) string { + if s.TargetToken == nil || *s.TargetToken == rest_model.TargetTokenACCESS { + return tokens.AccessToken + } + return tokens.IDToken +} + /* jwtPayload extracts the payload from a jwt so the contents can be logged and shown to the user */ func jwtPayload(token string) (string, error) { diff --git a/ziti/cmd/ops/verify/ops_verify_traffic.go b/ziti/cmd/ops/verify/ops_verify_traffic.go index 5d8f9e5a6..8fcf4dc99 100644 --- a/ziti/cmd/ops/verify/ops_verify_traffic.go +++ b/ziti/cmd/ops/verify/ops_verify_traffic.go @@ -19,6 +19,9 @@ package verify import ( "bufio" "context" + "encoding/base64" + "encoding/json" + "errors" "fmt" "io" "net" @@ -36,11 +39,13 @@ import ( "github.com/openziti/edge-api/rest_management_api_client/service_policy" "github.com/openziti/edge-api/rest_management_api_client/terminator" "github.com/openziti/edge-api/rest_model" + edge_apis "github.com/openziti/sdk-golang/v2/edge-apis" "github.com/openziti/sdk-golang/v2/ziti" "github.com/openziti/sdk-golang/v2/ziti/enroll" "github.com/openziti/ziti/v2/internal" "github.com/openziti/ziti/v2/internal/rest/mgmt" "github.com/openziti/ziti/v2/ziti/cmd/edge" + "github.com/openziti/ziti/v2/ziti/cmd/ops/verify/ext-jwt-signer/oidc" ) type traffic struct { @@ -50,6 +55,8 @@ type traffic struct { cleanup bool verbose bool allowMultipleServers bool + extJwtSigner string + redirectURL string client *rest_management_api_client.ZitiEdgeManagement svcName string @@ -85,6 +92,10 @@ func NewVerifyTraffic(out io.Writer, errOut io.Writer) *cobra.Command { t.mode = "both" } + if t.extJwtSigner != "" && t.loginOpts.ControllerUrl == "" { + return errors.New("--controller-url is required when using --ext-jwt-signer") + } + t.svcName = t.prefix + ".traffic" t.serverIdName = t.prefix + ".server" @@ -130,6 +141,8 @@ func NewVerifyTraffic(out io.Writer, errOut io.Writer) *cobra.Command { cmd.Flags().BoolVar(&t.cleanup, "cleanup", false, "Whether to perform cleanup.") cmd.Flags().BoolVar(&t.allowMultipleServers, "allow-multiple-servers", false, "Whether to allows the same server multiple times.") cmd.Flags().StringVar(&t.loginOpts.ControllerUrl, "controller-url", "", "The url of the controller") + cmd.Flags().StringVar(&t.extJwtSigner, "ext-jwt-signer", "", "[optional] Authenticate via this ext-jwt-signer (OIDC) instead of a certificate, exercising the certless data-plane path. With --mode both (the default) it is used for BOTH the server (bind) and client (dial). Requires --controller-url.") + cmd.Flags().StringVar(&t.redirectURL, "ext-jwt-redirect-url", "", "[optional] OIDC redirect URL for --ext-jwt-signer (default http://localhost:20314/auth/callback)") edge.AddLoginFlags(cmd, &t.loginOpts) t.loginOpts.Out = out @@ -443,18 +456,26 @@ func (t *traffic) configureService() { _ = createService(t.client, t.svcName, nil, []string{t.svcAttr()}) } - bind := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(t.bindSPName)) - if bind != nil && t.allowMultipleServers { - log.Debugf("service policy already exists. not creating: %s", t.bindSPName) - } else { - _ = createServicePolicy(t.client, t.bindSPName, rest_model.DialBindBind, rest_model.Roles{"#" + t.bindAttr()}, rest_model.Roles{"#" + t.svcAttr()}) + // As with the dialer, a cert-based binder matches the bind policy by attribute; an + // ext-jwt binder is a pre-existing OIDC identity granted bind later against its id. + if t.extJwtSigner == "" { + bind := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(t.bindSPName)) + if bind != nil && t.allowMultipleServers { + log.Debugf("service policy already exists. not creating: %s", t.bindSPName) + } else { + _ = createServicePolicy(t.client, t.bindSPName, rest_model.DialBindBind, rest_model.Roles{"#" + t.bindAttr()}, rest_model.Roles{"#" + t.svcAttr()}) + } } - dial := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(t.dialSPName)) - if dial != nil && t.allowMultipleServers { - log.Debugf("service policy already exists. not creating: %s", t.dialSPName) - } else { - _ = createServicePolicy(t.client, t.dialSPName, rest_model.DialBindDial, rest_model.Roles{"#" + t.dialAttr()}, rest_model.Roles{"#" + t.svcAttr()}) + // The cert-based dialer matches the dial policy by attribute. When authenticating via + // ext-jwt instead, the dialer is a pre-existing OIDC identity granted dial against its id. + if t.extJwtSigner == "" { + dial := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(t.dialSPName)) + if dial != nil && t.allowMultipleServers { + log.Debugf("service policy already exists. not creating: %s", t.dialSPName) + } else { + _ = createServicePolicy(t.client, t.dialSPName, rest_model.DialBindDial, rest_model.Roles{"#" + t.dialAttr()}, rest_model.Roles{"#" + t.svcAttr()}) + } } } @@ -492,6 +513,10 @@ func (t *traffic) cleanupClient() { } func (t *traffic) doBoth() { + if t.extJwtSigner != "" { + t.doBothExtJwt() + return + } t.configureService() wg := &sync.WaitGroup{} wg.Add(2) @@ -508,6 +533,11 @@ func (t *traffic) doBoth() { } func (t *traffic) doServer(ctx context.Context, configureServices bool) { + if t.extJwtSigner != "" { + t.doServerExtJwt(ctx, configureServices) + return + } + if configureServices { t.configureService() } @@ -519,6 +549,11 @@ func (t *traffic) doServer(ctx context.Context, configureServices bool) { } func (t *traffic) doClient(cancel context.CancelFunc) { + if t.extJwtSigner != "" { + t.doClientExtJwt(cancel) + return + } + clientCfg := t.configureClient() defer t.cleanupClient() if err := t.startClient(t.client, t.svcName, clientCfg); err != nil { @@ -530,3 +565,195 @@ func (t *traffic) doClient(cancel context.CancelFunc) { time.Sleep(1 * time.Second) log.Info("client complete") } + +// doBothExtJwt runs the server (bind) and client (dial) in one process as the SAME certless +// ext-jwt identity. It performs a single OIDC login and reuses the token for both sides, +// which avoids a second browser flow colliding on the OIDC redirect port and the +// bind-completes-after-the-client-already-gave-up-waiting race. +func (t *traffic) doBothExtJwt() { + log.Infof("--ext-jwt-signer %q will be used for BOTH the server (bind) and the client (dial)", t.extJwtSigner) + t.configureService() + defer t.cleanupServer() + + token, identityID := t.extJwtSession(t.extJwtSigner) + + bindPolicyName := t.bindSPName + ".extjwt" + dialPolicyName := t.dialSPName + ".extjwt" + _ = createServicePolicy(t.client, bindPolicyName, rest_model.DialBindBind, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) + _ = createServicePolicy(t.client, dialPolicyName, rest_model.DialBindDial, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) + defer func() { + deleteServicePolicy(t.client, mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(bindPolicyName))) + deleteServicePolicy(t.client, mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(dialPolicyName))) + }() + + wg := &sync.WaitGroup{} + wg.Add(2) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + defer wg.Done() + log.Infof("binding %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) + if err := t.startServer(ctx, t.svcName, t.extJwtConfig(token)); err != nil { + log.Errorf("ext-jwt server error: %v", err) + } + }() + go func() { + defer wg.Done() + log.Infof("dialing %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) + if err := t.startClient(t.client, t.svcName, t.extJwtConfig(token)); err != nil { + log.Errorf("ext-jwt client error: %v", err) + } + cancel() // end the server + time.Sleep(1 * time.Second) + log.Info("client complete") + }() + wg.Wait() +} + +// doClientExtJwt runs the dialing client as a certless ext-jwt (OIDC) identity instead of +// a cert-enrolled one: it authenticates via the signer, grants the matched identity dial +// access to the test service, then dials. This exercises the certless dial path. +func (t *traffic) doClientExtJwt(cancel context.CancelFunc) { + defer func() { + cancel() // end the server + time.Sleep(1 * time.Second) + log.Info("client complete") + }() + + token, identityID := t.extJwtSession(t.extJwtSigner) + + dialPolicyName := t.dialSPName + ".extjwt" + _ = createServicePolicy(t.client, dialPolicyName, rest_model.DialBindDial, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) + defer func() { + sp := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(dialPolicyName)) + deleteServicePolicy(t.client, sp) + }() + + log.Infof("dialing %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) + if err := t.startClient(t.client, t.svcName, t.extJwtConfig(token)); err != nil { + log.Fatalf("ext-jwt client failed: %v", err) + } + log.Debug("client received expected response. stopping server if it's running") +} + +// doServerExtJwt runs the hosting server as a certless ext-jwt (OIDC) identity: it +// authenticates via the signer, grants the matched identity bind access to the test +// service, then binds and serves. This exercises the certless bind path on the router. +func (t *traffic) doServerExtJwt(ctx context.Context, configureServices bool) { + if configureServices { + t.configureService() + } + defer t.cleanupServer() + + token, identityID := t.extJwtSession(t.extJwtSigner) + + bindPolicyName := t.bindSPName + ".extjwt" + _ = createServicePolicy(t.client, bindPolicyName, rest_model.DialBindBind, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) + defer func() { + sp := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(bindPolicyName)) + deleteServicePolicy(t.client, sp) + }() + + log.Infof("binding %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) + if err := t.startServer(ctx, t.svcName, t.extJwtConfig(token)); err != nil { + log.Fatalf("ext-jwt server failed: %v", err) + } +} + +// extJwtSession performs the OIDC flow for the named signer and returns the bearer token +// plus the id of the existing identity the controller will match it to. +func (t *traffic) extJwtSession(signerName string) (string, string) { + oidcOpts := &oidc.OidcVerificationConfig{} + oidcOpts.LoginOptions = t.loginOpts + oidcOpts.RedirectURL = t.redirectURL + + octx, ocancel := context.WithTimeout(context.Background(), oidc.Timeout) + defer ocancel() + tokens, signer, err := oidcOpts.AuthenticateWithSigner(octx, signerName) + if err != nil { + log.Fatalf("OIDC authentication with signer %q failed: %v", signerName, err) + } + token := oidc.TokenForSigner(tokens, signer) + if token == "" { + log.Fatal("IdP returned no usable token for the signer's target token type") + } + return token, t.findExtJwtIdentity(signerName, token) +} + +// extJwtConfig builds a certless SDK config that authenticates with the given bearer token. +func (t *traffic) extJwtConfig(token string) *ziti.Config { + ctrlUrl := t.loginOpts.ControllerUrl + if !strings.HasPrefix(ctrlUrl, "http") { + ctrlUrl = "https://" + ctrlUrl + } + ctrlUrl = strings.TrimRight(ctrlUrl, "/") + + caPool, caErr := ziti.GetControllerWellKnownCaPool(ctrlUrl) + if caErr != nil { + log.Fatalf("failed to fetch controller CA pool: %v", caErr) + } + creds := edge_apis.NewJwtCredentials(token) + creds.CaPool = caPool + return &ziti.Config{ZtAPI: ctrlUrl + "/edge/client/v1", Credentials: creds} +} + +// findExtJwtIdentity decodes the token and matches it to an existing identity the same way +// the controller will (the signer's claimsProperty + useExternalId), returning its id. +// ext-jwt auth does not auto-provision on a plain authenticate, so the identity must exist. +func (t *traffic) findExtJwtIdentity(signerName, token string) string { + signer := mgmt.ExternalJWTSignerFromFilter(t.client, mgmt.NameFilter(signerName)) + if signer == nil { + log.Fatalf("ext-jwt-signer %q not found via management api", signerName) + } + + claimName := "sub" + if signer.ClaimsProperty != nil && *signer.ClaimsProperty != "" { + claimName = *signer.ClaimsProperty + } + claims := decodeJwtClaims(token) + claimVal, _ := claims[claimName].(string) + if claimVal == "" { + log.Fatalf("token has no %q claim to match an identity", claimName) + } + + useExternal := signer.UseExternalID == nil || *signer.UseExternalID + var id *rest_model.IdentityDetail + if useExternal { + id = mgmt.IdentityFromFilter(t.client, fmt.Sprintf("externalId=\"%s\"", claimVal)) + } else { + id = mgmt.IdentityFromFilter(t.client, fmt.Sprintf("id=\"%s\"", claimVal)) + } + if id == nil { + log.Fatalf("no identity matches claim %s=%q; the ext-jwt identity must already exist", claimName, claimVal) + } + log.Infof("matched identity %s (%s) for service %s", *id.Name, *id.ID, t.svcName) + return *id.ID +} + +func decodeJwtClaims(token string) map[string]interface{} { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + if raw, err = base64.StdEncoding.DecodeString(addJwtPadding(parts[1])); err != nil { + return nil + } + } + var claims map[string]interface{} + if err := json.Unmarshal(raw, &claims); err != nil { + return nil + } + return claims +} + +func addJwtPadding(input string) string { + if m := len(input) % 4; m != 0 { + input += strings.Repeat("=", 4-m) + } + return input +} From 2923d6345f402104333cd5ab1c739f816f391046 Mon Sep 17 00:00:00 2001 From: dovholuknf <46322585+dovholuknf@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:05:14 -0400 Subject: [PATCH 2/2] remove fatal, prefer returning an error. cleanup jwt parsing --- .../ops/verify/ext-jwt-signer/oidc/oidc.go | 2 +- ziti/cmd/ops/verify/ops_verify_traffic.go | 334 ++++++++++-------- 2 files changed, 195 insertions(+), 141 deletions(-) diff --git a/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go b/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go index 4caeb4d54..4bf7d913f 100644 --- a/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go +++ b/ziti/cmd/ops/verify/ext-jwt-signer/oidc/oidc.go @@ -397,7 +397,7 @@ func NewOidcVerificationCmd(out io.Writer, errOut io.Writer, initialContext cont // AuthenticateWithSigner runs the OIDC auth-code/PKCE flow for the named ext-jwt-signer // using the configured login options, returning the obtained tokens and the signer // record. Shared by the `oidc` verify command and `ops verify traffic`'s -// --client-ext-jwt-signer path so both drive the exact same OIDC flow. +// --ext-jwt-signer path so both drive the exact same OIDC flow. func (opts *OidcVerificationConfig) AuthenticateWithSigner(ctx context.Context, signerName string) (*OIDCResponse, *rest_model.ClientExternalJWTSignerDetail, error) { log := pfxlog.Logger() diff --git a/ziti/cmd/ops/verify/ops_verify_traffic.go b/ziti/cmd/ops/verify/ops_verify_traffic.go index 8fcf4dc99..3594e5f74 100644 --- a/ziti/cmd/ops/verify/ops_verify_traffic.go +++ b/ziti/cmd/ops/verify/ops_verify_traffic.go @@ -19,8 +19,6 @@ package verify import ( "bufio" "context" - "encoding/base64" - "encoding/json" "errors" "fmt" "io" @@ -29,6 +27,7 @@ import ( "sync" "time" + "github.com/golang-jwt/jwt/v5" "github.com/michaelquigley/pfxlog" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -122,14 +121,14 @@ func NewVerifyTraffic(out io.Writer, errOut io.Writer) *cobra.Command { } if t.mode == "both" { - t.doBoth() + return t.doBoth() } else if t.mode == "server" { - t.doServer(context.Background(), true) + return t.doServer(context.Background(), true) } else if t.mode == "client" { _, c := context.WithCancel(context.Background()) - t.doClient(c) + return t.doClient(c) } else { - log.Fatal("no role supplied? should have defaulted to 'both'") + return fmt.Errorf("unknown mode: %s", t.mode) } return nil @@ -154,12 +153,12 @@ func NewVerifyTraffic(out io.Writer, errOut io.Writer) *cobra.Command { func (t *traffic) startServer(ctx context.Context, serviceName string, zitiCfg *ziti.Config) error { c, err := ziti.NewContext(zitiCfg) if err != nil { - log.Fatal(err) + return err } listener, err := c.Listen(serviceName) if err != nil { - log.Fatal(err) + return err } log.Infof("successfully bound service: %s.", serviceName) @@ -201,7 +200,8 @@ func handleConnection(conn net.Conn) { line, err := rw.ReadString('\n') if err != nil { - log.Fatal(err) + log.Errorf("error reading from connection: %v", err) + return } if strings.Contains(line, "traffic test") { log.Info("traffic test successfully detected") @@ -214,21 +214,23 @@ func handleConnection(conn net.Conn) { } func (t *traffic) startClient(client *rest_management_api_client.ZitiEdgeManagement, serviceName string, zitiCfg *ziti.Config) error { - waitForTerminator(client, serviceName, 10*time.Second) + if err := waitForTerminator(client, serviceName, 10*time.Second); err != nil { + return err + } c, err := ziti.NewContext(zitiCfg) if err != nil { - log.Fatal(err) + return err } foundSvc, ok := c.GetService(serviceName) if !ok { - log.Fatal("error when retrieving all the services for the provided config") + return errors.New("error when retrieving all the services for the provided config") } log.Infof("found service named: %s", *foundSvc.Name) svc, err := c.Dial(serviceName) //dial the service using the given name if err != nil { - log.Fatalf("error when dialing service name %s. %v", serviceName, err) + return fmt.Errorf("error when dialing service name %s. %v", serviceName, err) } log.Infof("successfully dialed service: %s.", serviceName) @@ -239,7 +241,7 @@ func (t *traffic) startClient(client *rest_management_api_client.ZitiEdgeManagem bytesRead, err := zitiWriter.WriteString(text) _ = zitiWriter.Flush() if err != nil { - log.Fatal(err) + return err } else { log.Debugf("wrote %d bytes", bytesRead) } @@ -253,7 +255,7 @@ func (t *traffic) startClient(client *rest_management_api_client.ZitiEdgeManagem return nil } -func terminatorExists(client *rest_management_api_client.ZitiEdgeManagement, serviceName string) bool { +func terminatorExists(client *rest_management_api_client.ZitiEdgeManagement, serviceName string) (bool, error) { filter := "service.name=\"" + serviceName + "\"" params := &terminator.ListTerminatorsParams{ Filter: &filter, @@ -262,30 +264,33 @@ func terminatorExists(client *rest_management_api_client.ZitiEdgeManagement, ser resp, err := client.Terminator.ListTerminators(params, nil) if err != nil { - log.Fatal(err) + return false, err } - return len(resp.Payload.Data) > 0 + return len(resp.Payload.Data) > 0, nil } -func waitForTerminator(client *rest_management_api_client.ZitiEdgeManagement, serviceName string, timeout time.Duration) bool { +func waitForTerminator(client *rest_management_api_client.ZitiEdgeManagement, serviceName string, timeout time.Duration) error { log.Infof("waiting %s for terminator for service: %s", timeout, serviceName) startTime := time.Now() for { - if terminatorExists(client, serviceName) { + exists, err := terminatorExists(client, serviceName) + if err != nil { + return err + } + if exists { log.Infof("found terminator for service: %s", serviceName) - return true + return nil } if time.Since(startTime) >= timeout { break } time.Sleep(100 * time.Millisecond) } - log.Fatalf("terminator not found for service: %s", serviceName) - return false + return fmt.Errorf("terminator not found for service: %s", serviceName) } -func createIdentity(client *rest_management_api_client.ZitiEdgeManagement, name string, roleAttributes rest_model.Attributes) *identity.CreateIdentityCreated { +func createIdentity(client *rest_management_api_client.ZitiEdgeManagement, name string, roleAttributes rest_model.Attributes) (*identity.CreateIdentityCreated, error) { falseVar := false usrType := rest_model.IdentityTypeUser i := &rest_model.IdentityCreate{ @@ -305,15 +310,15 @@ func createIdentity(client *rest_management_api_client.ZitiEdgeManagement, name if err != nil { id := mgmt.IdentityFromFilter(client, mgmt.NameFilter(name)) if id != nil { - log.Fatalf("Identity named %s exists. Remove the identity before trying again or use --cleanup.", name) + return nil, fmt.Errorf("Identity named %s exists. Remove the identity before trying again or use --cleanup.", name) } else { - log.Fatalf("Failed to create the identity: %v", err) + return nil, fmt.Errorf("Failed to create the identity: %v", err) } } - return ident + return ident, nil } -func createServicePolicy(client *rest_management_api_client.ZitiEdgeManagement, name string, servType rest_model.DialBind, identityRoles rest_model.Roles, serviceRoles rest_model.Roles) *rest_model.CreateLocation { +func createServicePolicy(client *rest_management_api_client.ZitiEdgeManagement, name string, servType rest_model.DialBind, identityRoles rest_model.Roles, serviceRoles rest_model.Roles) (*rest_model.CreateLocation, error) { defaultSemantic := rest_model.SemanticAllOf servicePolicy := &rest_model.ServicePolicyCreate{ IdentityRoles: identityRoles, @@ -329,13 +334,12 @@ func createServicePolicy(client *rest_management_api_client.ZitiEdgeManagement, params.SetTimeout(5 * time.Second) resp, err := client.ServicePolicy.CreateServicePolicy(params, nil) if resp == nil || err != nil { - log.Fatalf("Failed to create service policy: %s", name) - return nil + return nil, fmt.Errorf("Failed to create service policy: %s", name) } - return resp.Payload.Data + return resp.Payload.Data, nil } -func createService(client *rest_management_api_client.ZitiEdgeManagement, name string, serviceConfigs []string, roles rest_model.Attributes) *rest_model.CreateLocation { +func createService(client *rest_management_api_client.ZitiEdgeManagement, name string, serviceConfigs []string, roles rest_model.Attributes) (*rest_model.CreateLocation, error) { encryptOn := true serviceCreate := &rest_model.ServiceCreate{ Configs: serviceConfigs, @@ -353,10 +357,9 @@ func createService(client *rest_management_api_client.ZitiEdgeManagement, name s serviceParams.SetTimeout(5 * time.Second) resp, err := client.Service.CreateService(serviceParams, nil) if resp == nil || err != nil { - log.Fatalf("Failed to create service: %s. %v", name, err) - return nil + return nil, fmt.Errorf("Failed to create service: %s. %v", name, err) } - return resp.Payload.Data + return resp.Payload.Data, nil } func deleteIdentity(client *rest_management_api_client.ZitiEdgeManagement, toDelete *rest_model.IdentityDetail) { @@ -404,7 +407,7 @@ func deleteServicePolicy(client *rest_management_api_client.ZitiEdgeManagement, } } -func enrollIdentity(client *rest_management_api_client.ZitiEdgeManagement, id string) *ziti.Config { +func enrollIdentity(client *rest_management_api_client.ZitiEdgeManagement, id string) (*ziti.Config, error) { // Get the identity object params := &identity.DetailIdentityParams{ Context: context.Background(), @@ -414,13 +417,13 @@ func enrollIdentity(client *rest_management_api_client.ZitiEdgeManagement, id st resp, err := client.Identity.DetailIdentity(params, nil) if err != nil { - log.Fatal(err) + return nil, err } // Enroll the identity tkn, _, err := enroll.ParseToken(resp.Payload.Data.Enrollment.Ott.JWT) if err != nil { - log.Fatal(err) + return nil, err } flags := enroll.EnrollmentFlags{ @@ -430,10 +433,10 @@ func enrollIdentity(client *rest_management_api_client.ZitiEdgeManagement, id st conf, err := enroll.Enroll(flags) if err != nil { - log.Fatal(err) + return nil, err } - return conf + return conf, nil } func (t *traffic) bindAttr() string { @@ -448,12 +451,12 @@ func (t *traffic) svcAttr() string { return t.svcName } -func (t *traffic) configureService() { +func (t *traffic) configureService() error { svc := mgmt.ServiceFromFilter(t.client, mgmt.NameFilter(t.svcName)) if svc != nil && t.allowMultipleServers { log.Debugf("service already exists. not creating: %s", t.svcName) - } else { - _ = createService(t.client, t.svcName, nil, []string{t.svcAttr()}) + } else if _, err := createService(t.client, t.svcName, nil, []string{t.svcAttr()}); err != nil { + return err } // As with the dialer, a cert-based binder matches the bind policy by attribute; an @@ -462,8 +465,8 @@ func (t *traffic) configureService() { bind := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(t.bindSPName)) if bind != nil && t.allowMultipleServers { log.Debugf("service policy already exists. not creating: %s", t.bindSPName) - } else { - _ = createServicePolicy(t.client, t.bindSPName, rest_model.DialBindBind, rest_model.Roles{"#" + t.bindAttr()}, rest_model.Roles{"#" + t.svcAttr()}) + } else if _, err := createServicePolicy(t.client, t.bindSPName, rest_model.DialBindBind, rest_model.Roles{"#" + t.bindAttr()}, rest_model.Roles{"#" + t.svcAttr()}); err != nil { + return err } } @@ -473,25 +476,32 @@ func (t *traffic) configureService() { dial := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(t.dialSPName)) if dial != nil && t.allowMultipleServers { log.Debugf("service policy already exists. not creating: %s", t.dialSPName) - } else { - _ = createServicePolicy(t.client, t.dialSPName, rest_model.DialBindDial, rest_model.Roles{"#" + t.dialAttr()}, rest_model.Roles{"#" + t.svcAttr()}) + } else if _, err := createServicePolicy(t.client, t.dialSPName, rest_model.DialBindDial, rest_model.Roles{"#" + t.dialAttr()}, rest_model.Roles{"#" + t.svcAttr()}); err != nil { + return err } } + return nil } -func (t *traffic) configureServer() *ziti.Config { - serverIdent := createIdentity(t.client, t.serverIdName, []string{t.bindAttr()}) +func (t *traffic) configureServer() (*ziti.Config, error) { + serverIdent, err := createIdentity(t.client, t.serverIdName, []string{t.bindAttr()}) + if err != nil { + return nil, err + } return enrollIdentity(t.client, serverIdent.Payload.Data.ID) } -func (t *traffic) configureClient() *ziti.Config { - clientIdent := createIdentity(t.client, t.clientIdName, []string{t.dialAttr()}) +func (t *traffic) configureClient() (*ziti.Config, error) { + clientIdent, err := createIdentity(t.client, t.clientIdName, []string{t.dialAttr()}) + if err != nil { + return nil, err + } return enrollIdentity(t.client, clientIdent.Payload.Data.ID) } func (t *traffic) cleanupServer() { if t.allowMultipleServers { - if terminatorExists(t.client, t.svcName) { + if exists, _ := terminatorExists(t.client, t.svcName); exists { log.Debugf("found terminator for service: %s. cleanup will be skipped.", t.svcName) return } @@ -512,81 +522,108 @@ func (t *traffic) cleanupClient() { deleteIdentity(t.client, id) } -func (t *traffic) doBoth() { +func (t *traffic) doBoth() error { if t.extJwtSigner != "" { - t.doBothExtJwt() - return + return t.doBothExtJwt() + } + if err := t.configureService(); err != nil { + return err } - t.configureService() wg := &sync.WaitGroup{} wg.Add(2) ctx, cancel := context.WithCancel(context.Background()) go func() { defer wg.Done() - t.doServer(ctx, false) + if err := t.doServer(ctx, false); err != nil { + log.Error(err) + } }() go func() { defer wg.Done() - t.doClient(cancel) + if err := t.doClient(cancel); err != nil { + log.Error(err) + } }() wg.Wait() + return nil } -func (t *traffic) doServer(ctx context.Context, configureServices bool) { +func (t *traffic) doServer(ctx context.Context, configureServices bool) error { if t.extJwtSigner != "" { - t.doServerExtJwt(ctx, configureServices) - return + return t.doServerExtJwt(ctx, configureServices) } if configureServices { - t.configureService() + if err := t.configureService(); err != nil { + return err + } + } + serverCfg, err := t.configureServer() + if err != nil { + return err } - serverCfg := t.configureServer() defer t.cleanupServer() if err := t.startServer(ctx, t.svcName, serverCfg); err != nil { - log.Fatalf("unexpected error: %v", err) + return fmt.Errorf("unexpected error: %v", err) } + return nil } -func (t *traffic) doClient(cancel context.CancelFunc) { +func (t *traffic) doClient(cancel context.CancelFunc) error { if t.extJwtSigner != "" { - t.doClientExtJwt(cancel) - return + return t.doClientExtJwt(cancel) } - clientCfg := t.configureClient() + clientCfg, err := t.configureClient() + if err != nil { + return err + } defer t.cleanupClient() if err := t.startClient(t.client, t.svcName, clientCfg); err != nil { - log.Fatal(err) + return err } log.Debug("client received expected response. stopping server if it's running") cancel() //end the server time.Sleep(1 * time.Second) log.Info("client complete") + return nil } // doBothExtJwt runs the server (bind) and client (dial) in one process as the SAME certless // ext-jwt identity. It performs a single OIDC login and reuses the token for both sides, // which avoids a second browser flow colliding on the OIDC redirect port and the -// bind-completes-after-the-client-already-gave-up-waiting race. -func (t *traffic) doBothExtJwt() { +// bind-completes-after-the-client-already-gave-up-waiting race. The bind/dial grants use the +// base policy names so cleanupServer (and a later --cleanup) reclaim them. +func (t *traffic) doBothExtJwt() error { log.Infof("--ext-jwt-signer %q will be used for BOTH the server (bind) and the client (dial)", t.extJwtSigner) - t.configureService() + if err := t.configureService(); err != nil { + return err + } defer t.cleanupServer() - token, identityID := t.extJwtSession(t.extJwtSigner) + token, identityID, err := t.extJwtSession(t.extJwtSigner) + if err != nil { + return err + } - bindPolicyName := t.bindSPName + ".extjwt" - dialPolicyName := t.dialSPName + ".extjwt" - _ = createServicePolicy(t.client, bindPolicyName, rest_model.DialBindBind, - rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) - _ = createServicePolicy(t.client, dialPolicyName, rest_model.DialBindDial, - rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) - defer func() { - deleteServicePolicy(t.client, mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(bindPolicyName))) - deleteServicePolicy(t.client, mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(dialPolicyName))) - }() + if _, err := createServicePolicy(t.client, t.bindSPName, rest_model.DialBindBind, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}); err != nil { + return err + } + if _, err := createServicePolicy(t.client, t.dialSPName, rest_model.DialBindDial, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}); err != nil { + return err + } + + serverCfg, err := t.extJwtConfig(token) + if err != nil { + return err + } + clientCfg, err := t.extJwtConfig(token) + if err != nil { + return err + } wg := &sync.WaitGroup{} wg.Add(2) @@ -594,14 +631,14 @@ func (t *traffic) doBothExtJwt() { go func() { defer wg.Done() log.Infof("binding %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) - if err := t.startServer(ctx, t.svcName, t.extJwtConfig(token)); err != nil { + if err := t.startServer(ctx, t.svcName, serverCfg); err != nil { log.Errorf("ext-jwt server error: %v", err) } }() go func() { defer wg.Done() log.Infof("dialing %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) - if err := t.startClient(t.client, t.svcName, t.extJwtConfig(token)); err != nil { + if err := t.startClient(t.client, t.svcName, clientCfg); err != nil { log.Errorf("ext-jwt client error: %v", err) } cancel() // end the server @@ -609,63 +646,81 @@ func (t *traffic) doBothExtJwt() { log.Info("client complete") }() wg.Wait() + return nil } // doClientExtJwt runs the dialing client as a certless ext-jwt (OIDC) identity instead of // a cert-enrolled one: it authenticates via the signer, grants the matched identity dial // access to the test service, then dials. This exercises the certless dial path. -func (t *traffic) doClientExtJwt(cancel context.CancelFunc) { +func (t *traffic) doClientExtJwt(cancel context.CancelFunc) error { defer func() { cancel() // end the server time.Sleep(1 * time.Second) log.Info("client complete") }() - token, identityID := t.extJwtSession(t.extJwtSigner) + token, identityID, err := t.extJwtSession(t.extJwtSigner) + if err != nil { + return err + } - dialPolicyName := t.dialSPName + ".extjwt" - _ = createServicePolicy(t.client, dialPolicyName, rest_model.DialBindDial, - rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) + if _, err := createServicePolicy(t.client, t.dialSPName, rest_model.DialBindDial, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}); err != nil { + return err + } defer func() { - sp := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(dialPolicyName)) - deleteServicePolicy(t.client, sp) + deleteServicePolicy(t.client, mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(t.dialSPName))) }() + cfg, err := t.extJwtConfig(token) + if err != nil { + return err + } + log.Infof("dialing %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) - if err := t.startClient(t.client, t.svcName, t.extJwtConfig(token)); err != nil { - log.Fatalf("ext-jwt client failed: %v", err) + if err := t.startClient(t.client, t.svcName, cfg); err != nil { + return err } log.Debug("client received expected response. stopping server if it's running") + return nil } // doServerExtJwt runs the hosting server as a certless ext-jwt (OIDC) identity: it // authenticates via the signer, grants the matched identity bind access to the test // service, then binds and serves. This exercises the certless bind path on the router. -func (t *traffic) doServerExtJwt(ctx context.Context, configureServices bool) { +func (t *traffic) doServerExtJwt(ctx context.Context, configureServices bool) error { if configureServices { - t.configureService() + if err := t.configureService(); err != nil { + return err + } } defer t.cleanupServer() - token, identityID := t.extJwtSession(t.extJwtSigner) + token, identityID, err := t.extJwtSession(t.extJwtSigner) + if err != nil { + return err + } - bindPolicyName := t.bindSPName + ".extjwt" - _ = createServicePolicy(t.client, bindPolicyName, rest_model.DialBindBind, - rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}) - defer func() { - sp := mgmt.ServicePolicyFromFilter(t.client, mgmt.NameFilter(bindPolicyName)) - deleteServicePolicy(t.client, sp) - }() + if _, err := createServicePolicy(t.client, t.bindSPName, rest_model.DialBindBind, + rest_model.Roles{"@" + identityID}, rest_model.Roles{"#" + t.svcAttr()}); err != nil { + return err + } + + cfg, err := t.extJwtConfig(token) + if err != nil { + return err + } log.Infof("binding %s as certless ext-jwt identity (signer %q)", t.svcName, t.extJwtSigner) - if err := t.startServer(ctx, t.svcName, t.extJwtConfig(token)); err != nil { - log.Fatalf("ext-jwt server failed: %v", err) + if err := t.startServer(ctx, t.svcName, cfg); err != nil { + return fmt.Errorf("ext-jwt server failed: %v", err) } + return nil } // extJwtSession performs the OIDC flow for the named signer and returns the bearer token // plus the id of the existing identity the controller will match it to. -func (t *traffic) extJwtSession(signerName string) (string, string) { +func (t *traffic) extJwtSession(signerName string) (string, string, error) { oidcOpts := &oidc.OidcVerificationConfig{} oidcOpts.LoginOptions = t.loginOpts oidcOpts.RedirectURL = t.redirectURL @@ -674,39 +729,45 @@ func (t *traffic) extJwtSession(signerName string) (string, string) { defer ocancel() tokens, signer, err := oidcOpts.AuthenticateWithSigner(octx, signerName) if err != nil { - log.Fatalf("OIDC authentication with signer %q failed: %v", signerName, err) + return "", "", fmt.Errorf("OIDC authentication with signer %q failed: %v", signerName, err) } token := oidc.TokenForSigner(tokens, signer) if token == "" { - log.Fatal("IdP returned no usable token for the signer's target token type") + return "", "", errors.New("IdP returned no usable token for the signer's target token type") } - return token, t.findExtJwtIdentity(signerName, token) + identityID, err := t.findExtJwtIdentity(signerName, token) + if err != nil { + return "", "", err + } + return token, identityID, nil } // extJwtConfig builds a certless SDK config that authenticates with the given bearer token. -func (t *traffic) extJwtConfig(token string) *ziti.Config { +func (t *traffic) extJwtConfig(token string) (*ziti.Config, error) { ctrlUrl := t.loginOpts.ControllerUrl if !strings.HasPrefix(ctrlUrl, "http") { ctrlUrl = "https://" + ctrlUrl } ctrlUrl = strings.TrimRight(ctrlUrl, "/") - caPool, caErr := ziti.GetControllerWellKnownCaPool(ctrlUrl) - if caErr != nil { - log.Fatalf("failed to fetch controller CA pool: %v", caErr) + caPool, err := ziti.GetControllerWellKnownCaPool(ctrlUrl) + if err != nil { + return nil, fmt.Errorf("failed to fetch controller CA pool: %v", err) } creds := edge_apis.NewJwtCredentials(token) creds.CaPool = caPool - return &ziti.Config{ZtAPI: ctrlUrl + "/edge/client/v1", Credentials: creds} + return &ziti.Config{ZtAPI: ctrlUrl + "/edge/client/v1", Credentials: creds}, nil } // findExtJwtIdentity decodes the token and matches it to an existing identity the same way -// the controller will (the signer's claimsProperty + useExternalId), returning its id. +// the controller will. This mirrors the controller's ext-jwt identity matching +// (controller/model AuthModuleExtJwt: the signer's claimsProperty selects the claim, +// useExternalId picks externalId vs internal id), and must stay in sync with it. // ext-jwt auth does not auto-provision on a plain authenticate, so the identity must exist. -func (t *traffic) findExtJwtIdentity(signerName, token string) string { +func (t *traffic) findExtJwtIdentity(signerName, token string) (string, error) { signer := mgmt.ExternalJWTSignerFromFilter(t.client, mgmt.NameFilter(signerName)) if signer == nil { - log.Fatalf("ext-jwt-signer %q not found via management api", signerName) + return "", fmt.Errorf("ext-jwt-signer %q not found via management api", signerName) } claimName := "sub" @@ -716,7 +777,13 @@ func (t *traffic) findExtJwtIdentity(signerName, token string) string { claims := decodeJwtClaims(token) claimVal, _ := claims[claimName].(string) if claimVal == "" { - log.Fatalf("token has no %q claim to match an identity", claimName) + return "", fmt.Errorf("token has no %q claim to match an identity", claimName) + } + // claimVal comes from the IdP token and is interpolated into a controller filter + // below; reject filter-breaking characters rather than risk an injected query. + // Legitimate sub/externalId values (UUIDs, emails, usernames) never contain these. + if strings.ContainsAny(claimVal, "\"\\") { + return "", fmt.Errorf("claim %q value contains illegal characters", claimName) } useExternal := signer.UseExternalID == nil || *signer.UseExternalID @@ -727,33 +794,20 @@ func (t *traffic) findExtJwtIdentity(signerName, token string) string { id = mgmt.IdentityFromFilter(t.client, fmt.Sprintf("id=\"%s\"", claimVal)) } if id == nil { - log.Fatalf("no identity matches claim %s=%q; the ext-jwt identity must already exist", claimName, claimVal) + return "", fmt.Errorf("no identity matches claim %s=%q; the ext-jwt identity must already exist", claimName, claimVal) } log.Infof("matched identity %s (%s) for service %s", *id.Name, *id.ID, t.svcName) - return *id.ID + return *id.ID, nil } -func decodeJwtClaims(token string) map[string]interface{} { - parts := strings.Split(token, ".") - if len(parts) < 2 { - return nil - } - raw, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - if raw, err = base64.StdEncoding.DecodeString(addJwtPadding(parts[1])); err != nil { - return nil - } - } - var claims map[string]interface{} - if err := json.Unmarshal(raw, &claims); err != nil { +// decodeJwtClaims returns the JWT's claims without verifying its signature. This is only +// used to route to the matching identity; the controller re-validates the token's +// signature when the SDK authenticates on dial/bind, so a forged claim here at worst +// resolves the wrong identity, it cannot bypass authentication. +func decodeJwtClaims(token string) jwt.MapClaims { + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil { return nil } return claims } - -func addJwtPadding(input string) string { - if m := len(input) % 4; m != 0 { - input += strings.Repeat("=", 4-m) - } - return input -}