diff --git a/.golangci.yaml b/.golangci.yaml index ec1e153c..9578fd7e 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -60,7 +60,7 @@ issues: linters-settings: funlen: - lines: 125 + lines: 150 statements: 50 goheader: template: |- diff --git a/Dockerfile b/Dockerfile index 7ddea4d4..73803e6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN go mod download # Copy only the production source code to avoid cache misses when editing other files COPY generated ./generated COPY cmd ./cmd +COPY pkg ./pkg COPY internal ./internal COPY tools ./tools COPY hack ./hack diff --git a/apis/supervisor/idp/v1alpha1/types_tls.go.tmpl b/apis/supervisor/idp/v1alpha1/types_tls.go.tmpl new file mode 100644 index 00000000..fa4db315 --- /dev/null +++ b/apis/supervisor/idp/v1alpha1/types_tls.go.tmpl @@ -0,0 +1,11 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// Configuration for TLS parameters related to identity provider integration. +type TLSSpec struct { + // X.509 Certificate Authority (base64-encoded PEM bundle). If omitted, a default set of system roots will be trusted. + // +optional + CertificateAuthorityData string `json:"certificateAuthorityData,omitempty"` +} diff --git a/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go.tmpl b/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go.tmpl index cc8ca0fa..ea12b063 100644 --- a/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go.tmpl +++ b/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go.tmpl @@ -75,6 +75,10 @@ type UpstreamOIDCProviderSpec struct { // +kubebuilder:validation:Pattern=`^https://` Issuer string `json:"issuer"` + // TLS configuration for discovery/JWKS requests to the issuer. + // +optional + TLS *TLSSpec `json:"tls,omitempty"` + // AuthorizationConfig holds information about how to form the OAuth2 authorization request // parameters to be used with this OIDC identity provider. // +optional diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 3754f06a..1677c00f 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -4,7 +4,12 @@ package cmd import ( + "crypto/tls" + "crypto/x509" "encoding/json" + "fmt" + "io/ioutil" + "net/http" "os" "path/filepath" @@ -13,8 +18,8 @@ import ( clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "k8s.io/klog/v2/klogr" - "go.pinniped.dev/internal/oidcclient" - "go.pinniped.dev/internal/oidcclient/filesession" + "go.pinniped.dev/pkg/oidcclient" + "go.pinniped.dev/pkg/oidcclient/filesession" ) //nolint: gochecknoinits @@ -36,6 +41,7 @@ func oidcLoginCommand(loginFunc func(issuer string, clientID string, opts ...oid scopes []string skipBrowser bool sessionCachePath string + caBundlePaths []string debugSessionCache bool ) cmd.Flags().StringVar(&issuer, "issuer", "", "OpenID Connect issuer URL.") @@ -44,6 +50,7 @@ func oidcLoginCommand(loginFunc func(issuer string, clientID string, opts ...oid cmd.Flags().StringSliceVar(&scopes, "scopes", []string{"offline_access", "openid", "email", "profile"}, "OIDC scopes to request during login.") cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL).") cmd.Flags().StringVar(&sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file.") + cmd.Flags().StringSliceVar(&caBundlePaths, "ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated).") cmd.Flags().BoolVar(&debugSessionCache, "debug-session-cache", false, "Print debug logs related to the session cache.") mustMarkHidden(&cmd, "debug-session-cache") mustMarkRequired(&cmd, "issuer", "client-id") @@ -80,6 +87,27 @@ func oidcLoginCommand(loginFunc func(issuer string, clientID string, opts ...oid })) } + if len(caBundlePaths) > 0 { + pool := x509.NewCertPool() + for _, p := range caBundlePaths { + pem, err := ioutil.ReadFile(p) + if err != nil { + return fmt.Errorf("could not read --ca-bundle: %w", err) + } + pool.AppendCertsFromPEM(pem) + } + tlsConfig := tls.Config{ + RootCAs: pool, + MinVersion: tls.VersionTLS12, + } + opts = append(opts, oidcclient.WithClient(&http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tlsConfig, + }, + })) + } + tok, err := loginFunc(issuer, clientID, opts...) if err != nil { return err diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index 637a7af4..3a61934d 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/oidcclient" + "go.pinniped.dev/pkg/oidcclient" ) func TestLoginOIDCCommand(t *testing.T) { @@ -40,6 +40,7 @@ func TestLoginOIDCCommand(t *testing.T) { oidc --issuer ISSUER --client-id CLIENT_ID [flags] Flags: + --ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated). --client-id string OpenID Connect client ID. -h, --help help for oidc --issuer string OpenID Connect issuer URL. diff --git a/deploy/supervisor/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml b/deploy/supervisor/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml index 451a4474..780fe6fe 100644 --- a/deploy/supervisor/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml +++ b/deploy/supervisor/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml @@ -98,6 +98,15 @@ spec: minLength: 1 pattern: ^https:// type: string + tls: + description: TLS configuration for discovery/JWKS requests to the + issuer. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object required: - client - issuer diff --git a/generated/1.17/README.adoc b/generated/1.17/README.adoc index 1854d94a..a81aa6e9 100644 --- a/generated/1.17/README.adoc +++ b/generated/1.17/README.adoc @@ -373,6 +373,23 @@ OIDCClient contains information about an OIDC client (e.g., client ID and client |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-tlsspec"] +==== TLSSpec + +Configuration for TLS parameters related to identity provider integration. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-upstreamoidcproviderspec[$$UpstreamOIDCProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`certificateAuthorityData`* __string__ | X.509 Certificate Authority (base64-encoded PEM bundle). If omitted, a default set of system roots will be trusted. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-upstreamoidcprovider"] ==== UpstreamOIDCProvider @@ -409,6 +426,7 @@ Spec for configuring an OIDC identity provider. |=== | Field | Description | *`issuer`* __string__ | Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch /.well-known/openid-configuration. +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-tlsspec[$$TLSSpec$$]__ | TLS configuration for discovery/JWKS requests to the issuer. | *`authorizationConfig`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig[$$OIDCAuthorizationConfig$$]__ | AuthorizationConfig holds information about how to form the OAuth2 authorization request parameters to be used with this OIDC identity provider. | *`claims`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcclaims[$$OIDCClaims$$]__ | Claims provides the names of token claims that will be used when inspecting an identity from this OIDC identity provider. | *`client`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcclient[$$OIDCClient$$]__ | OIDCClient contains OIDC client information to be used used with this OIDC identity provider. diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/types_tls.go b/generated/1.17/apis/supervisor/idp/v1alpha1/types_tls.go new file mode 100644 index 00000000..fa4db315 --- /dev/null +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/types_tls.go @@ -0,0 +1,11 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// Configuration for TLS parameters related to identity provider integration. +type TLSSpec struct { + // X.509 Certificate Authority (base64-encoded PEM bundle). If omitted, a default set of system roots will be trusted. + // +optional + CertificateAuthorityData string `json:"certificateAuthorityData,omitempty"` +} diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go b/generated/1.17/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go index cc8ca0fa..ea12b063 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go @@ -75,6 +75,10 @@ type UpstreamOIDCProviderSpec struct { // +kubebuilder:validation:Pattern=`^https://` Issuer string `json:"issuer"` + // TLS configuration for discovery/JWKS requests to the issuer. + // +optional + TLS *TLSSpec `json:"tls,omitempty"` + // AuthorizationConfig holds information about how to form the OAuth2 authorization request // parameters to be used with this OIDC identity provider. // +optional diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 07cbb8b6..9eeade4e 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -81,6 +81,22 @@ func (in *OIDCClient) DeepCopy() *OIDCClient { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSpec. +func (in *TLSSpec) DeepCopy() *TLSSpec { + if in == nil { + return nil + } + out := new(TLSSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamOIDCProvider) DeepCopyInto(out *UpstreamOIDCProvider) { *out = *in @@ -145,6 +161,11 @@ func (in *UpstreamOIDCProviderList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamOIDCProviderSpec) DeepCopyInto(out *UpstreamOIDCProviderSpec) { *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } in.AuthorizationConfig.DeepCopyInto(&out.AuthorizationConfig) out.Claims = in.Claims out.Client = in.Client diff --git a/generated/1.17/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml b/generated/1.17/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml index 451a4474..780fe6fe 100644 --- a/generated/1.17/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml +++ b/generated/1.17/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml @@ -98,6 +98,15 @@ spec: minLength: 1 pattern: ^https:// type: string + tls: + description: TLS configuration for discovery/JWKS requests to the + issuer. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object required: - client - issuer diff --git a/generated/1.18/README.adoc b/generated/1.18/README.adoc index bb11a577..a2a20313 100644 --- a/generated/1.18/README.adoc +++ b/generated/1.18/README.adoc @@ -373,6 +373,23 @@ OIDCClient contains information about an OIDC client (e.g., client ID and client |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-tlsspec"] +==== TLSSpec + +Configuration for TLS parameters related to identity provider integration. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-upstreamoidcproviderspec[$$UpstreamOIDCProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`certificateAuthorityData`* __string__ | X.509 Certificate Authority (base64-encoded PEM bundle). If omitted, a default set of system roots will be trusted. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-upstreamoidcprovider"] ==== UpstreamOIDCProvider @@ -409,6 +426,7 @@ Spec for configuring an OIDC identity provider. |=== | Field | Description | *`issuer`* __string__ | Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch /.well-known/openid-configuration. +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-tlsspec[$$TLSSpec$$]__ | TLS configuration for discovery/JWKS requests to the issuer. | *`authorizationConfig`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig[$$OIDCAuthorizationConfig$$]__ | AuthorizationConfig holds information about how to form the OAuth2 authorization request parameters to be used with this OIDC identity provider. | *`claims`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcclaims[$$OIDCClaims$$]__ | Claims provides the names of token claims that will be used when inspecting an identity from this OIDC identity provider. | *`client`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcclient[$$OIDCClient$$]__ | OIDCClient contains OIDC client information to be used used with this OIDC identity provider. diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/types_tls.go b/generated/1.18/apis/supervisor/idp/v1alpha1/types_tls.go new file mode 100644 index 00000000..fa4db315 --- /dev/null +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/types_tls.go @@ -0,0 +1,11 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// Configuration for TLS parameters related to identity provider integration. +type TLSSpec struct { + // X.509 Certificate Authority (base64-encoded PEM bundle). If omitted, a default set of system roots will be trusted. + // +optional + CertificateAuthorityData string `json:"certificateAuthorityData,omitempty"` +} diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go b/generated/1.18/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go index cc8ca0fa..ea12b063 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go @@ -75,6 +75,10 @@ type UpstreamOIDCProviderSpec struct { // +kubebuilder:validation:Pattern=`^https://` Issuer string `json:"issuer"` + // TLS configuration for discovery/JWKS requests to the issuer. + // +optional + TLS *TLSSpec `json:"tls,omitempty"` + // AuthorizationConfig holds information about how to form the OAuth2 authorization request // parameters to be used with this OIDC identity provider. // +optional diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 07cbb8b6..9eeade4e 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -81,6 +81,22 @@ func (in *OIDCClient) DeepCopy() *OIDCClient { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSpec. +func (in *TLSSpec) DeepCopy() *TLSSpec { + if in == nil { + return nil + } + out := new(TLSSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamOIDCProvider) DeepCopyInto(out *UpstreamOIDCProvider) { *out = *in @@ -145,6 +161,11 @@ func (in *UpstreamOIDCProviderList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamOIDCProviderSpec) DeepCopyInto(out *UpstreamOIDCProviderSpec) { *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } in.AuthorizationConfig.DeepCopyInto(&out.AuthorizationConfig) out.Claims = in.Claims out.Client = in.Client diff --git a/generated/1.18/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml b/generated/1.18/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml index 451a4474..780fe6fe 100644 --- a/generated/1.18/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml +++ b/generated/1.18/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml @@ -98,6 +98,15 @@ spec: minLength: 1 pattern: ^https:// type: string + tls: + description: TLS configuration for discovery/JWKS requests to the + issuer. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object required: - client - issuer diff --git a/generated/1.19/README.adoc b/generated/1.19/README.adoc index 500bf9ea..e36be991 100644 --- a/generated/1.19/README.adoc +++ b/generated/1.19/README.adoc @@ -373,6 +373,23 @@ OIDCClient contains information about an OIDC client (e.g., client ID and client |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-tlsspec"] +==== TLSSpec + +Configuration for TLS parameters related to identity provider integration. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-upstreamoidcproviderspec[$$UpstreamOIDCProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`certificateAuthorityData`* __string__ | X.509 Certificate Authority (base64-encoded PEM bundle). If omitted, a default set of system roots will be trusted. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-upstreamoidcprovider"] ==== UpstreamOIDCProvider @@ -409,6 +426,7 @@ Spec for configuring an OIDC identity provider. |=== | Field | Description | *`issuer`* __string__ | Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch /.well-known/openid-configuration. +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-tlsspec[$$TLSSpec$$]__ | TLS configuration for discovery/JWKS requests to the issuer. | *`authorizationConfig`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig[$$OIDCAuthorizationConfig$$]__ | AuthorizationConfig holds information about how to form the OAuth2 authorization request parameters to be used with this OIDC identity provider. | *`claims`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcclaims[$$OIDCClaims$$]__ | Claims provides the names of token claims that will be used when inspecting an identity from this OIDC identity provider. | *`client`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcclient[$$OIDCClient$$]__ | OIDCClient contains OIDC client information to be used used with this OIDC identity provider. diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/types_tls.go b/generated/1.19/apis/supervisor/idp/v1alpha1/types_tls.go new file mode 100644 index 00000000..fa4db315 --- /dev/null +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/types_tls.go @@ -0,0 +1,11 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// Configuration for TLS parameters related to identity provider integration. +type TLSSpec struct { + // X.509 Certificate Authority (base64-encoded PEM bundle). If omitted, a default set of system roots will be trusted. + // +optional + CertificateAuthorityData string `json:"certificateAuthorityData,omitempty"` +} diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go b/generated/1.19/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go index cc8ca0fa..ea12b063 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/types_upstreamoidcprovider.go @@ -75,6 +75,10 @@ type UpstreamOIDCProviderSpec struct { // +kubebuilder:validation:Pattern=`^https://` Issuer string `json:"issuer"` + // TLS configuration for discovery/JWKS requests to the issuer. + // +optional + TLS *TLSSpec `json:"tls,omitempty"` + // AuthorizationConfig holds information about how to form the OAuth2 authorization request // parameters to be used with this OIDC identity provider. // +optional diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 07cbb8b6..9eeade4e 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -81,6 +81,22 @@ func (in *OIDCClient) DeepCopy() *OIDCClient { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSpec. +func (in *TLSSpec) DeepCopy() *TLSSpec { + if in == nil { + return nil + } + out := new(TLSSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamOIDCProvider) DeepCopyInto(out *UpstreamOIDCProvider) { *out = *in @@ -145,6 +161,11 @@ func (in *UpstreamOIDCProviderList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamOIDCProviderSpec) DeepCopyInto(out *UpstreamOIDCProviderSpec) { *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } in.AuthorizationConfig.DeepCopyInto(&out.AuthorizationConfig) out.Claims = in.Claims out.Client = in.Client diff --git a/generated/1.19/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml b/generated/1.19/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml index 451a4474..780fe6fe 100644 --- a/generated/1.19/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml +++ b/generated/1.19/crds/idp.supervisor.pinniped.dev_upstreamoidcproviders.yaml @@ -98,6 +98,15 @@ spec: minLength: 1 pattern: ^https:// type: string + tls: + description: TLS configuration for discovery/JWKS requests to the + issuer. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object required: - client - issuer diff --git a/hack/lib/tilt/Tiltfile b/hack/lib/tilt/Tiltfile index cc4d44d5..e657e967 100644 --- a/hack/lib/tilt/Tiltfile +++ b/hack/lib/tilt/Tiltfile @@ -19,23 +19,25 @@ local_resource( ) ##################################################################################################### -# Dex +# Test IDP (Dex + cert generation + squid proxy) # -# Render the Dex installation manifest using ytt. +# Render the IDP installation manifest using ytt. k8s_yaml(local(['ytt','--file', '../../../test/deploy/dex'])) # Tell tilt to watch all of those files for changes. watch_file('../../../test/deploy/dex') -# Collect all the deployed Dex resources under a "dex" resource tab. -k8s_resource( - workload='dex', # this is the deployment name - objects=[ - # these are the objects that would otherwise appear in the "uncategorized" tab in the tilt UI - 'dex:namespace', - 'dex-config:configmap', - ], -) +k8s_resource(objects=['dex:namespace'], new_name='dex-ns') +k8s_resource(workload='cert-issuer', resource_deps=['dex-ns'], objects=[ + 'cert-issuer:serviceaccount', + 'cert-issuer:role', + 'cert-issuer:rolebinding', +]) +k8s_resource(workload='proxy', resource_deps=['dex-ns']) +k8s_resource(workload='dex', resource_deps=['dex-ns', 'cert-issuer'], objects=[ + 'dex-config:configmap', +]) + ##################################################################################################### # Local-user-authenticator app @@ -186,6 +188,6 @@ k8s_resource( local_resource( 'test-env', 'TILT_MODE=yes ../../prepare-for-integration-tests.sh', - resource_deps=['local-user-auth', 'concierge', 'supervisor'], + resource_deps=['local-user-auth', 'concierge', 'supervisor', 'dex', 'proxy'], deps=['../../prepare-for-integration-tests.sh'], ) diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index d3d7cb24..09155dbb 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -265,6 +265,11 @@ if ! tilt_mode; then popd >/dev/null fi +# +# Download the test CA bundle that was generated in the Dex pod. +# +test_ca_bundle_pem="$(kubectl get secrets -n dex certs -o go-template='{{index .data "ca.pem" | base64decode}}')" + # # Create the environment file # @@ -286,7 +291,9 @@ export PINNIPED_TEST_SUPERVISOR_APP_NAME=${supervisor_app_name} export PINNIPED_TEST_SUPERVISOR_CUSTOM_LABELS='${supervisor_custom_labels}' export PINNIPED_TEST_SUPERVISOR_HTTP_ADDRESS="127.0.0.1:12345" export PINNIPED_TEST_SUPERVISOR_HTTPS_ADDRESS="localhost:12344" -export PINNIPED_TEST_CLI_OIDC_ISSUER=http://127.0.0.1:12346/dex +export PINNIPED_TEST_PROXY=http://127.0.0.1:12346 +export PINNIPED_TEST_CLI_OIDC_ISSUER=https://dex.dex.svc.cluster.local/dex +export PINNIPED_TEST_CLI_OIDC_ISSUER_CA_BUNDLE="${test_ca_bundle_pem}" export PINNIPED_TEST_CLI_OIDC_CLIENT_ID=pinniped-cli export PINNIPED_TEST_CLI_OIDC_LOCALHOST_PORT=48095 export PINNIPED_TEST_CLI_OIDC_USERNAME=pinny@example.com diff --git a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher.go b/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher.go index 144d2944..bc3db3bf 100644 --- a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher.go +++ b/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher.go @@ -6,7 +6,11 @@ package upstreamwatcher import ( "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" "fmt" + "net/http" "net/url" "sort" "time" @@ -48,10 +52,12 @@ const ( reasonMissingKeys = "SecretMissingKeys" reasonSuccess = "Success" reasonUnreachable = "Unreachable" + reasonInvalidTLSConfig = "InvalidTLSConfig" reasonInvalidResponse = "InvalidResponse" // Errors that are generated by our reconcile process. - errFailureStatus = constable.Error("UpstreamOIDCProvider has a failing condition") + errFailureStatus = constable.Error("UpstreamOIDCProvider has a failing condition") + errNoCertificates = constable.Error("no certificates found") ) // IDPCache is a thread safe cache that holds a list of validated upstream OIDC IDP configurations. @@ -59,13 +65,39 @@ type IDPCache interface { SetIDPList([]provider.UpstreamOIDCIdentityProvider) } +// lruValidatorCache caches the *oidc.Provider associated with a particular issuer/TLS configuration. +type lruValidatorCache struct{ cache *cache.Expiring } + +func (c *lruValidatorCache) getProvider(spec *v1alpha1.UpstreamOIDCProviderSpec) *oidc.Provider { + if result, ok := c.cache.Get(c.cacheKey(spec)); ok { + return result.(*oidc.Provider) + } + return nil +} + +func (c *lruValidatorCache) putProvider(spec *v1alpha1.UpstreamOIDCProviderSpec, provider *oidc.Provider) { + c.cache.Set(c.cacheKey(spec), provider, validatorCacheTTL) +} + +func (c *lruValidatorCache) cacheKey(spec *v1alpha1.UpstreamOIDCProviderSpec) interface{} { + var key struct{ issuer, caBundle string } + key.issuer = spec.Issuer + if spec.TLS != nil { + key.caBundle = spec.TLS.CertificateAuthorityData + } + return key +} + type controller struct { cache IDPCache log logr.Logger client pinnipedclientset.Interface providers idpinformers.UpstreamOIDCProviderInformer secrets corev1informers.SecretInformer - validatorCache *cache.Expiring + validatorCache interface { + getProvider(spec *v1alpha1.UpstreamOIDCProviderSpec) *oidc.Provider + putProvider(spec *v1alpha1.UpstreamOIDCProviderSpec, provider *oidc.Provider) + } } // New instantiates a new controllerlib.Controller which will populate the provided IDPCache. @@ -82,7 +114,7 @@ func New( client: client, providers: providers, secrets: secrets, - validatorCache: cache.NewExpiring(), + validatorCache: &lruValidatorCache{cache: cache.NewExpiring()}, } filter := pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()) return controllerlib.New( @@ -197,15 +229,22 @@ func (c *controller) validateSecret(upstream *v1alpha1.UpstreamOIDCProvider, res // validateIssuer validates the .spec.issuer field, performs OIDC discovery, and returns the appropriate OIDCDiscoverySucceeded condition. func (c *controller) validateIssuer(ctx context.Context, upstream *v1alpha1.UpstreamOIDCProvider, result *provider.UpstreamOIDCIdentityProvider) *v1alpha1.Condition { // Get the provider (from cache if possible). - var discoveredProvider *oidc.Provider - if cached, ok := c.validatorCache.Get(upstream.Spec.Issuer); ok { - discoveredProvider = cached.(*oidc.Provider) - } + discoveredProvider := c.validatorCache.getProvider(&upstream.Spec) // If the provider does not exist in the cache, do a fresh discovery lookup and save to the cache. if discoveredProvider == nil { - var err error - discoveredProvider, err = oidc.NewProvider(ctx, upstream.Spec.Issuer) + tlsConfig, err := getTLSConfig(upstream) + if err != nil { + return &v1alpha1.Condition{ + Type: typeOIDCDiscoverySucceeded, + Status: v1alpha1.ConditionFalse, + Reason: reasonInvalidTLSConfig, + Message: err.Error(), + } + } + httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}} + + discoveredProvider, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer) if err != nil { return &v1alpha1.Condition{ Type: typeOIDCDiscoverySucceeded, @@ -216,7 +255,7 @@ func (c *controller) validateIssuer(ctx context.Context, upstream *v1alpha1.Upst } // Update the cache with the newly discovered value. - c.validatorCache.Set(upstream.Spec.Issuer, discoveredProvider, validatorCacheTTL) + c.validatorCache.putProvider(&upstream.Spec, discoveredProvider) } // Parse out and validate the discovered authorize endpoint. @@ -248,6 +287,28 @@ func (c *controller) validateIssuer(ctx context.Context, upstream *v1alpha1.Upst } } +func getTLSConfig(upstream *v1alpha1.UpstreamOIDCProvider) (*tls.Config, error) { + result := tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if upstream.Spec.TLS == nil || upstream.Spec.TLS.CertificateAuthorityData == "" { + return &result, nil + } + + bundle, err := base64.StdEncoding.DecodeString(upstream.Spec.TLS.CertificateAuthorityData) + if err != nil { + return nil, fmt.Errorf("spec.certificateAuthorityData is invalid: %w", err) + } + + result.RootCAs = x509.NewCertPool() + if !result.RootCAs.AppendCertsFromPEM(bundle) { + return nil, fmt.Errorf("spec.certificateAuthorityData is invalid: %w", errNoCertificates) + } + + return &result, nil +} + func (c *controller) updateStatus(ctx context.Context, upstream *v1alpha1.UpstreamOIDCProvider, conditions []*v1alpha1.Condition) { log := c.log.WithValues("namespace", upstream.Namespace, "name", upstream.Name) updated := upstream.DeepCopy() diff --git a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher_test.go b/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher_test.go index 2e91a523..949effaf 100644 --- a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher_test.go +++ b/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher_test.go @@ -5,9 +5,9 @@ package upstreamwatcher import ( "context" + "encoding/base64" "encoding/json" "net/http" - "net/http/httptest" "net/url" "strings" "testing" @@ -25,6 +25,7 @@ import ( pinnipedinformers "go.pinniped.dev/generated/1.19/client/supervisor/informers/externalversions" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/testlogger" ) @@ -34,7 +35,8 @@ func TestController(t *testing.T) { earlier := metav1.NewTime(now.Add(-1 * time.Hour).UTC()) // Start another test server that answers discovery successfully. - testIssuer := newTestIssuer(t) + testIssuerCA, testIssuerURL := newTestIssuer(t) + testIssuerCABase64 := base64.StdEncoding.EncodeToString([]byte(testIssuerCA)) testIssuerAuthorizeURL, err := url.Parse("https://example.com/authorize") require.NoError(t, err) @@ -65,7 +67,8 @@ func TestController(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: testIssuer.URL, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, }, @@ -106,7 +109,8 @@ func TestController(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: testIssuer.URL, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, }, @@ -151,7 +155,8 @@ func TestController(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: testIssuer.URL, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, }, @@ -190,6 +195,102 @@ func TestController(t *testing.T) { }, }}, }, + { + name: "TLS CA bundle is invalid base64", + inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name"}, + Spec: v1alpha1.UpstreamOIDCProviderSpec{ + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{ + CertificateAuthorityData: "invalid-base64", + }, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: append(testAdditionalScopes, "xyz", "openid")}, + }, + }}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName}, + Type: "secrets.pinniped.dev/oidc-client", + Data: testValidSecretData, + }}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantLogs: []string{ + `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, + `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `upstream-observer "error"="UpstreamOIDCProvider has a failing condition" "msg"="found failing condition" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, + }, + wantResultingCache: []provider.UpstreamOIDCIdentityProvider{}, + wantResultingUpstreams: []v1alpha1.UpstreamOIDCProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, + Status: v1alpha1.UpstreamOIDCProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "ClientCredentialsValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: "loaded client credentials", + }, + { + Type: "OIDCDiscoverySucceeded", + Status: "False", + LastTransitionTime: now, + Reason: "InvalidTLSConfig", + Message: `spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7`, + }, + }, + }, + }}, + }, + { + name: "TLS CA bundle does not have any certificates", + inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name"}, + Spec: v1alpha1.UpstreamOIDCProviderSpec{ + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte("not-a-pem-ca-bundle")), + }, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: append(testAdditionalScopes, "xyz", "openid")}, + }, + }}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName}, + Type: "secrets.pinniped.dev/oidc-client", + Data: testValidSecretData, + }}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantLogs: []string{ + `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, + `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: no certificates found" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `upstream-observer "error"="UpstreamOIDCProvider has a failing condition" "msg"="found failing condition" "message"="spec.certificateAuthorityData is invalid: no certificates found" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, + }, + wantResultingCache: []provider.UpstreamOIDCIdentityProvider{}, + wantResultingUpstreams: []v1alpha1.UpstreamOIDCProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, + Status: v1alpha1.UpstreamOIDCProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "ClientCredentialsValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: "loaded client credentials", + }, + { + Type: "OIDCDiscoverySucceeded", + Status: "False", + LastTransitionTime: now, + Reason: "InvalidTLSConfig", + Message: `spec.certificateAuthorityData is invalid: no certificates found`, + }, + }, + }, + }}, + }, { name: "issuer is invalid URL", inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ @@ -240,7 +341,8 @@ func TestController(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: testIssuer.URL + "/invalid", + Issuer: testIssuerURL + "/invalid", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, }, @@ -285,7 +387,8 @@ func TestController(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: testIssuer.URL + "/insecure", + Issuer: testIssuerURL + "/insecure", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, }, @@ -330,7 +433,8 @@ func TestController(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name"}, Spec: v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: testIssuer.URL, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: append(testAdditionalScopes, "xyz", "openid")}, }, @@ -373,7 +477,8 @@ func TestController(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.UpstreamOIDCProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, Spec: v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: testIssuer.URL, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, }, @@ -486,10 +591,9 @@ func normalizeUpstreams(upstreams []v1alpha1.UpstreamOIDCProvider, now metav1.Ti return result } -func newTestIssuer(t *testing.T) *httptest.Server { +func newTestIssuer(t *testing.T) (string, string) { mux := http.NewServeMux() - testServer := httptest.NewServer(mux) - t.Cleanup(testServer.Close) + caBundlePEM, testURL := testutil.TLSTestServer(t, mux.ServeHTTP) type providerJSON struct { Issuer string `json:"issuer"` @@ -502,7 +606,7 @@ func newTestIssuer(t *testing.T) *httptest.Server { mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(&providerJSON{ - Issuer: testServer.URL, + Issuer: testURL, AuthURL: "https://example.com/authorize", }) }) @@ -511,7 +615,7 @@ func newTestIssuer(t *testing.T) *httptest.Server { mux.HandleFunc("/invalid/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(&providerJSON{ - Issuer: testServer.URL + "/invalid", + Issuer: testURL + "/invalid", AuthURL: "%", }) }) @@ -520,10 +624,10 @@ func newTestIssuer(t *testing.T) *httptest.Server { mux.HandleFunc("/insecure/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(&providerJSON{ - Issuer: testServer.URL + "/insecure", + Issuer: testURL + "/insecure", AuthURL: "http://example.com/authorize", }) }) - return testServer + return caBundlePEM, testURL } diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index de3c7f71..ec752cf3 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -19,9 +19,9 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/oidcclient/nonce" - "go.pinniped.dev/internal/oidcclient/pkce" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/pkg/oidcclient/nonce" + "go.pinniped.dev/pkg/oidcclient/pkce" ) const ( diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index e0fdbacd..dcd2f9d6 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -21,8 +21,8 @@ import ( "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/oidcclient/nonce" - "go.pinniped.dev/internal/oidcclient/pkce" + "go.pinniped.dev/pkg/oidcclient/nonce" + "go.pinniped.dev/pkg/oidcclient/pkce" ) func TestAuthorizationEndpoint(t *testing.T) { diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index 0687ba22..723cfe67 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -16,9 +16,9 @@ import ( "go.pinniped.dev/internal/oidc/discovery" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/oidcclient/nonce" - "go.pinniped.dev/internal/oidcclient/pkce" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/pkg/oidcclient/nonce" + "go.pinniped.dev/pkg/oidcclient/pkce" ) // Manager can manage multiple active OIDC providers. It acts as a request router for them. diff --git a/internal/oidcclient/filesession/cachefile.go b/pkg/oidcclient/filesession/cachefile.go similarity index 99% rename from internal/oidcclient/filesession/cachefile.go rename to pkg/oidcclient/filesession/cachefile.go index 14c055f3..3629ca5f 100644 --- a/internal/oidcclient/filesession/cachefile.go +++ b/pkg/oidcclient/filesession/cachefile.go @@ -16,7 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" - "go.pinniped.dev/internal/oidcclient" + "go.pinniped.dev/pkg/oidcclient" ) var ( diff --git a/internal/oidcclient/filesession/cachefile_test.go b/pkg/oidcclient/filesession/cachefile_test.go similarity index 99% rename from internal/oidcclient/filesession/cachefile_test.go rename to pkg/oidcclient/filesession/cachefile_test.go index d9094271..0ddcdf9b 100644 --- a/internal/oidcclient/filesession/cachefile_test.go +++ b/pkg/oidcclient/filesession/cachefile_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "go.pinniped.dev/internal/oidcclient" + "go.pinniped.dev/pkg/oidcclient" ) // validSession should be the same data as `testdata/valid.yaml`. diff --git a/internal/oidcclient/filesession/filesession.go b/pkg/oidcclient/filesession/filesession.go similarity index 99% rename from internal/oidcclient/filesession/filesession.go rename to pkg/oidcclient/filesession/filesession.go index 880d608e..47e0f761 100644 --- a/internal/oidcclient/filesession/filesession.go +++ b/pkg/oidcclient/filesession/filesession.go @@ -15,7 +15,7 @@ import ( "github.com/gofrs/flock" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "go.pinniped.dev/internal/oidcclient" + "go.pinniped.dev/pkg/oidcclient" ) const ( diff --git a/internal/oidcclient/filesession/filesession_test.go b/pkg/oidcclient/filesession/filesession_test.go similarity index 99% rename from internal/oidcclient/filesession/filesession_test.go rename to pkg/oidcclient/filesession/filesession_test.go index 8dd07bc9..1b28e184 100644 --- a/internal/oidcclient/filesession/filesession_test.go +++ b/pkg/oidcclient/filesession/filesession_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "go.pinniped.dev/internal/oidcclient" + "go.pinniped.dev/pkg/oidcclient" ) func TestNew(t *testing.T) { diff --git a/internal/oidcclient/filesession/testdata/invalid.yaml b/pkg/oidcclient/filesession/testdata/invalid.yaml similarity index 100% rename from internal/oidcclient/filesession/testdata/invalid.yaml rename to pkg/oidcclient/filesession/testdata/invalid.yaml diff --git a/internal/oidcclient/filesession/testdata/valid.yaml b/pkg/oidcclient/filesession/testdata/valid.yaml similarity index 100% rename from internal/oidcclient/filesession/testdata/valid.yaml rename to pkg/oidcclient/filesession/testdata/valid.yaml diff --git a/internal/oidcclient/filesession/testdata/wrong-version.yaml b/pkg/oidcclient/filesession/testdata/wrong-version.yaml similarity index 100% rename from internal/oidcclient/filesession/testdata/wrong-version.yaml rename to pkg/oidcclient/filesession/testdata/wrong-version.yaml diff --git a/internal/oidcclient/login.go b/pkg/oidcclient/login.go similarity index 96% rename from internal/oidcclient/login.go rename to pkg/oidcclient/login.go index 5ab3b5ff..0898f944 100644 --- a/internal/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -20,9 +20,9 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" - "go.pinniped.dev/internal/oidcclient/nonce" - "go.pinniped.dev/internal/oidcclient/pkce" - "go.pinniped.dev/internal/oidcclient/state" + "go.pinniped.dev/pkg/oidcclient/nonce" + "go.pinniped.dev/pkg/oidcclient/pkce" + "go.pinniped.dev/pkg/oidcclient/state" ) const ( @@ -44,6 +44,8 @@ type handlerState struct { scopes []string cache SessionCache + httpClient *http.Client + // Parameters of the localhost listener. listenAddr string callbackPath string @@ -122,6 +124,14 @@ func WithSessionCache(cache SessionCache) Option { } } +// WithClient sets the HTTP client used to make CLI-to-provider requests. +func WithClient(httpClient *http.Client) Option { + return func(h *handlerState) error { + h.httpClient = httpClient + return nil + } +} + // nopCache is a SessionCache that doesn't actually do anything. type nopCache struct{} @@ -144,6 +154,7 @@ func Login(issuer string, clientID string, opts ...Option) (*Token, error) { callbackPath: "/callback", ctx: context.Background(), callbacks: make(chan callbackResult), + httpClient: http.DefaultClient, // Default implementations of external dependencies (to be mocked in tests). generateState: state.Generate, @@ -163,6 +174,7 @@ func Login(issuer string, clientID string, opts ...Option) (*Token, error) { // Always set a long, but non-infinite timeout for this operation. ctx, cancel := context.WithTimeout(h.ctx, 10*time.Minute) defer cancel() + ctx = oidc.ClientContext(ctx, h.httpClient) h.ctx = ctx // Initialize login parameters. diff --git a/internal/oidcclient/login_test.go b/pkg/oidcclient/login_test.go similarity index 99% rename from internal/oidcclient/login_test.go rename to pkg/oidcclient/login_test.go index ea12737f..b323f586 100644 --- a/internal/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -23,9 +23,9 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/mocks/mockkeyset" - "go.pinniped.dev/internal/oidcclient/nonce" - "go.pinniped.dev/internal/oidcclient/pkce" - "go.pinniped.dev/internal/oidcclient/state" + "go.pinniped.dev/pkg/oidcclient/nonce" + "go.pinniped.dev/pkg/oidcclient/pkce" + "go.pinniped.dev/pkg/oidcclient/state" ) // mockSessionCache exists to avoid an import cycle if we generate mocks into another package. @@ -416,6 +416,7 @@ func TestLogin(t *testing.T) { require.Equal(t, []*Token{&testToken}, cache.sawPutTokens) }) require.NoError(t, WithSessionCache(cache)(h)) + require.NoError(t, WithClient(&http.Client{Timeout: 10 * time.Second})(h)) h.openURL = func(actualURL string) error { parsedActualURL, err := url.Parse(actualURL) diff --git a/internal/oidcclient/nonce/nonce.go b/pkg/oidcclient/nonce/nonce.go similarity index 100% rename from internal/oidcclient/nonce/nonce.go rename to pkg/oidcclient/nonce/nonce.go diff --git a/internal/oidcclient/nonce/nonce_test.go b/pkg/oidcclient/nonce/nonce_test.go similarity index 100% rename from internal/oidcclient/nonce/nonce_test.go rename to pkg/oidcclient/nonce/nonce_test.go diff --git a/internal/oidcclient/pkce/pkce.go b/pkg/oidcclient/pkce/pkce.go similarity index 100% rename from internal/oidcclient/pkce/pkce.go rename to pkg/oidcclient/pkce/pkce.go diff --git a/internal/oidcclient/pkce/pkce_test.go b/pkg/oidcclient/pkce/pkce_test.go similarity index 100% rename from internal/oidcclient/pkce/pkce_test.go rename to pkg/oidcclient/pkce/pkce_test.go diff --git a/internal/oidcclient/state/state.go b/pkg/oidcclient/state/state.go similarity index 100% rename from internal/oidcclient/state/state.go rename to pkg/oidcclient/state/state.go diff --git a/internal/oidcclient/state/state_test.go b/pkg/oidcclient/state/state_test.go similarity index 100% rename from internal/oidcclient/state/state_test.go rename to pkg/oidcclient/state/state_test.go diff --git a/internal/oidcclient/types.go b/pkg/oidcclient/types.go similarity index 100% rename from internal/oidcclient/types.go rename to pkg/oidcclient/types.go diff --git a/site/themes/pinniped/layouts/partials/use-cases.html b/site/themes/pinniped/layouts/partials/use-cases.html index 5e0e0755..740956b3 100644 --- a/site/themes/pinniped/layouts/partials/use-cases.html +++ b/site/themes/pinniped/layouts/partials/use-cases.html @@ -12,8 +12,8 @@
-

