Skip to content

Commit

Permalink
Merge pull request kubernetes#123527 from aramase/aramase/f/kep_3331_…
Browse files Browse the repository at this point in the history
…discovery_url

Add `DiscoveryURL` to Authentication Configuration
  • Loading branch information
k8s-ci-robot committed Mar 4, 2024
2 parents 6d2ee13 + 78fb0ba commit ee5eca2
Show file tree
Hide file tree
Showing 10 changed files with 629 additions and 59 deletions.
38 changes: 36 additions & 2 deletions staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,43 @@ type JWTAuthenticator struct {
UserValidationRules []UserValidationRule
}

// Issuer provides the configuration for a external provider specific settings.
// Issuer provides the configuration for an external provider's specific settings.
type Issuer struct {
URL string
// url points to the issuer URL in a format https://url or https://url/path.
// This must match the "iss" claim in the presented JWT, and the issuer returned from discovery.
// Same value as the --oidc-issuer-url flag.
// Discovery information is fetched from "{url}/.well-known/openid-configuration" unless overridden by discoveryURL.
// Required to be unique across all JWT authenticators.
// Note that egress selection configuration is not used for this network connection.
// +required
URL string
// discoveryURL, if specified, overrides the URL used to fetch discovery
// information instead of using "{url}/.well-known/openid-configuration".
// The exact value specified is used, so "/.well-known/openid-configuration"
// must be included in discoveryURL if needed.
//
// The "issuer" field in the fetched discovery information must match the "issuer.url" field
// in the AuthenticationConfiguration and will be used to validate the "iss" claim in the presented JWT.
// This is for scenarios where the well-known and jwks endpoints are hosted at a different
// location than the issuer (such as locally in the cluster).
//
// Example:
// A discovery url that is exposed using kubernetes service 'oidc' in namespace 'oidc-namespace'
// and discovery information is available at '/.well-known/openid-configuration'.
// discoveryURL: "https://oidc.oidc-namespace/.well-known/openid-configuration"
// certificateAuthority is used to verify the TLS connection and the hostname on the leaf certificate
// must be set to 'oidc.oidc-namespace'.
//
// curl https://oidc.oidc-namespace/.well-known/openid-configuration (.discoveryURL field)
// {
// issuer: "https://oidc.example.com" (.url field)
// }
//
// discoveryURL must be different from url.
// Required to be unique across all JWT authenticators.
// Note that egress selection configuration is not used for this network connection.
// +optional
DiscoveryURL string
CertificateAuthority string
Audiences []string
AudienceMatchPolicy AudienceMatchPolicyType
Expand Down
34 changes: 31 additions & 3 deletions staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,17 +209,45 @@ type JWTAuthenticator struct {
UserValidationRules []UserValidationRule `json:"userValidationRules,omitempty"`
}

// Issuer provides the configuration for a external provider specific settings.
// Issuer provides the configuration for an external provider's specific settings.
type Issuer struct {
// url points to the issuer URL in a format https://url or https://url/path.
// This must match the "iss" claim in the presented JWT, and the issuer returned from discovery.
// Same value as the --oidc-issuer-url flag.
// Used to fetch discovery information unless overridden by discoveryURL.
// Required to be unique.
// Discovery information is fetched from "{url}/.well-known/openid-configuration" unless overridden by discoveryURL.
// Required to be unique across all JWT authenticators.
// Note that egress selection configuration is not used for this network connection.
// +required
URL string `json:"url"`

// discoveryURL, if specified, overrides the URL used to fetch discovery
// information instead of using "{url}/.well-known/openid-configuration".
// The exact value specified is used, so "/.well-known/openid-configuration"
// must be included in discoveryURL if needed.
//
// The "issuer" field in the fetched discovery information must match the "issuer.url" field
// in the AuthenticationConfiguration and will be used to validate the "iss" claim in the presented JWT.
// This is for scenarios where the well-known and jwks endpoints are hosted at a different
// location than the issuer (such as locally in the cluster).
//
// Example:
// A discovery url that is exposed using kubernetes service 'oidc' in namespace 'oidc-namespace'
// and discovery information is available at '/.well-known/openid-configuration'.
// discoveryURL: "https://oidc.oidc-namespace/.well-known/openid-configuration"
// certificateAuthority is used to verify the TLS connection and the hostname on the leaf certificate
// must be set to 'oidc.oidc-namespace'.
//
// curl https://oidc.oidc-namespace/.well-known/openid-configuration (.discoveryURL field)
// {
// issuer: "https://oidc.example.com" (.url field)
// }
//
// discoveryURL must be different from url.
// Required to be unique across all JWT authenticators.
// Note that egress selection configuration is not used for this network connection.
// +optional
DiscoveryURL *string `json:"discoveryURL,omitempty"`

// certificateAuthority contains PEM-encoded certificate authority certificates
// used to validate the connection when fetching discovery information.
// If unset, the system verifier is used.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,40 @@ func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field
func validateIssuer(issuer api.Issuer, fldPath *field.Path) field.ErrorList {
var allErrs field.ErrorList

allErrs = append(allErrs, validateURL(issuer.URL, fldPath.Child("url"))...)
allErrs = append(allErrs, validateIssuerURL(issuer.URL, fldPath.Child("url"))...)
allErrs = append(allErrs, validateIssuerDiscoveryURL(issuer.URL, issuer.DiscoveryURL, fldPath.Child("discoveryURL"))...)
allErrs = append(allErrs, validateAudiences(issuer.Audiences, issuer.AudienceMatchPolicy, fldPath.Child("audiences"), fldPath.Child("audienceMatchPolicy"))...)
allErrs = append(allErrs, validateCertificateAuthority(issuer.CertificateAuthority, fldPath.Child("certificateAuthority"))...)

return allErrs
}

func validateURL(issuerURL string, fldPath *field.Path) field.ErrorList {
func validateIssuerURL(issuerURL string, fldPath *field.Path) field.ErrorList {
if len(issuerURL) == 0 {
return field.ErrorList{field.Required(fldPath, "URL is required")}
}

return validateURL(issuerURL, fldPath)
}

func validateIssuerDiscoveryURL(issuerURL, issuerDiscoveryURL string, fldPath *field.Path) field.ErrorList {
var allErrs field.ErrorList

if len(issuerURL) == 0 {
allErrs = append(allErrs, field.Required(fldPath, "URL is required"))
return allErrs
if len(issuerDiscoveryURL) == 0 {
return nil
}

if len(issuerURL) > 0 && strings.TrimRight(issuerURL, "/") == strings.TrimRight(issuerDiscoveryURL, "/") {
allErrs = append(allErrs, field.Invalid(fldPath, issuerDiscoveryURL, "discoveryURL must be different from URL"))
}

allErrs = append(allErrs, validateURL(issuerDiscoveryURL, fldPath)...)
return allErrs
}

func validateURL(issuerURL string, fldPath *field.Path) field.ErrorList {
var allErrs field.ErrorList

u, err := url.Parse(issuerURL)
if err != nil {
allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, err.Error()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func TestValidateAuthenticationConfiguration(t *testing.T) {
}
}

func TestValidateURL(t *testing.T) {
func TestValidateIssuerURL(t *testing.T) {
fldPath := field.NewPath("issuer", "url")

testCases := []struct {
Expand Down Expand Up @@ -259,7 +259,92 @@ func TestValidateURL(t *testing.T) {

for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := validateURL(tt.in, fldPath).ToAggregate()
got := validateIssuerURL(tt.in, fldPath).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("URL validation mismatch (-want +got):\n%s", d)
}
})
}
}

func TestValidateIssuerDiscoveryURL(t *testing.T) {
fldPath := field.NewPath("issuer", "discoveryURL")

testCases := []struct {
name string
in string
issuerURL string
want string
}{
{
name: "url is empty",
in: "",
want: "",
},
{
name: "url parse error",
in: "https://oidc.oidc-namespace.svc:invalid-port",
want: `issuer.discoveryURL: Invalid value: "https://oidc.oidc-namespace.svc:invalid-port": parse "https://oidc.oidc-namespace.svc:invalid-port": invalid port ":invalid-port" after host`,
},
{
name: "url is not https",
in: "http://oidc.oidc-namespace.svc",
want: `issuer.discoveryURL: Invalid value: "http://oidc.oidc-namespace.svc": URL scheme must be https`,
},
{
name: "url user info is not allowed",
in: "https://user:pass@oidc.oidc-namespace.svc",
want: `issuer.discoveryURL: Invalid value: "https://user:pass@oidc.oidc-namespace.svc": URL must not contain a username or password`,
},
{
name: "url raw query is not allowed",
in: "https://oidc.oidc-namespace.svc?query",
want: `issuer.discoveryURL: Invalid value: "https://oidc.oidc-namespace.svc?query": URL must not contain a query`,
},
{
name: "url fragment is not allowed",
in: "https://oidc.oidc-namespace.svc#fragment",
want: `issuer.discoveryURL: Invalid value: "https://oidc.oidc-namespace.svc#fragment": URL must not contain a fragment`,
},
{
name: "valid url",
in: "https://oidc.oidc-namespace.svc",
want: "",
},
{
name: "valid url with path",
in: "https://oidc.oidc-namespace.svc/path",
want: "",
},
{
name: "discovery url same as issuer url",
issuerURL: "https://issuer-url",
in: "https://issuer-url",
want: `issuer.discoveryURL: Invalid value: "https://issuer-url": discoveryURL must be different from URL`,
},
{
name: "discovery url same as issuer url, with trailing slash",
issuerURL: "https://issuer-url",
in: "https://issuer-url/",
want: `issuer.discoveryURL: Invalid value: "https://issuer-url/": discoveryURL must be different from URL`,
},
{
name: "discovery url same as issuer url, with multiple trailing slashes",
issuerURL: "https://issuer-url",
in: "https://issuer-url///",
want: `issuer.discoveryURL: Invalid value: "https://issuer-url///": discoveryURL must be different from URL`,
},
{
name: "discovery url same as issuer url, issuer url with trailing slash",
issuerURL: "https://issuer-url/",
in: "https://issuer-url",
want: `issuer.discoveryURL: Invalid value: "https://issuer-url": discoveryURL must be different from URL`,
},
}

for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := validateIssuerDiscoveryURL(tt.issuerURL, tt.in, fldPath).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("URL validation mismatch (-want +got):\n%s", d)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"reflect"
"strings"
"sync"
Expand Down Expand Up @@ -66,6 +67,10 @@ var (
synchronizeTokenIDVerifierForTest = false
)

const (
wellKnownEndpointPath = "/.well-known/openid-configuration"
)

type Options struct {
// JWTAuthenticator is the authenticator that will be used to verify the JWT.
JWTAuthenticator apiserver.JWTAuthenticator
Expand Down Expand Up @@ -268,6 +273,28 @@ func New(opts Options) (authenticator.Token, error) {
client = &http.Client{Transport: tr, Timeout: 30 * time.Second}
}

// If the discovery URL is set in authentication configuration, we set up a
// roundTripper to rewrite the {url}/.well-known/openid-configuration to
// the discovery URL. This is useful for self-hosted providers, for example,
// providers that run on top of Kubernetes itself.
if len(opts.JWTAuthenticator.Issuer.DiscoveryURL) > 0 {
discoveryURL, err := url.Parse(opts.JWTAuthenticator.Issuer.DiscoveryURL)
if err != nil {
return nil, fmt.Errorf("oidc: invalid discovery URL: %w", err)
}

clientWithDiscoveryURL := *client
baseTransport := clientWithDiscoveryURL.Transport
if baseTransport == nil {
baseTransport = http.DefaultTransport
}
// This matches the url construction in oidc.NewProvider as of go-oidc v2.2.1.
// xref: https://github.com/coreos/go-oidc/blob/40cd342c4a2076195294612a834d11df23c1b25a/oidc.go#L114
urlToRewrite := strings.TrimSuffix(opts.JWTAuthenticator.Issuer.URL, "/") + wellKnownEndpointPath
clientWithDiscoveryURL.Transport = &discoveryURLRoundTripper{baseTransport, discoveryURL, urlToRewrite}
client = &clientWithDiscoveryURL
}

ctx, cancel := context.WithCancel(context.Background())
ctx = oidc.ClientContext(ctx, client)

Expand Down Expand Up @@ -339,6 +366,26 @@ func New(opts Options) (authenticator.Token, error) {
return newInstrumentedAuthenticator(issuerURL, authenticator), nil
}

// discoveryURLRoundTripper is a http.RoundTripper that rewrites the
// {url}/.well-known/openid-configuration to the discovery URL.
type discoveryURLRoundTripper struct {
base http.RoundTripper
// discoveryURL is the URL to use to fetch the openid configuration
discoveryURL *url.URL
// urlToRewrite is the URL to rewrite to the discovery URL
urlToRewrite string
}

func (t *discoveryURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodGet && req.URL.String() == t.urlToRewrite {
clone := req.Clone(req.Context())
clone.Host = ""
clone.URL = t.discoveryURL
return t.base.RoundTrip(clone)
}
return t.base.RoundTrip(req)
}

// untrustedIssuer extracts an untrusted "iss" claim from the given JWT token,
// or returns an error if the token can not be parsed. Since the JWT is not
// verified, the returned issuer should not be trusted.
Expand Down
Loading

0 comments on commit ee5eca2

Please sign in to comment.