diff --git a/edge-apis/clients.go b/edge-apis/clients.go index eccdee7c..6737775a 100644 --- a/edge-apis/clients.go +++ b/edge-apis/clients.go @@ -18,16 +18,17 @@ package edge_apis import ( "crypto/x509" + "net/http" + "net/url" + "strings" + "sync/atomic" + "github.com/go-openapi/runtime" openapiclient "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/michaelquigley/pfxlog" "github.com/openziti/edge-api/rest_client_api_client" "github.com/openziti/edge-api/rest_management_api_client" - "net/http" - "net/url" - "strings" - "sync/atomic" ) // ApiType is an interface constraint for generics. The underlying go-swagger types only have fields, which are @@ -173,7 +174,7 @@ func (self *BaseClient[A]) ProcessControllers(authEnabledApi AuthEnabledApi) { list, err := authEnabledApi.ListControllers() if err != nil { - pfxlog.Logger().WithError(err).Error("error listing controllers, continuing with 1 default configured controller") + pfxlog.Logger().WithError(err).Debug("error listing controllers, continuing with 1 default configured controller") return } diff --git a/example/device-auth/README.md b/example/device-auth/README.md new file mode 100644 index 00000000..e79b539b --- /dev/null +++ b/example/device-auth/README.md @@ -0,0 +1,7 @@ +OIDC Device Code authentication example +--- + +This sample shows OpenZiti OIDC authentication with device code flow. +Prerequisites: +- your OpenZiti network is configured with an external OIDC provider +- your OIDC provider is configured to allow device code flow \ No newline at end of file diff --git a/example/device-auth/main.go b/example/device-auth/main.go new file mode 100644 index 00000000..4e9b3b51 --- /dev/null +++ b/example/device-auth/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/openziti/edge-api/rest_model" + "github.com/openziti/edge-api/rest_util" + nfx509 "github.com/openziti/foundation/v2/x509" + "github.com/openziti/sdk-golang/ziti" + "gopkg.in/square/go-jose.v2/json" +) + +func die[T interface{}](res T, err error) T { + if err != nil { + log.Fatal(err) + } + return res +} + +func main() { + cfg := flag.String("config", "", "path to config file") + openzitiURL := flag.String("ziti", "https://localhost:1280", "URL of the OpenZiti service") + flag.Parse() + + var config *ziti.Config + if cfg == nil || *cfg == "" { + config = &ziti.Config{ + ZtAPI: *openzitiURL, + } + // warning: this call is insecure and should not be used in production + ca := die(rest_util.GetControllerWellKnownCas(*openzitiURL)) + var buf bytes.Buffer + _ = nfx509.MarshalToPem(ca, &buf) + config.ID.CA = buf.String() + } else { + if openzitiURL == nil || *openzitiURL == "" { + log.Fatal("OpenZiti URL must be specified") + } + config = die(ziti.NewConfigFromFile(*cfg)) + } + ztx := die(ziti.NewContext(config)) + + err := ztx.Authenticate() + var provider *rest_model.ClientExternalJWTSignerDetail + if err != nil { + fmt.Println("Try authenticating with external provider") + idps := die(ztx.GetExternalSigners()) + for idx, idp := range idps { + fmt.Printf("%d: %s\n", idx, *idp.Name) + } + + fmt.Printf("Select provider allowing device code flow.\nEnter number[0-%d] to authenticate: ", len(idps)-1) + var id int + _ = die(fmt.Scanf("%d", &id)) + + provider = idps[id] + } + if provider == nil { + log.Fatal("No provider found") + } + fmt.Printf("Using %s\n", *provider.Name) + + resp := die(http.Get(*provider.ExternalAuthURL + "/.well-known/openid-configuration")) + var oidcConfig map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&oidcConfig) + + deviceAuth := oidcConfig["device_authorization_endpoint"].(string) + scopes := append(provider.Scopes, "openid") + ss := strings.Join(scopes, " ") + resp = die(http.PostForm(deviceAuth, url.Values{ + "client_id": {*provider.ClientID}, + "scope": {ss}, + "audience": {*provider.Audience}, + })) + + var deviceCode map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&deviceCode) + if completeUrl, ok := deviceCode["verification_uri_complete"]; ok { + fmt.Printf("Open %s in your browser\n", completeUrl.(string)) + } else if verifyUrl, ok := deviceCode["verification_uri"]; ok { + fmt.Printf("Open %s in your browser, and use code %s\n", + verifyUrl.(string), deviceCode["user_code"].(string)) + } else { + log.Fatal("Unable to determine verification URL") + } + + interval := time.Duration(int(deviceCode["interval"].(float64))) * time.Second + + var token map[string]interface{} + for { + clear(token) + time.Sleep(interval) + + tokenUrl := oidcConfig["token_endpoint"].(string) + resp = die(http.PostForm(tokenUrl, url.Values{ + "client_id": {*provider.ClientID}, + "device_code": {deviceCode["device_code"].(string)}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + })) + + json.NewDecoder(resp.Body).Decode(&token) + errmsg, hasErr := token["error"] + if !hasErr { + break + } + errormsg := errmsg.(string) + if errormsg == "authorization_pending" { + fmt.Println("Waiting for user to authorize...") + continue + } + log.Fatal(errormsg) + } + + accessToken := token["access_token"].(string) + tok, _ := jwt.Parse(accessToken, nil) + if claims, ok := tok.Claims.(jwt.MapClaims); ok { + for k, v := range claims { + fmt.Printf("\t%s: %v\n", k, v) + } + } + ztx.LoginWithJWT(accessToken) + + err = ztx.Authenticate() + if err != nil { + log.Fatal(err) + } + fmt.Println("Authenticated") + + services, _ := ztx.GetServices() + fmt.Println("Available Services:") + for _, svc := range services { + fmt.Printf("\t%s\n", *svc.Name) + } +} diff --git a/example/go.mod b/example/go.mod index 1e555358..2dfea3ec 100644 --- a/example/go.mod +++ b/example/go.mod @@ -8,9 +8,11 @@ replace github.com/openziti/sdk-golang => ../ require ( github.com/Jeffail/gabs v1.4.0 + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/michaelquigley/pfxlog v0.6.10 + github.com/openziti/edge-api v0.26.47 github.com/openziti/foundation/v2 v2.0.73 github.com/openziti/runzmd v1.0.33 github.com/openziti/sdk-golang v0.0.0 @@ -82,7 +84,6 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/openziti/channel/v4 v4.2.31 // indirect - github.com/openziti/edge-api v0.26.47 // indirect github.com/openziti/identity v1.0.112 // indirect github.com/openziti/metrics v1.4.2 // indirect github.com/openziti/secretstream v0.1.39 // indirect diff --git a/example/go.sum b/example/go.sum index afddf82a..18db2a7f 100644 --- a/example/go.sum +++ b/example/go.sum @@ -86,6 +86,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= diff --git a/ziti/client.go b/ziti/client.go index 72585164..c0db4662 100644 --- a/ziti/client.go +++ b/ziti/client.go @@ -25,6 +25,9 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" + "strings" + "sync/atomic" + "github.com/go-openapi/strfmt" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" @@ -46,8 +49,6 @@ import ( "github.com/openziti/sdk-golang/ziti/edge/posture" "github.com/openziti/transport/v2" "github.com/pkg/errors" - "strings" - "sync/atomic" ) // CtrlClient is a stateful version of ZitiEdgeClient that simplifies operations @@ -69,6 +70,15 @@ type CtrlClient struct { capabilitiesLoaded atomic.Bool } +func (self *CtrlClient) GetExternalSigners() (rest_model.ClientExternalJWTSignerList, error) { + response, err := self.API.ExternalJWTSigner.ListExternalJWTSigners(nil) + if err != nil { + return nil, err + } + + return response.Payload.Data, nil +} + // GetCurrentApiSession returns the current cached ApiSession or nil func (self *CtrlClient) GetCurrentApiSession() apis.ApiSession { return self.ClientApiClient.GetCurrentApiSession() @@ -92,7 +102,7 @@ func (self *CtrlClient) Refresh() (apis.ApiSession, error) { } // IsServiceListUpdateAvailable will contact the controller to determine if a new set of services are available. Service -// updates could entail gaining/losing services access via policy or runtime authorization revocation due to posture +// updates could entail gaining/losing service access via policy or runtime authorization revocation due to posture // checks. func (self *CtrlClient) IsServiceListUpdateAvailable() (bool, *strfmt.DateTime, error) { resp, err := self.API.CurrentAPISession.ListServiceUpdates(current_api_session.NewListServiceUpdatesParams(), self.GetCurrentApiSession()) @@ -104,7 +114,7 @@ func (self *CtrlClient) IsServiceListUpdateAvailable() (bool, *strfmt.DateTime, return self.lastServiceUpdate == nil || !resp.Payload.Data.LastChangeAt.Equal(*self.lastServiceUpdate), resp.Payload.Data.LastChangeAt, nil } -// Authenticate attempts to use authenticate, overwriting any existing ApiSession. +// Authenticate attempts to authenticate, overwriting any existing ApiSession. func (self *CtrlClient) Authenticate() (apis.ApiSession, error) { var err error diff --git a/ziti/contexts.go b/ziti/contexts.go index ae2eb202..e9ec9bc7 100644 --- a/ziti/contexts.go +++ b/ziti/contexts.go @@ -98,13 +98,9 @@ func NewContextWithOpts(cfg *Config, options *Options) (Context, error) { newContext.maxDefaultConnections = 1 } - if cfg.ID.Cert != "" && cfg.ID.Key != "" { - idCredentials := edge_apis.NewIdentityCredentialsFromConfig(cfg.ID) - idCredentials.ConfigTypes = cfg.ConfigTypes - cfg.Credentials = idCredentials - } else if cfg.Credentials == nil { - return nil, errors.New("either cfg.ID or cfg.Credentials must be provided") - } + idCredentials := edge_apis.NewIdentityCredentialsFromConfig(cfg.ID) + idCredentials.ConfigTypes = cfg.ConfigTypes + cfg.Credentials = idCredentials var apiStrs []string if len(cfg.ZtAPIs) > 0 { diff --git a/ziti/ziti.go b/ziti/ziti.go index c3a56438..cb74acce 100644 --- a/ziti/ziti.go +++ b/ziti/ziti.go @@ -88,9 +88,15 @@ type Context interface { // creation. Authenticate() error + // GetExternalSigners retrieves a list of external JWT signers with their details. + // Returns an error if the operation fails. + GetExternalSigners() ([]*rest_model.ClientExternalJWTSignerDetail, error) + // SetCredentials sets the credentials used to authenticate against the Edge Client API. SetCredentials(authenticator apis.Credentials) + LoginWithJWT(jst string) + // GetCredentials returns the currently set credentials used to authenticate against the Edge Client API. GetCredentials() apis.Credentials @@ -107,17 +113,17 @@ type Context interface { // DialWithOptions performs the same logic as Dial but allows specification of DialOptions. DialWithOptions(serviceName string, options *DialOptions) (edge.Conn, error) - // DialAddr finds the service for given address and performs a Dial for it. + // DialAddr finds the service for a given address and performs a Dial for it. DialAddr(network string, addr string) (edge.Conn, error) // Listen attempts to host a service by the given service name; authenticating as necessary in order to obtain // a service session, attach to Edge Routers, and bind (host) the service. Listen(serviceName string) (edge.Listener, error) - // ListenWithOptions performs the same logic as Listen, but allows the specification of ListenOptions. + // ListenWithOptions performs the same logic as Listen but allows the specification of ListenOptions. ListenWithOptions(serviceName string, options *ListenOptions) (edge.Listener, error) - // GetServiceId will return the id of a specific service by service name. If not found, false, will be returned + // GetServiceId will return the id of a specific service by service name. If not found, false will be returned // with an empty string. GetServiceId(serviceName string) (string, bool, error) @@ -128,7 +134,7 @@ type Context interface { // GetService will return the service details of a specific service by service name. GetService(serviceName string) (*rest_model.ServiceDetail, bool) - // GetServiceForAddr finds the service with intercept that matches best to given address + // GetServiceForAddr finds the service with intercept that matches best to the given address GetServiceForAddr(network, hostname string, port uint16) (*rest_model.ServiceDetail, int, error) // RefreshServices forces the context to refresh the list of services the current authenticating identity has access @@ -136,7 +142,7 @@ type Context interface { RefreshServices() error // RefreshService forces the context to refresh just the service with the given name. If the given service isn't - // found, a nil will be returned + // found, nil will be returned RefreshService(serviceName string) (*rest_model.ServiceDetail, error) // GetServiceTerminators will return a slice of rest_model.TerminatorClientDetail for a specific service name. @@ -482,6 +488,20 @@ func (context *ContextImpl) SetCredentials(credentials apis.Credentials) { context.CtrlClt.Credentials = credentials } +func (context *ContextImpl) LoginWithJWT(jwt string) { + cred := context.CtrlClt.Credentials + jwtCred := &apis.JwtCredentials{ + BaseCredentials: apis.BaseCredentials{ + ConfigTypes: cred.Payload().ConfigTypes, + EnvInfo: cred.Payload().EnvInfo, + SdkInfo: cred.Payload().SdkInfo, + CaPool: context.CtrlClt.CaPool, + }, + JWT: jwt, + } + context.SetCredentials(jwtCred) +} + func (context *ContextImpl) GetCredentials() apis.Credentials { return context.CtrlClt.Credentials } @@ -668,6 +688,11 @@ func (context *ContextImpl) refreshSessions() { } } +func (context *ContextImpl) GetExternalSigners() ([]*rest_model.ClientExternalJWTSignerDetail, error) { + result, err := context.CtrlClt.GetExternalSigners() + return result, err +} + func (context *ContextImpl) RefreshServices() error { return context.refreshServices(true, false) }