Skip to content

Commit 13cd4ef

Browse files
authored
Merge pull request #803 from openziti/external-oidc-support
simplify external OIDC support
2 parents 781ec45 + 476b044 commit 13cd4ef

File tree

8 files changed

+206
-22
lines changed

8 files changed

+206
-22
lines changed

edge-apis/clients.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@ package edge_apis
1818

1919
import (
2020
"crypto/x509"
21+
"net/http"
22+
"net/url"
23+
"strings"
24+
"sync/atomic"
25+
2126
"github.com/go-openapi/runtime"
2227
openapiclient "github.com/go-openapi/runtime/client"
2328
"github.com/go-openapi/strfmt"
2429
"github.com/michaelquigley/pfxlog"
2530
"github.com/openziti/edge-api/rest_client_api_client"
2631
"github.com/openziti/edge-api/rest_management_api_client"
27-
"net/http"
28-
"net/url"
29-
"strings"
30-
"sync/atomic"
3132
)
3233

3334
// 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) {
173174
list, err := authEnabledApi.ListControllers()
174175

175176
if err != nil {
176-
pfxlog.Logger().WithError(err).Error("error listing controllers, continuing with 1 default configured controller")
177+
pfxlog.Logger().WithError(err).Debug("error listing controllers, continuing with 1 default configured controller")
177178
return
178179
}
179180

example/device-auth/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
OIDC Device Code authentication example
2+
---
3+
4+
This sample shows OpenZiti OIDC authentication with device code flow.
5+
Prerequisites:
6+
- your OpenZiti network is configured with an external OIDC provider
7+
- your OIDC provider is configured to allow device code flow

