diff --git a/controller/oidc_auth/client.go b/controller/oidc_auth/client.go index f1d92289d..bb729a411 100644 --- a/controller/oidc_auth/client.go +++ b/controller/oidc_auth/client.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/config.go b/controller/oidc_auth/config.go index 40db09887..e86c65e20 100644 --- a/controller/oidc_auth/config.go +++ b/controller/oidc_auth/config.go @@ -1,16 +1,33 @@ +/* + 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" "crypto/sha256" "crypto/x509" + "github.com/openziti/identity" "github.com/openziti/ziti/common" "time" ) // Config represents the configuration necessary to operate an OIDC Provider type Config struct { - Issuers []string + Issuers []Issuer TokenSecret string Storage Storage Certificate *x509.Certificate @@ -22,10 +39,11 @@ type Config struct { PostLogoutURIs []string maxTokenDuration *time.Duration + Identity identity.Identity } // NewConfig will create a Config with default values -func NewConfig(issuers []string, cert *x509.Certificate, key crypto.PrivateKey) Config { +func NewConfig(issuers []Issuer, cert *x509.Certificate, key crypto.PrivateKey) Config { return Config{ Issuers: issuers, Certificate: cert, diff --git a/controller/oidc_auth/ctx.go b/controller/oidc_auth/ctx.go index 53de6c5ce..c47aae498 100644 --- a/controller/oidc_auth/ctx.go +++ b/controller/oidc_auth/ctx.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/errors.go b/controller/oidc_auth/errors.go index 2d73e2264..74c379dea 100644 --- a/controller/oidc_auth/errors.go +++ b/controller/oidc_auth/errors.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/issuer.go b/controller/oidc_auth/issuer.go new file mode 100644 index 000000000..901b44731 --- /dev/null +++ b/controller/oidc_auth/issuer.go @@ -0,0 +1,136 @@ +/* + 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/x509" + "fmt" + "net" + "strings" +) + +type Issuer interface { + // ValidFor parses and address (hostOrIp[:port]) and verifies it matches a given issuer's hostOrIp and port. + // If port is unspecified the default TLS port is assumed (443) + ValidFor(string) error + + // HostPort returns a string in the format of `host[:port]` + HostPort() string +} + +var _ Issuer = (*IssuerDns)(nil) + +type IssuerDns struct { + hostname string + port string +} + +func (i IssuerDns) HostPort() string { + if i.port == "" { + return i.hostname + } + + return net.JoinHostPort(i.hostname, i.port) +} + +func (i IssuerDns) ValidFor(address string) error { + host, port, err := getHostPort(address) + if err != nil { + return fmt.Errorf("invalid host[:port]: %w", err) + } + + cert := &x509.Certificate{ + DNSNames: []string{i.hostname}, + } + + if hostErr := cert.VerifyHostname(host); hostErr != nil { + return fmt.Errorf("error verifying hostname: %w", hostErr) + } + + if port != i.port { + return fmt.Errorf("invalid port %q, expected %q", port, i.port) + } + + return nil +} + +var _ Issuer = (*IssuerIp)(nil) + +type IssuerIp struct { + ip net.IP + port string +} + +func (i IssuerIp) HostPort() string { + if i.port == "" { + return i.ip.String() + } + + return net.JoinHostPort(i.ip.String(), i.port) +} + +func (i IssuerIp) ValidFor(address string) error { + host, port, err := getHostPort(address) + if err != nil { + return fmt.Errorf("invalid host[:port]: %w", err) + } + + cert := &x509.Certificate{ + IPAddresses: []net.IP{i.ip}, + } + + if hostErr := cert.VerifyHostname(host); hostErr != nil { + return fmt.Errorf("error verifying hostname: %w", hostErr) + } + + if port != i.port { + return fmt.Errorf("invalid port %q, expected %q", port, i.port) + } + + return nil +} + +func NewIssuer(address string) (Issuer, error) { + host, port, err := getHostPort(address) + + if err != nil { + return nil, err + } + + ip := net.ParseIP(host) + + if ip != nil { + return &IssuerIp{ip: ip, port: port}, nil + } else { + return &IssuerDns{hostname: host, port: port}, nil + } +} + +// getHostPort is similar to net.SplitHostPort but does not require a port +func getHostPort(address string) (string, string, error) { + port := "" + host := address + if strings.Contains(address, ":") { + var err error + host, port, err = net.SplitHostPort(address) + if err != nil { + return "", "", err + } + } + + return host, port, nil +} diff --git a/controller/oidc_auth/key.go b/controller/oidc_auth/key.go index 5bae2ad19..16d149488 100644 --- a/controller/oidc_auth/key.go +++ b/controller/oidc_auth/key.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/login.go b/controller/oidc_auth/login.go index f7053715b..8e4fc337c 100644 --- a/controller/oidc_auth/login.go +++ b/controller/oidc_auth/login.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/negotiate.go b/controller/oidc_auth/negotiate.go index 741ddd322..75166ae5a 100644 --- a/controller/oidc_auth/negotiate.go +++ b/controller/oidc_auth/negotiate.go @@ -1,9 +1,27 @@ +/* + 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 ( "fmt" "github.com/openziti/foundation/v2/errorz" "net/http" + "sort" + "strconv" "strings" ) @@ -12,16 +30,21 @@ import ( // cannot be satisfied an error is returned. func negotiateResponseContentType(r *http.Request) (string, *errorz.ApiError) { acceptHeader := r.Header.Get(AcceptHeader) + + if acceptHeader == "" { + return JsonContentType, nil + } + contentTypes := parseAcceptHeader(acceptHeader) - if len(contentTypes) == 0 || acceptHeader == "" { + if len(contentTypes) == 0 { return JsonContentType, nil } for _, contentType := range contentTypes { - if contentType == JsonContentType { - return contentType, nil - } else if contentType == HtmlContentType { + if contentType.Match(JsonContentType) { + return JsonContentType, nil + } else if contentType.Match(HtmlContentType) { return HtmlContentType, nil } } @@ -29,9 +52,25 @@ func negotiateResponseContentType(r *http.Request) (string, *errorz.ApiError) { return "", newNotAcceptableError(acceptHeader) } +// parseContentType extracts the media type from a Content-Type header value +func parseContentTypeMediaType(contentType string) string { + contentType = strings.TrimSpace(contentType) + + if contentType == "" { + return "" + } + parts := strings.Split(contentType, ";") + if len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + return "" +} + func negotiateBodyContentType(r *http.Request) (string, *errorz.ApiError) { contentType := r.Header.Get(ContentTypeHeader) + contentType = parseContentTypeMediaType(contentType) + switch contentType { case FormContentType: return FormContentType, nil @@ -49,17 +88,71 @@ func negotiateBodyContentType(r *http.Request) (string, *errorz.ApiError) { } } -// parseAcceptHeader parses HTTP accept headers and returns an array of supported -// content types sorted by quality factor (0=most desired response type). The return -// strings are the content type only (e.g. "application/json") -func parseAcceptHeader(acceptHeader string) []string { - parts := strings.Split(acceptHeader, ",") - contentTypes := make([]string, len(parts)) +// AcceptEntry represents a parsed Accept header entry +type AcceptEntry struct { + FullHeader string // Original full header + MediaType string // Parsed media type + QValue float64 // Quality factor (default 1.0) +} + +// Match checks if a given media type matches this AcceptEntry, supporting wildcards. +func (a AcceptEntry) Match(mediaType string) bool { + // Split main and subtype (e.g., "image/png" -> "image", "png") + expectedParts := strings.SplitN(a.MediaType, "/", 2) + incomingParts := strings.SplitN(mediaType, "/", 2) + + if len(expectedParts) != 2 || len(incomingParts) != 2 { + return false // Invalid format + } + + if a.MediaType == "*/*" { + return true // Matches anything + } + + // main type and sup type match exactly or * + return (expectedParts[0] == incomingParts[0] || expectedParts[0] == "*") && + (expectedParts[1] == incomingParts[1] || expectedParts[1] == "*") +} + +// parseAcceptHeader parses an Accept header string and returns a sorted slice of AcceptEntry based on q value (high to low) +func parseAcceptHeader(header string) []AcceptEntry { + if header == "" { + return nil + } + + entries := strings.Split(header, ",") + var acceptHeaders []AcceptEntry + + for _, entry := range entries { + entry = strings.TrimSpace(entry) + parts := strings.Split(entry, ";") + + mediaType := strings.TrimSpace(parts[0]) + qValue := 1.0 // Default q-value - for i, part := range parts { - typeAndFactor := strings.Split(strings.TrimSpace(part), ";") - contentTypes[i] = typeAndFactor[0] + // Check if there's a q= parameter + if len(parts) > 1 { + for _, param := range parts[1:] { + param = strings.TrimSpace(param) + if strings.HasPrefix(param, "q=") { + if val, err := strconv.ParseFloat(strings.TrimPrefix(param, "q="), 64); err == nil { + qValue = val + } + } + } + } + + acceptHeaders = append(acceptHeaders, AcceptEntry{ + FullHeader: entry, + MediaType: mediaType, + QValue: qValue, + }) } - return contentTypes + // Sort by q-value (descending) + sort.Slice(acceptHeaders, func(i, j int) bool { + return acceptHeaders[i].QValue > acceptHeaders[j].QValue + }) + + return acceptHeaders } diff --git a/controller/oidc_auth/negotiate_test.go b/controller/oidc_auth/negotiate_test.go new file mode 100644 index 000000000..b888aa83a --- /dev/null +++ b/controller/oidc_auth/negotiate_test.go @@ -0,0 +1,266 @@ +/* + 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 ( + "github.com/stretchr/testify/require" + "testing" +) + +func Test_parseContentTypeMediaType(t *testing.T) { + t.Run("empty string returns empty string", func(t *testing.T) { + req := require.New(t) + + result := parseContentTypeMediaType("") + req.Equal("", result) + }) + + t.Run("a media type only string returns the media type", func(t *testing.T) { + req := require.New(t) + + result := parseContentTypeMediaType("application/json") + req.Equal("application/json", result) + }) + + t.Run("a media type with a charset string returns the media type", func(t *testing.T) { + req := require.New(t) + + result := parseContentTypeMediaType("text/html; charset=UTF-8") + req.Equal("text/html", result) + }) + + t.Run("a media type with extra space and a charset string returns the media type", func(t *testing.T) { + req := require.New(t) + + result := parseContentTypeMediaType(" text/html; charset=UTF-8 ") + req.Equal("text/html", result) + }) + + t.Run("a media type with a boundary returns the media type", func(t *testing.T) { + req := require.New(t) + + result := parseContentTypeMediaType("multipart/form-data; boundary=----WebKitFormBoundaryxyz") + req.Equal("multipart/form-data", result) + }) +} + +func Test_parseAcceptHeader(t *testing.T) { + t.Run("an empty accept header returns nil", func(t *testing.T) { + req := require.New(t) + + result := parseAcceptHeader("") + req.Nil(result) + + }) + + t.Run("an invalid media type returns the value provided", func(t *testing.T) { + req := require.New(t) + + result := parseAcceptHeader("bobble") + req.Len(result, 1) + req.Equal("bobble", result[0].MediaType) + }) + + t.Run("*/* returns */*", func(t *testing.T) { + req := require.New(t) + + result := parseAcceptHeader("*/*") + req.Len(result, 1) + req.Equal("*/*", result[0].MediaType) + }) + + t.Run("image/png, image/jpeg;q=0.8, image/*;q=0.5 returns correct values in correct order", func(t *testing.T) { + req := require.New(t) + + result := parseAcceptHeader("image/png, image/*;q=0.5, image/jpeg;q=0.8") + req.Len(result, 3) + + req.Equal("image/png", result[0].MediaType) + req.Equal(1.0, result[0].QValue) + + req.Equal("image/jpeg", result[1].MediaType) + req.Equal(0.8, result[1].QValue) + + req.Equal("image/*", result[2].MediaType) + req.Equal(0.5, result[2].QValue) + }) + + t.Run("is not space sensitive", func(t *testing.T) { + req := require.New(t) + + result := parseAcceptHeader(" image/png ,image/*;q=0.5,image/jpeg;q=0.8 ") + req.Len(result, 3) + + req.Equal("image/png", result[0].MediaType) + req.Equal(1.0, result[0].QValue) + + req.Equal("image/jpeg", result[1].MediaType) + req.Equal(0.8, result[1].QValue) + + req.Equal("image/*", result[2].MediaType) + req.Equal(0.5, result[2].QValue) + }) +} + +func Test_AcceptHeader_Match(t *testing.T) { + t.Run("static main and sub type match", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "application/json", + MediaType: "application/json", + QValue: 1, + } + + result := entry.Match("application/json") + req.True(result) + }) + + t.Run("static main and sub types do not match if main is wrong", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "application/json", + MediaType: "application/json", + QValue: 1, + } + + result := entry.Match("badValue/json") + req.False(result) + }) + + t.Run("static main and sub types do not match if sub is wrong", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "application/json", + MediaType: "application/json", + QValue: 1, + } + result := entry.Match("application/badValue") + req.False(result) + }) + + t.Run("static main and sub types do not match if main and sub are wrong", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "application/json", + MediaType: "application/json", + QValue: 1, + } + result := entry.Match("badValue/anotherBadValue") + req.False(result) + }) + + t.Run("glob main type matches if static sub matches", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "*/json", + MediaType: "*/json", + QValue: 1, + } + + result := entry.Match("whatever/json") + req.True(result) + }) + + t.Run("glob main type does not match if static sub does not match", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "*/json", + MediaType: "*/json", + QValue: 1, + } + + result := entry.Match("whatever/badValue") + req.False(result) + }) + + t.Run("glob sub type matches if static main matches", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "application/*", + MediaType: "application/*", + QValue: 1, + } + + result := entry.Match("application/whatever") + req.True(result) + }) + + t.Run("glob sub type does not match if static main does not match", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "application/*", + MediaType: "application/*", + QValue: 1, + } + + result := entry.Match("badValue/whatever") + req.False(result) + }) + + t.Run("glob main and sub type matches any valid media type", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "*/*", + MediaType: "*/*", + QValue: 1, + } + + result := entry.Match("whatever1/whatever2") + req.True(result) + }) + + t.Run("glob main and sub type", func(t *testing.T) { + entry := &AcceptEntry{ + FullHeader: "*/*", + MediaType: "*/*", + QValue: 1, + } + + t.Run("does not match no slash in media type", func(t *testing.T) { + req := require.New(t) + result := entry.Match("invalidValueNoSlash") + req.False(result) + }) + + t.Run("does match too many slashes", func(t *testing.T) { + req := require.New(t) + + //extra slahes in subtype are not split and instead make up the entire subtype (e.g. subType == "whatever2/whatever3") + result := entry.Match("whatever1/whatever2/whatever3") + req.True(result) + }) + + t.Run("does not match empty media type", func(t *testing.T) { + req := require.New(t) + result := entry.Match("") + req.False(result) + }) + + }) + + t.Run("an invalid media type matches nothing", func(t *testing.T) { + req := require.New(t) + entry := &AcceptEntry{ + FullHeader: "iAmABadValue", + MediaType: "iAmABadValue", + QValue: 1, + } + + result := entry.Match("application/json") + req.False(result) + }) +} diff --git a/controller/oidc_auth/parse.go b/controller/oidc_auth/parse.go index 36fd8c9c8..b5dd57a45 100644 --- a/controller/oidc_auth/parse.go +++ b/controller/oidc_auth/parse.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/parse_test.go b/controller/oidc_auth/parse_test.go index 799eb42cc..80023d301 100644 --- a/controller/oidc_auth/parse_test.go +++ b/controller/oidc_auth/parse_test.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/provider.go b/controller/oidc_auth/provider.go index 67f94df11..4e9c187d0 100644 --- a/controller/oidc_auth/provider.go +++ b/controller/oidc_auth/provider.go @@ -1,3 +1,19 @@ +/* + 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 ( @@ -11,7 +27,6 @@ import ( "github.com/pkg/errors" "github.com/zitadel/oidc/v2/pkg/op" "golang.org/x/text/language" - "net" "net/http" ) @@ -29,123 +44,71 @@ const ( AuthMethodSecondaryExtJwt = "ejs" ) -// NewNativeOnlyOP creates an OIDC Provider that allows native clients and only the AuthCode PKCE flow. -func NewNativeOnlyOP(ctx context.Context, env model.Env, config Config) (http.Handler, error) { - cert, kid, method := env.GetServerCert() - config.Storage = NewStorage(kid, cert.Leaf.PublicKey, cert.PrivateKey, method, &config, env) - - handlers := map[string]http.Handler{} - - for _, issuer := range config.Issuers { - issuerUrl := "https://" + issuer + "/oidc" - provider, err := newOidcProvider(ctx, issuerUrl, config) - if err != nil { - return nil, fmt.Errorf("could not create OpenIdProvider: %w", err) - } - - oidcHandler, err := newHttpRouter(provider, config) - - openzitiClient := NativeClient(common.ClaimClientIdOpenZiti, config.RedirectURIs, config.PostLogoutURIs) - openzitiClient.idTokenDuration = config.IdTokenDuration - openzitiClient.loginURL = newLoginResolver(config.Storage) - config.Storage.AddClient(openzitiClient) - - //backwards compatibility client w/ early HA SDKs. Should be removed by the time HA is GA'ed. - nativeClient := NativeClient(common.ClaimLegacyNative, config.RedirectURIs, config.PostLogoutURIs) - nativeClient.idTokenDuration = config.IdTokenDuration - nativeClient.loginURL = newLoginResolver(config.Storage) - config.Storage.AddClient(nativeClient) - - if err != nil { - return nil, err - } - - handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - r := request.WithContext(context.WithValue(request.Context(), contextKeyHttpRequest, request)) - r = request.WithContext(context.WithValue(r.Context(), contextKeyTokenState, &TokenState{})) - r = request.WithContext(op.ContextWithIssuer(r.Context(), issuerUrl)) - - oidcHandler.ServeHTTP(writer, r) - }) +func createIssuerSpecificOidcProvider(ctx context.Context, issuer string, config Config) (http.Handler, error) { + issuerUrl := "https://" + issuer + provider, err := newOidcProvider(ctx, issuerUrl, config) + if err != nil { + return nil, fmt.Errorf("could not create OpenIdProvider: %w", err) + } - hostsToHandle := getHandledHostnames(issuer) + oidcHandler, err := newHttpRouter(provider, config) - for _, hostToHandle := range hostsToHandle { - handlers[hostToHandle] = handler - } + if err != nil { + return nil, err } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handler, ok := handlers[r.Host] - - if !ok { - http.NotFound(w, r) - return - } + handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + r := request.WithContext(context.WithValue(request.Context(), contextKeyHttpRequest, request)) + r = request.WithContext(context.WithValue(r.Context(), contextKeyTokenState, &TokenState{})) + r = request.WithContext(op.ContextWithIssuer(r.Context(), issuerUrl)) - handler.ServeHTTP(w, r) - }), nil + oidcHandler.ServeHTTP(writer, r) + }) + return handler, nil } -func getHandledHostnames(issuer string) []string { - const ( - DefaultTlsPort = "443" - LocalhostName = "localhost" - LocalhostIpv4 = "127.0.0.1" - LocalhostIpv6 = "::1" - ) - hostsToHandle := map[string]struct{}{ - issuer: {}, - } +// NewNativeOnlyOP creates an OIDC Provider that allows native clients and only the AuthCode PKCE flow. +func NewNativeOnlyOP(ctx context.Context, env model.Env, config Config) (http.Handler, error) { + cert, kid, method := env.GetServerCert() + config.Storage = NewStorage(kid, cert.Leaf.PublicKey, cert.PrivateKey, method, &config, env) - hostWithoutPort, port, err := net.SplitHostPort(issuer) - if err != nil { - var ret []string - for host := range hostsToHandle { - ret = append(ret, host) - } + openzitiClient := NativeClient(common.ClaimClientIdOpenZiti, config.RedirectURIs, config.PostLogoutURIs) + openzitiClient.idTokenDuration = config.IdTokenDuration + openzitiClient.loginURL = newLoginResolver(config.Storage) + config.Storage.AddClient(openzitiClient) - return ret - } + //backwards compatibility client w/ early HA SDKs. Should be removed by the time HA is GA'ed. + nativeClient := NativeClient(common.ClaimLegacyNative, config.RedirectURIs, config.PostLogoutURIs) + nativeClient.idTokenDuration = config.IdTokenDuration + nativeClient.loginURL = newLoginResolver(config.Storage) + config.Storage.AddClient(nativeClient) - shouldHandleDefaultPort := port == DefaultTlsPort - if shouldHandleDefaultPort { + handlers := map[Issuer]http.Handler{} - ip := net.ParseIP(hostWithoutPort) - isIpv6 := ip != nil && ip.To4() == nil + for _, issuer := range config.Issuers { + oidcIssuer := issuer.HostPort() + "/oidc" - if isIpv6 { - //ipv6 in urls always requires brackets even w/ default ports - hostsToHandle["["+hostWithoutPort+"]"] = struct{}{} - } else { - hostsToHandle[hostWithoutPort] = struct{}{} + handler, err := createIssuerSpecificOidcProvider(ctx, oidcIssuer, config) + if err != nil { + return nil, err } + thisIss := issuer + handlers[thisIss] = handler } - //local address in use, translate as needed - if hostWithoutPort == LocalhostName || hostWithoutPort == LocalhostIpv4 || hostWithoutPort == "::1" { - hostsToHandle[net.JoinHostPort(LocalhostName, port)] = struct{}{} - hostsToHandle[net.JoinHostPort(LocalhostIpv4, port)] = struct{}{} - hostsToHandle[net.JoinHostPort(LocalhostIpv6, port)] = struct{}{} - - if shouldHandleDefaultPort { - hostsToHandle[LocalhostName] = struct{}{} - hostsToHandle[LocalhostIpv4] = struct{}{} - - //ipv6 in urls always requires brackets even w/ default ports - hostsToHandle["["+LocalhostIpv6+"]"] = struct{}{} - + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for iss, handler := range handlers { + if err := iss.ValidFor(r.Host); err == nil { + handler.ServeHTTP(w, r) + return + } } - } - var ret []string - for host := range hostsToHandle { - ret = append(ret, host) - } + http.NotFound(w, r) + }), nil - return ret } // newHttpRouter creates an OIDC HTTP router diff --git a/controller/oidc_auth/provider_test.go b/controller/oidc_auth/provider_test.go deleted file mode 100644 index dbf3df6b5..000000000 --- a/controller/oidc_auth/provider_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package oidc_auth - -import ( - "github.com/stretchr/testify/require" - "testing" -) - -func Test_GetHandledHostNames(t *testing.T) { - - t.Run("localhost:443 returns all local addresses and supports implicit 443 port", func(t *testing.T) { - result := getHandledHostnames("localhost:443") - - req := require.New(t) - - req.Len(result, 6) - req.Contains(result, "localhost:443") - req.Contains(result, "localhost") - req.Contains(result, "127.0.0.1:443") - req.Contains(result, "127.0.0.1") - req.Contains(result, "[::1]") - req.Contains(result, "[::1]:443") - }) - t.Run("localhost:1234 returns all local addresses", func(t *testing.T) { - result := getHandledHostnames("localhost:1234") - - req := require.New(t) - - req.Len(result, 3) - req.Contains(result, "localhost:1234") - req.Contains(result, "127.0.0.1:1234") - req.Contains(result, "[::1]:1234") - }) - - t.Run("127.0.0.1:443 returns all local addresses and supports implicit 443 port", func(t *testing.T) { - result := getHandledHostnames("127.0.0.1:443") - - req := require.New(t) - - req.Len(result, 6) - req.Contains(result, "localhost:443") - req.Contains(result, "localhost") - req.Contains(result, "127.0.0.1:443") - req.Contains(result, "127.0.0.1") - req.Contains(result, "[::1]") - req.Contains(result, "[::1]:443") - }) - t.Run("127.0.0.1:1234 returns all local addresses", func(t *testing.T) { - result := getHandledHostnames("127.0.0.1:1234") - - req := require.New(t) - - req.Len(result, 3) - req.Contains(result, "localhost:1234") - req.Contains(result, "127.0.0.1:1234") - req.Contains(result, "[::1]:1234") - }) - - t.Run("[::1]:443 returns all local addresses and supports implicit 443 port", func(t *testing.T) { - result := getHandledHostnames("[::1]:443") - - req := require.New(t) - - req.Len(result, 6) - req.Contains(result, "localhost:443") - req.Contains(result, "localhost") - req.Contains(result, "127.0.0.1:443") - req.Contains(result, "127.0.0.1") - req.Contains(result, "[::1]") - req.Contains(result, "[::1]:443") - }) - t.Run("[::1]:1234 returns all local addresses", func(t *testing.T) { - result := getHandledHostnames("[::1]:1234") - - req := require.New(t) - - req.Len(result, 3) - req.Contains(result, "localhost:1234") - req.Contains(result, "127.0.0.1:1234") - req.Contains(result, "[::1]:1234") - }) -} diff --git a/controller/oidc_auth/render.go b/controller/oidc_auth/render.go index 647906296..5ab106da9 100644 --- a/controller/oidc_auth/render.go +++ b/controller/oidc_auth/render.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/requests.go b/controller/oidc_auth/requests.go index e3e65d60b..95fb6b7ff 100644 --- a/controller/oidc_auth/requests.go +++ b/controller/oidc_auth/requests.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/oidc_auth/storage.go b/controller/oidc_auth/storage.go index 62a072d46..48130b252 100644 --- a/controller/oidc_auth/storage.go +++ b/controller/oidc_auth/storage.go @@ -1,3 +1,19 @@ +/* + 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 ( diff --git a/controller/webapis/oidc-api.go b/controller/webapis/oidc-api.go index e4546addb..a24c9260a 100644 --- a/controller/webapis/oidc-api.go +++ b/controller/webapis/oidc-api.go @@ -21,6 +21,8 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "github.com/openziti/identity" + "net" "net/http" "strings" @@ -86,7 +88,7 @@ func (h OidcApiHandler) RootPath() string { } func (h OidcApiHandler) IsHandler(r *http.Request) bool { - return strings.HasPrefix(r.URL.Path, h.RootPath()) || r.URL.Path == oidc_auth.WellKnownOidcConfiguration + return strings.HasPrefix(r.URL.Path, h.RootPath()) } func (h OidcApiHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { @@ -108,12 +110,10 @@ func NewOidcApiHandler(serverConfig *xweb.ServerConfig, ae *env.AppEnv, options cert := serverCert[0].Leaf key := serverCert[0].PrivateKey - var issuers []string + issuers := getPossibleIssuers(serverConfig.Identity, serverConfig.BindPoints) - for _, bindPoint := range serverConfig.BindPoints { - issuers = append(issuers, bindPoint.Address) - } oidcConfig := oidc_auth.NewConfig(issuers, cert, key) + oidcConfig.Identity = serverConfig.Identity if secretVal, ok := options["secret"]; ok { if secret, ok := secretVal.(string); ok { @@ -181,3 +181,77 @@ func NewOidcApiHandler(serverConfig *xweb.ServerConfig, ae *env.AppEnv, options return oidcApi, nil } + +// getPossibleIssuers inspects the API server's identity and bind points for addresses, SAN DNS, and SAN IP entries +// that denote valid issuers. It returns a list of hostname:port combinations as a slice. It handles converting +// :443 to explicit and implicit ports for clients that may silently remove :443 +func getPossibleIssuers(id identity.Identity, bindPoints []*xweb.BindPointConfig) []oidc_auth.Issuer { + const ( + DefaultTlsPort = "443" + ) + + // The expected issuer's list is a combination of the following: + // - all explicit expected bind point address ip or hostname and ports + // - the IP and DNS SANs from all server certs + the port from the bind point address + issuerMap := map[string]struct{}{} + portMap := map[string]struct{}{} + + for _, bindPoint := range bindPoints { + host, port, err := net.SplitHostPort(bindPoint.Address) + if err != nil { + continue + + } + portMap[port] = struct{}{} + + if port == DefaultTlsPort { + issuerMap[host] = struct{}{} + } + + issuerMap[bindPoint.Address] = struct{}{} + } + + var ports []string + for port := range portMap { + ports = append(ports, port) + } + + for _, curServerCertChain := range id.GetX509ActiveServerCertChains() { + if len(curServerCertChain) == 0 { + continue + } + curServerCert := curServerCertChain[0] + for _, dnsName := range curServerCert.DNSNames { + for _, port := range ports { + newIssuer := net.JoinHostPort(dnsName, port) + issuerMap[newIssuer] = struct{}{} + if port == DefaultTlsPort { + issuerMap[dnsName] = struct{}{} + } + } + } + + for _, ipAddr := range curServerCert.IPAddresses { + for _, port := range ports { + ipStr := ipAddr.String() + newIssuer := net.JoinHostPort(ipStr, port) + issuerMap[newIssuer] = struct{}{} + if port == DefaultTlsPort { + issuerMap[ipStr] = struct{}{} + } + } + } + } + + var issuers []oidc_auth.Issuer + for address := range issuerMap { + issuer, err := oidc_auth.NewIssuer(address) + + if err != nil { + continue + } + issuers = append(issuers, issuer) + } + + return issuers +} diff --git a/controller/webapis/oidc-api_test.go b/controller/webapis/oidc-api_test.go new file mode 100644 index 000000000..6e810c382 --- /dev/null +++ b/controller/webapis/oidc-api_test.go @@ -0,0 +1,265 @@ +/* +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 webapis + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "github.com/openziti/identity" + "github.com/openziti/xweb/v2" + "github.com/stretchr/testify/require" + "math/big" + "net" + "testing" + "time" +) + +func Test_getPossibleIssuers(t *testing.T) { + req := require.New(t) + + caKey, caCertTemplate := mkCaCert("Parent CA") + parentDer, err := x509.CreateCertificate(rand.Reader, caCertTemplate, caCertTemplate, caKey.Public(), caKey) + req.NoError(err) + + childKey1, childCert1 := mkClientCert("Test Child 1") + childCert1Der, err := x509.CreateCertificate(rand.Reader, childCert1, caCertTemplate, childKey1.Public(), caKey) + req.NoError(err) + + childKey2, childCert2 := mkServerCert("Test Child 2", []string{"client2.netfoundry.io"}, []net.IP{net.ParseIP("127.0.0.1")}) + childCert2Der, err := x509.CreateCertificate(rand.Reader, childCert2, caCertTemplate, childKey2.Public(), caKey) + req.NoError(err) + + childKey3, childCert3 := mkServerCert("Test Child 3", []string{"client3.netfoundry.io"}, []net.IP{net.ParseIP("10.8.0.1")}) + childCert3Der, err := x509.CreateCertificate(rand.Reader, childCert3, caCertTemplate, childKey3.Public(), caKey) + req.NoError(err) + + childKey4, childCert4 := mkServerCert("Test Child 4", []string{"*.wildcard.io"}, []net.IP{net.ParseIP("192.168.0.1")}) + childCert4Der, err := x509.CreateCertificate(rand.Reader, childCert4, caCertTemplate, childKey4.Public(), caKey) + req.NoError(err) + + childKey1Der, _ := x509.MarshalECPrivateKey(childKey1.(*ecdsa.PrivateKey)) + childKey1Pem := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: childKey1Der, + } + + childKey2Der, _ := x509.MarshalECPrivateKey(childKey2.(*ecdsa.PrivateKey)) + childKey2Pem := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: childKey2Der, + } + + childKey3Der, _ := x509.MarshalECPrivateKey(childKey3.(*ecdsa.PrivateKey)) + childKey3Pem := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: childKey3Der, + } + + childKey4Der, _ := x509.MarshalECPrivateKey(childKey4.(*ecdsa.PrivateKey)) + childKey4Pem := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: childKey4Der, + } + + parentPem := &pem.Block{ + Type: "CERTIFICATE", + Bytes: parentDer, + } + + childCert1Pem := &pem.Block{ + Type: "CERTIFICATE", + Bytes: childCert1Der, + } + + childCert2Pem := &pem.Block{ + Type: "CERTIFICATE", + Bytes: childCert2Der, + } + + childCert3Pem := &pem.Block{ + Type: "CERTIFICATE", + Bytes: childCert3Der, + } + + childCert4Pem := &pem.Block{ + Type: "CERTIFICATE", + Bytes: childCert4Der, + } + + cfg := identity.Config{ + Key: "pem:" + string(pem.EncodeToMemory(childKey1Pem)), + Cert: "pem:" + string(pem.EncodeToMemory(childCert1Pem)) + string(pem.EncodeToMemory(parentPem)), + ServerKey: "pem:" + string(pem.EncodeToMemory(childKey2Pem)), + ServerCert: "pem:" + string(pem.EncodeToMemory(childCert2Pem)) + string(pem.EncodeToMemory(parentPem)), + AltServerCerts: []identity.ServerPair{ + { + ServerKey: "pem:" + string(pem.EncodeToMemory(childKey3Pem)), + ServerCert: "pem:" + string(pem.EncodeToMemory(childCert3Pem)) + string(pem.EncodeToMemory(parentPem)), + }, + { + ServerKey: "pem:" + string(pem.EncodeToMemory(childKey4Pem)), + ServerCert: "pem:" + string(pem.EncodeToMemory(childCert4Pem)) + string(pem.EncodeToMemory(parentPem)), + }, + }, + } + + id, err := identity.LoadIdentity(cfg) + req.NoError(err) + + t.Run("receives the proper issuers", func(t *testing.T) { + req := require.New(t) + const ( + bindPoint1Address = "test1.example.com:1234" + bindPoint2Address = "test2.example.com:443" + ) + + bindPoints := []*xweb.BindPointConfig{ + { + Address: bindPoint1Address, + }, + { + Address: bindPoint2Address, + }, + } + + issuers := getPossibleIssuers(id, bindPoints) + + req.Len(issuers, 21) + + isValidIssuer := func(address string) error { + for _, issuer := range issuers { + if err := issuer.ValidFor(address); err == nil { + return nil + } + } + + return fmt.Errorf("invalid address, no issuer supports: %s", address) + } + + req.NoError(isValidIssuer("test1.example.com:1234")) + req.NoError(isValidIssuer("test2.example.com:443")) + req.NoError(isValidIssuer("test2.example.com")) + + req.NoError(isValidIssuer("client2.netfoundry.io:1234")) + req.NoError(isValidIssuer("client2.netfoundry.io:443")) + req.NoError(isValidIssuer("client2.netfoundry.io")) + + req.NoError(isValidIssuer("client3.netfoundry.io:1234")) + req.NoError(isValidIssuer("client3.netfoundry.io:443")) + req.NoError(isValidIssuer("client3.netfoundry.io")) + + req.NoError(isValidIssuer("star.wildcard.io:1234")) + req.NoError(isValidIssuer("star.wildcard.io:443")) + req.NoError(isValidIssuer("star.wildcard.io")) + + req.NoError(isValidIssuer("127.0.0.1:1234")) + req.NoError(isValidIssuer("127.0.0.1:443")) + req.NoError(isValidIssuer("127.0.0.1")) + + req.NoError(isValidIssuer("10.8.0.1:1234")) + req.NoError(isValidIssuer("10.8.0.1:443")) + req.NoError(isValidIssuer("10.8.0.1")) + + req.NoError(isValidIssuer("192.168.0.1:1234")) + req.NoError(isValidIssuer("192.168.0.1:443")) + req.NoError(isValidIssuer("192.168.0.1")) + + req.Error(isValidIssuer("10.123.123.1")) + req.Error(isValidIssuer("star.wildcard.io:555")) + req.Error(isValidIssuer("10.8.0.1:555")) + req.Error(isValidIssuer("google.com")) + + }) +} + +// helpers + +var testSerial = int64(0) + +func mkCaCert(cn string) (crypto.Signer, *x509.Certificate) { + testSerial++ + + key, _ := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(testSerial), + Subject: pkix.Name{ + Organization: []string{"OpenZiti Identity Tests"}, + OrganizationalUnit: []string{"CA Certs"}, + CommonName: cn, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + ExtKeyUsage: []x509.ExtKeyUsage{}, // CAs typically don’t need ExtKeyUsage + IsCA: true, + BasicConstraintsValid: true, + MaxPathLen: 2, + MaxPathLenZero: false, + } + + return key, cert +} + +func mkServerCert(cn string, dns []string, ips []net.IP) (crypto.Signer, *x509.Certificate) { + testSerial++ + + key, _ := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(testSerial), + Subject: pkix.Name{ + Organization: []string{"OpenZiti Identity Tests"}, + OrganizationalUnit: []string{"Server Certs"}, + CommonName: cn, + }, + DNSNames: dns, + IPAddresses: ips, + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + return key, cert +} + +func mkClientCert(cn string) (crypto.Signer, *x509.Certificate) { + key, _ := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(testSerial), + Subject: pkix.Name{ + Organization: []string{"OpenZiti Identity Tests"}, + OrganizationalUnit: []string{"Client Certs"}, + CommonName: cn, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + return key, cert +} diff --git a/tests/endpoint_test.go b/tests/endpoint_test.go index 8ffbf43a6..caa20843c 100644 --- a/tests/endpoint_test.go +++ b/tests/endpoint_test.go @@ -1,6 +1,7 @@ package tests import ( + "net/http" "testing" ) @@ -39,13 +40,24 @@ func Test_Endpoints(t *testing.T) { ctx.Req.NotEmpty(resp.Body()) }) - t.Run("oidc-configuration works on .well-known", func(t *testing.T) { + t.Run("oidc-configuration does not work on root .well-known", func(t *testing.T) { ctx.testContextChanged(t) rootPathClient, _, _ := ctx.NewClientComponents("/") resp, err := rootPathClient.R().Get("https://" + ctx.ApiHost + "/.well-known/openid-configuration") + ctx.Req.NoError(err) + ctx.Req.Equal(http.StatusNotFound, resp.StatusCode()) + }) + + t.Run("oidc-configuration works on oidc/.well-known", func(t *testing.T) { + ctx.testContextChanged(t) + + rootPathClient, _, _ := ctx.NewClientComponents("/") + + resp, err := rootPathClient.R().Get("https://" + ctx.ApiHost + "/oidc/.well-known/openid-configuration") + ctx.Req.NoError(err) ctx.Req.Equal(200, resp.StatusCode()) ctx.Req.Equal("application/json", resp.Header().Get("Content-Type"))