Cluster Administration

-

Easily plug in external IDPs into Kubernetes clusters while offering a simple install and configuration experience. Leverage first class integration with Kubernetes and kubectl CLI.

+

Seamless Authentication

+

Give users a consistent, unified login experience across all your clusters, including on-premises and managed cloud environments.

@@ -25,8 +25,8 @@
-

Cluster Administration

-

Easily plug in external IDPs into Kubernetes clusters while offering a simple install and configuration experience. Leverage first class integration with Kubernetes and kubectl CLI.

+

Security for Enterprises & Teams Alike

+

Securely integrate with an enterprise IDP using standard protocols or use secure, externally-managed identities instead of relying on simple, shared credentials.

diff --git a/test/deploy/dex/cert-issuer.yaml b/test/deploy/dex/cert-issuer.yaml new file mode 100644 index 00000000..86eecc72 --- /dev/null +++ b/test/deploy/dex/cert-issuer.yaml @@ -0,0 +1,101 @@ +#! Copyright 2020 the Pinniped contributors. All Rights Reserved. +#! SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cert-issuer + namespace: dex + labels: + app: cert-issuer +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: cert-issuer + namespace: dex + labels: + app: cert-issuer +rules: + - apiGroups: [""] + resources: [secrets] + verbs: [create, get, patch, update, watch, delete] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: cert-issuer + namespace: dex + labels: + app: cert-issuer +subjects: + - kind: ServiceAccount + name: cert-issuer + namespace: dex +roleRef: + kind: Role + name: cert-issuer + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: cert-issuer + namespace: dex + labels: + app: cert-issuer +spec: + template: + spec: + serviceAccountName: cert-issuer + initContainers: + - name: generate-certs + image: cfssl/cfssl:1.5.0 + imagePullPolicy: IfNotPresent + command: ["/bin/bash"] + args: + - -c + - | + cd /var/certs + cfssl print-defaults config > /tmp/cfssl-default.json + echo '{"CN": "Pinniped Test","hosts": [],"key": {"algo": "ecdsa","size": 256},"names": [{}]}' > /tmp/csr.json + + echo "generating CA key..." + cfssl genkey \ + -config /tmp/cfssl-default.json \ + -initca /tmp/csr.json \ + | cfssljson -bare ca + + echo "generating Dex server certificate..." + cfssl gencert \ + -ca ca.pem -ca-key ca-key.pem \ + -config /tmp/cfssl-default.json \ + -profile www \ + -cn "dex.dex.svc.cluster.local" \ + -hostname "dex.dex.svc.cluster.local" \ + /tmp/csr.json \ + | cfssljson -bare dex + + chmod -R 777 /var/certs + + echo "generated certificates:" + ls -l /var/certs + volumeMounts: + - name: certs + mountPath: /var/certs + containers: + - name: save-certs + image: bitnami/kubectl + command: ["/bin/bash"] + args: + - -c + - | + kubectl get secrets -n dex certs -o jsonpath='created: {.metadata.creationTimestamp}' || \ + kubectl create secret generic certs --from-file=/var/certs + volumeMounts: + - name: certs + mountPath: /var/certs + volumes: + - name: certs + emptyDir: {} + restartPolicy: Never \ No newline at end of file diff --git a/test/deploy/dex/dex.yaml b/test/deploy/dex/dex.yaml index dcea584f..274fdf27 100644 --- a/test/deploy/dex/dex.yaml +++ b/test/deploy/dex/dex.yaml @@ -6,13 +6,15 @@ #@ load("@ytt:yaml", "yaml") #@ def dexConfig(): -issuer: #@ "http://127.0.0.1:" + str(data.values.ports.local) + "/dex" +issuer: https://dex.dex.svc.cluster.local/dex storage: type: sqlite3 config: file: ":memory:" web: - http: 0.0.0.0:5556 + https: 0.0.0.0:443 + tlsCert: /var/certs/dex.pem + tlsKey: /var/certs/dex-key.pem oauth2: skipApprovalScreen: true staticClients: @@ -68,23 +70,29 @@ spec: dexConfigHash: #@ sha256.sum(yaml.encode(dexConfig())) spec: containers: - - name: dex - image: quay.io/dexidp/dex:v2.10.0 - imagePullPolicy: IfNotPresent - command: - - /usr/local/bin/dex - - serve - - /etc/dex/cfg/config.yaml - ports: - - name: http - containerPort: 5556 - volumeMounts: - - name: config - mountPath: /etc/dex/cfg + - name: dex + image: quay.io/dexidp/dex:v2.10.0 + imagePullPolicy: IfNotPresent + command: + - /usr/local/bin/dex + - serve + - /etc/dex/cfg/config.yaml + ports: + - name: https + containerPort: 443 + volumeMounts: + - name: dex-config + mountPath: /etc/dex/cfg + - name: certs + mountPath: /var/certs + readOnly: true volumes: - - name: config + - name: dex-config configMap: name: dex-config + - name: certs + secret: + secretName: certs --- apiVersion: v1 kind: Service @@ -94,9 +102,9 @@ metadata: labels: app: dex spec: - type: NodePort + type: ClusterIP selector: app: dex ports: - - port: 5556 - nodePort: #@ data.values.ports.node + - port: 443 + name: https diff --git a/test/deploy/dex/proxy.yaml b/test/deploy/dex/proxy.yaml new file mode 100644 index 00000000..1d7d8a66 --- /dev/null +++ b/test/deploy/dex/proxy.yaml @@ -0,0 +1,58 @@ +#! Copyright 2020 the Pinniped contributors. All Rights Reserved. +#! SPDX-License-Identifier: Apache-2.0 + +#@ load("@ytt:data", "data") +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: proxy + namespace: dex + labels: + app: proxy +spec: + replicas: 1 + selector: + matchLabels: + app: proxy + template: + metadata: + labels: + app: proxy + spec: + containers: + - name: proxy + image: docker.io/getpinniped/test-forward-proxy + imagePullPolicy: Always + ports: + - name: http + containerPort: 3128 + resources: + requests: + cpu: "10m" + memory: "64Mi" + limits: + cpu: "10m" + memory: "64Mi" + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 5 + timeoutSeconds: 5 + periodSeconds: 5 + failureThreshold: 2 +--- +apiVersion: v1 +kind: Service +metadata: + name: proxy + namespace: dex + labels: + app: proxy +spec: + type: NodePort + selector: + app: proxy + ports: + - port: 3128 + nodePort: #@ data.values.ports.node \ No newline at end of file diff --git a/test/deploy/dex/values.yaml b/test/deploy/dex/values.yaml index 7d04cdb1..27022cdb 100644 --- a/test/deploy/dex/values.yaml +++ b/test/deploy/dex/values.yaml @@ -8,10 +8,10 @@ ports: #! Used in the Dex configuration to form the valid redirect URIs for our test client. cli: 48095 - #! Kubernetes NodePort that should be forwarded to the Dex service. + #! Kubernetes NodePort that should be forwarded to the proxy service. #! Used to create a Service of type: NodePort node: 31235 - #! External port where Dex ends up exposed on localhost during tests. This value comes from our - #! Kind configuration which maps 127.0.0.1:12346 to port 31235 on the Kind worker node. + #! External port where the proxy ends up exposed on localhost during tests. This value comes from + #! our Kind configuration which maps 127.0.0.1:12346 to port 31235 on the Kind worker node. local: 12346 diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 93d8fb19..48c65833 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -26,8 +26,8 @@ import ( "gopkg.in/square/go-jose.v2" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" - "go.pinniped.dev/internal/oidcclient" - "go.pinniped.dev/internal/oidcclient/filesession" + "go.pinniped.dev/pkg/oidcclient" + "go.pinniped.dev/pkg/oidcclient/filesession" "go.pinniped.dev/test/library" ) @@ -130,8 +130,8 @@ func getLoginProvider(t *testing.T) *loginProviderPatterns { }, { Name: "Dex", - IssuerPattern: regexp.MustCompile(`\Ahttp://127\.0\.0\.1.+/dex.*\z`), - LoginPagePattern: regexp.MustCompile(`\Ahttp://127\.0\.0\.1.+/dex/auth/local.+\z`), + IssuerPattern: regexp.MustCompile(`\Ahttps://dex\.dex\.svc\.cluster\.local/dex.*\z`), + LoginPagePattern: regexp.MustCompile(`\Ahttps://dex\.dex\.svc\.cluster\.local/dex/auth/local.+\z`), UsernameSelector: "input#login", PasswordSelector: "input#password", LoginButtonSelector: "button#submit-login", @@ -156,9 +156,21 @@ func TestCLILoginOIDC(t *testing.T) { // Start the browser driver. t.Logf("opening browser driver") + caps := agouti.NewCapabilities() + if env.Proxy != "" { + t.Logf("configuring Chrome to use proxy %q", env.Proxy) + caps = caps.Proxy(agouti.ProxyConfig{ + ProxyType: "manual", + HTTPProxy: env.Proxy, + SSLProxy: env.Proxy, + NoProxy: "127.0.0.1", + }) + } agoutiDriver := agouti.ChromeDriver( + agouti.Desired(caps), agouti.ChromeOptions("args", []string{ "--no-sandbox", + "--ignore-certificate-errors", "--headless", // Comment out this line to see the tests happen in a visible browser window. }), // Uncomment this to see stdout/stderr from chromedriver. @@ -395,11 +407,28 @@ func spawnTestGoroutine(t *testing.T, f func() error) { func oidcLoginCommand(ctx context.Context, t *testing.T, pinnipedExe string, sessionCachePath string) *exec.Cmd { env := library.IntegrationEnv(t) - return exec.CommandContext(ctx, pinnipedExe, "login", "oidc", + cmd := exec.CommandContext(ctx, pinnipedExe, "login", "oidc", "--issuer", env.OIDCUpstream.Issuer, "--client-id", env.OIDCUpstream.ClientID, "--listen-port", strconv.Itoa(env.OIDCUpstream.LocalhostPort), "--session-cache", sessionCachePath, "--skip-browser", ) + + // If there is a custom CA bundle, pass it via --ca-bundle and a temporary file. + if env.OIDCUpstream.CABundle != "" { + path := filepath.Join(t.TempDir(), "test-ca.pem") + require.NoError(t, ioutil.WriteFile(path, []byte(env.OIDCUpstream.CABundle), 0600)) + cmd.Args = append(cmd.Args, "--ca-bundle", path) + } + + // If there is a custom proxy, set it using standard environment variables. + if env.Proxy != "" { + cmd.Env = append(os.Environ(), + "http_proxy="+env.Proxy, + "https_proxy="+env.Proxy, + "no_proxy=127.0.0.1", + ) + } + return cmd } diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go new file mode 100644 index 00000000..3ac2fab8 --- /dev/null +++ b/test/integration/supervisor_login_test.go @@ -0,0 +1,196 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/coreos/go-oidc" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/pkg/oidcclient/nonce" + "go.pinniped.dev/pkg/oidcclient/pkce" + "go.pinniped.dev/pkg/oidcclient/state" + "go.pinniped.dev/test/library" +) + +func TestSupervisorLogin(t *testing.T) { + env := library.IntegrationEnv(t) + client := library.NewSupervisorClientset(t) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + // Create downstream OIDC provider (i.e., update supervisor with OIDC provider). + scheme := "http" + addr := env.SupervisorHTTPAddress + caBundle := "" + path := "/some/path" + issuer := fmt.Sprintf("https://%s%s", addr, path) + _, _ = requireCreatingOIDCProviderCausesDiscoveryEndpointsToAppear( + ctx, + t, + scheme, + addr, + caBundle, + issuer, + client, + ) + + // Create HTTP client. + httpClient := newHTTPClient(t, caBundle, nil) + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + // Don't follow any redirects right now, since we simply want to validate that our auth endpoint + // redirects us. + return http.ErrUseLastResponse + } + + // Declare the downstream auth endpoint url we will use. + downstreamAuthURL := makeDownstreamAuthURL(t, scheme, addr, path) + + // Make request to auth endpoint - should fail, since we have no upstreams. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthURL, nil) + require.NoError(t, err) + rsp, err := httpClient.Do(req) + require.NoError(t, err) + defer rsp.Body.Close() + require.Equal(t, http.StatusUnprocessableEntity, rsp.StatusCode) + + // Create upstream OIDC provider. + testClientID := "test-client-id" + testClientSecret := "test-client-secret" + spec := idpv1alpha1.UpstreamOIDCProviderSpec{ + Issuer: env.OIDCUpstream.Issuer, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.OIDCUpstream.CABundle)), + }, + AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: []string{}, + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: makeTestClientCredsSecret(t, testClientID, testClientSecret).Name, + }, + } + upstream := makeTestUpstream(t, spec, idpv1alpha1.PhaseReady) + + upstreamRedirectURI := fmt.Sprintf("https://%s/some/path/callback/%s", env.SupervisorHTTPAddress, upstream.Name) + + // Make request to authorize endpoint - should pass, since we now have an upstream. + req, err = http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthURL, nil) + require.NoError(t, err) + rsp, err = httpClient.Do(req) + require.NoError(t, err) + defer rsp.Body.Close() + require.Equal(t, http.StatusFound, rsp.StatusCode) + requireValidRedirectLocation( + ctx, + t, + upstream.Spec.Issuer, + testClientID, + upstreamRedirectURI, + rsp.Header.Get("Location"), + ) +} + +func makeDownstreamAuthURL(t *testing.T, scheme, addr, path string) string { + t.Helper() + downstreamOAuth2Config := oauth2.Config{ + // This is the hardcoded public client that the supervisor supports. + ClientID: "pinniped-cli", + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s://%s%s/oauth2/authorize", scheme, addr, path), + }, + // This is the hardcoded downstream redirect URI that the supervisor supports. + RedirectURL: "http://127.0.0.1/callback", + Scopes: []string{"openid"}, + } + state, nonce, pkce := generateAuthRequestParams(t) + return downstreamOAuth2Config.AuthCodeURL( + state.String(), + nonce.Param(), + pkce.Challenge(), + pkce.Method(), + ) +} + +func generateAuthRequestParams(t *testing.T) (state.State, nonce.Nonce, pkce.Code) { + t.Helper() + state, err := state.Generate() + require.NoError(t, err) + nonce, err := nonce.Generate() + require.NoError(t, err) + pkce, err := pkce.Generate() + require.NoError(t, err) + return state, nonce, pkce +} + +func requireValidRedirectLocation( + ctx context.Context, + t *testing.T, + issuer, clientID, redirectURI, actualLocation string, +) { + t.Helper() + env := library.IntegrationEnv(t) + + // Do OIDC discovery on our test issuer to get auth endpoint. + transport := http.Transport{} + if env.Proxy != "" { + transport.Proxy = func(_ *http.Request) (*url.URL, error) { + return url.Parse(env.Proxy) + } + } + if env.OIDCUpstream.CABundle != "" { + transport.TLSClientConfig = &tls.Config{RootCAs: x509.NewCertPool()} + transport.TLSClientConfig.RootCAs.AppendCertsFromPEM([]byte(env.OIDCUpstream.CABundle)) + } + + ctx = oidc.ClientContext(ctx, &http.Client{Transport: &transport}) + upstreamProvider, err := oidc.NewProvider(ctx, issuer) + require.NoError(t, err) + + // Parse expected upstream auth URL. + expectedLocationURL, err := url.Parse( + (&oauth2.Config{ + ClientID: clientID, + Endpoint: upstreamProvider.Endpoint(), + RedirectURL: redirectURI, + Scopes: []string{"openid"}, + }).AuthCodeURL("", oauth2.AccessTypeOffline), + ) + require.NoError(t, err) + + // Parse actual upstream auth URL. + actualLocationURL, err := url.Parse(actualLocation) + require.NoError(t, err) + + // First make some assertions on the query values. Note that we will not be able to know what + // certain query values are since they may be random (e.g., state, pkce, nonce). + expectedLocationQuery := expectedLocationURL.Query() + actualLocationQuery := actualLocationURL.Query() + require.NotEmpty(t, actualLocationQuery.Get("state")) + actualLocationQuery.Del("state") + require.NotEmpty(t, actualLocationQuery.Get("code_challenge")) + actualLocationQuery.Del("code_challenge") + require.NotEmpty(t, actualLocationQuery.Get("code_challenge_method")) + actualLocationQuery.Del("code_challenge_method") + require.NotEmpty(t, actualLocationQuery.Get("nonce")) + actualLocationQuery.Del("nonce") + require.Equal(t, expectedLocationQuery, actualLocationQuery) + + // Zero-out query values, since we made specific assertions about those above, and assert that the + // URL's are equal otherwise. + expectedLocationURL.RawQuery = "" + actualLocationURL.RawQuery = "" + require.Equal(t, expectedLocationURL, actualLocationURL) +} diff --git a/test/integration/supervisor_upstream_test.go b/test/integration/supervisor_upstream_test.go index 40ef0e90..c96ae7af 100644 --- a/test/integration/supervisor_upstream_test.go +++ b/test/integration/supervisor_upstream_test.go @@ -5,6 +5,7 @@ package integration import ( "context" + "encoding/base64" "testing" "time" @@ -17,7 +18,7 @@ import ( ) func TestSupervisorUpstreamOIDCDiscovery(t *testing.T) { - library.SkipUnlessIntegration(t) + env := library.IntegrationEnv(t) t.Run("invalid missing secret and bad issuer", func(t *testing.T) { t.Parallel() @@ -50,7 +51,10 @@ func TestSupervisorUpstreamOIDCDiscovery(t *testing.T) { t.Run("valid", func(t *testing.T) { t.Parallel() spec := v1alpha1.UpstreamOIDCProviderSpec{ - Issuer: "https://accounts.google.com", // Use Google as an example of a valid OIDC issuer for now. + Issuer: env.OIDCUpstream.Issuer, + TLS: &v1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.OIDCUpstream.CABundle)), + }, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ AdditionalScopes: []string{"email", "profile"}, }, diff --git a/test/library/env.go b/test/library/env.go index 8cba1c11..ce7e5f94 100644 --- a/test/library/env.go +++ b/test/library/env.go @@ -38,6 +38,7 @@ type TestEnv struct { SupervisorHTTPSAddress string `json:"supervisorHttpsAddress"` SupervisorHTTPSIngressAddress string `json:"supervisorHttpsIngressAddress"` SupervisorHTTPSIngressCABundle string `json:"supervisorHttpsIngressCABundle"` + Proxy string `json:"proxy"` TestUser struct { Token string `json:"token"` @@ -47,6 +48,7 @@ type TestEnv struct { OIDCUpstream struct { Issuer string `json:"issuer"` + CABundle string `json:"caBundle" ` ClientID string `json:"clientID"` LocalhostPort int `json:"localhostPort"` Username string `json:"username"` @@ -126,8 +128,10 @@ func loadEnvVars(t *testing.T, result *TestEnv) { require.NoErrorf(t, err, "PINNIPED_TEST_SUPERVISOR_CUSTOM_LABELS must be a YAML map of string to string") result.SupervisorCustomLabels = supervisorCustomLabels require.NotEmpty(t, result.SupervisorCustomLabels, "PINNIPED_TEST_SUPERVISOR_CUSTOM_LABELS cannot be empty") + result.Proxy = os.Getenv("PINNIPED_TEST_PROXY") result.OIDCUpstream.Issuer = needEnv(t, "PINNIPED_TEST_CLI_OIDC_ISSUER") + result.OIDCUpstream.CABundle = os.Getenv("PINNIPED_TEST_CLI_OIDC_ISSUER_CA_BUNDLE") result.OIDCUpstream.ClientID = needEnv(t, "PINNIPED_TEST_CLI_OIDC_CLIENT_ID") result.OIDCUpstream.LocalhostPort, _ = strconv.Atoi(needEnv(t, "PINNIPED_TEST_CLI_OIDC_LOCALHOST_PORT")) result.OIDCUpstream.Username = needEnv(t, "PINNIPED_TEST_CLI_OIDC_USERNAME")