example/device-auth/main.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"flag"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"time"
12+
13+
"github.com/dgrijalva/jwt-go"
14+
"github.com/openziti/edge-api/rest_model"
15+
"github.com/openziti/edge-api/rest_util"
16+
nfx509 "github.com/openziti/foundation/v2/x509"
17+
"github.com/openziti/sdk-golang/ziti"
18+
"gopkg.in/square/go-jose.v2/json"
19+
)
20+
21+
func die[T interface{}](res T, err error) T {
22+
if err != nil {
23+
log.Fatal(err)
24+
}
25+
return res
26+
}
27+
28+
func main() {
29+
cfg := flag.String("config", "", "path to config file")
30+
openzitiURL := flag.String("ziti", "https://localhost:1280", "URL of the OpenZiti service")
31+
flag.Parse()
32+
33+
var config *ziti.Config
34+
if cfg == nil || *cfg == "" {
35+
config = &ziti.Config{
36+
ZtAPI: *openzitiURL,
37+
}
38+
// warning: this call is insecure and should not be used in production
39+
ca := die(rest_util.GetControllerWellKnownCas(*openzitiURL))
40+
var buf bytes.Buffer
41+
_ = nfx509.MarshalToPem(ca, &buf)
42+
config.ID.CA = buf.String()
43+
} else {
44+
if openzitiURL == nil || *openzitiURL == "" {
45+
log.Fatal("OpenZiti URL must be specified")
46+
}
47+
config = die(ziti.NewConfigFromFile(*cfg))
48+
}
49+
ztx := die(ziti.NewContext(config))
50+
51+
err := ztx.Authenticate()
52+
var provider *rest_model.ClientExternalJWTSignerDetail
53+
if err != nil {
54+
fmt.Println("Try authenticating with external provider")
55+
idps := die(ztx.GetExternalSigners())
56+
for idx, idp := range idps {
57+
fmt.Printf("%d: %s\n", idx, *idp.Name)
58+
}
59+
60+
fmt.Printf("Select provider allowing device code flow.\nEnter number[0-%d] to authenticate: ", len(idps)-1)
61+
var id int
62+
_ = die(fmt.Scanf("%d", &id))
63+
64+
provider = idps[id]
65+
}
66+
if provider == nil {
67+
log.Fatal("No provider found")
68+
}
69+
fmt.Printf("Using %s\n", *provider.Name)
70+
71+
resp := die(http.Get(*provider.ExternalAuthURL + "/.well-known/openid-configuration"))
72+
var oidcConfig map[string]interface{}
73+
_ = json.NewDecoder(resp.Body).Decode(&oidcConfig)
74+
75+
deviceAuth := oidcConfig["device_authorization_endpoint"].(string)
76+
scopes := append(provider.Scopes, "openid")
77+
ss := strings.Join(scopes, " ")
78+
resp = die(http.PostForm(deviceAuth, url.Values{
79+
"client_id": {*provider.ClientID},
80+
"scope": {ss},
81+
"audience": {*provider.Audience},
82+
}))
83+
84+
var deviceCode map[string]interface{}
85+
_ = json.NewDecoder(resp.Body).Decode(&deviceCode)
86+
if completeUrl, ok := deviceCode["verification_uri_complete"]; ok {
87+
fmt.Printf("Open %s in your browser\n", completeUrl.(string))
88+
} else if verifyUrl, ok := deviceCode["verification_uri"]; ok {
89+
fmt.Printf("Open %s in your browser, and use code %s\n",
90+
verifyUrl.(string), deviceCode["user_code"].(string))
91+
} else {
92+
log.Fatal("Unable to determine verification URL")
93+
}
94+
95+
interval := time.Duration(int(deviceCode["interval"].(float64))) * time.Second
96+
97+
var token map[string]interface{}
98+
for {
99+
clear(token)
100+
time.Sleep(interval)
101+
102+
tokenUrl := oidcConfig["token_endpoint"].(string)
103+
resp = die(http.PostForm(tokenUrl, url.Values{
104+
"client_id": {*provider.ClientID},
105+
"device_code": {deviceCode["device_code"].(string)},
106+
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
107+
}))
108+
109+
json.NewDecoder(resp.Body).Decode(&token)
110+
errmsg, hasErr := token["error"]
111+
if !hasErr {
112+
break
113+
}
114+
errormsg := errmsg.(string)
115+
if errormsg == "authorization_pending" {
116+
fmt.Println("Waiting for user to authorize...")
117+
continue
118+
}
119+
log.Fatal(errormsg)
120+
}
121+
122+
accessToken := token["access_token"].(string)
123+
tok, _ := jwt.Parse(accessToken, nil)
124+
if claims, ok := tok.Claims.(jwt.MapClaims); ok {
125+
for k, v := range claims {
126+
fmt.Printf("\t%s: %v\n", k, v)
127+
}
128+
}
129+
ztx.LoginWithJWT(accessToken)
130+
131+
err = ztx.Authenticate()
132+
if err != nil {
133+
log.Fatal(err)
134+
}
135+
fmt.Println("Authenticated")
136+
137+
services, _ := ztx.GetServices()
138+
fmt.Println("Available Services:")
139+
for _, svc := range services {
140+
fmt.Printf("\t%s\n", *svc.Name)
141+
}
142+
}

example/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ replace github.com/openziti/sdk-golang => ../
88

99
require (
1010
github.com/Jeffail/gabs v1.4.0
11+
github.com/dgrijalva/jwt-go v3.2.0+incompatible
1112
github.com/google/uuid v1.6.0
1213
github.com/gorilla/mux v1.8.1
1314
github.com/michaelquigley/pfxlog v0.6.10
15+
github.com/openziti/edge-api v0.26.47
1416
github.com/openziti/foundation/v2 v2.0.73
1517
github.com/openziti/runzmd v1.0.33
1618
github.com/openziti/sdk-golang v0.0.0
@@ -82,7 +84,6 @@ require (
8284
github.com/oklog/ulid v1.3.1 // indirect
8385
github.com/opentracing/opentracing-go v1.2.0 // indirect
8486
github.com/openziti/channel/v4 v4.2.31 // indirect
85-
github.com/openziti/edge-api v0.26.47 // indirect
8687
github.com/openziti/identity v1.0.112 // indirect
8788
github.com/openziti/metrics v1.4.2 // indirect
8889
github.com/openziti/secretstream v0.1.39 // indirect

example/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
8686
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8787
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
8888
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
89+
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
90+
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
8991
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
9092
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
9193
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=

ziti/client.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import (
2525
"crypto/x509/pkix"
2626
"encoding/pem"
2727
"fmt"
28+
"strings"
29+
"sync/atomic"
30+
2831
"github.com/go-openapi/strfmt"
2932
"github.com/golang-jwt/jwt/v5"
3033
"github.com/google/uuid"
@@ -46,8 +49,6 @@ import (
4649
"github.com/openziti/sdk-golang/ziti/edge/posture"
4750
"github.com/openziti/transport/v2"
4851
"github.com/pkg/errors"
49-
"strings"
50-
"sync/atomic"
5152
)
5253

5354
// CtrlClient is a stateful version of ZitiEdgeClient that simplifies operations
@@ -69,6 +70,15 @@ type CtrlClient struct {
6970
capabilitiesLoaded atomic.Bool
7071
}
7172

73+
func (self *CtrlClient) GetExternalSigners() (rest_model.ClientExternalJWTSignerList, error) {
74+
response, err := self.API.ExternalJWTSigner.ListExternalJWTSigners(nil)
75+
if err != nil {
76+
return nil, err
77+
}
78+
79+
return response.Payload.Data, nil
80+
}
81+
7282
// GetCurrentApiSession returns the current cached ApiSession or nil
7383
func (self *CtrlClient) GetCurrentApiSession() apis.ApiSession {
7484
return self.ClientApiClient.GetCurrentApiSession()
@@ -92,7 +102,7 @@ func (self *CtrlClient) Refresh() (apis.ApiSession, error) {
92102
}
93103

94104
// IsServiceListUpdateAvailable will contact the controller to determine if a new set of services are available. Service
95-
// updates could entail gaining/losing services access via policy or runtime authorization revocation due to posture
105+
// updates could entail gaining/losing service access via policy or runtime authorization revocation due to posture
96106
// checks.
97107
func (self *CtrlClient) IsServiceListUpdateAvailable() (bool, *strfmt.DateTime, error) {
98108
resp, err := self.API.CurrentAPISession.ListServiceUpdates(current_api_session.NewListServiceUpdatesParams(), self.GetCurrentApiSession())
@@ -104,7 +114,7 @@ func (self *CtrlClient) IsServiceListUpdateAvailable() (bool, *strfmt.DateTime,
104114
return self.lastServiceUpdate == nil || !resp.Payload.Data.LastChangeAt.Equal(*self.lastServiceUpdate), resp.Payload.Data.LastChangeAt, nil
105115
}
106116

107-
// Authenticate attempts to use authenticate, overwriting any existing ApiSession.
117+
// Authenticate attempts to authenticate, overwriting any existing ApiSession.
108118
func (self *CtrlClient) Authenticate() (apis.ApiSession, error) {
109119
var err error
110120

ziti/contexts.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,9 @@ func NewContextWithOpts(cfg *Config, options *Options) (Context, error) {
9898
newContext.maxDefaultConnections = 1
9999
}
100100

101-
if cfg.ID.Cert != "" && cfg.ID.Key != "" {
102-
idCredentials := edge_apis.NewIdentityCredentialsFromConfig(cfg.ID)
103-
idCredentials.ConfigTypes = cfg.ConfigTypes
104-
cfg.Credentials = idCredentials
105-
} else if cfg.Credentials == nil {
106-
return nil, errors.New("either cfg.ID or cfg.Credentials must be provided")
107-
}
101+
idCredentials := edge_apis.NewIdentityCredentialsFromConfig(cfg.ID)
102+
idCredentials.ConfigTypes = cfg.ConfigTypes
103+
cfg.Credentials = idCredentials
108104

109105
var apiStrs []string
110106
if len(cfg.ZtAPIs) > 0 {

ziti/ziti.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,15 @@ type Context interface {
8888
// creation.
8989
Authenticate() error
9090

91+
// GetExternalSigners retrieves a list of external JWT signers with their details.
92+
// Returns an error if the operation fails.
93+
GetExternalSigners() ([]*rest_model.ClientExternalJWTSignerDetail, error)
94+
9195
// SetCredentials sets the credentials used to authenticate against the Edge Client API.
9296
SetCredentials(authenticator apis.Credentials)
9397

98+
LoginWithJWT(jst string)
99+
94100
// GetCredentials returns the currently set credentials used to authenticate against the Edge Client API.
95101
GetCredentials() apis.Credentials
96102

@@ -107,17 +113,17 @@ type Context interface {
107113
// DialWithOptions performs the same logic as Dial but allows specification of DialOptions.
108114
DialWithOptions(serviceName string, options *DialOptions) (edge.Conn, error)
109115

110-
// DialAddr finds the service for given address and performs a Dial for it.
116+
// DialAddr finds the service for a given address and performs a Dial for it.
111117
DialAddr(network string, addr string) (edge.Conn, error)
112118

113119
// Listen attempts to host a service by the given service name; authenticating as necessary in order to obtain
114120
// a service session, attach to Edge Routers, and bind (host) the service.
115121
Listen(serviceName string) (edge.Listener, error)
116122

117-
// ListenWithOptions performs the same logic as Listen, but allows the specification of ListenOptions.
123+
// ListenWithOptions performs the same logic as Listen but allows the specification of ListenOptions.
118124
ListenWithOptions(serviceName string, options *ListenOptions) (edge.Listener, error)
119125

120-
// GetServiceId will return the id of a specific service by service name. If not found, false, will be returned
126+
// GetServiceId will return the id of a specific service by service name. If not found, false will be returned
121127
// with an empty string.
122128
GetServiceId(serviceName string) (string, bool, error)
123129

@@ -128,15 +134,15 @@ type Context interface {
128134
// GetService will return the service details of a specific service by service name.
129135
GetService(serviceName string) (*rest_model.ServiceDetail, bool)
130136

131-
// GetServiceForAddr finds the service with intercept that matches best to given address
137+
// GetServiceForAddr finds the service with intercept that matches best to the given address
132138
GetServiceForAddr(network, hostname string, port uint16) (*rest_model.ServiceDetail, int, error)
133139

134140
// RefreshServices forces the context to refresh the list of services the current authenticating identity has access
135141
// to.
136142
RefreshServices() error
137143

138144
// RefreshService forces the context to refresh just the service with the given name. If the given service isn't
139-
// found, a nil will be returned
145+
// found, nil will be returned
140146
RefreshService(serviceName string) (*rest_model.ServiceDetail, error)
141147

142148
// 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) {
482488
context.CtrlClt.Credentials = credentials
483489
}
484490

491+
func (context *ContextImpl) LoginWithJWT(jwt string) {
492+
cred := context.CtrlClt.Credentials
493+
jwtCred := &apis.JwtCredentials{
494+
BaseCredentials: apis.BaseCredentials{
495+
ConfigTypes: cred.Payload().ConfigTypes,
496+
EnvInfo: cred.Payload().EnvInfo,
497+
SdkInfo: cred.Payload().SdkInfo,
498+
CaPool: context.CtrlClt.CaPool,
499+
},
500+
JWT: jwt,
501+
}
502+
context.SetCredentials(jwtCred)
503+
}
504+
485505
func (context *ContextImpl) GetCredentials() apis.Credentials {
486506
return context.CtrlClt.Credentials
487507
}
@@ -668,6 +688,11 @@ func (context *ContextImpl) refreshSessions() {
668688
}
669689
}
670690

691+
func (context *ContextImpl) GetExternalSigners() ([]*rest_model.ClientExternalJWTSignerDetail, error) {
692+
result, err := context.CtrlClt.GetExternalSigners()
693+
return result, err
694+
}
695+
671696
func (context *ContextImpl) RefreshServices() error {
672697
return context.refreshServices(true, false)
673698
}

0 commit comments

Comments
 (0)