Merge branch 'main' into upstream_access_revocation_during_gc

This commit is contained in:
Ryan Richard 2022-01-14 10:49:22 -08:00
commit 814399324f
88 changed files with 5180 additions and 1586 deletions

View File

@ -1,9 +1,9 @@
# syntax = docker/dockerfile:1.0-experimental # syntax = docker/dockerfile:1.0-experimental
# Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. # Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
FROM golang:1.17.4 as build-env FROM golang:1.17.6 as build-env
WORKDIR /work WORKDIR /work
COPY . . COPY . .
@ -24,7 +24,7 @@ RUN \
ln -s /usr/local/bin/pinniped-server /usr/local/bin/local-user-authenticator ln -s /usr/local/bin/pinniped-server /usr/local/bin/local-user-authenticator
# Use a distroless runtime image with CA certificates, timezone data, and not much else. # Use a distroless runtime image with CA certificates, timezone data, and not much else.
FROM gcr.io/distroless/static:nonroot@sha256:bca3c203cdb36f5914ab8568e4c25165643ea9b711b41a8a58b42c80a51ed609 FROM gcr.io/distroless/static:nonroot@sha256:80c956fb0836a17a565c43a4026c9c80b2013c83bea09f74fa4da195a59b7a99
# Copy the server binary from the build-env stage. # Copy the server binary from the build-env stage.
COPY --from=build-env /usr/local/bin /usr/local/bin COPY --from=build-env /usr/local/bin /usr/local/bin

View File

@ -32,12 +32,14 @@ building and testing the code, submitting PRs, and other contributor topics.
## Community meetings ## Community meetings
Pinniped is better because of our contributors and [maintainers](MAINTAINERS.md). It is because of you that we can bring great Pinniped is better because of our contributors and [maintainers](MAINTAINERS.md). It is because of you that we can bring great
software to the community. Please join us during our online community meetings, software to the community. Please join us during our online community meetings, occurring every first and third
occurring every first and third Thursday of the month at 9 AM PT / 12 PM ET. Thursday of the month at 9 AM PT / 12 PM ET.
Use [this Zoom Link](https://go.pinniped.dev/community/zoom)
to attend and add any agenda items you wish to discuss **Note:** Community meetings are currently paused until early 2022 as we wind down 2021!
to [the notes document](https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view).
Use [this Zoom Link](https://go.pinniped.dev/community/zoom) to attend and add any agenda items you wish to
discuss to [the notes document](https://go.pinniped.dev/community/agenda).
Join our [Google Group](https://groups.google.com/g/project-pinniped) to receive invites to this meeting. Join our [Google Group](https://groups.google.com/g/project-pinniped) to receive invites to this meeting.
If the meeting day falls on a US holiday, please consider that occurrence of the meeting to be canceled. If the meeting day falls on a US holiday, please consider that occurrence of the meeting to be canceled.

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package v1alpha1 package v1alpha1

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package cmd package cmd
@ -2850,7 +2850,7 @@ func TestGetKubeconfig(t *testing.T) {
}) })
issuerEndpointPtr = &issuerEndpoint issuerEndpointPtr = &issuerEndpoint
testLog := testlogger.New(t) testLog := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
cmd := kubeconfigCommand(kubeconfigDeps{ cmd := kubeconfigCommand(kubeconfigDeps{
getPathToSelf: func() (string, error) { getPathToSelf: func() (string, error) {
if tt.getPathToSelfErr != nil { if tt.getPathToSelfErr != nil {
@ -2876,7 +2876,7 @@ func TestGetKubeconfig(t *testing.T) {
} }
return fake, nil return fake, nil
}, },
log: testLog, log: testLog.Logger,
}) })
require.NotNil(t, cmd) require.NotNil(t, cmd)

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package cmd package cmd
@ -358,8 +358,8 @@ func TestLoginOIDCCommand(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
testLogger := testlogger.New(t) testLogger := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
klog.SetLogger(testLogger) klog.SetLogger(testLogger.Logger)
var ( var (
gotOptions []oidcclient.Option gotOptions []oidcclient.Option
) )

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package cmd package cmd
@ -165,8 +165,8 @@ func TestLoginStaticCommand(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
testLogger := testlogger.New(t) testLogger := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
klog.SetLogger(testLogger) klog.SetLogger(testLogger.Logger)
cmd := staticLoginCommand(staticLoginDeps{ cmd := staticLoginCommand(staticLoginDeps{
lookupEnv: func(s string) (string, bool) { lookupEnv: func(s string) (string, bool) {
v, ok := tt.env[s] v, ok := tt.env[s]

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package cmd package cmd
@ -7,8 +7,6 @@ import (
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.pinniped.dev/internal/plog"
) )
//nolint: gochecknoglobals //nolint: gochecknoglobals
@ -19,12 +17,6 @@ var rootCmd = &cobra.Command{
SilenceUsage: true, // do not print usage message when commands fail SilenceUsage: true, // do not print usage message when commands fail
} }
//nolint: gochecknoinits
func init() {
// We don't want klog flags showing up in our CLI.
plog.RemoveKlogGlobalFlags()
}
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd. // This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() { func Execute() {

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package v1alpha1 package v1alpha1

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package v1alpha1 package v1alpha1

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package v1alpha1 package v1alpha1

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package v1alpha1 package v1alpha1

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package v1alpha1 package v1alpha1

197
go.mod
View File

@ -2,152 +2,189 @@ module go.pinniped.dev
go 1.17 go 1.17
// Unfortuntely, having any indirect dependency on github.com/oleiade/reflections@v1.0.0
// seems to cause Dependabot to stop scanning our dependencies due to a checksum error for the package.
// The cause of the checksum error is described in https://github.com/oleiade/reflections/issues/14.
//
// According to `go mod graph`, this dependency is (currently) coming from:
// go.pinniped.dev -> github.com/ory/x@v0.0.212 -> github.com/ory/analytics-go/v4@v4.0.0 -> github.com/ory/x@v0.0.110 -> github.com/ory/fosite@v0.29.0 -> github.com/oleiade/reflections@v1.0.0
// So the issue is that older versions of ory/x had a direct dependency on an old version of Fosite.
// Newer versions of ory/x do not depend on fosite anymore. We can use a replace directive until none
// of our indirect dependencies pull in any old versions of ory/x anymore.
//
// Whenever we upgrade fosite and ory/x, we can try removing this replace directive and running
// `go mod tidy` to see if github.com/oleiade/reflections@v1.0.0 still appears in our go.sum.
// As long as it does, we probably need to keep this replace directive.
replace github.com/oleiade/reflections => github.com/oleiade/reflections v1.0.1
// bumping github.com/ory/x to higher than v0.0.297 breaks k8s.io/apiserver via go.opentelemetry.io/otel/semconv
// force the use of an old version for now as it seems to allow a newer ory/x without breaking the apiserver lib.
// all go.opentelemetry.io replace directives are copied from:
// https://github.com/kubernetes/kubernetes/blob/3bce0502aac87f9907af0ef19df5935632ceafdf/go.mod#L432-L443
replace (
go.opentelemetry.io/contrib => go.opentelemetry.io/contrib v0.20.0
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc => go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp => go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0
go.opentelemetry.io/otel => go.opentelemetry.io/otel v0.20.0
go.opentelemetry.io/otel/exporters/otlp => go.opentelemetry.io/otel/exporters/otlp v0.20.0
go.opentelemetry.io/otel/metric => go.opentelemetry.io/otel/metric v0.20.0
go.opentelemetry.io/otel/oteltest => go.opentelemetry.io/otel/oteltest v0.20.0
go.opentelemetry.io/otel/sdk => go.opentelemetry.io/otel/sdk v0.20.0
go.opentelemetry.io/otel/sdk/export/metric => go.opentelemetry.io/otel/sdk/export/metric v0.20.0
go.opentelemetry.io/otel/sdk/metric => go.opentelemetry.io/otel/sdk/metric v0.20.0
go.opentelemetry.io/otel/trace => go.opentelemetry.io/otel/trace v0.20.0
go.opentelemetry.io/proto/otlp => go.opentelemetry.io/proto/otlp v0.7.0
)
require ( require (
github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/MakeNowJust/heredoc/v2 v2.0.1
github.com/coreos/go-oidc/v3 v3.0.0 github.com/coreos/go-oidc/v3 v3.1.0
github.com/creack/pty v1.1.14 github.com/creack/pty v1.1.17
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/felixge/httpsnoop v1.0.2
github.com/go-ldap/ldap/v3 v3.4.1 github.com/go-ldap/ldap/v3 v3.4.1
github.com/go-logr/logr v0.4.0 github.com/go-logr/logr v1.2.2
github.com/go-logr/stdr v0.4.0 github.com/go-logr/stdr v1.2.2
github.com/gofrs/flock v0.8.1 github.com/gofrs/flock v0.8.1
github.com/golang/mock v1.6.0 github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.5.6 github.com/google/go-cmp v0.5.6
github.com/google/gofuzz v1.2.0 github.com/google/gofuzz v1.2.0
github.com/google/uuid v1.1.2 github.com/google/uuid v1.3.0
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
github.com/gorilla/websocket v1.4.2 github.com/gorilla/websocket v1.4.2
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
github.com/ory/fosite v0.40.2 github.com/ory/fosite v0.41.0
github.com/ory/x v0.0.212 github.com/ory/x v0.0.331
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/sclevine/agouti v3.0.0+incompatible github.com/sclevine/agouti v3.0.0+incompatible
github.com/sclevine/spec v1.4.0 github.com/sclevine/spec v1.4.0
github.com/spf13/cobra v1.2.1 github.com/spf13/cobra v1.3.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.0 github.com/stretchr/testify v1.7.0
github.com/tdewolff/minify/v2 v2.9.21 github.com/tdewolff/minify/v2 v2.9.26
go.uber.org/atomic v1.7.0 go.uber.org/atomic v1.9.0
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023 golang.org/x/net v0.0.0-20211216030914-fe4d6282115f
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/text v0.3.7
gopkg.in/square/go-jose.v2 v2.6.0 gopkg.in/square/go-jose.v2 v2.6.0
k8s.io/api v0.22.2 k8s.io/api v0.23.1
k8s.io/apiextensions-apiserver v0.22.2 k8s.io/apiextensions-apiserver v0.23.1
k8s.io/apimachinery v0.22.2 k8s.io/apimachinery v0.23.1
k8s.io/apiserver v0.22.2 k8s.io/apiserver v0.23.1
k8s.io/client-go v0.22.2 k8s.io/client-go v0.23.1
k8s.io/component-base v0.22.2 k8s.io/component-base v0.23.1
k8s.io/gengo v0.0.0-20210203185629-de9496dff47b k8s.io/gengo v0.0.0-20211129171323-c02415ce4185
k8s.io/klog/v2 v2.10.0 k8s.io/klog/v2 v2.40.1
k8s.io/kube-aggregator v0.22.1 k8s.io/kube-aggregator v0.23.1
k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a k8s.io/utils v0.0.0-20211208161948-7d6a63dca704
sigs.k8s.io/yaml v1.2.0 sigs.k8s.io/yaml v1.3.0
) )
require ( require (
cloud.google.com/go v0.81.0 // indirect cloud.google.com/go v0.99.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.18 // indirect github.com/Azure/go-autorest/autorest v0.11.23 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect github.com/Azure/go-autorest/autorest/adal v0.9.18 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver v3.5.1+incompatible // indirect
github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect github.com/coreos/go-oidc v2.2.1+incompatible // indirect
github.com/coreos/go-oidc v2.1.0+incompatible // indirect
github.com/coreos/go-semver v0.3.0 // indirect github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/dgraph-io/ristretto v0.0.3 // indirect github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/emicklei/go-restful v2.9.5+incompatible // indirect github.com/dustin/go-humanize v1.0.0 // indirect
github.com/evanphx/json-patch v4.11.0+incompatible // indirect github.com/emicklei/go-restful v2.15.0+incompatible // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect
github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect github.com/form3tech-oss/jwt-go v3.2.5+incompatible // indirect
github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect github.com/go-asn1-ber/asn1-ber v1.5.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/swag v0.19.14 // indirect github.com/go-openapi/swag v0.19.15 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.2.0 // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/googleapis/gnostic v0.5.5 // indirect github.com/googleapis/gnostic v0.5.5 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/imdario/mergo v0.3.5 // indirect github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.11 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/magiconair/properties v1.8.5 // indirect github.com/magiconair/properties v1.8.5 // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/goveralls v0.0.6 // indirect github.com/mattn/goveralls v0.0.11 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/moby/spdystream v0.2.0 // indirect github.com/moby/spdystream v0.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ory/go-acc v0.2.6 // indirect github.com/ory/go-acc v0.2.6 // indirect
github.com/ory/go-convenience v0.1.0 // indirect github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/viper v1.7.5 // indirect github.com/ory/viper v1.7.5 // indirect
github.com/pborman/uuid v1.2.0 // indirect github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.3 // indirect github.com/pelletier/go-toml v1.9.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021 // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect
github.com/prometheus/client_golang v1.11.0 // indirect github.com/prometheus/client_golang v1.11.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.6.0 // indirect github.com/prometheus/procfs v0.7.3 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/spf13/afero v1.7.1 // indirect
github.com/spf13/afero v1.6.0 // indirect github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cast v1.3.2-0.20200723214538-8d17101741c8 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.2.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect
github.com/tdewolff/parse/v2 v2.5.19 // indirect github.com/tdewolff/parse/v2 v2.5.26 // indirect
go.etcd.io/etcd/api/v3 v3.5.0 // indirect go.etcd.io/etcd/api/v3 v3.5.1 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.0 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.1 // indirect
go.etcd.io/etcd/client/v3 v3.5.0 // indirect go.etcd.io/etcd/client/v3 v3.5.1 // indirect
go.opentelemetry.io/contrib v0.20.0 // indirect go.opentelemetry.io/contrib v0.20.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0 // indirect
go.opentelemetry.io/otel v0.20.0 // indirect go.opentelemetry.io/otel v1.2.0 // indirect
go.opentelemetry.io/otel/exporters/otlp v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp v0.20.0 // indirect
go.opentelemetry.io/otel/metric v0.20.0 // indirect go.opentelemetry.io/otel/metric v0.20.0 // indirect
go.opentelemetry.io/otel/sdk v0.20.0 // indirect go.opentelemetry.io/otel/sdk v1.2.0 // indirect
go.opentelemetry.io/otel/sdk/export/metric v0.20.0 // indirect go.opentelemetry.io/otel/sdk/export/metric v0.20.0 // indirect
go.opentelemetry.io/otel/sdk/metric v0.20.0 // indirect go.opentelemetry.io/otel/sdk/metric v0.20.0 // indirect
go.opentelemetry.io/otel/trace v0.20.0 // indirect go.opentelemetry.io/otel/trace v1.2.0 // indirect
go.opentelemetry.io/proto/otlp v0.7.0 // indirect go.opentelemetry.io/proto/otlp v0.10.0 // indirect
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.7.0 // indirect
go.uber.org/zap v1.17.0 // indirect go.uber.org/zap v1.19.1 // indirect
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect golang.org/x/mod v0.5.1 // indirect
golang.org/x/text v0.3.6 // indirect golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
golang.org/x/tools v0.1.2 // indirect golang.org/x/tools v0.1.8 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c // indirect google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb // indirect
google.golang.org/grpc v1.38.0 // indirect google.golang.org/grpc v1.43.0 // indirect
google.golang.org/protobuf v1.26.0 // indirect google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/ini.v1 v1.66.2 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e // indirect k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.22 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.27 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.0 // indirect
) )

1161
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,2 @@
Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0 SPDX-License-Identifier: Apache-2.0

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package authenticators contains authenticator interfaces. // Package authenticators contains authenticator interfaces.
@ -35,6 +35,7 @@ type UserAuthenticator interface {
} }
type Response struct { type Response struct {
User user.Info User user.Info
DN string DN string
ExtraRefreshAttributes map[string]string
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package impersonator package impersonator
@ -31,6 +31,7 @@ import (
utilnet "k8s.io/apimachinery/pkg/util/net" utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/sets"
auditinternal "k8s.io/apiserver/pkg/apis/audit" auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/audit/policy" "k8s.io/apiserver/pkg/audit/policy"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/request/bearertoken"
@ -211,7 +212,7 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
} }
// wire up a fake audit backend at the metadata level so we can preserve the original user during nested impersonation // wire up a fake audit backend at the metadata level so we can preserve the original user during nested impersonation
serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil) serverConfig.AuditPolicyRuleEvaluator = policy.NewFakePolicyRuleEvaluator(auditinternal.LevelMetadata, nil)
serverConfig.AuditBackend = &auditfake.Backend{} serverConfig.AuditBackend = &auditfake.Backend{}
// Probe the API server to figure out if anonymous auth is enabled. // Probe the API server to figure out if anonymous auth is enabled.
@ -511,7 +512,7 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
return return
} }
ae := request.AuditEventFrom(r.Context()) ae := audit.AuditEventFrom(r.Context())
if ae == nil { if ae == nil {
plog.Warning("aggregated API server logic did not set audit event but it is always supposed to do so", plog.Warning("aggregated API server logic did not set audit event but it is always supposed to do so",
"url", r.URL.String(), "url", r.URL.String(),

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package impersonator package impersonator
@ -27,6 +27,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/httpstream"
auditinternal "k8s.io/apiserver/pkg/apis/audit" auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
@ -712,8 +713,8 @@ func TestImpersonator(t *testing.T) {
http.NotFound(w, r) http.NotFound(w, r)
return return
case "/apis/flowcontrol.apiserver.k8s.io/v1beta1/prioritylevelconfigurations", case "/apis/flowcontrol.apiserver.k8s.io/v1beta2/prioritylevelconfigurations",
"/apis/flowcontrol.apiserver.k8s.io/v1beta1/flowschemas": "/apis/flowcontrol.apiserver.k8s.io/v1beta2/flowschemas":
// ignore requests related to priority and fairness logic // ignore requests related to priority and fairness logic
require.Equal(t, http.MethodGet, r.Method) require.Equal(t, http.MethodGet, r.Method)
http.NotFound(w, r) http.NotFound(w, r)
@ -1125,7 +1126,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
Groups: testGroups, Groups: testGroups,
Extra: testExtra, Extra: testExtra,
}, nil, "") }, nil, "")
ctx := request.WithAuditEvent(req.Context(), nil) ctx := audit.WithAuditContext(req.Context(), nil)
req = req.WithContext(ctx) req = req.WithContext(ctx)
return req return req
}(), }(),
@ -1880,7 +1881,7 @@ func newRequest(t *testing.T, h http.Header, userInfo user.Info, event *auditint
if event != nil { if event != nil {
ae = event ae = event
} }
ctx = request.WithAuditEvent(ctx, ae) ctx = audit.WithAuditContext(ctx, &audit.AuditContext{Event: ae})
reqInfo := &request.RequestInfo{ reqInfo := &request.RequestInfo{
IsResourceRequest: false, IsResourceRequest: false,

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package server is the command line entry point for pinniped-concierge. // Package server is the command line entry point for pinniped-concierge.
@ -35,7 +35,6 @@ import (
"go.pinniped.dev/internal/here" "go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/issuer" "go.pinniped.dev/internal/issuer"
"go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/registry/credentialrequest" "go.pinniped.dev/internal/registry/credentialrequest"
) )
@ -96,8 +95,6 @@ func addCommandlineFlagsToCommand(cmd *cobra.Command, app *App) {
"/etc/podinfo", "/etc/podinfo",
"path to Downward API volume mount", "path to Downward API volume mount",
) )
plog.RemoveKlogGlobalFlags()
} }
// Boot the aggregated API server, which will in turn boot the controllers. // Boot the aggregated API server, which will in turn boot the controllers.

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package apicerts package apicerts
@ -251,13 +251,10 @@ func TestExpirerControllerSync(t *testing.T) {
0, 0,
) )
opts := &[]metav1.DeleteOptions{}
trackDeleteClient := testutil.NewDeleteOptionsRecorder(kubeAPIClient, opts)
c := NewCertsExpirerController( c := NewCertsExpirerController(
namespace, namespace,
certsSecretResourceName, certsSecretResourceName,
trackDeleteClient, kubeAPIClient,
kubeInformers.Core().V1().Secrets(), kubeInformers.Core().V1().Secrets(),
controllerlib.WithInformer, controllerlib.WithInformer,
test.renewBefore, test.renewBefore,
@ -281,7 +278,7 @@ func TestExpirerControllerSync(t *testing.T) {
if test.wantDelete { if test.wantDelete {
exActions = append( exActions = append(
exActions, exActions,
kubetesting.NewDeleteAction( kubetesting.NewDeleteActionWithOptions(
schema.GroupVersionResource{ schema.GroupVersionResource{
Group: "", Group: "",
Version: "v1", Version: "v1",
@ -289,18 +286,12 @@ func TestExpirerControllerSync(t *testing.T) {
}, },
namespace, namespace,
name, name,
testutil.NewPreconditions(testUID, testRV),
), ),
) )
} }
acActions := kubeAPIClient.Actions() acActions := kubeAPIClient.Actions()
require.Equal(t, exActions, acActions) require.Equal(t, exActions, acActions)
if test.wantDelete {
require.Len(t, *opts, 1)
require.Equal(t, testutil.NewPreconditions(testUID, testRV), (*opts)[0])
} else {
require.Len(t, *opts, 0)
}
}) })
} }
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package cachecleaner package cachecleaner
@ -143,11 +143,11 @@ func TestController(t *testing.T) {
if tt.initialCache != nil { if tt.initialCache != nil {
tt.initialCache(t, cache) tt.initialCache(t, cache)
} }
testLog := testlogger.New(t) testLog := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
webhooks := informers.Authentication().V1alpha1().WebhookAuthenticators() webhooks := informers.Authentication().V1alpha1().WebhookAuthenticators()
jwtAuthenticators := informers.Authentication().V1alpha1().JWTAuthenticators() jwtAuthenticators := informers.Authentication().V1alpha1().JWTAuthenticators()
controller := New(cache, webhooks, jwtAuthenticators, testLog) controller := New(cache, webhooks, jwtAuthenticators, testLog.Logger)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package jwtcachefiller package jwtcachefiller
@ -318,13 +318,13 @@ func TestController(t *testing.T) {
fakeClient := pinnipedfake.NewSimpleClientset(tt.jwtAuthenticators...) fakeClient := pinnipedfake.NewSimpleClientset(tt.jwtAuthenticators...)
informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0) informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
cache := authncache.New() cache := authncache.New()
testLog := testlogger.New(t) testLog := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
if tt.cache != nil { if tt.cache != nil {
tt.cache(t, cache, tt.wantClose) tt.cache(t, cache, tt.wantClose)
} }
controller := New(cache, informers.Authentication().V1alpha1().JWTAuthenticators(), testLog) controller := New(cache, informers.Authentication().V1alpha1().JWTAuthenticators(), testLog.Logger)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package webhookcachefiller package webhookcachefiller
@ -88,9 +88,9 @@ func TestController(t *testing.T) {
fakeClient := pinnipedfake.NewSimpleClientset(tt.webhooks...) fakeClient := pinnipedfake.NewSimpleClientset(tt.webhooks...)
informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0) informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
cache := authncache.New() cache := authncache.New()
testLog := testlogger.New(t) testLog := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
controller := New(cache, informers.Authentication().V1alpha1().WebhookAuthenticators(), testLog) controller := New(cache, informers.Authentication().V1alpha1().WebhookAuthenticators(), testLog.Logger)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package impersonatorconfig package impersonatorconfig
@ -21,7 +21,6 @@ import (
"k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/equality"
k8serrors "k8s.io/apimachinery/pkg/api/errors" k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/apimachinery/pkg/util/errors" "k8s.io/apimachinery/pkg/util/errors"
utilerrors "k8s.io/apimachinery/pkg/util/errors" utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/intstr"
@ -31,6 +30,7 @@ import (
corev1informers "k8s.io/client-go/informers/core/v1" corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/klog/v2" "k8s.io/klog/v2"
"k8s.io/utils/clock"
"go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
pinnipedclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" pinnipedclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned"

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package impersonatorconfig package impersonatorconfig
@ -29,12 +29,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/intstr"
kubeinformers "k8s.io/client-go/informers" kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
kubernetesfake "k8s.io/client-go/kubernetes/fake" kubernetesfake "k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake" pinnipedfake "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake"
@ -95,7 +94,7 @@ func TestImpersonatorConfigControllerOptions(t *testing.T) {
nil, nil,
caSignerName, caSignerName,
nil, nil,
testLog, testLog.Logger,
) )
credIssuerInformerFilter = observableWithInformerOption.GetFilterForInformer(credIssuerInformer) credIssuerInformerFilter = observableWithInformerOption.GetFilterForInformer(credIssuerInformer)
servicesInformerFilter = observableWithInformerOption.GetFilterForInformer(servicesInformer) servicesInformerFilter = observableWithInformerOption.GetFilterForInformer(servicesInformer)
@ -270,8 +269,6 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
var subject controllerlib.Controller var subject controllerlib.Controller
var kubeAPIClient *kubernetesfake.Clientset var kubeAPIClient *kubernetesfake.Clientset
var deleteOptions *[]metav1.DeleteOptions
var deleteOptionsRecorder kubernetes.Interface
var pinnipedAPIClient *pinnipedfake.Clientset var pinnipedAPIClient *pinnipedfake.Clientset
var pinnipedInformerClient *pinnipedfake.Clientset var pinnipedInformerClient *pinnipedfake.Clientset
var pinnipedInformers pinnipedinformers.SharedInformerFactory var pinnipedInformers pinnipedinformers.SharedInformerFactory
@ -550,7 +547,7 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
subject = NewImpersonatorConfigController( subject = NewImpersonatorConfigController(
installedInNamespace, installedInNamespace,
credentialIssuerResourceName, credentialIssuerResourceName,
deleteOptionsRecorder, kubeAPIClient,
pinnipedAPIClient, pinnipedAPIClient,
pinnipedInformers.Config().V1alpha1().CredentialIssuers(), pinnipedInformers.Config().V1alpha1().CredentialIssuers(),
kubeInformers.Core().V1().Services(), kubeInformers.Core().V1().Services(),
@ -562,11 +559,11 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
tlsSecretName, tlsSecretName,
caSecretName, caSecretName,
labels, labels,
clock.NewFakeClock(frozenNow), clocktesting.NewFakeClock(frozenNow),
impersonatorFunc, impersonatorFunc,
caSignerName, caSignerName,
signingCertProvider, signingCertProvider,
testLog, testLog.Logger,
) )
controllerlib.TestWrap(t, subject, func(syncer controllerlib.Syncer) controllerlib.Syncer { controllerlib.TestWrap(t, subject, func(syncer controllerlib.Syncer) controllerlib.Syncer {
tlsServingCertDynamicCertProvider = syncer.(*impersonatorConfigController).tlsServingCertDynamicCertProvider tlsServingCertDynamicCertProvider = syncer.(*impersonatorConfigController).tlsServingCertDynamicCertProvider
@ -1032,10 +1029,7 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
r.Equal("secrets", deleteAction.GetResource().Resource) r.Equal("secrets", deleteAction.GetResource().Resource)
// validate that we set delete preconditions correctly // validate that we set delete preconditions correctly
r.NotEmpty(*deleteOptions) r.Equal(testutil.NewPreconditions("uid-1234", "rv-5678"), deleteAction.GetDeleteOptions())
for _, opt := range *deleteOptions {
r.Equal(testutil.NewPreconditions("uid-1234", "rv-5678"), opt)
}
} }
var requireCASecretWasCreated = func(action coretesting.Action) []byte { var requireCASecretWasCreated = func(action coretesting.Action) []byte {
@ -1114,8 +1108,6 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
kubeinformers.WithNamespace(installedInNamespace), kubeinformers.WithNamespace(installedInNamespace),
) )
kubeAPIClient = kubernetesfake.NewSimpleClientset() kubeAPIClient = kubernetesfake.NewSimpleClientset()
deleteOptions = &[]metav1.DeleteOptions{}
deleteOptionsRecorder = testutil.NewDeleteOptionsRecorder(kubeAPIClient, deleteOptions)
pinnipedAPIClient = pinnipedfake.NewSimpleClientset() pinnipedAPIClient = pinnipedfake.NewSimpleClientset()
frozenNow = time.Date(2021, time.March, 2, 7, 42, 0, 0, time.Local) frozenNow = time.Date(2021, time.March, 2, 7, 42, 0, 0, time.Local)
signingCertProvider = dynamiccert.NewCA(name) signingCertProvider = dynamiccert.NewCA(name)

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package kubecertagent provides controllers that ensure a pod (the kube-cert-agent), is // Package kubecertagent provides controllers that ensure a pod (the kube-cert-agent), is
@ -23,13 +23,13 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/cache" "k8s.io/apimachinery/pkg/util/cache"
"k8s.io/apimachinery/pkg/util/clock"
utilerrors "k8s.io/apimachinery/pkg/util/errors" utilerrors "k8s.io/apimachinery/pkg/util/errors"
appsv1informers "k8s.io/client-go/informers/apps/v1" appsv1informers "k8s.io/client-go/informers/apps/v1"
corev1informers "k8s.io/client-go/informers/core/v1" corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2" "k8s.io/klog/v2"
"k8s.io/klog/v2/klogr" "k8s.io/klog/v2/klogr"
"k8s.io/utils/clock"
"k8s.io/utils/pointer" "k8s.io/utils/pointer"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package kubecertagent package kubecertagent
@ -20,11 +20,11 @@ import (
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/cache" "k8s.io/apimachinery/pkg/util/cache"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/informers" "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
kubefake "k8s.io/client-go/kubernetes/fake" kubefake "k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"k8s.io/utils/pointer" "k8s.io/utils/pointer"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
@ -1027,17 +1027,14 @@ func TestAgentController(t *testing.T) {
tt.addKubeReactions(kubeClientset) tt.addKubeReactions(kubeClientset)
} }
actualDeleteActionOpts := &[]metav1.DeleteOptions{}
trackDeleteKubeClient := testutil.NewDeleteOptionsRecorder(kubeClientset, actualDeleteActionOpts)
kubeInformers := informers.NewSharedInformerFactory(kubeClientset, 0) kubeInformers := informers.NewSharedInformerFactory(kubeClientset, 0)
log := testlogger.New(t) log := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
mockExecutor := mocks.NewMockPodCommandExecutor(ctrl) mockExecutor := mocks.NewMockPodCommandExecutor(ctrl)
mockDynamicCert := mocks.NewMockDynamicCertPrivate(ctrl) mockDynamicCert := mocks.NewMockDynamicCertPrivate(ctrl)
fakeClock := clock.NewFakeClock(now) fakeClock := clocktesting.NewFakeClock(now)
execCache := cache.NewExpiringWithClock(fakeClock) execCache := cache.NewExpiringWithClock(fakeClock)
if tt.mocks != nil { if tt.mocks != nil {
tt.mocks(t, mockExecutor.EXPECT(), mockDynamicCert.EXPECT(), execCache) tt.mocks(t, mockExecutor.EXPECT(), mockDynamicCert.EXPECT(), execCache)
@ -1059,7 +1056,7 @@ func TestAgentController(t *testing.T) {
}, },
DiscoveryURLOverride: tt.discoveryURLOverride, DiscoveryURLOverride: tt.discoveryURLOverride,
}, },
&kubeclient.Client{Kubernetes: trackDeleteKubeClient, PinnipedConcierge: conciergeClientset}, &kubeclient.Client{Kubernetes: kubeClientset, PinnipedConcierge: conciergeClientset},
kubeInformers.Core().V1().Pods(), kubeInformers.Core().V1().Pods(),
kubeInformers.Apps().V1().Deployments(), kubeInformers.Apps().V1().Deployments(),
kubeInformers.Core().V1().Pods(), kubeInformers.Core().V1().Pods(),
@ -1069,13 +1066,13 @@ func TestAgentController(t *testing.T) {
mockDynamicCert, mockDynamicCert,
fakeClock, fakeClock,
execCache, execCache,
log, log.Logger,
) )
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
errorMessages := runControllerUntilQuiet(ctx, t, controller, hasDeploymentSynced(trackDeleteKubeClient, kubeInformers), kubeInformers, conciergeInformers) errorMessages := runControllerUntilQuiet(ctx, t, controller, hasDeploymentSynced(kubeClientset, kubeInformers), kubeInformers, conciergeInformers)
actualErrors := deduplicate(errorMessages) actualErrors := deduplicate(errorMessages)
assert.Subsetf(t, actualErrors, tt.wantDistinctErrors, "required error(s) were not found in the actual errors") assert.Subsetf(t, actualErrors, tt.wantDistinctErrors, "required error(s) were not found in the actual errors")
@ -1088,16 +1085,20 @@ func TestAgentController(t *testing.T) {
// Assert on all actions that happened to deployments. // Assert on all actions that happened to deployments.
var actualDeploymentActionVerbs []string var actualDeploymentActionVerbs []string
var actualDeleteActionOpts []metav1.DeleteOptions
for _, a := range kubeClientset.Actions() { for _, a := range kubeClientset.Actions() {
if a.GetResource().Resource == "deployments" && a.GetVerb() != "get" { // ignore gets caused by hasDeploymentSynced if a.GetResource().Resource == "deployments" && a.GetVerb() != "get" { // ignore gets caused by hasDeploymentSynced
actualDeploymentActionVerbs = append(actualDeploymentActionVerbs, a.GetVerb()) actualDeploymentActionVerbs = append(actualDeploymentActionVerbs, a.GetVerb())
if deleteAction, ok := a.(coretesting.DeleteAction); ok {
actualDeleteActionOpts = append(actualDeleteActionOpts, deleteAction.GetDeleteOptions())
}
} }
} }
if tt.wantDeploymentActionVerbs != nil { if tt.wantDeploymentActionVerbs != nil {
assert.Equal(t, tt.wantDeploymentActionVerbs, actualDeploymentActionVerbs) assert.Equal(t, tt.wantDeploymentActionVerbs, actualDeploymentActionVerbs)
} }
if tt.wantDeploymentDeleteActionOpts != nil { if tt.wantDeploymentDeleteActionOpts != nil {
assert.Equal(t, tt.wantDeploymentDeleteActionOpts, *actualDeleteActionOpts) assert.Equal(t, tt.wantDeploymentDeleteActionOpts, actualDeleteActionOpts)
} }
// Assert that the agent deployment is in the expected final state. // Assert that the agent deployment is in the expected final state.

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package kubecertagent package kubecertagent
@ -58,12 +58,10 @@ func TestLegacyPodCleanerController(t *testing.T) {
wantDistinctErrors []string wantDistinctErrors []string
wantDistinctLogs []string wantDistinctLogs []string
wantActions []coretesting.Action wantActions []coretesting.Action
wantDeleteOptions []metav1.DeleteOptions
}{ }{
{ {
name: "no pods", name: "no pods",
wantActions: []coretesting.Action{}, wantActions: []coretesting.Action{},
wantDeleteOptions: []metav1.DeleteOptions{},
}, },
{ {
name: "mix of pods", name: "mix of pods",
@ -78,12 +76,9 @@ func TestLegacyPodCleanerController(t *testing.T) {
}, },
wantActions: []coretesting.Action{ // the first delete triggers the informer again, but the second invocation triggers a Not Found wantActions: []coretesting.Action{ // the first delete triggers the informer again, but the second invocation triggers a Not Found
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteActionWithOptions(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name, testutil.NewPreconditions("3", "4")),
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
}, },
wantDeleteOptions: []metav1.DeleteOptions{
testutil.NewPreconditions("3", "4"),
},
}, },
{ {
name: "fail to delete", name: "fail to delete",
@ -102,13 +97,9 @@ func TestLegacyPodCleanerController(t *testing.T) {
}, },
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteActionWithOptions(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name, testutil.NewPreconditions("3", "4")),
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteActionWithOptions(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name, testutil.NewPreconditions("3", "4")),
},
wantDeleteOptions: []metav1.DeleteOptions{
testutil.NewPreconditions("3", "4"),
testutil.NewPreconditions("3", "4"),
}, },
}, },
{ {
@ -126,10 +117,7 @@ func TestLegacyPodCleanerController(t *testing.T) {
wantDistinctErrors: []string{""}, wantDistinctErrors: []string{""},
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteActionWithOptions(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name, testutil.NewPreconditions("3", "4")),
},
wantDeleteOptions: []metav1.DeleteOptions{
testutil.NewPreconditions("3", "4"),
}, },
}, },
{ {
@ -148,7 +136,6 @@ func TestLegacyPodCleanerController(t *testing.T) {
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
}, },
wantDeleteOptions: []metav1.DeleteOptions{},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@ -161,19 +148,16 @@ func TestLegacyPodCleanerController(t *testing.T) {
tt.addKubeReactions(kubeClientset) tt.addKubeReactions(kubeClientset)
} }
opts := &[]metav1.DeleteOptions{}
trackDeleteClient := testutil.NewDeleteOptionsRecorder(kubeClientset, opts)
kubeInformers := informers.NewSharedInformerFactory(kubeClientset, 0) kubeInformers := informers.NewSharedInformerFactory(kubeClientset, 0)
log := testlogger.New(t) log := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
controller := NewLegacyPodCleanerController( controller := NewLegacyPodCleanerController(
AgentConfig{ AgentConfig{
Namespace: "concierge", Namespace: "concierge",
Labels: map[string]string{"extralabel": "labelvalue"}, Labels: map[string]string{"extralabel": "labelvalue"},
}, },
&kubeclient.Client{Kubernetes: trackDeleteClient}, &kubeclient.Client{Kubernetes: kubeClientset},
kubeInformers.Core().V1().Pods(), kubeInformers.Core().V1().Pods(),
log, log.Logger,
) )
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@ -183,7 +167,6 @@ func TestLegacyPodCleanerController(t *testing.T) {
assert.Equal(t, tt.wantDistinctErrors, deduplicate(errorMessages), "unexpected errors") assert.Equal(t, tt.wantDistinctErrors, deduplicate(errorMessages), "unexpected errors")
assert.Equal(t, tt.wantDistinctLogs, deduplicate(log.Lines()), "unexpected logs") assert.Equal(t, tt.wantDistinctLogs, deduplicate(log.Lines()), "unexpected logs")
assert.Equal(t, tt.wantActions, kubeClientset.Actions()[2:], "unexpected actions") assert.Equal(t, tt.wantActions, kubeClientset.Actions()[2:], "unexpected actions")
assert.Equal(t, tt.wantDeleteOptions, *opts, "unexpected delete options")
}) })
} }
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package activedirectoryupstreamwatcher implements a controller which watches ActiveDirectoryIdentityProviders. // Package activedirectoryupstreamwatcher implements a controller which watches ActiveDirectoryIdentityProviders.
@ -7,8 +7,12 @@ package activedirectoryupstreamwatcher
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strconv"
"strings"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -52,6 +56,21 @@ const (
// - has a member that matches the DN of the user we successfully logged in as. // - has a member that matches the DN of the user we successfully logged in as.
// - perform nested group search by default. // - perform nested group search by default.
defaultActiveDirectoryGroupSearchFilter = "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={}))" defaultActiveDirectoryGroupSearchFilter = "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={}))"
sAMAccountNameAttribute = "sAMAccountName"
// pwdLastSetAttribute is the date and time that the password for this account was last changed.
// https://docs.microsoft.com/en-us/windows/win32/adschema/a-pwdlastset
pwdLastSetAttribute = "pwdLastSet"
// userAccountControlAttribute represents a bitmap of user properties.
// https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
userAccountControlAttribute = "userAccountControl"
// userAccountControlComputedAttribute represents a bitmap of user properties.
// https://docs.microsoft.com/en-us/windows/win32/adschema/a-msds-user-account-control-computed
userAccountControlComputedAttribute = "msDS-User-Account-Control-Computed"
// 0x0002 ACCOUNTDISABLE in userAccountControl bitmap.
accountDisabledBitmapValue = 2
// 0x0010 UF_LOCKOUT in msDS-User-Account-Control-Computed bitmap.
accountLockedBitmapValue = 16
) )
type activeDirectoryUpstreamGenericLDAPImpl struct { type activeDirectoryUpstreamGenericLDAPImpl struct {
@ -315,12 +334,21 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context,
Filter: adUpstreamImpl.Spec().GroupSearch().Filter(), Filter: adUpstreamImpl.Spec().GroupSearch().Filter(),
GroupNameAttribute: adUpstreamImpl.Spec().GroupSearch().GroupNameAttribute(), GroupNameAttribute: adUpstreamImpl.Spec().GroupSearch().GroupNameAttribute(),
}, },
Dialer: c.ldapDialer, Dialer: c.ldapDialer,
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){
"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID"),
},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
pwdLastSetAttribute: upstreamldap.AttributeUnchangedSinceLogin(pwdLastSetAttribute),
userAccountControlAttribute: validUserAccountControl,
userAccountControlComputedAttribute: validComputedUserAccountControl,
},
} }
if spec.GroupSearch.Attributes.GroupName == "" { if spec.GroupSearch.Attributes.GroupName == "" {
config.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){defaultActiveDirectoryGroupNameAttributeName: upstreamldap.GroupSAMAccountNameWithDomainSuffix} config.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){
defaultActiveDirectoryGroupNameAttributeName: groupSAMAccountNameWithDomainSuffix,
}
} }
conditions := upstreamwatchers.ValidateGenericLDAP(ctx, adUpstreamImpl, c.secretInformer, c.validatedSecretVersionsCache, config) conditions := upstreamwatchers.ValidateGenericLDAP(ctx, adUpstreamImpl, c.secretInformer, c.validatedSecretVersionsCache, config)
@ -353,3 +381,84 @@ func (c *activeDirectoryWatcherController) updateStatus(ctx context.Context, ups
log.Error(err, "failed to update status") log.Error(err, "failed to update status")
} }
} }
func microsoftUUIDFromBinaryAttr(attributeName string) func(entry *ldap.Entry) (string, error) {
// validation has already been done so we can just get the attribute...
return func(entry *ldap.Entry) (string, error) {
binaryUUID := entry.GetRawAttributeValue(attributeName)
return microsoftUUIDFromBinary(binaryUUID)
}
}
func microsoftUUIDFromBinary(binaryUUID []byte) (string, error) {
uuidVal, err := uuid.FromBytes(binaryUUID) // start out with the RFC4122 version
if err != nil {
return "", err
}
// then swap it because AD stores the first 3 fields little-endian rather than the expected
// big-endian.
uuidVal[0], uuidVal[1], uuidVal[2], uuidVal[3] = uuidVal[3], uuidVal[2], uuidVal[1], uuidVal[0]
uuidVal[4], uuidVal[5] = uuidVal[5], uuidVal[4]
uuidVal[6], uuidVal[7] = uuidVal[7], uuidVal[6]
return uuidVal.String(), nil
}
func groupSAMAccountNameWithDomainSuffix(entry *ldap.Entry) (string, error) {
sAMAccountNameAttributeValues := entry.GetAttributeValues(sAMAccountNameAttribute)
if len(sAMAccountNameAttributeValues) != 1 {
return "", fmt.Errorf(`found %d values for attribute %q, but expected 1 result`,
len(sAMAccountNameAttributeValues), sAMAccountNameAttribute,
)
}
sAMAccountName := sAMAccountNameAttributeValues[0]
if len(sAMAccountName) == 0 {
return "", fmt.Errorf(`found empty value for attribute %q, but expected value to be non-empty`,
sAMAccountNameAttribute,
)
}
distinguishedName := entry.DN
domain, err := getDomainFromDistinguishedName(distinguishedName)
if err != nil {
return "", err
}
return sAMAccountName + "@" + domain, nil
}
var domainComponentsRegexp = regexp.MustCompile(",DC=|,dc=")
func getDomainFromDistinguishedName(distinguishedName string) (string, error) {
domainComponents := domainComponentsRegexp.Split(distinguishedName, -1)
if len(domainComponents) == 1 {
return "", fmt.Errorf("did not find domain components in group dn: %s", distinguishedName)
}
return strings.Join(domainComponents[1:], "."), nil
}
func validUserAccountControl(entry *ldap.Entry, _ provider.StoredRefreshAttributes) error {
userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlAttribute))
if err != nil {
return err
}
deactivated := userAccountControl & accountDisabledBitmapValue // bitwise and.
if deactivated != 0 {
return fmt.Errorf("user has been deactivated")
}
return nil
}
func validComputedUserAccountControl(entry *ldap.Entry, _ provider.StoredRefreshAttributes) error {
userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlComputedAttribute))
if err != nil {
return err
}
locked := userAccountControl & accountLockedBitmapValue // bitwise and
if locked != 0 {
return fmt.Errorf("user has been locked")
}
return nil
}

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package activedirectoryupstreamwatcher package activedirectoryupstreamwatcher
@ -220,7 +220,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
} }
// Make a copy with targeted changes. // Make a copy with targeted changes.
@ -536,7 +541,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -592,7 +602,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: "sAMAccountName", GroupNameAttribute: "sAMAccountName",
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -651,7 +666,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -710,7 +730,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantErr: controllerlib.ErrSyntheticRequeue.Error(),
@ -768,7 +793,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -897,7 +927,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -1021,8 +1056,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
}, RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
}},
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234},
@ -1072,7 +1111,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -1271,8 +1315,13 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={}))", Filter: "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={}))",
GroupNameAttribute: "sAMAccountName", GroupNameAttribute: "sAMAccountName",
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": upstreamldap.GroupSAMAccountNameWithDomainSuffix}, GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": groupSAMAccountNameWithDomainSuffix},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -1324,7 +1373,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -1380,7 +1434,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -1430,7 +1489,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -1626,7 +1690,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter, Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupNameAttrName, GroupNameAttribute: testGroupNameAttrName,
}, },
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
"pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
},
}, },
}, },
wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{
@ -1753,6 +1822,16 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
require.Equal(t, reflect.ValueOf(v).Pointer(), reflect.ValueOf(actualGroupAttributeParsingOverrides[k]).Pointer()) require.Equal(t, reflect.ValueOf(v).Pointer(), reflect.ValueOf(actualGroupAttributeParsingOverrides[k]).Pointer())
} }
expectedRefreshAttributeChecks := copyOfExpectedValueForResultingCache.RefreshAttributeChecks
actualRefreshAttributeChecks := actualConfig.RefreshAttributeChecks
copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{}
actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{}
require.Equal(t, len(expectedRefreshAttributeChecks), len(actualRefreshAttributeChecks))
for k, v := range expectedRefreshAttributeChecks {
require.NotNil(t, actualRefreshAttributeChecks[k])
require.Equal(t, reflect.ValueOf(v).Pointer(), reflect.ValueOf(actualRefreshAttributeChecks[k]).Pointer())
}
require.Equal(t, copyOfExpectedValueForResultingCache, actualConfig) require.Equal(t, copyOfExpectedValueForResultingCache, actualConfig)
} }
@ -1800,3 +1879,270 @@ func normalizeActiveDirectoryUpstreams(upstreams []v1alpha1.ActiveDirectoryIdent
return result return result
} }
func TestGroupSAMAccountNameWithDomainSuffix(t *testing.T) {
tests := []struct {
name string
entry *ldap.Entry
wantResult string
wantErr string
}{
{
name: "happy path with DN and valid sAMAccountName",
entry: &ldap.Entry{
DN: "CN=animals,OU=Users,OU=pinniped-ad,DC=mycompany,DC=example,DC=com",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}),
},
},
wantResult: "Mammals@mycompany.example.com",
},
{
name: "no domain components in DN",
entry: &ldap.Entry{
DN: "no-domain-components",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}),
},
},
wantErr: "did not find domain components in group dn: no-domain-components",
},
{
name: "multiple values for sAMAccountName attribute",
entry: &ldap.Entry{
DN: "CN=animals,OU=Users,OU=pinniped-ad,DC=mycompany,DC=example,DC=com",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals", "Eukaryotes"}),
},
},
wantErr: "found 2 values for attribute \"sAMAccountName\", but expected 1 result",
},
{
name: "no values for sAMAccountName attribute",
entry: &ldap.Entry{
DN: "CN=animals,OU=Users,OU=pinniped-ad,DC=mycompany,DC=example,DC=com",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{}),
},
},
wantErr: "found 0 values for attribute \"sAMAccountName\", but expected 1 result",
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
suffixedSAMAccountName, err := groupSAMAccountNameWithDomainSuffix(tt.entry)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantResult, suffixedSAMAccountName)
})
}
}
func TestGetMicrosoftFormattedUUID(t *testing.T) {
tests := []struct {
name string
binaryUUID []byte
wantString string
wantErr string
}{
{
name: "happy path",
binaryUUID: []byte("\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16"),
wantString: "04030201-0605-0807-0910-111213141516",
},
{
name: "not the right length",
binaryUUID: []byte("2\xf8\xb0\xaa\xb6V\xb1D\x8b(\xee"),
wantErr: "invalid UUID (got 11 bytes)",
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
actualUUIDString, err := microsoftUUIDFromBinary(tt.binaryUUID)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantString, actualUUIDString)
})
}
}
func TestGetDomainFromDistinguishedName(t *testing.T) {
tests := []struct {
name string
distinguishedName string
wantDomain string
wantErr string
}{
{
name: "happy path",
distinguishedName: "CN=Mammals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com",
wantDomain: "activedirectory.mycompany.example.com",
},
{
name: "lowercased happy path",
distinguishedName: "cn=Mammals,ou=Users,ou=pinniped-ad,dc=activedirectory,dc=mycompany,dc=example,dc=com",
wantDomain: "activedirectory.mycompany.example.com",
},
{
name: "no domain components",
distinguishedName: "not-a-dn",
wantErr: "did not find domain components in group dn: not-a-dn",
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
actualDomain, err := getDomainFromDistinguishedName(tt.distinguishedName)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantDomain, actualDomain)
})
}
}
func TestValidUserAccountControl(t *testing.T) {
tests := []struct {
name string
entry *ldap.Entry
wantErr string
}{
{
name: "happy normal user",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: "userAccountControl",
Values: []string{"512"},
},
},
},
},
{
name: "happy user whose password doesn't expire",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: "userAccountControl",
Values: []string{"65536"},
},
},
},
},
{
name: "deactivated user",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: "userAccountControl",
Values: []string{"514"},
},
},
},
wantErr: "user has been deactivated",
},
{
name: "non-integer result",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: "userAccountControl",
Values: []string{"not-an-int"},
},
},
},
wantErr: "strconv.Atoi: parsing \"not-an-int\": invalid syntax",
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
err := validUserAccountControl(tt.entry, provider.StoredRefreshAttributes{})
if tt.wantErr != "" {
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestValidComputedUserAccountControl(t *testing.T) {
tests := []struct {
name string
entry *ldap.Entry
wantErr string
}{
{
name: "happy normal user",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: "msDS-User-Account-Control-Computed",
Values: []string{"0"},
},
},
},
},
{
name: "locked user",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: "msDS-User-Account-Control-Computed",
Values: []string{"16"},
},
},
},
wantErr: "user has been locked",
},
{
name: "non-integer result",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: "msDS-User-Account-Control-Computed",
Values: []string{"not-an-int"},
},
},
},
wantErr: "strconv.Atoi: parsing \"not-an-int\": invalid syntax",
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
err := validComputedUserAccountControl(tt.entry, provider.StoredRefreshAttributes{})
if tt.wantErr != "" {
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package supervisorconfig package supervisorconfig
@ -11,10 +11,10 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/apimachinery/pkg/util/errors" "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/client-go/util/retry" "k8s.io/client-go/util/retry"
"k8s.io/klog/v2" "k8s.io/klog/v2"
"k8s.io/utils/clock"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package supervisorconfig package supervisorconfig
@ -20,8 +20,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" pinnipedfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
@ -116,7 +116,7 @@ func TestSync(t *testing.T) {
// Set this at the last second to allow for injection of server override. // Set this at the last second to allow for injection of server override.
subject = NewFederationDomainWatcherController( subject = NewFederationDomainWatcherController(
providersSetter, providersSetter,
clock.NewFakeClock(frozenNow), clocktesting.NewFakeClock(frozenNow),
pinnipedAPIClient, pinnipedAPIClient,
federationDomainInformers.Config().V1alpha1().FederationDomains(), federationDomainInformers.Config().V1alpha1().FederationDomains(),
controllerlib.WithInformer, controllerlib.WithInformer,

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package oidcupstreamwatcher implements a controller which watches OIDCIdentityProviders. // Package oidcupstreamwatcher implements a controller which watches OIDCIdentityProviders.
@ -324,6 +324,11 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1
} }
} }
_, issuerURLCondition := validateHTTPSURL(upstream.Spec.Issuer, "issuer", reasonUnreachable)
if issuerURLCondition != nil {
return issuerURLCondition
}
discoveredProvider, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer) discoveredProvider, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer)
if err != nil { if err != nil {
const klogLevelTrace = 6 const klogLevelTrace = 6
@ -359,46 +364,35 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1
} }
} }
if additionalDiscoveryClaims.RevocationEndpoint != "" { if additionalDiscoveryClaims.RevocationEndpoint != "" {
// Found a revocation URL. Try to parse it. // Found a revocation URL. Validate it.
revocationURL, err := url.Parse(additionalDiscoveryClaims.RevocationEndpoint) revocationURL, revocationURLCondition := validateHTTPSURL(
if err != nil { additionalDiscoveryClaims.RevocationEndpoint,
return &v1alpha1.Condition{ "revocation endpoint",
Type: typeOIDCDiscoverySucceeded, reasonInvalidResponse,
Status: v1alpha1.ConditionFalse, )
Reason: reasonInvalidResponse, if revocationURLCondition != nil {
Message: fmt.Sprintf("failed to parse revocation endpoint URL: %v", err), return revocationURLCondition
}
}
// Don't want to send refresh tokens to an insecure revocation endpoint, so require that it use https.
if revocationURL.Scheme != "https" {
return &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reasonInvalidResponse,
Message: fmt.Sprintf(`revocation endpoint URL scheme must be "https", not %q`, revocationURL.Scheme),
}
} }
// Remember the URL for later use. // Remember the URL for later use.
result.RevocationURL = revocationURL result.RevocationURL = revocationURL
} }
// Parse out and validate the discovered authorize endpoint. _, authorizeURLCondition := validateHTTPSURL(
authURL, err := url.Parse(discoveredProvider.Endpoint().AuthURL) discoveredProvider.Endpoint().AuthURL,
if err != nil { "authorization endpoint",
return &v1alpha1.Condition{ reasonInvalidResponse,
Type: typeOIDCDiscoverySucceeded, )
Status: v1alpha1.ConditionFalse, if authorizeURLCondition != nil {
Reason: reasonInvalidResponse, return authorizeURLCondition
Message: fmt.Sprintf("failed to parse authorization endpoint URL: %v", err),
}
} }
if authURL.Scheme != "https" {
return &v1alpha1.Condition{ _, tokenURLCondition := validateHTTPSURL(
Type: typeOIDCDiscoverySucceeded, discoveredProvider.Endpoint().TokenURL,
Status: v1alpha1.ConditionFalse, "token endpoint",
Reason: reasonInvalidResponse, reasonInvalidResponse,
Message: fmt.Sprintf(`authorization endpoint URL scheme must be "https", not %q`, authURL.Scheme), )
} if tokenURLCondition != nil {
return tokenURLCondition
} }
// If everything is valid, update the result and set the condition to true. // If everything is valid, update the result and set the condition to true.
@ -489,3 +483,32 @@ func truncateMostLongErr(err error) string {
return msg[:max] + fmt.Sprintf(" [truncated %d chars]", len(msg)-max) return msg[:max] + fmt.Sprintf(" [truncated %d chars]", len(msg)-max)
} }
func validateHTTPSURL(maybeHTTPSURL, endpointType, reason string) (*url.URL, *v1alpha1.Condition) {
parsedURL, err := url.Parse(maybeHTTPSURL)
if err != nil {
return nil, &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reason,
Message: fmt.Sprintf("failed to parse %s URL: %v", endpointType, truncateMostLongErr(err)),
}
}
if parsedURL.Scheme != "https" {
return nil, &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reason,
Message: fmt.Sprintf(`%s URL '%s' must have "https" scheme, not %q`, endpointType, maybeHTTPSURL, parsedURL.Scheme),
}
}
if len(parsedURL.Query()) != 0 || parsedURL.Fragment != "" {
return nil, &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reason,
Message: fmt.Sprintf(`%s URL '%s' cannot contain query or fragment component`, endpointType, maybeHTTPSURL),
}
}
return parsedURL, nil
}

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package oidcupstreamwatcher package oidcupstreamwatcher
@ -91,7 +91,7 @@ func TestOIDCUpstreamWatcherControllerFilterSecret(t *testing.T) {
nil, nil,
pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(), pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(),
secretInformer, secretInformer,
testLog, testLog.Logger,
withInformer.WithInformer, withInformer.WithInformer,
) )
@ -399,7 +399,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{ Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", Issuer: "%invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
Client: v1alpha1.OIDCClient{SecretName: testSecretName}, Client: v1alpha1.OIDCClient{SecretName: testSecretName},
}, },
}}, }},
@ -410,11 +410,10 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}}, }},
wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{ wantLogs: []string{
`oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="Get \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": unsupported protocol scheme \"\"" "issuer"="invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "name"="test-name" "namespace"="test-namespace"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to parse issuer URL: parse \"%invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\": invalid URL escape \"%in\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to parse issuer URL: parse \"%invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\": invalid URL escape \"%in\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
}, },
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{}, wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -435,8 +434,145 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Status: "False", Status: "False",
LastTransitionTime: now, LastTransitionTime: now,
Reason: "Unreachable", Reason: "Unreachable",
Message: `failed to perform OIDC discovery against "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee": Message: `failed to parse issuer URL: parse "%invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee": invalid URL escape "%in"`,
Get "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration": unsupported protocol [truncated 9 chars]`, },
},
},
}},
},
{
name: "issuer is insecure http URL",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: strings.Replace(testIssuerURL, "https", "http", 1),
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
},
}},
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{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="issuer URL '` + strings.Replace(testIssuerURL, "https", "http", 1) + `' must have \"https\" scheme, not \"http\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="issuer URL '` + strings.Replace(testIssuerURL, "https", "http", 1) + `' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "Unreachable",
Message: `issuer URL '` + strings.Replace(testIssuerURL, "https", "http", 1) + `' must have "https" scheme, not "http"`,
},
},
},
}},
},
{
name: "issuer contains a query param",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "?sub=foo",
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
},
}},
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{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="issuer URL '` + testIssuerURL + "?sub=foo" + `' cannot contain query or fragment component" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="issuer URL '` + testIssuerURL + "?sub=foo" + `' cannot contain query or fragment component" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "Unreachable",
Message: `issuer URL '` + testIssuerURL + "?sub=foo" + `' cannot contain query or fragment component`,
},
},
},
}},
},
{
name: "issuer contains a fragment",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "#fragment",
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
},
}},
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{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="issuer URL '` + testIssuerURL + "#fragment" + `' cannot contain query or fragment component" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="issuer URL '` + testIssuerURL + "#fragment" + `' cannot contain query or fragment component" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "Unreachable",
Message: `issuer URL '` + testIssuerURL + "#fragment" + `' cannot contain query or fragment component`,
}, },
}, },
}, },
@ -603,9 +739,9 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{ wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="authorization endpoint URL scheme must be \"https\", not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="authorization endpoint URL 'http://example.com/authorize' must have \"https\" scheme, not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL scheme must be \"https\", not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL 'http://example.com/authorize' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
}, },
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{}, wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -626,7 +762,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Status: "False", Status: "False",
LastTransitionTime: now, LastTransitionTime: now,
Reason: "InvalidResponse", Reason: "InvalidResponse",
Message: `authorization endpoint URL scheme must be "https", not "http"`, Message: `authorization endpoint URL 'http://example.com/authorize' must have "https" scheme, not "http"`,
}, },
}, },
}, },
@ -650,9 +786,9 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{ wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="revocation endpoint URL scheme must be \"https\", not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="revocation endpoint URL 'http://example.com/revoke' must have \"https\" scheme, not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="revocation endpoint URL scheme must be \"https\", not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="revocation endpoint URL 'http://example.com/revoke' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
}, },
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{}, wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -673,7 +809,148 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Status: "False", Status: "False",
LastTransitionTime: now, LastTransitionTime: now,
Reason: "InvalidResponse", Reason: "InvalidResponse",
Message: `revocation endpoint URL scheme must be "https", not "http"`, Message: `revocation endpoint URL 'http://example.com/revoke' must have "https" scheme, not "http"`,
},
},
},
}},
},
{
name: "issuer returns insecure token URL",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "/insecure-token-url",
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
},
}},
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{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="token endpoint URL 'http://example.com/token' must have \"https\" scheme, not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="token endpoint URL 'http://example.com/token' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "InvalidResponse",
Message: `token endpoint URL 'http://example.com/token' must have "https" scheme, not "http"`,
},
},
},
}},
},
{
name: "issuer returns no token URL",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "/missing-token-url",
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
},
}},
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{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="token endpoint URL '' must have \"https\" scheme, not \"\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="token endpoint URL '' must have \"https\" scheme, not \"\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "InvalidResponse",
Message: `token endpoint URL '' must have "https" scheme, not ""`,
},
},
},
}},
},
{
name: "issuer returns no auth URL",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "/missing-auth-url",
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
},
}},
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{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="authorization endpoint URL '' must have \"https\" scheme, not \"\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL '' must have \"https\" scheme, not \"\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "InvalidResponse",
Message: `authorization endpoint URL '' must have "https" scheme, not ""`,
}, },
}, },
}, },
@ -1123,7 +1400,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs
pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0) pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0)
fakeKubeClient := fake.NewSimpleClientset(tt.inputSecrets...) fakeKubeClient := fake.NewSimpleClientset(tt.inputSecrets...)
kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0)
testLog := testlogger.New(t) testLog := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
cache := provider.NewDynamicUpstreamIDPProvider() cache := provider.NewDynamicUpstreamIDPProvider()
cache.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ cache.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{
&upstreamoidc.ProviderConfig{Name: "initial-entry"}, &upstreamoidc.ProviderConfig{Name: "initial-entry"},
@ -1134,7 +1411,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs
fakePinnipedClient, fakePinnipedClient,
pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(), pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(),
kubeInformers.Core().V1().Secrets(), kubeInformers.Core().V1().Secrets(),
testLog, testLog.Logger,
controllerlib.WithInformer, controllerlib.WithInformer,
) )
@ -1253,6 +1530,7 @@ func newTestIssuer(t *testing.T) (string, string) {
Issuer: testURL, Issuer: testURL,
AuthURL: "https://example.com/authorize", AuthURL: "https://example.com/authorize",
RevocationURL: "https://example.com/revoke", RevocationURL: "https://example.com/revoke",
TokenURL: "https://example.com/token",
}) })
}) })
@ -1263,6 +1541,7 @@ func newTestIssuer(t *testing.T) (string, string) {
Issuer: testURL + "/valid-without-revocation", Issuer: testURL + "/valid-without-revocation",
AuthURL: "https://example.com/authorize", AuthURL: "https://example.com/authorize",
RevocationURL: "", // none RevocationURL: "", // none
TokenURL: "https://example.com/token",
}) })
}) })
@ -1270,8 +1549,9 @@ func newTestIssuer(t *testing.T) (string, string) {
mux.HandleFunc("/invalid/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/invalid/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json") w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{ _ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/invalid", Issuer: testURL + "/invalid",
AuthURL: "%", AuthURL: "%",
TokenURL: "https://example.com/token",
}) })
}) })
@ -1282,6 +1562,7 @@ func newTestIssuer(t *testing.T) (string, string) {
Issuer: testURL + "/invalid-revocation-url", Issuer: testURL + "/invalid-revocation-url",
AuthURL: "https://example.com/authorize", AuthURL: "https://example.com/authorize",
RevocationURL: "%", RevocationURL: "%",
TokenURL: "https://example.com/token",
}) })
}) })
@ -1289,18 +1570,52 @@ func newTestIssuer(t *testing.T) (string, string) {
mux.HandleFunc("/insecure/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/insecure/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json") w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{ _ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/insecure", Issuer: testURL + "/insecure",
AuthURL: "http://example.com/authorize", AuthURL: "http://example.com/authorize",
TokenURL: "https://example.com/token",
}) })
}) })
// At "/insecure", serve an issuer that returns an insecure authorization URL (not https://). // At "/insecure-revocation-url", serve an issuer that returns an insecure revocation URL (not https://).
mux.HandleFunc("/insecure-revocation-url/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/insecure-revocation-url/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json") w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{ _ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/insecure-revocation-url", Issuer: testURL + "/insecure-revocation-url",
AuthURL: "https://example.com/authorize", AuthURL: "https://example.com/authorize",
RevocationURL: "http://example.com/revoke", RevocationURL: "http://example.com/revoke",
TokenURL: "https://example.com/token",
})
})
// At "/insecure-token-url", serve an issuer that returns an insecure token URL (not https://).
mux.HandleFunc("/insecure-token-url/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/insecure-token-url",
AuthURL: "https://example.com/authorize",
RevocationURL: "https://example.com/revoke",
TokenURL: "http://example.com/token",
})
})
// At "/missing-token-url", serve an issuer that returns no token URL (is required by the spec unless it's an idp which only supports
// implicit flow, which we don't support). So for our purposes we need to always get a token url
mux.HandleFunc("/missing-token-url/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/missing-token-url",
AuthURL: "https://example.com/authorize",
RevocationURL: "https://example.com/revoke",
})
})
// At "/missing-auth-url", serve an issuer that returns no auth URL, which is required by the spec.
mux.HandleFunc("/missing-auth-url/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/missing-auth-url",
RevocationURL: "https://example.com/revoke",
TokenURL: "https://example.com/token",
}) })
}) })
@ -1316,6 +1631,7 @@ func newTestIssuer(t *testing.T) (string, string) {
Issuer: testURL + "/ends-with-slash/", Issuer: testURL + "/ends-with-slash/",
AuthURL: "https://example.com/authorize", AuthURL: "https://example.com/authorize",
RevocationURL: "https://example.com/revoke", RevocationURL: "https://example.com/revoke",
TokenURL: "https://example.com/token",
}) })
}) })

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package supervisorstorage package supervisorstorage
@ -13,9 +13,10 @@ import (
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/clock"
corev1informers "k8s.io/client-go/informers/core/v1" corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/utils/clock"
clocktesting "k8s.io/utils/clock/testing"
"k8s.io/utils/strings/slices" "k8s.io/utils/strings/slices"
pinnipedcontroller "go.pinniped.dev/internal/controller" pinnipedcontroller "go.pinniped.dev/internal/controller"
@ -88,7 +89,7 @@ func GarbageCollectorController(
func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error { func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error {
// make sure we have a consistent, static meaning for the current time during the sync loop // make sure we have a consistent, static meaning for the current time during the sync loop
frozenClock := clock.NewFakeClock(c.clock.Now()) frozenClock := clocktesting.NewFakeClock(c.clock.Now())
// The Sync method is triggered upon any change to any Secret, which would make this // The Sync method is triggered upon any change to any Secret, which would make this
// controller too chatty, so it rate limits itself to a more reasonable interval. // controller too chatty, so it rate limits itself to a more reasonable interval.

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package supervisorstorage package supervisorstorage
@ -18,11 +18,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
kubeinformers "k8s.io/client-go/informers" kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
kubernetesfake "k8s.io/client-go/kubernetes/fake" kubernetesfake "k8s.io/client-go/kubernetes/fake"
kubetesting "k8s.io/client-go/testing" kubetesting "k8s.io/client-go/testing"
"k8s.io/utils/clock"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/accesstoken"
@ -127,13 +127,11 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
subject controllerlib.Controller subject controllerlib.Controller
kubeInformerClient *kubernetesfake.Clientset kubeInformerClient *kubernetesfake.Clientset
kubeClient *kubernetesfake.Clientset kubeClient *kubernetesfake.Clientset
deleteOptions *[]metav1.DeleteOptions
deleteOptionsRecorder kubernetes.Interface
kubeInformers kubeinformers.SharedInformerFactory kubeInformers kubeinformers.SharedInformerFactory
cancelContext context.Context cancelContext context.Context
cancelContextCancelFunc context.CancelFunc cancelContextCancelFunc context.CancelFunc
syncContext *controllerlib.Context syncContext *controllerlib.Context
fakeClock *clock.FakeClock fakeClock *clocktesting.FakeClock
frozenNow time.Time frozenNow time.Time
) )
@ -144,7 +142,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
subject = GarbageCollectorController( subject = GarbageCollectorController(
idpCache, idpCache,
fakeClock, fakeClock,
deleteOptionsRecorder, kubeClient,
kubeInformers.Core().V1().Secrets(), kubeInformers.Core().V1().Secrets(),
controllerlib.WithInformer, controllerlib.WithInformer,
) )
@ -172,11 +170,9 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
kubeInformerClient = kubernetesfake.NewSimpleClientset() kubeInformerClient = kubernetesfake.NewSimpleClientset()
kubeClient = kubernetesfake.NewSimpleClientset() kubeClient = kubernetesfake.NewSimpleClientset()
deleteOptions = &[]metav1.DeleteOptions{}
deleteOptionsRecorder = testutil.NewDeleteOptionsRecorder(kubeClient, deleteOptions)
kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0) kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0)
frozenNow = time.Now().UTC() frozenNow = time.Now().UTC()
fakeClock = clock.NewFakeClock(frozenNow) fakeClock = clocktesting.NewFakeClock(frozenNow)
unrelatedSecret := &corev1.Secret{ unrelatedSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
@ -252,18 +248,11 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "first expired secret"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "first expired secret", testutil.NewPreconditions("uid-123", "rv-456")),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "second expired secret"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "second expired secret", testutil.NewPreconditions("uid-789", "rv-555")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-456"),
testutil.NewPreconditions("uid-789", "rv-555"),
},
*deleteOptions,
)
list, err := kubeClient.CoreV1().Secrets(installedInNamespace).List(context.Background(), metav1.ListOptions{}) list, err := kubeClient.CoreV1().Secrets(installedInNamespace).List(context.Background(), metav1.ListOptions{})
r.NoError(err) r.NoError(err)
r.Len(list.Items, 2) r.Len(list.Items, 2)
@ -514,18 +503,11 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// Both authcode session secrets are deleted. // Both authcode session secrets are deleted.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "inactiveOIDCAuthcodeSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "inactiveOIDCAuthcodeSession", testutil.NewPreconditions("uid-456", "rv-456")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-123"),
testutil.NewPreconditions("uid-456", "rv-456"),
},
*deleteOptions,
)
}) })
}) })
@ -590,16 +572,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// The invalid authcode session secrets is still deleted because it is expired. // The invalid authcode session secrets is still deleted because it is expired.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "invalidOIDCAuthcodeSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "invalidOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-123"),
},
*deleteOptions,
)
}) })
}) })
@ -666,16 +642,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// The authcode session secrets is still deleted because it is expired. // The authcode session secrets is still deleted because it is expired.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-123"),
},
*deleteOptions,
)
}) })
}) })
@ -742,16 +712,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// The authcode session secrets is still deleted because it is expired. // The authcode session secrets is still deleted because it is expired.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-123"),
},
*deleteOptions,
)
}) })
}) })
@ -936,16 +900,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// The authcode session secrets is deleted. // The authcode session secrets is deleted.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-123"),
},
*deleteOptions,
)
}) })
}) })
@ -1063,18 +1021,11 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// Both session secrets are deleted. // Both session secrets are deleted.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "offlineAccessGrantedOIDCAccessTokenSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "offlineAccessGrantedOIDCAccessTokenSession", testutil.NewPreconditions("uid-123", "rv-123")),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "offlineAccessNotGrantedOIDCAccessTokenSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "offlineAccessNotGrantedOIDCAccessTokenSession", testutil.NewPreconditions("uid-456", "rv-456")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-123"),
testutil.NewPreconditions("uid-456", "rv-456"),
},
*deleteOptions,
)
}) })
}) })
@ -1358,16 +1309,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// The secret is deleted. // The secret is deleted.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "oidcRefreshSession"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "oidcRefreshSession", testutil.NewPreconditions("uid-123", "rv-123")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
r.ElementsMatch(
[]metav1.DeleteOptions{
testutil.NewPreconditions("uid-123", "rv-123"),
},
*deleteOptions,
)
}) })
}) })
@ -1376,8 +1321,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// Add a secret that will expire in 20 seconds. // Add a secret that will expire in 20 seconds.
expiredSecret := &corev1.Secret{ expiredSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "expired secret", Name: "expired secret",
Namespace: installedInNamespace, Namespace: installedInNamespace,
UID: "uid-747",
ResourceVersion: "rv-609",
Annotations: map[string]string{ Annotations: map[string]string{
"storage.pinniped.dev/garbage-collect-after": frozenNow.Add(20 * time.Second).Format(time.RFC3339), "storage.pinniped.dev/garbage-collect-after": frozenNow.Add(20 * time.Second).Format(time.RFC3339),
}, },
@ -1415,7 +1362,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// It should have deleted the expired secret. // It should have deleted the expired secret.
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "expired secret"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "expired secret", testutil.NewPreconditions("uid-747", "rv-609")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
@ -1441,8 +1388,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.NoError(kubeClient.Tracker().Add(malformedSecret)) r.NoError(kubeClient.Tracker().Add(malformedSecret))
expiredSecret := &corev1.Secret{ expiredSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "expired secret", Name: "expired secret",
Namespace: installedInNamespace, Namespace: installedInNamespace,
UID: "uid-748",
ResourceVersion: "rv-608",
Annotations: map[string]string{ Annotations: map[string]string{
"storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339), "storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339),
}, },
@ -1458,7 +1407,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "expired secret"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "expired secret", testutil.NewPreconditions("uid-748", "rv-608")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )
@ -1473,8 +1422,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
it.Before(func() { it.Before(func() {
erroringSecret := &corev1.Secret{ erroringSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "erroring secret", Name: "erroring secret",
Namespace: installedInNamespace, Namespace: installedInNamespace,
UID: "uid-111",
ResourceVersion: "rv-222",
Annotations: map[string]string{ Annotations: map[string]string{
"storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339), "storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339),
}, },
@ -1490,8 +1441,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
}) })
expiredSecret := &corev1.Secret{ expiredSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "expired secret", Name: "expired secret",
Namespace: installedInNamespace, Namespace: installedInNamespace,
UID: "uid-333",
ResourceVersion: "rv-444",
Annotations: map[string]string{ Annotations: map[string]string{
"storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339), "storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339),
}, },
@ -1507,8 +1460,8 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.ElementsMatch( r.ElementsMatch(
[]kubetesting.Action{ []kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "erroring secret"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "erroring secret", testutil.NewPreconditions("uid-111", "rv-222")),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "expired secret"), kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "expired secret", testutil.NewPreconditions("uid-333", "rv-444")),
}, },
kubeClient.Actions(), kubeClient.Actions(),
) )

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package controllermanager provides an entrypoint into running all of the controllers that run as // Package controllermanager provides an entrypoint into running all of the controllers that run as
@ -9,10 +9,10 @@ import (
"fmt" "fmt"
"time" "time"
"k8s.io/apimachinery/pkg/util/clock"
k8sinformers "k8s.io/client-go/informers" k8sinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/klog/v2/klogr" "k8s.io/klog/v2/klogr"
"k8s.io/utils/clock"
pinnipedclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" pinnipedclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned"
pinnipedinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions" pinnipedinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions"

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package crud package crud
@ -18,9 +18,9 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
) )
func TestStorage(t *testing.T) { func TestStorage(t *testing.T) {
@ -62,7 +62,7 @@ func TestStorage(t *testing.T) {
name string name string
resource string resource string
mocks func(*testing.T, mocker) mocks func(*testing.T, mocker)
run func(*testing.T, Storage, *clock.FakeClock) error run func(*testing.T, Storage, *clocktesting.FakeClock) error
wantActions []coretesting.Action wantActions []coretesting.Action
wantSecrets []corev1.Secret wantSecrets []corev1.Secret
wantErr string wantErr string
@ -71,7 +71,7 @@ func TestStorage(t *testing.T) {
name: "get non-existent", name: "get non-existent",
resource: "authcode", resource: "authcode",
mocks: nil, mocks: nil,
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
_, err := storage.Get(ctx, "not-exists", nil) _, err := storage.Get(ctx, "not-exists", nil)
return err return err
}, },
@ -85,7 +85,7 @@ func TestStorage(t *testing.T) {
name: "delete non-existent", name: "delete non-existent",
resource: "tokens", resource: "tokens",
mocks: nil, mocks: nil,
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
return storage.Delete(ctx, "not-a-token") return storage.Delete(ctx, "not-a-token")
}, },
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
@ -98,7 +98,7 @@ func TestStorage(t *testing.T) {
name: "delete non-existent by label", name: "delete non-existent by label",
resource: "tokens", resource: "tokens",
mocks: nil, mocks: nil,
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value") return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value")
}, },
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
@ -113,7 +113,7 @@ func TestStorage(t *testing.T) {
name: "create and get", name: "create and get",
resource: "access-tokens", resource: "access-tokens",
mocks: nil, mocks: nil,
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode1) signature := hmac.AuthorizeCodeSignature(authorizationCode1)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -177,7 +177,7 @@ func TestStorage(t *testing.T) {
name: "create multiple, each gets the correct lifetime timestamp", name: "create multiple, each gets the correct lifetime timestamp",
resource: "access-tokens", resource: "access-tokens",
mocks: nil, mocks: nil,
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
data := &testJSON{Data: "create1"} data := &testJSON{Data: "create1"}
rv1, err := storage.Create(ctx, "sig1", data, nil) rv1, err := storage.Create(ctx, "sig1", data, nil)
require.Empty(t, rv1) // fake client does not set this require.Empty(t, rv1) // fake client does not set this
@ -272,7 +272,7 @@ func TestStorage(t *testing.T) {
name: "create and get with additional labels", name: "create and get with additional labels",
resource: "access-tokens", resource: "access-tokens",
mocks: nil, mocks: nil,
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode1) signature := hmac.AuthorizeCodeSignature(authorizationCode1)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -360,7 +360,7 @@ func TestStorage(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode2) signature := hmac.AuthorizeCodeSignature(authorizationCode2)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -429,7 +429,7 @@ func TestStorage(t *testing.T) {
return false, nil, nil // we mutated the secret in place but we do not "handle" it return false, nil, nil // we mutated the secret in place but we do not "handle" it
}) })
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode3) signature := hmac.AuthorizeCodeSignature(authorizationCode3)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -521,7 +521,7 @@ func TestStorage(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode2) signature := hmac.AuthorizeCodeSignature(authorizationCode2)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -615,7 +615,7 @@ func TestStorage(t *testing.T) {
Type: "storage.pinniped.dev/walruses", Type: "storage.pinniped.dev/walruses",
})) }))
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value") return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value")
}, },
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
@ -696,7 +696,7 @@ func TestStorage(t *testing.T) {
return true, nil, fmt.Errorf("some delete error") return true, nil, fmt.Errorf("some delete error")
}) })
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value") return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value")
}, },
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
@ -749,7 +749,7 @@ func TestStorage(t *testing.T) {
return true, nil, fmt.Errorf("some listing error") return true, nil, fmt.Errorf("some listing error")
}) })
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value") return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value")
}, },
wantActions: []coretesting.Action{ wantActions: []coretesting.Action{
@ -783,7 +783,7 @@ func TestStorage(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode3) signature := hmac.AuthorizeCodeSignature(authorizationCode3)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -847,7 +847,7 @@ func TestStorage(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode3) signature := hmac.AuthorizeCodeSignature(authorizationCode3)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -911,7 +911,7 @@ func TestStorage(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode3) signature := hmac.AuthorizeCodeSignature(authorizationCode3)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -975,7 +975,7 @@ func TestStorage(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
}, },
run: func(t *testing.T, storage Storage, fakeClock *clock.FakeClock) error { run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode3) signature := hmac.AuthorizeCodeSignature(authorizationCode3)
require.NotEmpty(t, signature) require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
@ -1025,7 +1025,7 @@ func TestStorage(t *testing.T) {
tt.mocks(t, client) tt.mocks(t, client)
} }
secrets := client.CoreV1().Secrets(namespace) secrets := client.CoreV1().Secrets(namespace)
fakeClock := clock.NewFakeClock(fakeNow) fakeClock := clocktesting.NewFakeClock(fakeNow)
storage := New(tt.resource, secrets, fakeClock.Now, lifetime) storage := New(tt.resource, secrets, fakeClock.Now, lifetime)
err := tt.run(t, storage, fakeClock) err := tt.run(t, storage, fakeClock)

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package accesstoken package accesstoken
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
@ -53,7 +53,7 @@ func TestAccessTokenStorage(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/access-token", Type: "storage.pinniped.dev/access-token",
@ -122,7 +122,7 @@ func TestAccessTokenStorageRevocation(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/access-token", Type: "storage.pinniped.dev/access-token",
@ -276,7 +276,7 @@ func TestCreateWithoutRequesterID(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, RevocationStorage) { func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, RevocationStorage) {
client := fake.NewSimpleClientset() client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace) secrets := client.CoreV1().Secrets(namespace)
return context.Background(), client, secrets, New(secrets, clock.NewFakeClock(fakeNow).Now, lifetime) return context.Background(), client, secrets, New(secrets, clocktesting.NewFakeClock(fakeNow).Now, lifetime)
} }
func TestReadFromSecret(t *testing.T) { func TestReadFromSecret(t *testing.T) {

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package authorizationcode package authorizationcode
@ -196,32 +196,38 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
"client": { "client": {
"id": ":NJ¸Ɣ8(黋馛ÄRɴJa¶z", "id": ":NJ¸Ɣ8(黋馛ÄRɴJa¶z",
"client_secret": "UQ==", "client_secret": "UQ==",
"rotated_secrets": [
"Bno=",
"0j8=",
"1c4="
],
"redirect_uris": [ "redirect_uris": [
"ǖ枭kʍ切厦ȳ箦;¥ʊXĝ奨誷傥祩d", "ʊXĝ",
"Ƿ"
],
"grant_types": [
"祩d",
"zŇZ", "zŇZ",
"優蒼ĊɌț訫DŽǽeʀO2ƚ\u0026N" "優蒼ĊɌț訫DŽǽeʀO2ƚ\u0026N"
], ],
"grant_types": [ "response_types": [
"唐W6ɻ橩斚薛ɑƐ" "唐W6ɻ橩斚薛ɑƐ"
], ],
"response_types": [ "scopes": [
"w", "w",
"ǔŭe[u@阽羂ŷ-Ĵ½輢OÅ濲喾H" "ǔŭe[u@阽羂ŷ-Ĵ½輢OÅ濲喾H"
], ],
"scopes": [ "audience": [
"G螩歐湡ƙı唡ɸğƎ\u0026胢輢Ƈĵƚ" "G螩歐湡ƙı唡ɸğƎ\u0026胢輢Ƈĵƚ"
], ],
"audience": [
"ě"
],
"public": false, "public": false,
"jwks_uri": "o*泞羅ʘ Ⱦķ瀊垰7ã\")", "jwks_uri": "潌țjA9;焋Ēƕ",
"jwks": { "jwks": {
"keys": [ "keys": [
{ {
"kty": "OKP", "kty": "OKP",
"crv": "Ed25519", "crv": "Ed25519",
"x": "nK9xgX_iN7u3u_i8YOO7ZRT_WK028Vd_nhtsUu7Eo6E", "x": "LHMZ29A64WecPQSLotS8hfZ2mae0SR17CtPdnMDP7ZI",
"x5u": { "x5u": {
"Scheme": "", "Scheme": "",
"Opaque": "", "Opaque": "",
@ -238,7 +244,24 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
{ {
"kty": "OKP", "kty": "OKP",
"crv": "Ed25519", "crv": "Ed25519",
"x": "UbbswQgzWhfGCRlwQmMp6fw_HoIoqkIaKT-2XN2fuYU", "x": "1PwKrC4qDe8cabzGTdA0NjuMJhAZAw7Bu7Tj9z2Y4pE",
"x5u": {
"Scheme": "",
"Opaque": "",
"User": null,
"Host": "",
"Path": "",
"RawPath": "",
"ForceQuery": false,
"RawQuery": "",
"Fragment": "",
"RawFragment": ""
}
},
{
"kty": "OKP",
"crv": "Ed25519",
"x": "j4b-Vld5buh_2KIpjjaDRJ8OY7l7d6XAumvDtVTT9BI",
"x5u": { "x5u": {
"Scheme": "", "Scheme": "",
"Opaque": "", "Opaque": "",
@ -254,117 +277,128 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
} }
] ]
}, },
"token_endpoint_auth_method": "ƿʥǟȒ伉\u003cx¹T鼓c吏", "token_endpoint_auth_method": "趀Ȁ;hYGe天蹗ĽǙ澅j翕q骽",
"request_uris": [ "request_uris": [
"Ć捘j]=谅ʑɑɮ$Ól4Ȟ", "Ǐ蛓ȿ,JwwƐ\u003c涵ØƉKĵ",
",Q7钎漡臧n" "Ȟú",
"Q7钎漡臧n栀,i"
], ],
"request_object_signing_alg": "3@¡廜+v,淬Ʋ4Dʧ呩锏緍场", "request_object_signing_alg": "廜+v,淬Ʋ4Dʧ呩锏緍场",
"token_endpoint_auth_signing_alg": "(ưƓǴ罷ǹ~]ea胠" "token_endpoint_auth_signing_alg": "ưƓǴ罷ǹ~]ea胠Ĺĩv絹b垇I"
}, },
"scopes": [ "scopes": [
v絹b垇IŕĩǀŻQ'k頂箨J-a", ǀŻQ'k頂箨J-a",
"啶#昏Q遐*\\髎bŸ" "ɓ啶#昏Q遐*\\髎bŸ1慂U"
], ],
"grantedScopes": [ "grantedScopes": [
"慂UFƼĮǡ鑻Z" "ƼĮǡ鑻Z¥篚h°ʣ£ǖ%\"砬ʍ"
], ],
"form": { "form": {
"褾攚ŝlĆ厦駳骪l拁乖¡J¿Ƈ妔": [ "¡": [
"懧¥ɂĵ~Čyʊ恀c\"NJřðȿ/", "Ła卦牟懧¥ɂĵ",
"裢?霃谥vƘ:ƿ/濔Aʉ\u003c", "ɎǛƍdÚ慂+槰蚪i齥篗裢?霃谥vƘ:",
"ȭ$奍囀Dž悷鵱民撲ʓeŘ嬀j¤" "/濔Aʉ\u003cS獾蔀OƭUǦ"
], ],
"诞": [ "民撲ʓeŘ嬀j¤囡莒汗狲N\u003cCq": [
"狲N\u003cCq罉ZPſĝEK郊©l", "5ȏ樛ȧ.mĔ櫓Ǩ療騃Ǐ}ɟ",
"餚LJ/ɷȑ潠[ĝU噤'pX ", "潠[ĝU噤'",
"Y妶ǵ!ȁu狍ɶȳsčɦƦ诱" "ŁȗɉY妶ǵ!ȁ"
],
"褰ʎɰ癟VĎĢ婄磫绒u妔隤ʑƍš駎竪": [
"鱙翑ȲŻ麤ã桒嘞\\摗Ǘū稖咾鎅ǸÖ"
] ]
}, },
"session": { "session": {
"fosite": { "fosite": {
"Claims": { "Claims": {
"JTI": "u妔隤ʑƍš駎竪0ɔ闏À1", "JTI": "褗6巽ēđų蓼tùZ蛆鬣a\"ÙǞ0觢",
"Issuer": "麤ã桒嘞\\摗Ǘū稖咾鎅ǸÖ绝TF", "Issuer": "j¦鲶H股ƲLŋZ-{",
"Subject": "巽ēđų蓼tùZ蛆鬣a\"ÙǞ0觢Û±", "Subject": "ehpƧ",
"Audience": [ "Audience": [
"H股ƲL", "驜Ŗ~ů崧軒q腟u尿宲!"
"肟v\u0026đehpƧ",
"5^驜Ŗ~ů崧軒q腟u尿"
], ],
"Nonce": "ğ", "Nonce": "ǎ^嫯R忑隯ƗƋ*L\u0026",
"ExpiresAt": "2016-11-22T21:33:58.460521133Z", "ExpiresAt": "1989-06-02T14:40:29.613836765Z",
"IssuedAt": "1990-07-25T23:42:07.055978334Z", "IssuedAt": "2052-03-26T02:39:27.882495556Z",
"RequestedAt": "1971-01-30T00:23:36.377684025Z", "RequestedAt": "2038-04-06T10:46:24.698586972Z",
"AuthTime": "2088-11-09T12:09:14.051840239Z", "AuthTime": "2003-01-05T11:30:18.206004879Z",
"AccessTokenHash": "蕖¤'+ʣȍ瓁U4鞀", "AccessTokenHash": "ğǫ\\aȊ4ț髄Al",
"AuthenticationContextClassReference": "ʏÑęN\u003c_z", "AuthenticationContextClassReference": "曓蓳n匟鯘磹*金爃鶴滱ůĮǐ_c3#",
"AuthenticationMethodsReference": "ț髄A", "AuthenticationMethodsReferences": [
"CodeHash": "4磔_袻vÓG-壧丵礴鋈k蟵pAɂʅ", "装ƹýĸŴB岺Ð嫹Sx镯荫őł疂ư墫"
],
"CodeHash": "\u0026鶡",
"Extra": { "Extra": {
"#\u0026PƢ曰l騌蘙螤\\阏Đ镴Ƥm蔻ǭ\\鿞": 1677215584, "rǓ\\BRë_g\"ʎ啴SƇMǃļū": {
"Y\u0026鶡萷ɵ啜s攦Ɩïdnǔ": { "4撎胬龯,t猟i\u0026\u0026Q@ǤǟǗ": [
",t猟i\u0026\u0026Q@ǤǟǗǪ飘ȱF?Ƈ": { 1239190737
"~劰û橸ɽ銐ƭ?}H": null, ],
"癑勦e骲v0H晦XŘO溪V蔓": { "飘ȱF?Ƈ畋": {
"碼Ǫ": false "劰û橸ɽ銐ƭ?}HƟ玈鳚": null,
"骲v0H晦XŘO溪V蔓Ȍ+~ē埅Ȝ": {
"4Ǟ": false
} }
}, }
"钻煐ɨəÅDČ{Ȩʦ4撎": [ },
3684968178 "鑳绪": 2738428764
]
}
} }
}, },
"Headers": { "Headers": {
"Extra": { "Extra": {
"ĊdŘ鸨EJ毕懴řĬń戹": { "d謺錳4帳ŅǃĊ": 663773398,
"诳DT=3骜Ǹ,": { "Ř鸨EJ": {
"\u003e": { "Ǽǟ迍阊v\"豑觳翢砜": [
"ǰ": false 995342744
}, ],
"ɁOƪ穋嶿鳈恱va": null "ȏl鐉诳DT=3骜Ǹ": {
}, "厷ɁOƪ穋嶿鳈恱va|载ǰɱ汶C]ɲ": null,
"豑觳翢砜Fȏl": [ "荤Ý呐ʣ®DžȪǣǎǔ爣縗ɦü": {
927958776 "H :靥湤庤毩fɤȆʪ融ƆuŤn": true
] }
}, }
"埅ȜʁɁ;Bd謺錳4帳Ņ": 388005986 }
} }
}, },
"ExpiresAt": { "ExpiresAt": {
"C]ɲ'=ĸ闒NȢȰ.醋": "1970-07-19T18:03:29.902062193Z", "韁臯氃妪婝rȤ\"h丬鎒ơ娻}ɼƟ": "1970-04-27T04:31:30.902468229Z"
"fɤȆʪ融ƆuŤn": "2064-01-24T20:34:16.593152073Z",
"爣縗ɦüHêQ仏1ő": "2102-03-17T06:24:40.256846902Z"
}, },
"Username": "韁臯氃妪婝rȤ\"h丬鎒ơ娻}ɼƟ", "Username": "髉龳ǽÙ",
"Subject": "闺髉龳ǽÙ龦O亾EW莛8嘶×" "Subject": "\u0026¥潝邎Ȗ莅ŝǔ盕戙鵮碡ʯiŬŽ"
}, },
"custom": { "custom": {
"providerUID": "鵮碡ʯiŬŽ非Ĝ眧Ĭ葜SŦ餧Ĭ倏4", "providerUID": "Ĝ眧Ĭ",
"providerName": "nŐǛ3", "providerName": "ʼn2ƋŢ觛ǂ焺nŐǛ",
"providerType": "闣ʬ橳(ý綃ʃʚƟ覣k眐4Ĉt", "providerType": "ɥ闣ʬ橳(ý綃ʃʚƟ覣k眐4",
"oidc": { "oidc": {
"upstreamRefreshToken": "嵽痊w©Ź榨Q|ôɵt毇妬", "upstreamRefreshToken": "tC嵽痊w",
"upstreamAccessToken": "巈環_ɑ" "upstreamAccessToken": "a紽ǒ|鰽ŋ猊I",
"upstreamSubject": "妬\u003e6鉢緋uƴŤȱʀ",
"upstreamIssuer": ":設虝27就伒犘c"
}, },
"ldap": { "ldap": {
"userDN": "ƍ蛊ʚ£:設虝27" "userDN": "ɏȫ齁š%Op",
"extraRefreshAttributes": {
"T妼É4İ\u003e×1": "ʥ笿0D",
"÷驣7Ʀ澉1æɽ誮": "ʫ繕ȫ",
"ŚB碠k9": "i磊ůď逳鞪?3)藵睋邔\u0026Ű"
}
}, },
"activedirectory": { "activedirectory": {
"userDN": "伒犘c钡ɏȫ" "userDN": "s",
"extraRefreshAttributes": {
"ƉǢIȽ齤士bEǎ儯惝IozŁ5rƖ螼": "偶宾儮猷V麹Œ颛Ė應,Ɣ鬅X¤"
}
} }
} }
}, },
"requestedAudience": [ "requestedAudience": [
"š%OpKȱ藚ɏ¬Ê蒭堜", "tO灞浛a齙\\蹼偦歛ơ",
"ɽ誮rʨ鷞aŚB碠k", "皦pSǬŝ社Vƅȭǝ*"
"Ċi磊ůď逳鞪?3)藵睋"
], ],
"grantedAudience": [ "grantedAudience": [
"\u0026Ű惫蜀Ģ¡圔", "ĝ\"zvưã置bņ抰蛖a³2ʫ",
"墀jMʥ", "Ŷɽ蔒PR}Ųʓl{鼐jÃ轘屔挝",
"+î艔垎0" "Œų崓ļ憽-蹐È_¸]fś"
] ]
}, },
"version": "2" "version": "2"

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package authorizationcode package authorizationcode
@ -28,10 +28,10 @@ import (
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
kubetesting "k8s.io/client-go/testing" kubetesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/fositestorage" "go.pinniped.dev/internal/fositestorage"
"go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidc/clientregistry"
@ -65,7 +65,7 @@ func TestAuthorizationCodeStorage(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/authcode", Type: "storage.pinniped.dev/authcode",
@ -84,7 +84,7 @@ func TestAuthorizationCodeStorage(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/authcode", Type: "storage.pinniped.dev/authcode",
@ -258,7 +258,7 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, oauth2.AuthorizeCodeStorage) { func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, oauth2.AuthorizeCodeStorage) {
client := fake.NewSimpleClientset() client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace) secrets := client.CoreV1().Secrets(namespace)
return context.Background(), client, secrets, New(secrets, clock.NewFakeClock(fakeNow).Now, lifetime) return context.Background(), client, secrets, New(secrets, clocktesting.NewFakeClock(fakeNow).Now, lifetime)
} }
// TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession asserts that we can correctly round trip our authorize code session. // TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession asserts that we can correctly round trip our authorize code session.
@ -398,7 +398,7 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) {
// while the fuzzer will panic if AuthorizeRequest changes in a way that cannot be fuzzed, // while the fuzzer will panic if AuthorizeRequest changes in a way that cannot be fuzzed,
// if it adds a new field that can be fuzzed, this check will fail // if it adds a new field that can be fuzzed, this check will fail
// thus if AuthorizeRequest changes, we will detect it here (though we could possibly miss an omitempty field) // thus if AuthorizeRequest changes, we will detect it here (though we could possibly miss an omitempty field)
require.JSONEq(t, ExpectedAuthorizeCodeSessionJSONFromFuzzing, authorizeCodeSessionJSONFromFuzzing) require.JSONEq(t, ExpectedAuthorizeCodeSessionJSONFromFuzzing, authorizeCodeSessionJSONFromFuzzing, "actual:\n%s", authorizeCodeSessionJSONFromFuzzing)
} }
func TestReadFromSecret(t *testing.T) { func TestReadFromSecret(t *testing.T) {

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package openidconnect package openidconnect
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
@ -52,7 +52,7 @@ func TestOpenIdConnectStorage(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/oidc", Type: "storage.pinniped.dev/oidc",
@ -100,7 +100,7 @@ func TestOpenIdConnectStorage(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, request, newRequest) require.Equal(t, request, newRequest)
err = storage.DeleteOpenIDConnectSession(ctx, "fancy-code.fancy-signature") err = storage.DeleteOpenIDConnectSession(ctx, "fancy-code.fancy-signature") //nolint: staticcheck // we know this is deprecated and never called. our GC controller cleans these up.
require.NoError(t, err) require.NoError(t, err)
testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed
@ -200,5 +200,5 @@ func TestAuthcodeHasNoDot(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, openid.OpenIDConnectRequestStorage) { func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, openid.OpenIDConnectRequestStorage) {
client := fake.NewSimpleClientset() client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace) secrets := client.CoreV1().Secrets(namespace)
return context.Background(), client, secrets, New(secrets, clock.NewFakeClock(fakeNow).Now, lifetime) return context.Background(), client, secrets, New(secrets, clocktesting.NewFakeClock(fakeNow).Now, lifetime)
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package pkce package pkce
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
@ -52,7 +52,7 @@ func TestPKCEStorage(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/pkce", Type: "storage.pinniped.dev/pkce",
@ -199,5 +199,5 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, pkce.PKCERequestStorage) { func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, pkce.PKCERequestStorage) {
client := fake.NewSimpleClientset() client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace) secrets := client.CoreV1().Secrets(namespace)
return context.Background(), client, secrets, New(secrets, clock.NewFakeClock(fakeNow).Now, lifetime) return context.Background(), client, secrets, New(secrets, clocktesting.NewFakeClock(fakeNow).Now, lifetime)
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package refreshtoken package refreshtoken
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing" coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
@ -52,7 +52,7 @@ func TestRefreshTokenStorage(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/refresh-token", Type: "storage.pinniped.dev/refresh-token",
@ -122,7 +122,7 @@ func TestRefreshTokenStorageRevocation(t *testing.T) {
}, },
}, },
Data: map[string][]byte{ Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":""}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-version": []byte("1"), "pinniped-storage-version": []byte("1"),
}, },
Type: "storage.pinniped.dev/refresh-token", Type: "storage.pinniped.dev/refresh-token",
@ -276,7 +276,7 @@ func TestCreateWithoutRequesterID(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, RevocationStorage) { func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, RevocationStorage) {
client := fake.NewSimpleClientset() client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace) secrets := client.CoreV1().Secrets(namespace)
return context.Background(), client, secrets, New(secrets, clock.NewFakeClock(fakeNow).Now, lifetime) return context.Background(), client, secrets, New(secrets, clocktesting.NewFakeClock(fakeNow).Now, lifetime)
} }
func TestReadFromSecret(t *testing.T) { func TestReadFromSecret(t *testing.T) {

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package kubeclient package kubeclient
@ -8,8 +8,6 @@ import (
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"net/http" "net/http"
"reflect"
"unsafe"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
@ -155,7 +153,7 @@ func createSecureKubeConfig(kubeConfig *restclient.Config) (*restclient.Config,
} }
}() }()
tlsConfig, err := netTLSClientConfig(rt) tlsConfig, err := net.TLSClientConfig(rt)
if err != nil { if err != nil {
// this assumes none of our production code calls Wrap or messes with WrapTransport. // this assumes none of our production code calls Wrap or messes with WrapTransport.
// this is a reasonable assumption because all such code should live in this package // this is a reasonable assumption because all such code should live in this package
@ -205,7 +203,7 @@ func AssertSecureConfig(kubeConfig *restclient.Config) error {
} }
func AssertSecureTransport(rt http.RoundTripper) error { func AssertSecureTransport(rt http.RoundTripper) error {
tlsConfig, err := netTLSClientConfig(rt) tlsConfig, err := net.TLSClientConfig(rt)
if err != nil { if err != nil {
return fmt.Errorf("failed to get TLS config: %w", err) return fmt.Errorf("failed to get TLS config: %w", err)
} }
@ -224,33 +222,6 @@ func AssertSecureTransport(rt http.RoundTripper) error {
return nil return nil
} }
func netTLSClientConfig(rt http.RoundTripper) (*tls.Config, error) {
tlsConfig, err := net.TLSClientConfig(rt)
if err == nil {
return tlsConfig, nil
}
// TODO fix when we pick up https://github.com/kubernetes/kubernetes/pull/106014
if err.Error() == "unknown transport type: *exec.roundTripper" {
return net.TLSClientConfig(extractRTUnsafe(rt))
}
return nil, err
}
func extractRTUnsafe(rt http.RoundTripper) (out http.RoundTripper) {
for wrapper, ok := rt.(net.RoundTripperWrapper); ok; wrapper, ok = rt.(net.RoundTripperWrapper) {
// keep peeling the wrappers until we get to the exec.roundTripper
rt = wrapper.WrappedRoundTripper()
}
// this is some dark magic to read a private field
baseField := reflect.ValueOf(rt).Elem().FieldByName("base")
basePointer := (*http.RoundTripper)(unsafe.Pointer(baseField.UnsafeAddr()))
return *basePointer
}
func Secure(config *restclient.Config) (kubernetes.Interface, *restclient.Config, error) { func Secure(config *restclient.Config) (kubernetes.Interface, *restclient.Config, error) {
// our middleware does not apply to the returned restclient.Config, therefore, this // our middleware does not apply to the returned restclient.Config, therefore, this
// client not having a leader election lock is irrelevant since it would not be enforced // client not having a leader election lock is irrelevant since it would not be enforced

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package kubeclient package kubeclient
@ -19,6 +19,7 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/net"
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api" clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@ -1109,7 +1110,7 @@ func testUnwrap(t *testing.T, client *Client, serverSubjects [][]byte) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() // make sure to run in parallel to confirm that our client-go TLS cache busting works (i.e. assert no data races) t.Parallel() // make sure to run in parallel to confirm that our client-go TLS cache busting works (i.e. assert no data races)
tlsConfig, err := netTLSClientConfig(tt.rt) tlsConfig, err := net.TLSClientConfig(tt.rt)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, tlsConfig) require.NotNil(t, tlsConfig)

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //
@ -186,6 +186,20 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetUsernameClaim() *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsernameClaim", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetUsernameClaim)) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsernameClaim", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetUsernameClaim))
} }
// HasUserInfoURL mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) HasUserInfoURL() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HasUserInfoURL")
ret0, _ := ret[0].(bool)
return ret0
}
// HasUserInfoURL indicates an expected call of HasUserInfoURL.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) HasUserInfoURL() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasUserInfoURL", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).HasUserInfoURL))
}
// PasswordCredentialsGrantAndValidateTokens mocks base method. // PasswordCredentialsGrantAndValidateTokens mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) PasswordCredentialsGrantAndValidateTokens(arg0 context.Context, arg1, arg2 string) (*oidctypes.Token, error) { func (m *MockUpstreamOIDCIdentityProviderI) PasswordCredentialsGrantAndValidateTokens(arg0 context.Context, arg1, arg2 string) (*oidctypes.Token, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -230,17 +244,17 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) RevokeToken(arg0, arg1,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeToken", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).RevokeToken), arg0, arg1, arg2) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeToken", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).RevokeToken), arg0, arg1, arg2)
} }
// ValidateToken mocks base method. // ValidateTokenAndMergeWithUserInfo mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) ValidateToken(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce) (*oidctypes.Token, error) { func (m *MockUpstreamOIDCIdentityProviderI) ValidateTokenAndMergeWithUserInfo(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce, arg3, arg4 bool) (*oidctypes.Token, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateToken", arg0, arg1, arg2) ret := m.ctrl.Call(m, "ValidateTokenAndMergeWithUserInfo", arg0, arg1, arg2, arg3, arg4)
ret0, _ := ret[0].(*oidctypes.Token) ret0, _ := ret[0].(*oidctypes.Token)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// ValidateToken indicates an expected call of ValidateToken. // ValidateTokenAndMergeWithUserInfo indicates an expected call of ValidateTokenAndMergeWithUserInfo.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) ValidateToken(arg0, arg1, arg2 interface{}) *gomock.Call { func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) ValidateTokenAndMergeWithUserInfo(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateToken", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).ValidateToken), arg0, arg1, arg2) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateTokenAndMergeWithUserInfo", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).ValidateTokenAndMergeWithUserInfo), arg0, arg1, arg2, arg3, arg4)
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package auth provides a handler for the OIDC authorization endpoint. // Package auth provides a handler for the OIDC authorization endpoint.
@ -10,6 +10,7 @@ import (
"time" "time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc" coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/felixge/httpsnoop"
"github.com/ory/fosite" "github.com/ory/fosite"
"github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt" "github.com/ory/fosite/token/jwt"
@ -89,7 +90,7 @@ func handleAuthRequestForLDAPUpstream(
ldapUpstream provider.UpstreamLDAPIdentityProviderI, ldapUpstream provider.UpstreamLDAPIdentityProviderI,
idpType psession.ProviderType, idpType psession.ProviderType,
) error { ) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper) authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, true)
if !created { if !created {
return nil return nil
} }
@ -106,7 +107,7 @@ func handleAuthRequestForLDAPUpstream(
} }
if !authenticated { if !authenticated {
return writeAuthorizeError(w, oauthHelper, authorizeRequester, return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider.")) fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider."), true)
} }
subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse)
@ -122,12 +123,14 @@ func handleAuthRequestForLDAPUpstream(
if idpType == psession.ProviderTypeLDAP { if idpType == psession.ProviderTypeLDAP {
customSessionData.LDAP = &psession.LDAPSessionData{ customSessionData.LDAP = &psession.LDAPSessionData{
UserDN: dn, UserDN: dn,
ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes,
} }
} }
if idpType == psession.ProviderTypeActiveDirectory { if idpType == psession.ProviderTypeActiveDirectory {
customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{ customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{
UserDN: dn, UserDN: dn,
ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes,
} }
} }
@ -141,7 +144,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
oauthHelper fosite.OAuth2Provider, oauthHelper fosite.OAuth2Provider,
oidcUpstream provider.UpstreamOIDCIdentityProviderI, oidcUpstream provider.UpstreamOIDCIdentityProviderI,
) error { ) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper) authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, true)
if !created { if !created {
return nil return nil
} }
@ -155,7 +158,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
// Return a user-friendly error for this case which is entirely within our control. // Return a user-friendly error for this case which is entirely within our control.
return writeAuthorizeError(w, oauthHelper, authorizeRequester, return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHint( fosite.ErrAccessDenied.WithHint(
"Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.")) "Resource owner password credentials grant is not allowed for this upstream provider according to its configuration."), true)
} }
token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password) token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password)
@ -168,35 +171,24 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
// the OIDC spec, so we don't try too hard to read the upstream errors in this case. (E.g. Dex departs from the // the OIDC spec, so we don't try too hard to read the upstream errors in this case. (E.g. Dex departs from the
// spec and returns something other than an "invalid_grant" error for bad resource owner credentials.) // spec and returns something other than an "invalid_grant" error for bad resource owner credentials.)
return writeAuthorizeError(w, oauthHelper, authorizeRequester, return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithDebug(err.Error())) // WithDebug hides the error from the client fosite.ErrAccessDenied.WithDebug(err.Error()), true) // WithDebug hides the error from the client
}
if token.RefreshToken == nil || token.RefreshToken.Token == "" {
plog.Warning("refresh token not returned by upstream provider during password grant, "+
"please check configuration of OIDCIdentityProvider and the client in the upstream provider's API/UI",
"upstreamName", oidcUpstream.GetName(),
"scopes", oidcUpstream.GetScopes())
return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHint(
"Refresh token not returned by upstream provider during password grant."))
} }
subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims)
if err != nil { if err != nil {
// Return a user-friendly error for this case which is entirely within our control. // Return a user-friendly error for this case which is entirely within our control.
return writeAuthorizeError(w, oauthHelper, authorizeRequester, return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true,
) )
} }
customSessionData := &psession.CustomSessionData{ customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token)
ProviderUID: oidcUpstream.GetResourceUID(), if err != nil {
ProviderName: oidcUpstream.GetName(), return writeAuthorizeError(w, oauthHelper, authorizeRequester,
ProviderType: psession.ProviderTypeOIDC, fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true,
OIDC: &psession.OIDCSessionData{ )
UpstreamRefreshToken: token.RefreshToken.Token,
},
} }
return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups, customSessionData) return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups, customSessionData)
} }
@ -212,7 +204,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
upstreamStateEncoder oidc.Encoder, upstreamStateEncoder oidc.Encoder,
cookieCodec oidc.Codec, cookieCodec oidc.Codec,
) error { ) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper) authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false)
if !created { if !created {
return nil return nil
} }
@ -229,7 +221,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
}, },
}) })
if err != nil { if err != nil {
return writeAuthorizeError(w, oauthHelper, authorizeRequester, err) return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, false)
} }
csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE)
@ -272,7 +264,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
promptParam := r.Form.Get(promptParamName) promptParam := r.Form.Get(promptParamName)
if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) {
return writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired) return writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false)
} }
for key, val := range oidcUpstream.GetAdditionalAuthcodeParams() { for key, val := range oidcUpstream.GetAdditionalAuthcodeParams() {
@ -293,13 +285,13 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
encodedStateParamValue, encodedStateParamValue,
authCodeOptions..., authCodeOptions...,
), ),
302, http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11
) )
return nil return nil
} }
func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error) error { func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error, isBrowserless bool) error {
if plog.Enabled(plog.LevelTrace) { if plog.Enabled(plog.LevelTrace) {
// When trace level logging is enabled, include the stack trace in the log message. // When trace level logging is enabled, include the stack trace in the log message.
keysAndValues := oidc.FositeErrorForLog(err) keysAndValues := oidc.FositeErrorForLog(err)
@ -312,6 +304,9 @@ func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provide
} else { } else {
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
} }
if isBrowserless {
w = rewriteStatusSeeOtherToStatusFoundForBrowserless(w)
}
// Return an error according to OIDC spec 3.1.2.6 (second paragraph). // Return an error according to OIDC spec 3.1.2.6 (second paragraph).
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil return nil
@ -331,29 +326,53 @@ func makeDownstreamSessionAndReturnAuthcodeRedirect(
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil { if err != nil {
return writeAuthorizeError(w, oauthHelper, authorizeRequester, err) return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, true)
} }
w = rewriteStatusSeeOtherToStatusFoundForBrowserless(w)
oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder)
return nil return nil
} }
func rewriteStatusSeeOtherToStatusFoundForBrowserless(w http.ResponseWriter) http.ResponseWriter {
// rewrite http.StatusSeeOther to http.StatusFound for backwards compatibility with old pinniped CLIs.
// we can drop this in a few releases once we feel enough time has passed for users to update.
//
// WriteAuthorizeResponse/WriteAuthorizeError calls used to result in http.StatusFound until
// https://github.com/ory/fosite/pull/636 changed it to http.StatusSeeOther to address
// https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11
// Safari has the bad behavior in the case of http.StatusFound and not just http.StatusTemporaryRedirect.
//
// in the browserless flows, the OAuth client is the pinniped CLI and it already has access to the user's
// password. Thus there is no security issue with using http.StatusFound vs. http.StatusSeeOther.
return httpsnoop.Wrap(w, httpsnoop.Hooks{
WriteHeader: func(delegate httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc {
return func(code int) {
if code == http.StatusSeeOther {
code = http.StatusFound
}
delegate(code)
}
},
})
}
func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) {
username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName) username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName)
password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName) password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName)
if username == "" || password == "" { if username == "" || password == "" {
_ = writeAuthorizeError(w, oauthHelper, authorizeRequester, _ = writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("Missing or blank username or password.")) fosite.ErrAccessDenied.WithHintf("Missing or blank username or password."), true)
return "", "", false return "", "", false
} }
return username, password, true return username, password, true
} }
func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider) (fosite.AuthorizeRequester, bool) { func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, isBrowserless bool) (fosite.AuthorizeRequester, bool) {
authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r) authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r)
if err != nil { if err != nil {
_ = writeAuthorizeError(w, oauthHelper, authorizeRequester, err) _ = writeAuthorizeError(w, oauthHelper, authorizeRequester, err, isBrowserless)
return nil, false return nil, false
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package auth package auth
@ -56,6 +56,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
oidcUpstreamUsernameClaim = "the-user-claim" oidcUpstreamUsernameClaim = "the-user-claim"
oidcUpstreamGroupsClaim = "the-groups-claim" oidcUpstreamGroupsClaim = "the-groups-claim"
oidcPasswordGrantUpstreamRefreshToken = "some-opaque-token" //nolint: gosec oidcPasswordGrantUpstreamRefreshToken = "some-opaque-token" //nolint: gosec
oidcUpstreamAccessToken = "some-access-token"
downstreamIssuer = "https://my-downstream-issuer.com/some-path" downstreamIssuer = "https://my-downstream-issuer.com/some-path"
downstreamRedirectURI = "http://127.0.0.1/callback" downstreamRedirectURI = "http://127.0.0.1/callback"
@ -154,9 +155,15 @@ func TestAuthorizationEndpoint(t *testing.T) {
"state": happyState, "state": happyState,
} }
fositeAccessDeniedWithMissingRefreshTokenErrorQuery = map[string]string{ fositeAccessDeniedWithMissingAccessTokenErrorQuery = map[string]string{
"error": "access_denied", "error": "access_denied",
"error_description": "The resource owner or authorization server denied the request. Refresh token not returned by upstream provider during password grant.", "error_description": "The resource owner or authorization server denied the request. Reason: neither access token nor refresh token returned by upstream provider.",
"state": happyState,
}
fositeAccessDeniedWithMissingUserInfoEndpointErrorQuery = map[string]string{
"error": "access_denied",
"error_description": "The resource owner or authorization server denied the request. Reason: access token was returned by upstream provider but there was no userinfo endpoint.",
"state": happyState, "state": happyState,
} }
@ -269,6 +276,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
happyLDAPUID := "some-ldap-uid" happyLDAPUID := "some-ldap-uid"
happyLDAPUserDN := "cn=foo,dn=bar" happyLDAPUserDN := "cn=foo,dn=bar"
happyLDAPGroups := []string{"group1", "group2", "group3"} happyLDAPGroups := []string{"group1", "group2", "group3"}
happyLDAPExtraRefreshAttribute := "some-refresh-attribute"
happyLDAPExtraRefreshValue := "some-refresh-attribute-value"
parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL) parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL)
require.NoError(t, err) require.NoError(t, err)
@ -285,6 +294,9 @@ func TestAuthorizationEndpoint(t *testing.T) {
Groups: happyLDAPGroups, Groups: happyLDAPGroups,
}, },
DN: happyLDAPUserDN, DN: happyLDAPUserDN,
ExtraRefreshAttributes: map[string]string{
happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue,
},
}, true, nil }, true, nil
} }
return nil, false, nil return nil, false, nil
@ -442,7 +454,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
OIDC: nil, OIDC: nil,
LDAP: nil, LDAP: nil,
ActiveDirectory: &psession.ActiveDirectorySessionData{ ActiveDirectory: &psession.ActiveDirectorySessionData{
UserDN: happyLDAPUserDN, UserDN: happyLDAPUserDN,
ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue},
}, },
} }
@ -452,7 +465,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
ProviderType: psession.ProviderTypeLDAP, ProviderType: psession.ProviderTypeLDAP,
OIDC: nil, OIDC: nil,
LDAP: &psession.LDAPSessionData{ LDAP: &psession.LDAPSessionData{
UserDN: happyLDAPUserDN, UserDN: happyLDAPUserDN,
ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue},
}, },
ActiveDirectory: nil, ActiveDirectory: nil,
} }
@ -463,6 +477,19 @@ func TestAuthorizationEndpoint(t *testing.T) {
ProviderType: psession.ProviderTypeOIDC, ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{ OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: oidcPasswordGrantUpstreamRefreshToken, UpstreamRefreshToken: oidcPasswordGrantUpstreamRefreshToken,
UpstreamSubject: oidcUpstreamSubject,
UpstreamIssuer: oidcUpstreamIssuer,
},
}
expectedHappyOIDCPasswordGrantCustomSessionWithAccessToken := &psession.CustomSessionData{
ProviderUID: oidcPasswordGrantUpstreamResourceUID,
ProviderName: oidcPasswordGrantUpstreamName,
ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{
UpstreamAccessToken: oidcUpstreamAccessToken,
UpstreamSubject: oidcUpstreamSubject,
UpstreamIssuer: oidcUpstreamIssuer,
}, },
} }
@ -526,7 +553,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: happyGetRequestPath, path: happyGetRequestPath,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil), wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil),
@ -608,7 +635,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: happyGetRequestPath, path: happyGetRequestPath,
csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ",
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), nil), wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), nil),
wantUpstreamStateParamInLocationHeader: true, wantUpstreamStateParamInLocationHeader: true,
@ -626,7 +653,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: "/some/path", path: "/some/path",
contentType: "application/x-www-form-urlencoded", contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap), body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "", wantContentType: "",
wantBodyString: "", wantBodyString: "",
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
@ -715,7 +742,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}),
contentType: "application/x-www-form-urlencoded", contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap), body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBodyStringWithLocationInHref: true, wantBodyStringWithLocationInHref: true,
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
@ -734,7 +761,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}),
contentType: "application/x-www-form-urlencoded", contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap), body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBodyStringWithLocationInHref: true, wantBodyStringWithLocationInHref: true,
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
@ -753,7 +780,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none"}), path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none"}),
contentType: "application/x-www-form-urlencoded", contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap), body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -769,7 +796,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: happyGetRequestPath, path: happyGetRequestPath,
csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped",
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
// Generated a new CSRF cookie and set it in the response. // Generated a new CSRF cookie and set it in the response.
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
@ -789,7 +816,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{ path: modifiedHappyGetRequestPath(map[string]string{
"redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client
}), }),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{
@ -855,7 +882,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid offline_access"}), path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid offline_access"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{
@ -864,6 +891,50 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantUpstreamStateParamInLocationHeader: true, wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true, wantBodyStringWithLocationInHref: true,
}, },
{
name: "OIDC password grant happy path when upstream IDP returned empty refresh token but it did return an access token and has a userinfo endpoint",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken).WithUserInfoURL().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword),
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: htmlContentType,
wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithAccessToken,
},
{
name: "OIDC password grant happy path when upstream IDP did not return a refresh token but it did return an access token and has a userinfo endpoint",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken).WithUserInfoURL().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword),
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: htmlContentType,
wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithAccessToken,
},
{ {
name: "error during upstream LDAP authentication", name: "error during upstream LDAP authentication",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider),
@ -1006,8 +1077,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantBodyString: "", wantBodyString: "",
}, },
{ {
name: "return an error when upstream IDP did not return a refresh token", name: "password grant returns an error when upstream IDP returns no refresh token with an access token but has no userinfo endpoint",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken).WithoutUserInfoURL().Build()),
method: http.MethodGet, method: http.MethodGet,
path: happyGetRequestPath, path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
@ -1015,12 +1086,12 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound, wantStatus: http.StatusFound,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingRefreshTokenErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUserInfoEndpointErrorQuery),
wantBodyString: "", wantBodyString: "",
}, },
{ {
name: "return an error when upstream IDP did not return a refresh token", name: "password grant returns an error when upstream IDP returns empty refresh token with an access token but has no userinfo endpoint",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken).WithoutUserInfoURL().Build()),
method: http.MethodGet, method: http.MethodGet,
path: happyGetRequestPath, path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
@ -1028,7 +1099,59 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound, wantStatus: http.StatusFound,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingRefreshTokenErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUserInfoEndpointErrorQuery),
wantBodyString: "",
},
{
name: "password grant returns an error when upstream IDP returns empty refresh token and empty access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithEmptyAccessToken().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword),
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery),
wantBodyString: "",
},
{
name: "password grant returns an error when upstream IDP returns no refresh and no access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithoutAccessToken().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword),
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery),
wantBodyString: "",
},
{
name: "password grant returns an error when upstream IDP returns no refresh token and empty access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithEmptyAccessToken().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword),
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery),
wantBodyString: "",
},
{
name: "password grant returns an error when upstream IDP returns empty refresh token and no access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithoutAccessToken().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword),
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery),
wantBodyString: "", wantBodyString: "",
}, },
{ {
@ -1163,7 +1286,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1210,7 +1333,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid profile email tuna"}), path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid profile email tuna"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1261,7 +1384,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1342,7 +1465,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1384,7 +1507,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1426,7 +1549,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1468,7 +1591,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1512,7 +1635,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -1559,7 +1682,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
// The following prompt value is illegal when openid is requested, but note that openid is not requested. // The following prompt value is illegal when openid is requested, but note that openid is not requested.
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login", "scope": "email"}), path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login", "scope": "email"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF, wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam( wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(
@ -2042,7 +2165,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder, cookieEncoder: happyCookieEncoder,
method: http.MethodGet, method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"state": "short"}), path: modifiedHappyGetRequestPath(map[string]string{"state": "short"}),
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8", wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery), wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery),
wantBodyString: "", wantBodyString: "",
@ -2301,8 +2424,16 @@ func TestAuthorizationEndpoint(t *testing.T) {
case test.wantBodyJSON != "": case test.wantBodyJSON != "":
require.JSONEq(t, test.wantBodyJSON, rsp.Body.String()) require.JSONEq(t, test.wantBodyJSON, rsp.Body.String())
case test.wantBodyStringWithLocationInHref: case test.wantBodyStringWithLocationInHref:
anchorTagWithLocationHref := fmt.Sprintf("<a href=\"%s\">Found</a>.\n\n", html.EscapeString(actualLocation)) switch code := rsp.Code; code {
require.Equal(t, anchorTagWithLocationHref, rsp.Body.String()) case http.StatusFound:
anchorTagWithLocationHref := fmt.Sprintf("<a href=\"%s\">Found</a>.\n\n", html.EscapeString(actualLocation))
require.Equal(t, anchorTagWithLocationHref, rsp.Body.String())
case http.StatusSeeOther:
anchorTagWithLocationHref := fmt.Sprintf("<a href=\"%s\">See Other</a>.\n\n", html.EscapeString(actualLocation))
require.Equal(t, anchorTagWithLocationHref, rsp.Body.String())
default:
t.Errorf("unexpected response code: %v", code)
}
default: default:
require.Equal(t, test.wantBodyString, rsp.Body.String()) require.Equal(t, test.wantBodyString, rsp.Body.String())
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package callback provides a handler for the OIDC callback endpoint. // Package callback provides a handler for the OIDC callback endpoint.
@ -19,7 +19,6 @@ import (
"go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/oidc/provider/formposthtml"
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
) )
func NewHandler( func NewHandler(
@ -69,28 +68,17 @@ func NewHandler(
return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens") return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens")
} }
if token.RefreshToken == nil || token.RefreshToken.Token == "" {
plog.Warning("refresh token not returned by upstream provider during authcode exchange, "+
"please check configuration of OIDCIdentityProvider and the client in the upstream provider's API/UI",
"upstreamName", upstreamIDPConfig.GetName(),
"scopes", upstreamIDPConfig.GetScopes(),
"additionalParams", upstreamIDPConfig.GetAdditionalAuthcodeParams())
return httperr.New(http.StatusUnprocessableEntity, "refresh token not returned by upstream provider during authcode exchange")
}
subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims)
if err != nil { if err != nil {
return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
} }
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, &psession.CustomSessionData{ customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(upstreamIDPConfig, token)
ProviderUID: upstreamIDPConfig.GetResourceUID(), if err != nil {
ProviderName: upstreamIDPConfig.GetName(), return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
ProviderType: psession.ProviderTypeOIDC, }
OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: token.RefreshToken.Token, openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData)
},
})
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil { if err != nil {

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package callback package callback
@ -31,6 +31,7 @@ const (
oidcUpstreamIssuer = "https://my-upstream-issuer.com" oidcUpstreamIssuer = "https://my-upstream-issuer.com"
oidcUpstreamRefreshToken = "test-refresh-token" oidcUpstreamRefreshToken = "test-refresh-token"
oidcUpstreamAccessToken = "test-access-token"
oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL
oidcUpstreamSubjectQueryEscaped = "abc123-some+guid" oidcUpstreamSubjectQueryEscaped = "abc123-some+guid"
oidcUpstreamUsername = "test-pinniped-username" oidcUpstreamUsername = "test-pinniped-username"
@ -77,7 +78,21 @@ var (
ProviderUID: happyUpstreamIDPResourceUID, ProviderUID: happyUpstreamIDPResourceUID,
ProviderName: happyUpstreamIDPName, ProviderName: happyUpstreamIDPName,
ProviderType: psession.ProviderTypeOIDC, ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamRefreshToken}, OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: oidcUpstreamRefreshToken,
UpstreamIssuer: oidcUpstreamIssuer,
UpstreamSubject: oidcUpstreamSubject,
},
}
happyDownstreamAccessTokenCustomSessionData = &psession.CustomSessionData{
ProviderUID: happyUpstreamIDPResourceUID,
ProviderName: happyUpstreamIDPName,
ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{
UpstreamAccessToken: oidcUpstreamAccessToken,
UpstreamIssuer: oidcUpstreamIssuer,
UpstreamSubject: oidcUpstreamSubject,
},
} }
) )
@ -174,12 +189,12 @@ func TestCallbackEndpoint(t *testing.T) {
}, },
}, },
{ {
name: "GET with good state and cookie and successful upstream token exchange returns 302 to downstream client callback with its state and code", name: "GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback with its state and code",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -196,6 +211,29 @@ func TestCallbackEndpoint(t *testing.T) {
args: happyExchangeAndValidateTokensArgs, args: happyExchangeAndValidateTokensArgs,
}, },
}, },
{
name: "GET with authcode exchange that returns an access token but no refresh token when there is a userinfo endpoint returns 303 to downstream client callback with its state and code",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken).WithUserInfoURL().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamAccessTokenCustomSessionData,
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{ {
name: "upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups", name: "upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
@ -204,7 +242,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -229,7 +267,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -256,7 +294,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -284,7 +322,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, // succeed despite `email_verified=false` because we're not using the email claim for anything wantStatus: http.StatusSeeOther, // succeed despite `email_verified=false` because we're not using the email claim for anything
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -319,28 +357,70 @@ func TestCallbackEndpoint(t *testing.T) {
}, },
}, },
{ {
name: "return an error when upstream IDP did not return a refresh token", name: "return an error when upstream IDP returned no refresh token with an access token when there is no userinfo endpoint",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken).WithoutUserInfoURL().Build()),
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity, wantStatus: http.StatusUnprocessableEntity,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBody: "Unprocessable Entity: refresh token not returned by upstream provider during authcode exchange\n", wantBody: "Unprocessable Entity: access token was returned by upstream provider but there was no userinfo endpoint\n",
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName, performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs, args: happyExchangeAndValidateTokensArgs,
}, },
}, },
{ {
name: "return an error when upstream IDP returned an empty refresh token", name: "return an error when upstream IDP returned no refresh token and no access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().WithoutAccessToken().Build()),
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity, wantStatus: http.StatusUnprocessableEntity,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBody: "Unprocessable Entity: refresh token not returned by upstream provider during authcode exchange\n", wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n",
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "return an error when upstream IDP returned an empty refresh token and empty access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithEmptyAccessToken().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantContentType: htmlContentType,
wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n",
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "return an error when upstream IDP returned no refresh token and empty access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().WithEmptyAccessToken().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantContentType: htmlContentType,
wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n",
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "return an error when upstream IDP returned an empty refresh token and no access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithoutAccessToken().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantContentType: htmlContentType,
wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n",
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName, performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs, args: happyExchangeAndValidateTokensArgs,
@ -372,7 +452,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -397,7 +477,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -422,7 +502,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -575,7 +655,7 @@ func TestCallbackEndpoint(t *testing.T) {
Build(t, happyStateCodec), Build(t, happyStateCodec),
).String(), ).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -601,7 +681,7 @@ func TestCallbackEndpoint(t *testing.T) {
Build(t, happyStateCodec), Build(t, happyStateCodec),
).String(), ).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access&state=` + happyDownstreamState, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -698,7 +778,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(), path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "", wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,

View File

@ -1,10 +1,11 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package downstreamsession provides some shared helpers for creating downstream OIDC sessions. // Package downstreamsession provides some shared helpers for creating downstream OIDC sessions.
package downstreamsession package downstreamsession
import ( import (
"errors"
"fmt" "fmt"
"net/url" "net/url"
"time" "time"
@ -19,6 +20,7 @@ import (
"go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
) )
const ( const (
@ -58,6 +60,55 @@ func MakeDownstreamSession(subject string, username string, groups []string, cus
return openIDSession return openIDSession
} }
func MakeDownstreamOIDCCustomSessionData(oidcUpstream provider.UpstreamOIDCIdentityProviderI, token *oidctypes.Token) (*psession.CustomSessionData, error) {
upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, oidcUpstream.GetName(), token.IDToken.Claims)
if err != nil {
return nil, err
}
upstreamIssuer, err := ExtractStringClaimValue(oidc.IDTokenIssuerClaim, oidcUpstream.GetName(), token.IDToken.Claims)
if err != nil {
return nil, err
}
customSessionData := &psession.CustomSessionData{
ProviderUID: oidcUpstream.GetResourceUID(),
ProviderName: oidcUpstream.GetName(),
ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{
UpstreamIssuer: upstreamIssuer,
UpstreamSubject: upstreamSubject,
},
}
const pleaseCheck = "please check configuration of OIDCIdentityProvider and the client in the " +
"upstream provider's API/UI and try to get a refresh token if possible"
logKV := []interface{}{
"upstreamName", oidcUpstream.GetName(),
"scopes", oidcUpstream.GetScopes(),
"additionalParams", oidcUpstream.GetAdditionalAuthcodeParams(),
}
hasRefreshToken := token.RefreshToken != nil && token.RefreshToken.Token != ""
hasAccessToken := token.AccessToken != nil && token.AccessToken.Token != ""
switch {
case hasRefreshToken: // we prefer refresh tokens, so check for this first
customSessionData.OIDC.UpstreamRefreshToken = token.RefreshToken.Token
case hasAccessToken: // as a fallback, we can use the access token as long as there is a userinfo endpoint
if !oidcUpstream.HasUserInfoURL() {
plog.Warning("access token was returned by upstream provider during login without a refresh token "+
"and there was no userinfo endpoint available on the provider. "+pleaseCheck, logKV...)
return nil, errors.New("access token was returned by upstream provider but there was no userinfo endpoint")
}
plog.Info("refresh token not returned by upstream provider during login, using access token instead. "+pleaseCheck, logKV...)
customSessionData.OIDC.UpstreamAccessToken = token.AccessToken.Token
default:
plog.Warning("refresh token and access token not returned by upstream provider during login. "+pleaseCheck, logKV...)
return nil, errors.New("neither access token nor refresh token returned by upstream provider")
}
return customSessionData, nil
}
// GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested. // GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested.
func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) { func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) {
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID) oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID)
@ -89,11 +140,11 @@ func getSubjectAndUsernameFromUpstreamIDToken(
) (string, string, error) { ) (string, string, error) {
// The spec says the "sub" claim is only unique per issuer, // The spec says the "sub" claim is only unique per issuer,
// so we will prepend the issuer string to make it globally unique. // so we will prepend the issuer string to make it globally unique.
upstreamIssuer, err := extractStringClaimValue(oidc.IDTokenIssuerClaim, upstreamIDPConfig.GetName(), idTokenClaims) upstreamIssuer, err := ExtractStringClaimValue(oidc.IDTokenIssuerClaim, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
upstreamSubject, err := extractStringClaimValue(oidc.IDTokenSubjectClaim, upstreamIDPConfig.GetName(), idTokenClaims) upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@ -128,7 +179,7 @@ func getSubjectAndUsernameFromUpstreamIDToken(
} }
} }
username, err := extractStringClaimValue(usernameClaimName, upstreamIDPConfig.GetName(), idTokenClaims) username, err := ExtractStringClaimValue(usernameClaimName, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@ -136,7 +187,7 @@ func getSubjectAndUsernameFromUpstreamIDToken(
return subject, username, nil return subject, username, nil
} }
func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenClaims map[string]interface{}) (string, error) { func ExtractStringClaimValue(claimName string, upstreamIDPName string, idTokenClaims map[string]interface{}) (string, error) {
value, ok := idTokenClaims[claimName] value, ok := idTokenClaims[claimName]
if !ok { if !ok {
plog.Warning( plog.Warning(

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package oidc package oidc
@ -114,7 +114,7 @@ func (k KubeStorage) GetOpenIDConnectSession(ctx context.Context, fullAuthcode s
} }
func (k KubeStorage) DeleteOpenIDConnectSession(ctx context.Context, fullAuthcode string) error { func (k KubeStorage) DeleteOpenIDConnectSession(ctx context.Context, fullAuthcode string) error {
return k.oidcStorage.DeleteOpenIDConnectSession(ctx, fullAuthcode) return k.oidcStorage.DeleteOpenIDConnectSession(ctx, fullAuthcode) //nolint: staticcheck // we know this is deprecated and never called. our GC controller cleans these up.
} }
// //

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package provider package provider
@ -40,6 +40,9 @@ type UpstreamOIDCIdentityProviderI interface {
// GetAuthorizationURL returns the Authorization Endpoint fetched from discovery. // GetAuthorizationURL returns the Authorization Endpoint fetched from discovery.
GetAuthorizationURL() *url.URL GetAuthorizationURL() *url.URL
// HasUserInfoURL returns whether there is a non-empty value for userinfo_endpoint fetched from discovery.
HasUserInfoURL() bool
// GetScopes returns the scopes to request in authorization (authcode or password grant) flow. // GetScopes returns the scopes to request in authorization (authcode or password grant) flow.
GetScopes() []string GetScopes() []string
@ -83,10 +86,10 @@ type UpstreamOIDCIdentityProviderI interface {
// represent an error such that it is not worth retrying revocation later, even though revocation failed. // represent an error such that it is not worth retrying revocation later, even though revocation failed.
RevokeToken(ctx context.Context, token string, tokenType RevocableTokenType) error RevokeToken(ctx context.Context, token string, tokenType RevocableTokenType) error
// ValidateToken will validate the ID token. It will also merge the claims from the userinfo endpoint response // ValidateTokenAndMergeWithUserInfo will validate the ID token. It will also merge the claims from the userinfo endpoint response
// into the ID token's claims, if the provider offers the userinfo endpoint. It returns the validated/updated // into the ID token's claims, if the provider offers the userinfo endpoint. It returns the validated/updated
// tokens, or an error. // tokens, or an error.
ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) ValidateTokenAndMergeWithUserInfo(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce, requireIDToken bool, requireUserInfo bool) (*oidctypes.Token, error)
} }
type UpstreamLDAPIdentityProviderI interface { type UpstreamLDAPIdentityProviderI interface {
@ -105,7 +108,14 @@ type UpstreamLDAPIdentityProviderI interface {
authenticators.UserAuthenticator authenticators.UserAuthenticator
// PerformRefresh performs a refresh against the upstream LDAP identity provider // PerformRefresh performs a refresh against the upstream LDAP identity provider
PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error PerformRefresh(ctx context.Context, storedRefreshAttributes StoredRefreshAttributes) error
}
type StoredRefreshAttributes struct {
Username string
Subject string
DN string
AdditionalAttributes map[string]string
} }
type DynamicUpstreamIDPProvider interface { type DynamicUpstreamIDPProvider interface {

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package manager package manager
@ -121,7 +121,7 @@ func TestManager(t *testing.T) {
r.False(fallbackHandlerWasCalled) r.False(fallbackHandlerWasCalled)
// Minimal check to ensure that the right endpoint was called // Minimal check to ensure that the right endpoint was called
r.Equal(http.StatusFound, recorder.Code) r.Equal(http.StatusSeeOther, recorder.Code)
actualLocation := recorder.Header().Get("Location") actualLocation := recorder.Header().Get("Location")
r.True( r.True(
strings.HasPrefix(actualLocation, expectedRedirectLocationPrefix), strings.HasPrefix(actualLocation, expectedRedirectLocationPrefix),
@ -160,7 +160,7 @@ func TestManager(t *testing.T) {
// Check just enough of the response to ensure that we wired up the callback endpoint correctly. // Check just enough of the response to ensure that we wired up the callback endpoint correctly.
// The endpoint's own unit tests cover everything else. // The endpoint's own unit tests cover everything else.
r.Equal(http.StatusFound, recorder.Code) r.Equal(http.StatusSeeOther, recorder.Code)
actualLocation := recorder.Header().Get("Location") actualLocation := recorder.Header().Get("Location")
r.True( r.True(
strings.HasPrefix(actualLocation, downstreamRedirectURL), strings.HasPrefix(actualLocation, downstreamRedirectURL),

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package token provides a handler for the OIDC token endpoint. // Package token provides a handler for the OIDC token endpoint.
@ -6,16 +6,19 @@ package token
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"github.com/ory/fosite" "github.com/ory/fosite"
"github.com/ory/x/errorsx" "github.com/ory/x/errorsx"
"golang.org/x/oauth2"
"go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
) )
var ( var (
@ -75,11 +78,6 @@ func NewHandler(
func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error { func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error {
session := accessRequest.GetSession().(*psession.PinnipedSession) session := accessRequest.GetSession().(*psession.PinnipedSession)
downstreamUsername, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
downstreamSubject := session.Fosite.Claims.Subject
customSessionData := session.Custom customSessionData := session.Custom
if customSessionData == nil { if customSessionData == nil {
@ -93,18 +91,27 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester,
switch customSessionData.ProviderType { switch customSessionData.ProviderType {
case psession.ProviderTypeOIDC: case psession.ProviderTypeOIDC:
return upstreamOIDCRefresh(ctx, customSessionData, providerCache) return upstreamOIDCRefresh(ctx, session, providerCache)
case psession.ProviderTypeLDAP: case psession.ProviderTypeLDAP:
return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject) return upstreamLDAPRefresh(ctx, providerCache, session)
case psession.ProviderTypeActiveDirectory: case psession.ProviderTypeActiveDirectory:
return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject) return upstreamLDAPRefresh(ctx, providerCache, session)
default: default:
return errorsx.WithStack(errMissingUpstreamSessionInternalError) return errorsx.WithStack(errMissingUpstreamSessionInternalError)
} }
} }
func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister) error { func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession, providerCache oidc.UpstreamIdentityProvidersLister) error {
if s.OIDC == nil || s.OIDC.UpstreamRefreshToken == "" { s := session.Custom
if s.OIDC == nil {
return errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
accessTokenStored := s.OIDC.UpstreamAccessToken != ""
refreshTokenStored := s.OIDC.UpstreamRefreshToken != ""
exactlyOneTokenStored := (accessTokenStored || refreshTokenStored) && !(accessTokenStored && refreshTokenStored)
if !exactlyOneTokenStored {
return errorsx.WithStack(errMissingUpstreamSessionInternalError) return errorsx.WithStack(errMissingUpstreamSessionInternalError)
} }
@ -116,42 +123,98 @@ func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, pro
plog.Debug("attempting upstream refresh request", plog.Debug("attempting upstream refresh request",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID) "providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
refreshedTokens, err := p.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken) var tokens *oauth2.Token
if err != nil { if refreshTokenStored {
return errorsx.WithStack(errUpstreamRefreshError.WithHint( tokens, err = p.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken)
"Upstream refresh failed.", if err != nil {
).WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) return errorsx.WithStack(errUpstreamRefreshError.WithHint(
"Upstream refresh failed.",
).WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
} else {
tokens = &oauth2.Token{AccessToken: s.OIDC.UpstreamAccessToken}
} }
// Upstream refresh may or may not return a new ID token. From the spec: // Upstream refresh may or may not return a new ID token. From the spec:
// "the response body is the Token Response of Section 3.1.3.3 except that it might not contain an id_token." // "the response body is the Token Response of Section 3.1.3.3 except that it might not contain an id_token."
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse // https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
_, hasIDTok := refreshedTokens.Extra("id_token").(string) _, hasIDTok := tokens.Extra("id_token").(string)
if hasIDTok {
// The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at // The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at
// least some providers do not include one, so we skip the nonce validation here (but not other validations). // least some providers do not include one, so we skip the nonce validation here (but not other validations).
_, err = p.ValidateToken(ctx, refreshedTokens, "") validatedTokens, err := p.ValidateTokenAndMergeWithUserInfo(ctx, tokens, "", hasIDTok, accessTokenStored)
if err != nil { if err != nil {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf( return errorsx.WithStack(errUpstreamRefreshError.WithHintf(
"Upstream refresh returned an invalid ID token.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) "Upstream refresh returned an invalid ID token or UserInfo response.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
} }
} else {
plog.Debug("upstream refresh request did not return a new ID token", err = validateIdentityUnchangedSinceInitialLogin(validatedTokens, session, p.GetUsernameClaim())
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID) if err != nil {
return err
} }
// Upstream refresh may or may not return a new refresh token. If we got a new refresh token, then update it in // Upstream refresh may or may not return a new refresh token. If we got a new refresh token, then update it in
// the user's session. If we did not get a new refresh token, then keep the old one in the session by avoiding // the user's session. If we did not get a new refresh token, then keep the old one in the session by avoiding
// overwriting the old one. // overwriting the old one.
if refreshedTokens.RefreshToken != "" { if tokens.RefreshToken != "" {
plog.Debug("upstream refresh request did not return a new refresh token", plog.Debug("upstream refresh request returned a new refresh token",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID) "providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
s.OIDC.UpstreamRefreshToken = refreshedTokens.RefreshToken s.OIDC.UpstreamRefreshToken = tokens.RefreshToken
} }
return nil return nil
} }
func validateIdentityUnchangedSinceInitialLogin(validatedTokens *oidctypes.Token, session *psession.PinnipedSession, usernameClaimName string) error {
s := session.Custom
mergedClaims := validatedTokens.IDToken.Claims
// If we have any claims at all, we better have a subject, and it better match the previous value.
// but it's possible that we don't because both returning a new id token on refresh and having a userinfo
// endpoint are optional.
if len(mergedClaims) == 0 {
return nil
}
newSub, hasSub := getString(mergedClaims, oidc.IDTokenSubjectClaim)
if !hasSub {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf(
"Upstream refresh failed.").WithWrap(errors.New("subject in upstream refresh not found")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
if s.OIDC.UpstreamSubject != newSub {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf(
"Upstream refresh failed.").WithWrap(errors.New("subject in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
newUsername, hasUsername := getString(mergedClaims, usernameClaimName)
oldUsername := session.Fosite.Claims.Extra[oidc.DownstreamUsernameClaim]
// It's possible that a username wasn't returned by the upstream provider during refresh,
// but if it is, verify that it hasn't changed.
if hasUsername && oldUsername != newUsername {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf(
"Upstream refresh failed.").WithWrap(errors.New("username in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
newIssuer, hasIssuer := getString(mergedClaims, oidc.IDTokenIssuerClaim)
// It's possible that an issuer wasn't returned by the upstream provider during refresh,
// but if it is, verify that it hasn't changed.
if hasIssuer && s.OIDC.UpstreamIssuer != newIssuer {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf(
"Upstream refresh failed.").WithWrap(errors.New("issuer in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
return nil
}
func getString(m map[string]interface{}, key string) (string, bool) {
val, ok := m[key].(string)
return val, ok
}
func findOIDCProviderByNameAndValidateUID( func findOIDCProviderByNameAndValidateUID(
s *psession.CustomSessionData, s *psession.CustomSessionData,
providerCache oidc.UpstreamIdentityProvidersLister, providerCache oidc.UpstreamIdentityProvidersLister,
@ -169,7 +232,15 @@ func findOIDCProviderByNameAndValidateUID(
WithHint("Provider from upstream session data was not found.").WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) WithHint("Provider from upstream session data was not found.").WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
} }
func upstreamLDAPRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister, username string, subject string) error { func upstreamLDAPRefresh(ctx context.Context, providerCache oidc.UpstreamIdentityProvidersLister, session *psession.PinnipedSession) error {
username, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
subject := session.Fosite.Claims.Subject
s := session.Custom
// if you have neither a valid ldap session config nor a valid active directory session config // if you have neither a valid ldap session config nor a valid active directory session config
validLDAP := s.ProviderType == psession.ProviderTypeLDAP && s.LDAP != nil && s.LDAP.UserDN != "" validLDAP := s.ProviderType == psession.ProviderTypeLDAP && s.LDAP != nil && s.LDAP.UserDN != ""
validAD := s.ProviderType == psession.ProviderTypeActiveDirectory && s.ActiveDirectory != nil && s.ActiveDirectory.UserDN != "" validAD := s.ProviderType == psession.ProviderTypeActiveDirectory && s.ActiveDirectory != nil && s.ActiveDirectory.UserDN != ""
@ -177,13 +248,28 @@ func upstreamLDAPRefresh(ctx context.Context, s *psession.CustomSessionData, pro
return errorsx.WithStack(errMissingUpstreamSessionInternalError) return errorsx.WithStack(errMissingUpstreamSessionInternalError)
} }
var additionalAttributes map[string]string
if s.ProviderType == psession.ProviderTypeLDAP {
additionalAttributes = s.LDAP.ExtraRefreshAttributes
} else {
additionalAttributes = s.ActiveDirectory.ExtraRefreshAttributes
}
// get ldap/ad provider out of cache // get ldap/ad provider out of cache
p, dn, err := findLDAPProviderByNameAndValidateUID(s, providerCache) p, dn, err := findLDAPProviderByNameAndValidateUID(s, providerCache)
if err != nil { if err != nil {
return err return err
} }
if session.IDTokenClaims().AuthTime.IsZero() {
return errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
// run PerformRefresh // run PerformRefresh
err = p.PerformRefresh(ctx, dn, username, subject) err = p.PerformRefresh(ctx, provider.StoredRefreshAttributes{
Username: username,
Subject: subject,
DN: dn,
AdditionalAttributes: additionalAttributes,
})
if err != nil { if err != nil {
return errorsx.WithStack(errUpstreamRefreshError.WithHint( return errorsx.WithStack(errUpstreamRefreshError.WithHint(
"Upstream refresh failed.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) "Upstream refresh failed.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package token package token
@ -52,10 +52,12 @@ import (
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/internal/testutil/oidctestutil"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
) )
const ( const (
goodIssuer = "https://some-issuer.com" goodIssuer = "https://some-issuer.com"
goodUpstreamSubject = "some-subject"
goodClient = "pinniped-cli" goodClient = "pinniped-cli"
goodRedirectURI = "http://127.0.0.1/callback" goodRedirectURI = "http://127.0.0.1/callback"
goodPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements" goodPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements"
@ -223,7 +225,7 @@ type expectedUpstreamRefresh struct {
type expectedUpstreamValidateTokens struct { type expectedUpstreamValidateTokens struct {
performedByUpstreamName string performedByUpstreamName string
args *oidctestutil.ValidateTokenArgs args *oidctestutil.ValidateTokenAndMergeWithUserInfoArgs
} }
type tokenEndpointResponseExpectedValues struct { type tokenEndpointResponseExpectedValues struct {
@ -879,6 +881,7 @@ func TestRefreshGrant(t *testing.T) {
oidcUpstreamInitialRefreshToken = "initial-upstream-refresh-token" oidcUpstreamInitialRefreshToken = "initial-upstream-refresh-token"
oidcUpstreamRefreshedIDToken = "fake-refreshed-id-token" oidcUpstreamRefreshedIDToken = "fake-refreshed-id-token"
oidcUpstreamRefreshedRefreshToken = "fake-refreshed-refresh-token" oidcUpstreamRefreshedRefreshToken = "fake-refreshed-refresh-token"
oidcUpstreamAccessToken = "fake-upstream-access-token" //nolint:gosec
ldapUpstreamName = "some-ldap-idp" ldapUpstreamName = "some-ldap-idp"
ldapUpstreamResourceUID = "ldap-resource-uid" ldapUpstreamResourceUID = "ldap-resource-uid"
@ -902,19 +905,34 @@ func TestRefreshGrant(t *testing.T) {
WithResourceUID(oidcUpstreamResourceUID) WithResourceUID(oidcUpstreamResourceUID)
} }
initialUpstreamOIDCCustomSessionData := func() *psession.CustomSessionData { initialUpstreamOIDCRefreshTokenCustomSessionData := func() *psession.CustomSessionData {
return &psession.CustomSessionData{ return &psession.CustomSessionData{
ProviderName: oidcUpstreamName, ProviderName: oidcUpstreamName,
ProviderUID: oidcUpstreamResourceUID, ProviderUID: oidcUpstreamResourceUID,
ProviderType: oidcUpstreamType, ProviderType: oidcUpstreamType,
OIDC: &psession.OIDCSessionData{ OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: oidcUpstreamInitialRefreshToken, UpstreamRefreshToken: oidcUpstreamInitialRefreshToken,
UpstreamSubject: goodUpstreamSubject,
UpstreamIssuer: goodIssuer,
},
}
}
initialUpstreamOIDCAccessTokenCustomSessionData := func() *psession.CustomSessionData {
return &psession.CustomSessionData{
ProviderName: oidcUpstreamName,
ProviderUID: oidcUpstreamResourceUID,
ProviderType: oidcUpstreamType,
OIDC: &psession.OIDCSessionData{
UpstreamAccessToken: oidcUpstreamAccessToken,
UpstreamSubject: goodUpstreamSubject,
UpstreamIssuer: goodIssuer,
}, },
} }
} }
upstreamOIDCCustomSessionDataWithNewRefreshToken := func(newRefreshToken string) *psession.CustomSessionData { upstreamOIDCCustomSessionDataWithNewRefreshToken := func(newRefreshToken string) *psession.CustomSessionData {
sessionData := initialUpstreamOIDCCustomSessionData() sessionData := initialUpstreamOIDCRefreshTokenCustomSessionData()
sessionData.OIDC.UpstreamRefreshToken = newRefreshToken sessionData.OIDC.UpstreamRefreshToken = newRefreshToken
return sessionData return sessionData
} }
@ -953,13 +971,15 @@ func TestRefreshGrant(t *testing.T) {
} }
} }
happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token) *expectedUpstreamValidateTokens { happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token, requireIDToken bool) *expectedUpstreamValidateTokens {
return &expectedUpstreamValidateTokens{ return &expectedUpstreamValidateTokens{
performedByUpstreamName: oidcUpstreamName, performedByUpstreamName: oidcUpstreamName,
args: &oidctestutil.ValidateTokenArgs{ args: &oidctestutil.ValidateTokenAndMergeWithUserInfoArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below Ctx: nil, // this will be filled in with the actual request context by the test below
Tok: expectedTokens, Tok: expectedTokens,
ExpectedIDTokenNonce: "", // always expect empty string ExpectedIDTokenNonce: "", // always expect empty string
RequireUserInfo: false,
RequireIDToken: requireIDToken,
}, },
} }
} }
@ -981,9 +1001,8 @@ func TestRefreshGrant(t *testing.T) {
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored) want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
// Should always try to perform an upstream refresh. // Should always try to perform an upstream refresh.
want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall() want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall()
// Should only try to ValidateToken when there was an id token returned by the upstream refresh.
if expectToValidateToken != nil { if expectToValidateToken != nil {
want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken) want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true)
} }
return want return want
} }
@ -1046,11 +1065,17 @@ func TestRefreshGrant(t *testing.T) {
{ {
name: "happy path refresh grant with openid scope granted (id token returned)", name: "happy path refresh grant with openid scope granted (id token returned)",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"sub": goodUpstreamSubject,
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( want: happyRefreshTokenResponseForOpenIDAndOfflineAccess(
@ -1059,19 +1084,88 @@ func TestRefreshGrant(t *testing.T) {
), ),
}, },
}, },
{
name: "refresh grant with unchanged username claim",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithUsernameClaim("username-claim").WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"some-claim": "some-value",
"sub": goodUpstreamSubject,
"username-claim": goodUsername,
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForOpenIDAndOfflineAccess(
upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
refreshedUpstreamTokensWithIDAndRefreshTokens(),
),
},
},
{
name: "refresh grant when the customsessiondata has a stored access token and no stored refresh token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithUsernameClaim("username-claim").
WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"some-claim": "some-value",
"sub": goodUpstreamSubject,
"username-claim": goodUsername,
},
},
AccessToken: &oidctypes.AccessToken{
Token: oidcUpstreamAccessToken,
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCAccessTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCAccessTokenCustomSessionData()),
}, // do not want upstreamRefreshRequest???
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access"},
wantGrantedScopes: []string{"openid", "offline_access"},
wantUpstreamOIDCValidateTokenCall: &expectedUpstreamValidateTokens{
oidcUpstreamName,
&oidctestutil.ValidateTokenAndMergeWithUserInfoArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
Tok: &oauth2.Token{AccessToken: oidcUpstreamAccessToken}, // only the old access token
ExpectedIDTokenNonce: "", // always expect empty string
RequireIDToken: false,
RequireUserInfo: true,
},
},
wantCustomSessionDataStored: initialUpstreamOIDCAccessTokenCustomSessionData(), // doesn't change when we refresh
},
},
},
{ {
name: "happy path refresh grant without openid scope granted (no id token returned)", name: "happy path refresh grant without openid scope granted (no id token returned)",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"}, wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
}, },
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
@ -1081,7 +1175,7 @@ func TestRefreshGrant(t *testing.T) {
wantRequestedScopes: []string{"offline_access"}, wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
}, },
}, },
@ -1089,31 +1183,46 @@ func TestRefreshGrant(t *testing.T) {
{ {
name: "happy path refresh grant when the upstream refresh does not return a new ID token", name: "happy path refresh grant when the upstream refresh does not return a new ID token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()), upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( want: tokenEndpointResponseExpectedValues{
upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantStatus: http.StatusOK,
nil, // expect ValidateToken is *not* called wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"},
), wantRequestedScopes: []string{"openid", "offline_access"},
wantGrantedScopes: []string{"openid", "offline_access"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
},
}, },
}, },
{ {
name: "happy path refresh grant when the upstream refresh does not return a new refresh token", name: "happy path refresh grant when the upstream refresh does not return a new refresh token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDTokenWithoutRefreshToken()).Build()), upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"sub": goodUpstreamSubject,
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDTokenWithoutRefreshToken()).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( want: happyRefreshTokenResponseForOpenIDAndOfflineAccess(
initialUpstreamOIDCCustomSessionData(), // still has the initial refresh token stored initialUpstreamOIDCRefreshTokenCustomSessionData(), // still has the initial refresh token stored
refreshedUpstreamTokensWithIDTokenWithoutRefreshToken(), refreshedUpstreamTokensWithIDTokenWithoutRefreshToken(),
), ),
}, },
@ -1121,11 +1230,17 @@ func TestRefreshGrant(t *testing.T) {
{ {
name: "when the refresh request adds a new scope to the list of requested scopes then it is ignored", name: "when the refresh request adds a new scope to the list of requested scopes then it is ignored",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"sub": goodUpstreamSubject,
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) {
@ -1140,16 +1255,22 @@ func TestRefreshGrant(t *testing.T) {
{ {
name: "when the refresh request removes a scope which was originally granted from the list of requested scopes then it is granted anyway", name: "when the refresh request removes a scope which was originally granted from the list of requested scopes then it is granted anyway",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"sub": goodUpstreamSubject,
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access pinniped:request-audience") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access pinniped:request-audience") },
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
}, },
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
@ -1162,7 +1283,7 @@ func TestRefreshGrant(t *testing.T) {
wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
}, },
}, },
@ -1170,11 +1291,17 @@ func TestRefreshGrant(t *testing.T) {
{ {
name: "when the refresh request does not include a scope param then it gets all the same scopes as the original authorization request", name: "when the refresh request does not include a scope param then it gets all the same scopes as the original authorization request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"sub": goodUpstreamSubject,
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) {
@ -1190,14 +1317,14 @@ func TestRefreshGrant(t *testing.T) {
name: "when a bad refresh token is sent in the refresh request", name: "when a bad refresh token is sent in the refresh request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"}, wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
}, },
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
@ -1214,14 +1341,14 @@ func TestRefreshGrant(t *testing.T) {
name: "when the access token is sent as if it were a refresh token", name: "when the access token is sent as if it were a refresh token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"}, wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
}, },
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
@ -1238,14 +1365,14 @@ func TestRefreshGrant(t *testing.T) {
name: "when the wrong client ID is included in the refresh request", name: "when the wrong client ID is included in the refresh request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"}, wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
}, },
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
@ -1409,7 +1536,7 @@ func TestRefreshGrant(t *testing.T) {
}, },
}, },
{ {
name: "when there is no OIDC refresh token in custom session data found in the session storage during the refresh request", name: "when there is no OIDC refresh token nor access token in custom session data found in the session storage during the refresh request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: &psession.CustomSessionData{ customSessionData: &psession.CustomSessionData{
@ -1417,7 +1544,8 @@ func TestRefreshGrant(t *testing.T) {
ProviderUID: oidcUpstreamResourceUID, ProviderUID: oidcUpstreamResourceUID,
ProviderType: oidcUpstreamType, ProviderType: oidcUpstreamType,
OIDC: &psession.OIDCSessionData{ OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: "", // this should not happen in practice UpstreamRefreshToken: "", // this should not happen in practice. we should always have exactly one of these.
UpstreamAccessToken: "",
}, },
}, },
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
@ -1428,6 +1556,7 @@ func TestRefreshGrant(t *testing.T) {
ProviderType: oidcUpstreamType, ProviderType: oidcUpstreamType,
OIDC: &psession.OIDCSessionData{ OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: "", // this should not happen in practice UpstreamRefreshToken: "", // this should not happen in practice
UpstreamAccessToken: "",
}, },
}, },
), ),
@ -1508,9 +1637,9 @@ func TestRefreshGrant(t *testing.T) {
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder(). idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().
WithPerformRefreshError(errors.New("some upstream refresh error")).Build()), WithPerformRefreshError(errors.New("some upstream refresh error")).Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
@ -1529,23 +1658,146 @@ func TestRefreshGrant(t *testing.T) {
name: "when the upstream refresh returns an invalid ID token during the refresh request", name: "when the upstream refresh returns an invalid ID token during the refresh request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder(). idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().
WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()). WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).
// This is the current format of the errors returned by the production code version of ValidateToken, see ValidateToken in upstreamoidc.go // This is the current format of the errors returned by the production code version of ValidateTokenAndMergeWithUserInfo, see ValidateTokenAndMergeWithUserInfo in upstreamoidc.go
WithValidateTokenError(httperr.Wrap(http.StatusBadRequest, "some validate error", errors.New("some validate cause"))). WithValidateTokenAndMergeWithUserInfoError(httperr.Wrap(http.StatusBadRequest, "some validate error", errors.New("some validate cause"))).
Build()), Build()),
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(), customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized, wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(` wantErrorResponseBody: here.Doc(`
{ {
"error": "error", "error": "error",
"error_description": "Error during upstream refresh. Upstream refresh returned an invalid ID token." "error_description": "Error during upstream refresh. Upstream refresh returned an invalid ID token or UserInfo response."
}
`),
},
},
},
{
name: "when the upstream refresh returns an ID token with a different subject than the original",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().
WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).
// This is the current format of the errors returned by the production code version of ValidateTokenAndMergeWithUserInfo, see ValidateTokenAndMergeWithUserInfo in upstreamoidc.go
WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"sub": "something-different",
},
},
}).
Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
}
`),
},
},
},
{
name: "refresh grant with claims but not the subject claim",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"some-claim": "some-value",
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
}
`),
},
},
},
{
name: "refresh grant with changed username claim",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithUsernameClaim("username-claim").WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"some-claim": "some-value",
"sub": goodUpstreamSubject,
"username-claim": "some-changed-username",
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
}
`),
},
},
},
{
name: "refresh grant with changed issuer claim",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithUsernameClaim("username-claim").WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{
"some-claim": "some-value",
"sub": goodUpstreamSubject,
"iss": "some-changed-issuer",
},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
} }
`), `),
}, },
@ -2058,6 +2310,219 @@ func TestRefreshGrant(t *testing.T) {
}, },
}, },
}, },
{
name: "upstream ldap idp not found",
idps: oidctestutil.NewUpstreamIDPListerBuilder(),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyLDAPCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyLDAPCustomSessionData,
),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Provider from upstream session data was not found."
}
`),
},
},
},
{
name: "upstream active directory idp not found",
idps: oidctestutil.NewUpstreamIDPListerBuilder(),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyActiveDirectoryCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyActiveDirectoryCustomSessionData,
),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Provider from upstream session data was not found."
}
`),
},
},
},
{
name: "fosite session is empty",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: ldapUpstreamName,
ResourceUID: ldapUpstreamResourceUID,
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyLDAPCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyLDAPCustomSessionData,
),
},
modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) {
refreshTokenSignature := getFositeDataSignature(t, refreshToken)
firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil)
require.NoError(t, err)
session := firstRequester.GetSession().(*psession.PinnipedSession)
session.Fosite = &openid.DefaultSession{}
err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature)
require.NoError(t, err)
err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester)
require.NoError(t, err)
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusInternalServerError,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "There was an internal server error. Required upstream data not found in session."
}
`),
},
},
},
{
name: "username not found in extra field",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: ldapUpstreamName,
ResourceUID: ldapUpstreamResourceUID,
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyLDAPCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyLDAPCustomSessionData,
),
},
modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) {
refreshTokenSignature := getFositeDataSignature(t, refreshToken)
firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil)
require.NoError(t, err)
session := firstRequester.GetSession().(*psession.PinnipedSession)
session.Fosite = &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Extra: map[string]interface{}{},
},
}
err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature)
require.NoError(t, err)
err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester)
require.NoError(t, err)
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusInternalServerError,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "There was an internal server error. Required upstream data not found in session."
}
`),
},
},
},
{
name: "auth time is the zero value", // time.Times can never be nil, but it is possible that it would be the zero value which would mean something's wrong
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: ldapUpstreamName,
ResourceUID: ldapUpstreamResourceUID,
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyLDAPCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyLDAPCustomSessionData,
),
},
modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) {
refreshTokenSignature := getFositeDataSignature(t, refreshToken)
firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil)
require.NoError(t, err)
session := firstRequester.GetSession().(*psession.PinnipedSession)
fositeSessionClaims := session.Fosite.IDTokenClaims()
fositeSessionClaims.AuthTime = time.Time{}
session.Fosite.Claims = fositeSessionClaims
err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature)
require.NoError(t, err)
err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester)
require.NoError(t, err)
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusInternalServerError,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "There was an internal server error. Required upstream data not found in session."
}
`),
},
},
},
{
name: "when the ldap provider in the session storage is found but has the wrong resource UID during the refresh request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: ldapUpstreamName,
ResourceUID: "the-wrong-uid",
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyLDAPCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyLDAPCustomSessionData,
),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication."
}
`),
},
},
},
{
name: "when the active directory provider in the session storage is found but has the wrong resource UID during the refresh request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: activeDirectoryUpstreamName,
ResourceUID: "the-wrong-uid",
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyActiveDirectoryCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyActiveDirectoryCustomSessionData,
),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication."
}
`),
},
},
},
} }
for _, test := range tests { for _, test := range tests {
test := test test := test
@ -2758,7 +3223,7 @@ func requireValidStoredRequest(
// At this time, we don't use any of these optional (per the OIDC spec) fields. // At this time, we don't use any of these optional (per the OIDC spec) fields.
require.Empty(t, claims.AuthenticationContextClassReference) require.Empty(t, claims.AuthenticationContextClassReference)
require.Empty(t, claims.AuthenticationMethodsReference) require.Empty(t, claims.AuthenticationMethodsReferences)
require.Empty(t, claims.CodeHash) require.Empty(t, claims.CodeHash)
} }

View File

@ -1,44 +1,14 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package plog package plog
import ( import (
"fmt" "fmt"
"sync"
"github.com/spf13/pflag"
"k8s.io/klog/v2" "k8s.io/klog/v2"
) )
//nolint: gochecknoglobals
var removeKlogGlobalFlagsLock sync.Mutex
// RemoveKlogGlobalFlags attempts to "remove" flags that get unconditionally added by importing klog.
func RemoveKlogGlobalFlags() {
// since we mess with global state, we need a lock to synchronize us when called in parallel during tests
removeKlogGlobalFlagsLock.Lock()
defer removeKlogGlobalFlagsLock.Unlock()
// if this function starts to panic, it likely means that klog stopped mucking with global flags
const globalLogFlushFlag = "log-flush-frequency"
if err := pflag.CommandLine.MarkHidden(globalLogFlushFlag); err != nil {
panic(err)
}
if err := pflag.CommandLine.MarkDeprecated(globalLogFlushFlag, "unsupported"); err != nil {
panic(err)
}
if pflag.CommandLine.Changed(globalLogFlushFlag) {
panic("unsupported global klog flag set")
}
}
// KRef is (mostly) copied from klog - it is a standard way to represent a metav1.Object in logs
// when you only have access to the namespace and name of the object.
func KRef(namespace, name string) string {
return fmt.Sprintf("%s/%s", namespace, name)
}
// KObj is (mostly) copied from klog - it is a standard way to represent a metav1.Object in logs. // KObj is (mostly) copied from klog - it is a standard way to represent a metav1.Object in logs.
func KObj(obj klog.KMetadata) string { func KObj(obj klog.KMetadata) string {
return fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName()) return fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName())

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package psession package psession
@ -74,16 +74,26 @@ type OIDCSessionData struct {
// non-empty, then this field should be empty, indicating that we should use the upstream refresh token during // non-empty, then this field should be empty, indicating that we should use the upstream refresh token during
// downstream refresh. // downstream refresh.
UpstreamAccessToken string `json:"upstreamAccessToken"` UpstreamAccessToken string `json:"upstreamAccessToken"`
// UpstreamSubject is the "sub" claim from the upstream identity provider from the user's initial login. We store this
// so that we can validate that it does not change upon refresh.
UpstreamSubject string `json:"upstreamSubject"`
// UpstreamIssuer is the "iss" claim from the upstream identity provider from the user's initial login. We store this
// so that we can validate that it does not change upon refresh.
UpstreamIssuer string `json:"upstreamIssuer"`
} }
// LDAPSessionData is the additional data needed by Pinniped when the upstream IDP is an LDAP provider. // LDAPSessionData is the additional data needed by Pinniped when the upstream IDP is an LDAP provider.
type LDAPSessionData struct { type LDAPSessionData struct {
UserDN string `json:"userDN"` UserDN string `json:"userDN"`
ExtraRefreshAttributes map[string]string `json:"extraRefreshAttributes,omitempty"`
} }
// ActiveDirectorySessionData is the additional data needed by Pinniped when the upstream IDP is an Active Directory provider. // ActiveDirectorySessionData is the additional data needed by Pinniped when the upstream IDP is an Active Directory provider.
type ActiveDirectorySessionData struct { type ActiveDirectorySessionData struct {
UserDN string `json:"userDN"` UserDN string `json:"userDN"`
ExtraRefreshAttributes map[string]string `json:"extraRefreshAttributes,omitempty"`
} }
// NewPinnipedSession returns a new empty session. // NewPinnipedSession returns a new empty session.

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package credentialrequest package credentialrequest
@ -10,6 +10,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-logr/logr"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/sclevine/spec" "github.com/sclevine/spec"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -71,11 +72,11 @@ func TestCreate(t *testing.T) {
r = require.New(t) r = require.New(t)
ctrl = gomock.NewController(t) ctrl = gomock.NewController(t)
logger = testutil.NewTranscriptLogger(t) logger = testutil.NewTranscriptLogger(t)
klog.SetLogger(logger) // this is unfortunately a global logger, so can't run these tests in parallel :( klog.SetLogger(logr.New(logger)) // this is unfortunately a global logger, so can't run these tests in parallel :(
}) })
it.After(func() { it.After(func() {
klog.SetLogger(nil) klog.ClearLogger()
ctrl.Finish() ctrl.Finish()
}) })

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package server defines the entrypoint for the Pinniped Supervisor server. // Package server defines the entrypoint for the Pinniped Supervisor server.
@ -20,7 +20,6 @@ import (
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/clock"
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters" genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
kubeinformers "k8s.io/client-go/informers" kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@ -29,6 +28,7 @@ import (
"k8s.io/component-base/logs" "k8s.io/component-base/logs"
"k8s.io/klog/v2" "k8s.io/klog/v2"
"k8s.io/klog/v2/klogr" "k8s.io/klog/v2/klogr"
"k8s.io/utils/clock"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
@ -431,7 +431,6 @@ func runSupervisor(podInfo *downward.PodInfo, cfg *supervisor.Config) error {
func main() error { // return an error instead of klog.Fatal to allow defer statements to run func main() error { // return an error instead of klog.Fatal to allow defer statements to run
logs.InitLogs() logs.InitLogs()
defer logs.FlushLogs() defer logs.FlushLogs()
plog.RemoveKlogGlobalFlags() // move this whenever the below code gets refactored to use cobra
klog.Infof("Running %s at %#v", rest.DefaultKubernetesUserAgent(), version.Get()) klog.Infof("Running %s at %#v", rest.DefaultKubernetesUserAgent(), version.Get())
klog.Infof("Command-line arguments were: %s %s %s", os.Args[0], os.Args[1], os.Args[2]) klog.Infof("Command-line arguments were: %s %s %s", os.Args[0], os.Args[1], os.Args[2])

View File

@ -1,90 +1,13 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package testutil package testutil
import ( import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
appsv1client "k8s.io/client-go/kubernetes/typed/apps/v1"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
) )
func NewDeleteOptionsRecorder(client kubernetes.Interface, opts *[]metav1.DeleteOptions) kubernetes.Interface {
return &clientWrapper{
Interface: client,
opts: opts,
}
}
type clientWrapper struct {
kubernetes.Interface
opts *[]metav1.DeleteOptions
}
func (c *clientWrapper) CoreV1() corev1client.CoreV1Interface {
return &coreWrapper{CoreV1Interface: c.Interface.CoreV1(), opts: c.opts}
}
func (c *clientWrapper) AppsV1() appsv1client.AppsV1Interface {
return &appsWrapper{AppsV1Interface: c.Interface.AppsV1(), opts: c.opts}
}
type coreWrapper struct {
corev1client.CoreV1Interface
opts *[]metav1.DeleteOptions
}
func (c *coreWrapper) Pods(namespace string) corev1client.PodInterface {
return &podsWrapper{PodInterface: c.CoreV1Interface.Pods(namespace), opts: c.opts}
}
func (c *coreWrapper) Secrets(namespace string) corev1client.SecretInterface {
return &secretsWrapper{SecretInterface: c.CoreV1Interface.Secrets(namespace), opts: c.opts}
}
type appsWrapper struct {
appsv1client.AppsV1Interface
opts *[]metav1.DeleteOptions
}
func (c *appsWrapper) Deployments(namespace string) appsv1client.DeploymentInterface {
return &deploymentsWrapper{DeploymentInterface: c.AppsV1Interface.Deployments(namespace), opts: c.opts}
}
type podsWrapper struct {
corev1client.PodInterface
opts *[]metav1.DeleteOptions
}
func (s *podsWrapper) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
*s.opts = append(*s.opts, opts)
return s.PodInterface.Delete(ctx, name, opts)
}
type secretsWrapper struct {
corev1client.SecretInterface
opts *[]metav1.DeleteOptions
}
func (s *secretsWrapper) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
*s.opts = append(*s.opts, opts)
return s.SecretInterface.Delete(ctx, name, opts)
}
type deploymentsWrapper struct {
appsv1client.DeploymentInterface
opts *[]metav1.DeleteOptions
}
func (s *deploymentsWrapper) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
*s.opts = append(*s.opts, opts)
return s.DeploymentInterface.Delete(ctx, name, opts)
}
func NewPreconditions(uid types.UID, rv string) metav1.DeleteOptions { func NewPreconditions(uid types.UID, rv string) metav1.DeleteOptions {
return metav1.DeleteOptions{ return metav1.DeleteOptions{
Preconditions: &metav1.Preconditions{ Preconditions: &metav1.Preconditions{

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package oidctestutil package oidctestutil
@ -76,12 +76,20 @@ type RevokeTokenArgs struct {
TokenType provider.RevocableTokenType TokenType provider.RevocableTokenType
} }
// ValidateTokenArgs is used to spy on calls to // ValidateTokenAndMergeWithUserInfoArgs is used to spy on calls to
// TestUpstreamOIDCIdentityProvider.ValidateTokenFunc(). // TestUpstreamOIDCIdentityProvider.ValidateTokenAndMergeWithUserInfoFunc().
type ValidateTokenArgs struct { type ValidateTokenAndMergeWithUserInfoArgs struct {
Ctx context.Context Ctx context.Context
Tok *oauth2.Token Tok *oauth2.Token
ExpectedIDTokenNonce nonce.Nonce ExpectedIDTokenNonce nonce.Nonce
RequireIDToken bool
RequireUserInfo bool
}
type ValidateRefreshArgs struct {
Ctx context.Context
Tok *oauth2.Token
StoredAttributes provider.StoredRefreshAttributes
} }
type TestUpstreamLDAPIdentityProvider struct { type TestUpstreamLDAPIdentityProvider struct {
@ -112,16 +120,16 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL {
return u.URL return u.URL
} }
func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error { func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.StoredRefreshAttributes) error {
if u.performRefreshArgs == nil { if u.performRefreshArgs == nil {
u.performRefreshArgs = make([]*PerformRefreshArgs, 0) u.performRefreshArgs = make([]*PerformRefreshArgs, 0)
} }
u.performRefreshCallCount++ u.performRefreshCallCount++
u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{ u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{
Ctx: ctx, Ctx: ctx,
DN: userDN, DN: storedRefreshAttributes.DN,
ExpectedUsername: expectedUsername, ExpectedUsername: storedRefreshAttributes.Username,
ExpectedSubject: expectedSubject, ExpectedSubject: storedRefreshAttributes.Subject,
}) })
if u.PerformRefreshErr != nil { if u.PerformRefreshErr != nil {
return u.PerformRefreshErr return u.PerformRefreshErr
@ -145,6 +153,7 @@ type TestUpstreamOIDCIdentityProvider struct {
ClientID string ClientID string
ResourceUID types.UID ResourceUID types.UID
AuthorizationURL url.URL AuthorizationURL url.URL
UserInfoURL bool
RevocationURL *url.URL RevocationURL *url.URL
UsernameClaim string UsernameClaim string
GroupsClaim string GroupsClaim string
@ -169,7 +178,7 @@ type TestUpstreamOIDCIdentityProvider struct {
RevokeTokenFunc func(ctx context.Context, refreshToken string, tokenType provider.RevocableTokenType) error RevokeTokenFunc func(ctx context.Context, refreshToken string, tokenType provider.RevocableTokenType) error
ValidateTokenFunc func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) ValidateTokenAndMergeWithUserInfoFunc func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error)
exchangeAuthcodeAndValidateTokensCallCount int exchangeAuthcodeAndValidateTokensCallCount int
exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs
@ -179,8 +188,8 @@ type TestUpstreamOIDCIdentityProvider struct {
performRefreshArgs []*PerformRefreshArgs performRefreshArgs []*PerformRefreshArgs
revokeTokenCallCount int revokeTokenCallCount int
revokeTokenArgs []*RevokeTokenArgs revokeTokenArgs []*RevokeTokenArgs
validateTokenCallCount int validateTokenAndMergeWithUserInfoCallCount int
validateTokenArgs []*ValidateTokenArgs validateTokenAndMergeWithUserInfoArgs []*ValidateTokenAndMergeWithUserInfoArgs
} }
var _ provider.UpstreamOIDCIdentityProviderI = &TestUpstreamOIDCIdentityProvider{} var _ provider.UpstreamOIDCIdentityProviderI = &TestUpstreamOIDCIdentityProvider{}
@ -205,6 +214,10 @@ func (u *TestUpstreamOIDCIdentityProvider) GetAuthorizationURL() *url.URL {
return &u.AuthorizationURL return &u.AuthorizationURL
} }
func (u *TestUpstreamOIDCIdentityProvider) HasUserInfoURL() bool {
return u.UserInfoURL
}
func (u *TestUpstreamOIDCIdentityProvider) GetRevocationURL() *url.URL { func (u *TestUpstreamOIDCIdentityProvider) GetRevocationURL() *url.URL {
return u.RevocationURL return u.RevocationURL
} }
@ -314,28 +327,30 @@ func (u *TestUpstreamOIDCIdentityProvider) RevokeTokenArgs(call int) *RevokeToke
return u.revokeTokenArgs[call] return u.revokeTokenArgs[call]
} }
func (u *TestUpstreamOIDCIdentityProvider) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenAndMergeWithUserInfo(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce, requireIDToken bool, requireUserInfo bool) (*oidctypes.Token, error) {
if u.validateTokenArgs == nil { if u.validateTokenAndMergeWithUserInfoArgs == nil {
u.validateTokenArgs = make([]*ValidateTokenArgs, 0) u.validateTokenAndMergeWithUserInfoArgs = make([]*ValidateTokenAndMergeWithUserInfoArgs, 0)
} }
u.validateTokenCallCount++ u.validateTokenAndMergeWithUserInfoCallCount++
u.validateTokenArgs = append(u.validateTokenArgs, &ValidateTokenArgs{ u.validateTokenAndMergeWithUserInfoArgs = append(u.validateTokenAndMergeWithUserInfoArgs, &ValidateTokenAndMergeWithUserInfoArgs{
Ctx: ctx, Ctx: ctx,
Tok: tok, Tok: tok,
ExpectedIDTokenNonce: expectedIDTokenNonce, ExpectedIDTokenNonce: expectedIDTokenNonce,
RequireIDToken: requireIDToken,
RequireUserInfo: requireUserInfo,
}) })
return u.ValidateTokenFunc(ctx, tok, expectedIDTokenNonce) return u.ValidateTokenAndMergeWithUserInfoFunc(ctx, tok, expectedIDTokenNonce)
} }
func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenCallCount() int { func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenAndMergeWithUserInfoCallCount() int {
return u.validateTokenCallCount return u.validateTokenAndMergeWithUserInfoCallCount
} }
func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenArgs(call int) *ValidateTokenArgs { func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenAndMergeWithUserInfoArgs(call int) *ValidateTokenAndMergeWithUserInfoArgs {
if u.validateTokenArgs == nil { if u.validateTokenAndMergeWithUserInfoArgs == nil {
u.validateTokenArgs = make([]*ValidateTokenArgs, 0) u.validateTokenAndMergeWithUserInfoArgs = make([]*ValidateTokenAndMergeWithUserInfoArgs, 0)
} }
return u.validateTokenArgs[call] return u.validateTokenAndMergeWithUserInfoArgs[call]
} }
type UpstreamIDPListerBuilder struct { type UpstreamIDPListerBuilder struct {
@ -520,25 +535,25 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *te
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToValidateToken( func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToValidateToken(
t *testing.T, t *testing.T,
expectedPerformedByUpstreamName string, expectedPerformedByUpstreamName string,
expectedArgs *ValidateTokenArgs, expectedArgs *ValidateTokenAndMergeWithUserInfoArgs,
) { ) {
t.Helper() t.Helper()
var actualArgs *ValidateTokenArgs var actualArgs *ValidateTokenAndMergeWithUserInfoArgs
var actualNameOfUpstreamWhichMadeCall string var actualNameOfUpstreamWhichMadeCall string
actualCallCountAcrossAllOIDCUpstreams := 0 actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstreamOIDC.validateTokenCallCount callCountOnThisUpstream := upstreamOIDC.validateTokenAndMergeWithUserInfoCallCount
actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream
if callCountOnThisUpstream == 1 { if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name
actualArgs = upstreamOIDC.validateTokenArgs[0] actualArgs = upstreamOIDC.validateTokenAndMergeWithUserInfoArgs[0]
} }
} }
require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams, require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams,
"should have been exactly one call to ValidateToken() by all OIDC upstreams", "should have been exactly one call to ValidateTokenAndMergeWithUserInfo() by all OIDC upstreams",
) )
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"ValidateToken() was called on the wrong OIDC upstream", "ValidateTokenAndMergeWithUserInfo() was called on the wrong OIDC upstream",
) )
require.Equal(t, expectedArgs, actualArgs) require.Equal(t, expectedArgs, actualArgs)
} }
@ -547,10 +562,10 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToValidateToken(t *tes
t.Helper() t.Helper()
actualCallCountAcrossAllOIDCUpstreams := 0 actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.validateTokenCallCount actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.validateTokenAndMergeWithUserInfoCallCount
} }
require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams, require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams,
"expected exactly zero calls to ValidateToken()", "expected exactly zero calls to ValidateTokenAndMergeWithUserInfo()",
) )
} }
@ -596,24 +611,26 @@ func NewUpstreamIDPListerBuilder() *UpstreamIDPListerBuilder {
} }
type TestUpstreamOIDCIdentityProviderBuilder struct { type TestUpstreamOIDCIdentityProviderBuilder struct {
name string name string
resourceUID types.UID resourceUID types.UID
clientID string clientID string
scopes []string scopes []string
idToken map[string]interface{} idToken map[string]interface{}
refreshToken *oidctypes.RefreshToken refreshToken *oidctypes.RefreshToken
usernameClaim string accessToken *oidctypes.AccessToken
groupsClaim string usernameClaim string
refreshedTokens *oauth2.Token groupsClaim string
validatedTokens *oidctypes.Token refreshedTokens *oauth2.Token
authorizationURL url.URL validatedAndMergedWithUserInfoTokens *oidctypes.Token
additionalAuthcodeParams map[string]string authorizationURL url.URL
allowPasswordGrant bool hasUserInfoURL bool
authcodeExchangeErr error additionalAuthcodeParams map[string]string
passwordGrantErr error allowPasswordGrant bool
performRefreshErr error authcodeExchangeErr error
revokeTokenErr error passwordGrantErr error
validateTokenErr error performRefreshErr error
revokeTokenErr error
validateTokenAndMergeWithUserInfoErr error
} }
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithName(value string) *TestUpstreamOIDCIdentityProviderBuilder { func (u *TestUpstreamOIDCIdentityProviderBuilder) WithName(value string) *TestUpstreamOIDCIdentityProviderBuilder {
@ -636,6 +653,16 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAuthorizationURL(value url
return u return u
} }
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithUserInfoURL() *TestUpstreamOIDCIdentityProviderBuilder {
u.hasUserInfoURL = true
return u
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutUserInfoURL() *TestUpstreamOIDCIdentityProviderBuilder {
u.hasUserInfoURL = false
return u
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAllowPasswordGrant(value bool) *TestUpstreamOIDCIdentityProviderBuilder { func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAllowPasswordGrant(value bool) *TestUpstreamOIDCIdentityProviderBuilder {
u.allowPasswordGrant = value u.allowPasswordGrant = value
return u return u
@ -699,6 +726,20 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutRefreshToken() *TestUps
return u return u
} }
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAccessToken(token string) *TestUpstreamOIDCIdentityProviderBuilder {
u.accessToken = &oidctypes.AccessToken{Token: token}
return u
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithEmptyAccessToken() *TestUpstreamOIDCIdentityProviderBuilder {
u.accessToken = &oidctypes.AccessToken{Token: ""}
return u
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutAccessToken() *TestUpstreamOIDCIdentityProviderBuilder {
u.accessToken = nil
return u
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithUpstreamAuthcodeExchangeError(err error) *TestUpstreamOIDCIdentityProviderBuilder { func (u *TestUpstreamOIDCIdentityProviderBuilder) WithUpstreamAuthcodeExchangeError(err error) *TestUpstreamOIDCIdentityProviderBuilder {
u.authcodeExchangeErr = err u.authcodeExchangeErr = err
return u return u
@ -719,13 +760,13 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithPerformRefreshError(err er
return u return u
} }
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidatedTokens(tokens *oidctypes.Token) *TestUpstreamOIDCIdentityProviderBuilder { func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidatedAndMergedWithUserInfoTokens(tokens *oidctypes.Token) *TestUpstreamOIDCIdentityProviderBuilder {
u.validatedTokens = tokens u.validatedAndMergedWithUserInfoTokens = tokens
return u return u
} }
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidateTokenError(err error) *TestUpstreamOIDCIdentityProviderBuilder { func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidateTokenAndMergeWithUserInfoError(err error) *TestUpstreamOIDCIdentityProviderBuilder {
u.validateTokenErr = err u.validateTokenAndMergeWithUserInfoErr = err
return u return u
} }
@ -744,18 +785,19 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdent
Scopes: u.scopes, Scopes: u.scopes,
AllowPasswordGrant: u.allowPasswordGrant, AllowPasswordGrant: u.allowPasswordGrant,
AuthorizationURL: u.authorizationURL, AuthorizationURL: u.authorizationURL,
UserInfoURL: u.hasUserInfoURL,
AdditionalAuthcodeParams: u.additionalAuthcodeParams, AdditionalAuthcodeParams: u.additionalAuthcodeParams,
ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
if u.authcodeExchangeErr != nil { if u.authcodeExchangeErr != nil {
return nil, u.authcodeExchangeErr return nil, u.authcodeExchangeErr
} }
return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}, RefreshToken: u.refreshToken}, nil return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}, RefreshToken: u.refreshToken, AccessToken: u.accessToken}, nil
}, },
PasswordCredentialsGrantAndValidateTokensFunc: func(ctx context.Context, username, password string) (*oidctypes.Token, error) { PasswordCredentialsGrantAndValidateTokensFunc: func(ctx context.Context, username, password string) (*oidctypes.Token, error) {
if u.passwordGrantErr != nil { if u.passwordGrantErr != nil {
return nil, u.passwordGrantErr return nil, u.passwordGrantErr
} }
return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}, RefreshToken: u.refreshToken}, nil return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}, RefreshToken: u.refreshToken, AccessToken: u.accessToken}, nil
}, },
PerformRefreshFunc: func(ctx context.Context, refreshToken string) (*oauth2.Token, error) { PerformRefreshFunc: func(ctx context.Context, refreshToken string) (*oauth2.Token, error) {
if u.performRefreshErr != nil { if u.performRefreshErr != nil {
@ -766,11 +808,11 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdent
RevokeTokenFunc: func(ctx context.Context, refreshToken string, tokenType provider.RevocableTokenType) error { RevokeTokenFunc: func(ctx context.Context, refreshToken string, tokenType provider.RevocableTokenType) error {
return u.revokeTokenErr return u.revokeTokenErr
}, },
ValidateTokenFunc: func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { ValidateTokenAndMergeWithUserInfoFunc: func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
if u.validateTokenErr != nil { if u.validateTokenAndMergeWithUserInfoErr != nil {
return nil, u.validateTokenErr return nil, u.validateTokenAndMergeWithUserInfoErr
} }
return u.validatedTokens, nil return u.validatedAndMergedWithUserInfoTokens, nil
}, },
} }
} }
@ -1003,7 +1045,7 @@ func validateAuthcodeStorage(
require.Empty(t, actualClaims.CodeHash) require.Empty(t, actualClaims.CodeHash)
require.Empty(t, actualClaims.AccessTokenHash) require.Empty(t, actualClaims.AccessTokenHash)
require.Empty(t, actualClaims.AuthenticationContextClassReference) require.Empty(t, actualClaims.AuthenticationContextClassReference)
require.Empty(t, actualClaims.AuthenticationMethodsReference) require.Empty(t, actualClaims.AuthenticationMethodsReferences)
// Check that the custom Pinniped session data matches. // Check that the custom Pinniped session data matches.
require.Equal(t, wantCustomSessionData, storedSessionFromAuthcode.Custom) require.Equal(t, wantCustomSessionData, storedSessionFromAuthcode.Custom)

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package testutil package testutil
@ -29,6 +29,8 @@ func NewFakePinnipedSession() *psession.PinnipedSession {
ProviderName: "fake-provider-name", ProviderName: "fake-provider-name",
OIDC: &psession.OIDCSessionData{ OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: "fake-upstream-refresh-token", UpstreamRefreshToken: "fake-upstream-refresh-token",
UpstreamSubject: "some-subject",
UpstreamIssuer: "some-issuer",
}, },
}, },
} }

View File

@ -0,0 +1,168 @@
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package testlogger
import (
"bytes"
"encoding/json"
"fmt"
"log"
"runtime"
"sort"
"github.com/go-logr/logr"
"github.com/go-logr/stdr"
)
// newStdLogger returns a logr.Logger that matches the legacy v0.4.0 stdr.New implementation.
// All unnecessary functionality has been stripped out. Avoid using this if possible.
func newStdLogger(std stdr.StdLogger) logr.Logger {
return logr.New(logger{
std: std,
prefix: "",
values: nil,
})
}
type logger struct {
std stdr.StdLogger
prefix string
values []interface{}
}
func (l logger) clone() logger {
out := l
l.values = copySlice(l.values)
return out
}
func copySlice(in []interface{}) []interface{} {
out := make([]interface{}, len(in))
copy(out, in)
return out
}
// Magic string for intermediate frames that we should ignore.
const autogeneratedFrameName = "<autogenerated>"
// Discover how many frames we need to climb to find the caller. This approach
// was suggested by Ian Lance Taylor of the Go team, so it *should* be safe
// enough (famous last words).
func framesToCaller() int {
// 1 is the immediate caller. 3 should be too many.
for i := 1; i < 3; i++ {
_, file, _, _ := runtime.Caller(i + 1) // +1 for this function's frame
if file != autogeneratedFrameName {
return i
}
}
return 1 // something went wrong, this is safe
}
func flatten(kvList ...interface{}) string {
keys := make([]string, 0, len(kvList))
vals := make(map[string]interface{}, len(kvList))
for i := 0; i < len(kvList); i += 2 {
k, ok := kvList[i].(string)
if !ok {
panic(fmt.Sprintf("key is not a string: %s", pretty(kvList[i])))
}
var v interface{}
if i+1 < len(kvList) {
v = kvList[i+1]
}
keys = append(keys, k)
vals[k] = v
}
sort.Strings(keys)
buf := bytes.Buffer{}
for i, k := range keys {
v := vals[k]
if i > 0 {
buf.WriteRune(' ')
}
buf.WriteString(pretty(k))
buf.WriteString("=")
buf.WriteString(pretty(v))
}
return buf.String()
}
func pretty(value interface{}) string {
jb, _ := json.Marshal(value)
return string(jb)
}
func (l logger) Info(level int, msg string, kvList ...interface{}) {
if l.Enabled(level) {
builtin := make([]interface{}, 0, 4)
builtin = append(builtin, "level", level, "msg", msg)
builtinStr := flatten(builtin...)
fixedStr := flatten(l.values...)
userStr := flatten(kvList...)
l.output(framesToCaller(), fmt.Sprintln(l.prefix, builtinStr, fixedStr, userStr))
}
}
func (l logger) Enabled(level int) bool {
return true
}
func (l logger) Error(err error, msg string, kvList ...interface{}) {
builtin := make([]interface{}, 0, 4)
builtin = append(builtin, "msg", msg)
builtinStr := flatten(builtin...)
var loggableErr interface{}
if err != nil {
loggableErr = err.Error()
}
errStr := flatten("error", loggableErr)
fixedStr := flatten(l.values...)
userStr := flatten(kvList...)
l.output(framesToCaller(), fmt.Sprintln(l.prefix, builtinStr, errStr, fixedStr, userStr))
}
func (l logger) output(calldepth int, s string) {
depth := calldepth + 2 // offset for this adapter
// ignore errors - what can we really do about them?
if l.std != nil {
_ = l.std.Output(depth, s)
} else {
_ = log.Output(depth, s)
}
}
func (l logger) V(level int) logr.LogSink {
return l.clone()
}
// WithName returns a new logr.Logger with the specified name appended. stdr
// uses '/' characters to separate name elements. Callers should not pass '/'
// in the provided name string, but this library does not actually enforce that.
func (l logger) WithName(name string) logr.LogSink {
new := l.clone()
if len(l.prefix) > 0 {
new.prefix = l.prefix + "/"
}
new.prefix += name
return new
}
// WithValues returns a new logr.Logger with the specified key-and-values
// saved.
func (l logger) WithValues(kvList ...interface{}) logr.LogSink {
new := l.clone()
new.values = append(new.values, kvList...)
return new
}
func (l logger) WithCallDepth(depth int) logr.LogSink {
return l.clone()
}
var _ logr.LogSink = logger{}
var _ logr.CallDepthLogSink = logger{}
func (l logger) Init(info logr.RuntimeInfo) {}

View File

@ -1,7 +1,7 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package testlogger implements a logr.Logger suitable for writing test assertions. // Package testlogger wraps logr.Logger to allow for writing test assertions.
package testlogger package testlogger
import ( import (
@ -17,20 +17,27 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// Logger implements logr.Logger in a way that captures logs for test assertions. // Logger wraps logr.Logger in a way that captures logs for test assertions.
type Logger struct { type Logger struct {
logr.Logger Logger logr.Logger
t *testing.T t *testing.T
buffer syncBuffer buffer syncBuffer
} }
// New returns a new test Logger. // New returns a new test Logger. Use this for all new tests.
func New(t *testing.T) *Logger { func New(t *testing.T) *Logger {
res := Logger{t: t} res := Logger{t: t}
res.Logger = stdr.New(log.New(&res.buffer, "", 0)) res.Logger = stdr.New(log.New(&res.buffer, "", 0))
return &res return &res
} }
// Deprecated: NewLegacy returns a new test Logger. Use this for old tests if necessary.
func NewLegacy(t *testing.T) *Logger {
res := New(t)
res.Logger = newStdLogger(log.New(&res.buffer, "", 0))
return res
}
// Lines returns the lines written to the test logger. // Lines returns the lines written to the test logger.
func (l *Logger) Lines() []string { func (l *Logger) Lines() []string {
l.t.Helper() l.t.Helper()

View File

@ -1,4 +1,4 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package testutil package testutil
@ -17,7 +17,7 @@ type TranscriptLogger struct {
transcript []TranscriptLogMessage transcript []TranscriptLogMessage
} }
var _ logr.Logger = &TranscriptLogger{} var _ logr.LogSink = &TranscriptLogger{}
type TranscriptLogMessage struct { type TranscriptLogMessage struct {
Level string Level string
@ -36,7 +36,7 @@ func (log *TranscriptLogger) Transcript() []TranscriptLogMessage {
return result return result
} }
func (log *TranscriptLogger) Info(msg string, keysAndValues ...interface{}) { func (log *TranscriptLogger) Info(level int, msg string, keysAndValues ...interface{}) {
log.lock.Lock() log.lock.Lock()
defer log.lock.Unlock() defer log.lock.Unlock()
log.transcript = append(log.transcript, TranscriptLogMessage{ log.transcript = append(log.transcript, TranscriptLogMessage{
@ -54,18 +54,20 @@ func (log *TranscriptLogger) Error(_ error, msg string, _ ...interface{}) {
}) })
} }
func (*TranscriptLogger) Enabled() bool { func (log *TranscriptLogger) Enabled(level int) bool {
return true return true
} }
func (log *TranscriptLogger) V(_ int) logr.Logger { func (log *TranscriptLogger) V(_ int) logr.LogSink {
return log return log
} }
func (log *TranscriptLogger) WithName(_ string) logr.Logger { func (log *TranscriptLogger) WithName(_ string) logr.LogSink {
return log return log
} }
func (log *TranscriptLogger) WithValues(_ ...interface{}) logr.Logger { func (log *TranscriptLogger) WithValues(_ ...interface{}) logr.LogSink {
return log return log
} }
func (log *TranscriptLogger) Init(info logr.RuntimeInfo) {}

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package upstreamldap implements an abstraction of upstream LDAP IDP interactions. // Package upstreamldap implements an abstraction of upstream LDAP IDP interactions.
@ -13,13 +13,11 @@ import (
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"regexp"
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/utils/trace" "k8s.io/utils/trace"
@ -39,7 +37,6 @@ const (
groupSearchPageSize = uint32(250) groupSearchPageSize = uint32(250)
defaultLDAPPort = uint16(389) defaultLDAPPort = uint16(389)
defaultLDAPSPort = uint16(636) defaultLDAPSPort = uint16(636)
sAMAccountNameAttribute = "sAMAccountName"
) )
// Conn abstracts the upstream LDAP communication protocol (mostly for testing). // Conn abstracts the upstream LDAP communication protocol (mostly for testing).
@ -119,6 +116,9 @@ type ProviderConfig struct {
// GroupNameMappingOverrides are the mappings between an attribute name and a way to parse it as a group // GroupNameMappingOverrides are the mappings between an attribute name and a way to parse it as a group
// name when it comes out of LDAP. // name when it comes out of LDAP.
GroupAttributeParsingOverrides map[string]func(*ldap.Entry) (string, error) GroupAttributeParsingOverrides map[string]func(*ldap.Entry) (string, error)
// RefreshAttributeChecks are extra checks that attributes in a refresh response are as expected.
RefreshAttributeChecks map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error
} }
// UserSearchConfig contains information about how to search for users in the upstream LDAP IDP. // UserSearchConfig contains information about how to search for users in the upstream LDAP IDP.
@ -170,9 +170,11 @@ func (p *Provider) GetConfig() ProviderConfig {
return p.c return p.c
} }
func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error { func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.StoredRefreshAttributes) error {
t := trace.FromContext(ctx).Nest("slow ldap refresh attempt", trace.Field{Key: "providerName", Value: p.GetName()}) t := trace.FromContext(ctx).Nest("slow ldap refresh attempt", trace.Field{Key: "providerName", Value: p.GetName()})
defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches
userDN := storedRefreshAttributes.DN
searchResult, err := p.performRefresh(ctx, userDN) searchResult, err := p.performRefresh(ctx, userDN)
if err != nil { if err != nil {
p.traceRefreshFailure(t, err) p.traceRefreshFailure(t, err)
@ -182,23 +184,23 @@ func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername,
// if any more or less than one entry, error. // if any more or less than one entry, error.
// we don't need to worry about logging this because we know it's a dn. // we don't need to worry about logging this because we know it's a dn.
if len(searchResult.Entries) != 1 { if len(searchResult.Entries) != 1 {
return fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`, return fmt.Errorf(`searching for user %q resulted in %d search results, but expected 1 result`,
userDN, len(searchResult.Entries), userDN, len(searchResult.Entries),
) )
} }
userEntry := searchResult.Entries[0] userEntry := searchResult.Entries[0]
if len(userEntry.DN) == 0 { if len(userEntry.DN) == 0 {
return fmt.Errorf(`searching for user with original DN "%s" resulted in search result without DN`, userDN) return fmt.Errorf(`searching for user with original DN %q resulted in search result without DN`, userDN)
} }
newUsername, err := p.getSearchResultAttributeValue(p.c.UserSearch.UsernameAttribute, userEntry, userDN) newUsername, err := p.getSearchResultAttributeValue(p.c.UserSearch.UsernameAttribute, userEntry, userDN)
if err != nil { if err != nil {
return err return err
} }
if newUsername != expectedUsername { if newUsername != storedRefreshAttributes.Username {
return fmt.Errorf(`searching for user "%s" returned a different username than the previous value. expected: "%s", actual: "%s"`, return fmt.Errorf(`searching for user %q returned a different username than the previous value. expected: %q, actual: %q`,
userDN, expectedUsername, newUsername, userDN, storedRefreshAttributes.Username, newUsername,
) )
} }
@ -207,10 +209,15 @@ func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername,
return err return err
} }
newSubject := downstreamsession.DownstreamLDAPSubject(newUID, *p.GetURL()) newSubject := downstreamsession.DownstreamLDAPSubject(newUID, *p.GetURL())
if newSubject != expectedSubject { if newSubject != storedRefreshAttributes.Subject {
return fmt.Errorf(`searching for user "%s" produced a different subject than the previous value. expected: "%s", actual: "%s"`, userDN, expectedSubject, newSubject) return fmt.Errorf(`searching for user %q produced a different subject than the previous value. expected: %q, actual: %q`, userDN, storedRefreshAttributes.Subject, newSubject)
}
for attribute, validateFunc := range p.c.RefreshAttributeChecks {
err = validateFunc(userEntry, storedRefreshAttributes)
if err != nil {
return fmt.Errorf(`validation for attribute %q failed during upstream refresh: %w`, attribute, err)
}
} }
// we checked that the user still exists and their information is the same, so just return. // we checked that the user still exists and their information is the same, so just return.
return nil return nil
} }
@ -220,19 +227,19 @@ func (p *Provider) performRefresh(ctx context.Context, userDN string) (*ldap.Sea
conn, err := p.dial(ctx) conn, err := p.dial(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) return nil, fmt.Errorf(`error dialing host %q: %w`, p.c.Host, err)
} }
defer conn.Close() defer conn.Close()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword) err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
if err != nil { if err != nil {
return nil, fmt.Errorf(`error binding as "%s" before user search: %w`, p.c.BindUsername, err) return nil, fmt.Errorf(`error binding as %q before user search: %w`, p.c.BindUsername, err)
} }
searchResult, err := conn.Search(search) searchResult, err := conn.Search(search)
if err != nil { if err != nil {
return nil, fmt.Errorf(`error searching for user "%s": %w`, userDN, err) return nil, fmt.Errorf(`error searching for user %q: %w`, userDN, err)
} }
return searchResult, nil return searchResult, nil
} }
@ -362,13 +369,13 @@ func (p *Provider) TestConnection(ctx context.Context) error {
conn, err := p.dial(ctx) conn, err := p.dial(ctx)
if err != nil { if err != nil {
return fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) return fmt.Errorf(`error dialing host %q: %w`, p.c.Host, err)
} }
defer conn.Close() defer conn.Close()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword) err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
if err != nil { if err != nil {
return fmt.Errorf(`error binding as "%s": %w`, p.c.BindUsername, err) return fmt.Errorf(`error binding as %q: %w`, p.c.BindUsername, err)
} }
return nil return nil
@ -413,14 +420,14 @@ func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bi
conn, err := p.dial(ctx) conn, err := p.dial(ctx)
if err != nil { if err != nil {
p.traceAuthFailure(t, err) p.traceAuthFailure(t, err)
return nil, false, fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) return nil, false, fmt.Errorf(`error dialing host %q: %w`, p.c.Host, err)
} }
defer conn.Close() defer conn.Close()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword) err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
if err != nil { if err != nil {
p.traceAuthFailure(t, err) p.traceAuthFailure(t, err)
return nil, false, fmt.Errorf(`error binding as "%s" before user search: %w`, p.c.BindUsername, err) return nil, false, fmt.Errorf(`error binding as %q before user search: %w`, p.c.BindUsername, err)
} }
response, err := p.searchAndBindUser(conn, username, bindFunc) response, err := p.searchAndBindUser(conn, username, bindFunc)
@ -448,7 +455,7 @@ func (p *Provider) searchGroupsForUserDN(conn Conn, userDN string) ([]string, er
groupAttributeName = distinguishedNameAttributeName groupAttributeName = distinguishedNameAttributeName
} }
groups := []string{} var groups []string
entries: entries:
for _, groupEntry := range searchResult.Entries { for _, groupEntry := range searchResult.Entries {
if len(groupEntry.DN) == 0 { if len(groupEntry.DN) == 0 {
@ -488,14 +495,14 @@ func (p *Provider) SearchForDefaultNamingContext(ctx context.Context) (string, e
conn, err := p.dial(ctx) conn, err := p.dial(ctx)
if err != nil { if err != nil {
p.traceSearchBaseDiscoveryFailure(t, err) p.traceSearchBaseDiscoveryFailure(t, err)
return "", fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) return "", fmt.Errorf(`error dialing host %q: %w`, p.c.Host, err)
} }
defer conn.Close() defer conn.Close()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword) err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
if err != nil { if err != nil {
p.traceSearchBaseDiscoveryFailure(t, err) p.traceSearchBaseDiscoveryFailure(t, err)
return "", fmt.Errorf(`error binding as "%s" before querying for defaultNamingContext: %w`, p.c.BindUsername, err) return "", fmt.Errorf(`error binding as %q before querying for defaultNamingContext: %w`, p.c.BindUsername, err)
} }
searchResult, err := conn.Search(p.defaultNamingContextRequest()) searchResult, err := conn.Search(p.defaultNamingContextRequest())
@ -539,13 +546,13 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
// At this point, we have matched at least one entry, so we can be confident that the username is not actually // At this point, we have matched at least one entry, so we can be confident that the username is not actually
// someone's password mistakenly entered into the username field, so we can log it without concern. // someone's password mistakenly entered into the username field, so we can log it without concern.
if len(searchResult.Entries) > 1 { if len(searchResult.Entries) > 1 {
return nil, fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`, return nil, fmt.Errorf(`searching for user %q resulted in %d search results, but expected 1 result`,
username, len(searchResult.Entries), username, len(searchResult.Entries),
) )
} }
userEntry := searchResult.Entries[0] userEntry := searchResult.Entries[0]
if len(userEntry.DN) == 0 { if len(userEntry.DN) == 0 {
return nil, fmt.Errorf(`searching for user "%s" resulted in search result without DN`, username) return nil, fmt.Errorf(`searching for user %q resulted in search result without DN`, username)
} }
mappedUsername, err := p.getSearchResultAttributeValue(p.c.UserSearch.UsernameAttribute, userEntry, username) mappedUsername, err := p.getSearchResultAttributeValue(p.c.UserSearch.UsernameAttribute, userEntry, username)
@ -560,7 +567,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
return nil, err return nil, err
} }
mappedGroupNames := []string{} var mappedGroupNames []string
if len(p.c.GroupSearch.Base) > 0 { if len(p.c.GroupSearch.Base) > 0 {
mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN) mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN)
if err != nil { if err != nil {
@ -569,6 +576,15 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
} }
sort.Strings(mappedGroupNames) sort.Strings(mappedGroupNames)
mappedRefreshAttributes := make(map[string]string)
for k := range p.c.RefreshAttributeChecks {
mappedVal, err := p.getSearchResultAttributeRawValueEncoded(k, userEntry, username)
if err != nil {
return nil, err
}
mappedRefreshAttributes[k] = mappedVal
}
// Caution: Note that any other LDAP commands after this bind will be run as this user instead of as the configured BindUsername! // Caution: Note that any other LDAP commands after this bind will be run as this user instead of as the configured BindUsername!
err = bindFunc(conn, userEntry.DN) err = bindFunc(conn, userEntry.DN)
if err != nil { if err != nil {
@ -578,7 +594,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
if errors.As(err, &ldapErr) && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials { if errors.As(err, &ldapErr) && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf(`error binding for user "%s" using provided password against DN "%s": %w`, username, userEntry.DN, err) return nil, fmt.Errorf(`error binding for user %q using provided password against DN %q: %w`, username, userEntry.DN, err)
} }
if len(mappedUsername) == 0 || len(mappedUID) == 0 { if len(mappedUsername) == 0 || len(mappedUID) == 0 {
@ -592,7 +608,8 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
UID: mappedUID, UID: mappedUID,
Groups: mappedGroupNames, Groups: mappedGroupNames,
}, },
DN: userEntry.DN, DN: userEntry.DN,
ExtraRefreshAttributes: mappedRefreshAttributes,
} }
return response, nil return response, nil
@ -658,13 +675,16 @@ func (p *Provider) refreshUserSearchRequest(dn string) *ldap.SearchRequest {
} }
func (p *Provider) userSearchRequestedAttributes() []string { func (p *Provider) userSearchRequestedAttributes() []string {
attributes := []string{} attributes := make([]string, 0, len(p.c.RefreshAttributeChecks)+2)
if p.c.UserSearch.UsernameAttribute != distinguishedNameAttributeName { if p.c.UserSearch.UsernameAttribute != distinguishedNameAttributeName {
attributes = append(attributes, p.c.UserSearch.UsernameAttribute) attributes = append(attributes, p.c.UserSearch.UsernameAttribute)
} }
if p.c.UserSearch.UIDAttribute != distinguishedNameAttributeName { if p.c.UserSearch.UIDAttribute != distinguishedNameAttributeName {
attributes = append(attributes, p.c.UserSearch.UIDAttribute) attributes = append(attributes, p.c.UserSearch.UIDAttribute)
} }
for k := range p.c.RefreshAttributeChecks {
attributes = append(attributes, k)
}
return attributes return attributes
} }
@ -716,14 +736,14 @@ func (p *Provider) getSearchResultAttributeRawValueEncoded(attributeName string,
attributeValues := entry.GetRawAttributeValues(attributeName) attributeValues := entry.GetRawAttributeValues(attributeName)
if len(attributeValues) != 1 { if len(attributeValues) != 1 {
return "", fmt.Errorf(`found %d values for attribute "%s" while searching for user "%s", but expected 1 result`, return "", fmt.Errorf(`found %d values for attribute %q while searching for user %q, but expected 1 result`,
len(attributeValues), attributeName, username, len(attributeValues), attributeName, username,
) )
} }
attributeValue := attributeValues[0] attributeValue := attributeValues[0]
if len(attributeValue) == 0 { if len(attributeValue) == 0 {
return "", fmt.Errorf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, return "", fmt.Errorf(`found empty value for attribute %q while searching for user %q, but expected value to be non-empty`,
attributeName, username, attributeName, username,
) )
} }
@ -743,14 +763,14 @@ func (p *Provider) getSearchResultAttributeValue(attributeName string, entry *ld
attributeValues := entry.GetAttributeValues(attributeName) attributeValues := entry.GetAttributeValues(attributeName)
if len(attributeValues) != 1 { if len(attributeValues) != 1 {
return "", fmt.Errorf(`found %d values for attribute "%s" while searching for user "%s", but expected 1 result`, return "", fmt.Errorf(`found %d values for attribute %q while searching for user %q, but expected 1 result`,
len(attributeValues), attributeName, username, len(attributeValues), attributeName, username,
) )
} }
attributeValue := attributeValues[0] attributeValue := attributeValues[0]
if len(attributeValue) == 0 { if len(attributeValue) == 0 {
return "", fmt.Errorf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, return "", fmt.Errorf(`found empty value for attribute %q while searching for user %q, but expected value to be non-empty`,
attributeName, username, attributeName, username,
) )
} }
@ -782,57 +802,18 @@ func (p *Provider) traceRefreshFailure(t *trace.Trace, err error) {
) )
} }
func MicrosoftUUIDFromBinary(attributeName string) func(entry *ldap.Entry) (string, error) { func AttributeUnchangedSinceLogin(attribute string) func(*ldap.Entry, provider.StoredRefreshAttributes) error {
// validation has already been done so we can just get the attribute... return func(entry *ldap.Entry, storedAttributes provider.StoredRefreshAttributes) error {
return func(entry *ldap.Entry) (string, error) { prevAttributeValue := storedAttributes.AdditionalAttributes[attribute]
binaryUUID := entry.GetRawAttributeValue(attributeName) newValues := entry.GetRawAttributeValues(attribute)
return microsoftUUIDFromBinary(binaryUUID)
if len(newValues) != 1 {
return fmt.Errorf(`expected to find 1 value for %q attribute, but found %d`, attribute, len(newValues))
}
encodedNewValue := base64.RawURLEncoding.EncodeToString(newValues[0])
if prevAttributeValue != encodedNewValue {
return fmt.Errorf(`value for attribute %q has changed since initial value at login`, attribute)
}
return nil
} }
} }
func microsoftUUIDFromBinary(binaryUUID []byte) (string, error) {
uuidVal, err := uuid.FromBytes(binaryUUID) // start out with the RFC4122 version
if err != nil {
return "", err
}
// then swap it because AD stores the first 3 fields little-endian rather than the expected
// big-endian.
uuidVal[0], uuidVal[1], uuidVal[2], uuidVal[3] = uuidVal[3], uuidVal[2], uuidVal[1], uuidVal[0]
uuidVal[4], uuidVal[5] = uuidVal[5], uuidVal[4]
uuidVal[6], uuidVal[7] = uuidVal[7], uuidVal[6]
return uuidVal.String(), nil
}
func GroupSAMAccountNameWithDomainSuffix(entry *ldap.Entry) (string, error) {
sAMAccountNameAttributeValues := entry.GetAttributeValues(sAMAccountNameAttribute)
if len(sAMAccountNameAttributeValues) != 1 {
return "", fmt.Errorf(`found %d values for attribute "%s", but expected 1 result`,
len(sAMAccountNameAttributeValues), sAMAccountNameAttribute,
)
}
sAMAccountName := sAMAccountNameAttributeValues[0]
if len(sAMAccountName) == 0 {
return "", fmt.Errorf(`found empty value for attribute "%s", but expected value to be non-empty`,
sAMAccountNameAttribute,
)
}
distinguishedName := entry.DN
domain, err := getDomainFromDistinguishedName(distinguishedName)
if err != nil {
return "", err
}
return sAMAccountName + "@" + domain, nil
}
var domainComponentsRegexp = regexp.MustCompile(",DC=|,dc=")
func getDomainFromDistinguishedName(distinguishedName string) (string, error) {
domainComponents := domainComponentsRegexp.Split(distinguishedName, -1)
if len(domainComponents) == 1 {
return "", fmt.Errorf("did not find domain components in group dn: %s", distinguishedName)
}
return strings.Join(domainComponents[1:], "."), nil
}

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package upstreamldap package upstreamldap
@ -26,6 +26,7 @@ import (
"go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/crypto/ptls"
"go.pinniped.dev/internal/endpointaddr" "go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/mocks/mockldapconn" "go.pinniped.dev/internal/mocks/mockldapconn"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/tlsserver" "go.pinniped.dev/internal/testutil/tlsserver"
) )
@ -154,16 +155,17 @@ func TestEndUserAuthentication(t *testing.T) {
} }
// The auth response which matches the exampleUserSearchResult and exampleGroupSearchResult. // The auth response which matches the exampleUserSearchResult and exampleGroupSearchResult.
expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticators.Response { expectedAuthResponse := func(editFunc func(r *authenticators.Response)) *authenticators.Response {
u := &user.DefaultInfo{ u := &user.DefaultInfo{
Name: testUserSearchResultUsernameAttributeValue, Name: testUserSearchResultUsernameAttributeValue,
UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2},
} }
response := &authenticators.Response{User: u, DN: testUserSearchResultDNValue, ExtraRefreshAttributes: map[string]string{}}
if editFunc != nil { if editFunc != nil {
editFunc(u) editFunc(response)
} }
return &authenticators.Response{User: u, DN: testUserSearchResultDNValue} return response
} }
tests := []struct { tests := []struct {
@ -250,8 +252,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) { bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
r.Groups = []string{} info := r.User.(*user.DefaultInfo)
info.Groups = nil
}), }),
}, },
{ {
@ -282,8 +285,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) { bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
r.Name = testUserSearchResultDNValue info := r.User.(*user.DefaultInfo)
info.Name = testUserSearchResultDNValue
}), }),
}, },
{ {
@ -314,8 +318,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) { bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
r.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue)) info := r.User.(*user.DefaultInfo)
info.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue))
}), }),
}, },
{ {
@ -337,8 +342,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) { bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} info := r.User.(*user.DefaultInfo)
info.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2}
}), }),
}, },
{ {
@ -360,8 +366,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) { bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} info := r.User.(*user.DefaultInfo)
info.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2}
}), }),
}, },
{ {
@ -507,33 +514,37 @@ func TestEndUserAuthentication(t *testing.T) {
UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
Groups: []string{"a", "b", "c"}, Groups: []string{"a", "b", "c"},
}, },
DN: testUserSearchResultDNValue, DN: testUserSearchResultDNValue,
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
name: "override UID parsing to work with microsoft style objectGUIDs", name: "requesting additional refresh related attributes",
username: testUpstreamUsername, username: testUpstreamUsername,
password: testUpstreamPassword, password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) { providerConfig: providerConfig(func(p *ProviderConfig) {
p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){ p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{
"objectGUID": MicrosoftUUIDFromBinary("objectGUID"), "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error {
return nil
},
} }
p.UserSearch.UIDAttribute = "objectGUID"
}), }),
searchMocks: func(conn *mockldapconn.MockConn) { searchMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) {
r.Attributes = []string{testUserSearchUsernameAttribute, "objectGUID"} r.Attributes = append(r.Attributes, "some-attribute-to-check-during-refresh")
})).Return(&ldap.SearchResult{ })).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{ Entries: []*ldap.Entry{
{ {
DN: testUserSearchResultDNValue, DN: testUserSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{ Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}),
ldap.NewEntryAttribute("objectGUID", []string{"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16"}), ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}),
ldap.NewEntryAttribute("some-attribute-to-check-during-refresh", []string{"some-attribute-value"}),
}, },
}, },
}}, nil).Times(1) },
}, nil).Times(1)
conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize).
Return(exampleGroupSearchResult, nil).Times(1) Return(exampleGroupSearchResult, nil).Times(1)
conn.EXPECT().Close().Times(1) conn.EXPECT().Close().Times(1)
@ -541,172 +552,31 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) { bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
r.UID = "04030201-0605-0807-0910-111213141516" r.ExtraRefreshAttributes = map[string]string{"some-attribute-to-check-during-refresh": "c29tZS1hdHRyaWJ1dGUtdmFsdWU"}
}), }),
}, },
{ {
name: "override UID parsing when the attribute name doesn't match what's returned does default parsing", name: "requesting additional refresh related attributes, but they aren't returned",
username: testUpstreamUsername, username: testUpstreamUsername,
password: testUpstreamPassword, password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) { providerConfig: providerConfig(func(p *ProviderConfig) {
p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){ p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{
"objectGUID": MicrosoftUUIDFromBinary("objectGUID"), "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error {
return nil
},
} }
}), }),
searchMocks: func(conn *mockldapconn.MockConn) { searchMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) {
r.Attributes = append(r.Attributes, "some-attribute-to-check-during-refresh")
})).Return(exampleUserSearchResult, nil).Times(1)
conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize).
Return(exampleGroupSearchResult, nil).Times(1) Return(exampleGroupSearchResult, nil).Times(1)
conn.EXPECT().Close().Times(1) conn.EXPECT().Close().Times(1)
}, },
bindEndUserMocks: func(conn *mockldapconn.MockConn) { wantError: "found 0 values for attribute \"some-attribute-to-check-during-refresh\" while searching for user \"some-upstream-username\", but expected 1 result",
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(nil),
},
{
name: "override group parsing to create new group names",
username: testUpstreamUsername,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.GroupSearch.GroupNameAttribute = "sAMAccountName"
p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){
"sAMAccountName": GroupSAMAccountNameWithDomainSuffix,
}
}),
searchMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1)
conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) {
r.Attributes = []string{"sAMAccountName"}
}), expectedGroupSearchPageSize).
Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: "CN=Mammals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}),
},
},
{
DN: "CN=Animals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Animals"}),
},
},
},
Referrals: []string{}, // note that we are not following referrals at this time
Controls: []ldap.Control{},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.Groups = []string{"Animals@activedirectory.mycompany.example.com", "Mammals@activedirectory.mycompany.example.com"}
}),
},
{
name: "override group parsing when domain can't be determined from dn",
username: testUpstreamUsername,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.GroupSearch.GroupNameAttribute = "sAMAccountName"
p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){
"sAMAccountName": GroupSAMAccountNameWithDomainSuffix,
}
}),
searchMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1)
conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) {
r.Attributes = []string{"sAMAccountName"}
}), expectedGroupSearchPageSize).
Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: "no-domain-components",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}),
},
},
{
DN: "CN=Animals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Animals"}),
},
},
},
Referrals: []string{}, // note that we are not following referrals at this time
Controls: []ldap.Control{},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantError: "error finding groups for user some-upstream-user-dn: did not find domain components in group dn: no-domain-components",
},
{
name: "override group parsing when entry has multiple values for attribute",
username: testUpstreamUsername,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.GroupSearch.GroupNameAttribute = "sAMAccountName"
p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){
"sAMAccountName": GroupSAMAccountNameWithDomainSuffix,
}
}),
searchMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1)
conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) {
r.Attributes = []string{"sAMAccountName"}
}), expectedGroupSearchPageSize).
Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: "no-domain-components",
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals", "Eukaryotes"}),
},
},
},
Referrals: []string{}, // note that we are not following referrals at this time
Controls: []ldap.Control{},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantError: "error finding groups for user some-upstream-user-dn: found 2 values for attribute \"sAMAccountName\", but expected 1 result",
}, {
name: "override group parsing when entry has no values for attribute",
username: testUpstreamUsername,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.GroupSearch.GroupNameAttribute = "sAMAccountName"
p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){
"sAMAccountName": GroupSAMAccountNameWithDomainSuffix,
}
}),
searchMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1)
conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) {
r.Attributes = []string{"sAMAccountName"}
}), expectedGroupSearchPageSize).
Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: "no-domain-components",
Attributes: []*ldap.EntryAttribute{},
},
},
Referrals: []string{}, // note that we are not following referrals at this time
Controls: []ldap.Control{},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantError: "error finding groups for user some-upstream-user-dn: found 0 values for attribute \"sAMAccountName\", but expected 1 result",
}, },
{ {
name: "when dial fails", name: "when dial fails",
@ -1162,9 +1032,9 @@ func TestEndUserAuthentication(t *testing.T) {
return conn, nil return conn, nil
}) })
provider := New(*tt.providerConfig) ldapProvider := New(*tt.providerConfig)
authResponse, authenticated, err := provider.AuthenticateUser(context.Background(), tt.username, tt.password) authResponse, authenticated, err := ldapProvider.AuthenticateUser(context.Background(), tt.username, tt.password)
require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) require.Equal(t, !tt.wantToSkipDial, dialWasAttempted)
switch { switch {
case tt.wantError != "": case tt.wantError != "":
@ -1196,7 +1066,7 @@ func TestEndUserAuthentication(t *testing.T) {
} }
// Skip tt.bindEndUserMocks since DryRunAuthenticateUser() never binds as the end user. // Skip tt.bindEndUserMocks since DryRunAuthenticateUser() never binds as the end user.
authResponse, authenticated, err = provider.DryRunAuthenticateUser(context.Background(), tt.username) authResponse, authenticated, err = ldapProvider.DryRunAuthenticateUser(context.Background(), tt.username)
require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) require.Equal(t, !tt.wantToSkipDial, dialWasAttempted)
switch { switch {
case tt.wantError != "": case tt.wantError != "":
@ -1217,6 +1087,7 @@ func TestEndUserAuthentication(t *testing.T) {
} }
func TestUpstreamRefresh(t *testing.T) { func TestUpstreamRefresh(t *testing.T) {
pwdLastSetAttribute := "pwdLastSet"
expectedUserSearch := &ldap.SearchRequest{ expectedUserSearch := &ldap.SearchRequest{
BaseDN: testUserSearchResultDNValue, BaseDN: testUserSearchResultDNValue,
Scope: ldap.ScopeBaseObject, Scope: ldap.ScopeBaseObject,
@ -1225,7 +1096,7 @@ func TestUpstreamRefresh(t *testing.T) {
TimeLimit: 90, TimeLimit: 90,
TypesOnly: false, TypesOnly: false,
Filter: "(objectClass=*)", Filter: "(objectClass=*)",
Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute}, Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, pwdLastSetAttribute},
Controls: nil, // don't need paging because we set the SizeLimit so small Controls: nil, // don't need paging because we set the SizeLimit so small
} }
@ -1242,6 +1113,11 @@ func TestUpstreamRefresh(t *testing.T) {
Name: testUserSearchUIDAttribute, Name: testUserSearchUIDAttribute,
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
}, },
{
Name: pwdLastSetAttribute,
Values: []string{"132801740800000000"},
ByteValues: [][]byte{[]byte("132801740800000000")},
},
}, },
}, },
}, },
@ -1259,6 +1135,9 @@ func TestUpstreamRefresh(t *testing.T) {
UIDAttribute: testUserSearchUIDAttribute, UIDAttribute: testUserSearchUIDAttribute,
UsernameAttribute: testUserSearchUsernameAttribute, UsernameAttribute: testUserSearchUsernameAttribute,
}, },
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
pwdLastSetAttribute: AttributeUnchangedSinceLogin(pwdLastSetAttribute),
},
} }
tests := []struct { tests := []struct {
@ -1512,6 +1391,36 @@ func TestUpstreamRefresh(t *testing.T) {
}, },
wantErr: "found 2 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result", wantErr: "found 2 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
}, },
{
name: "search result has a changed pwdLastSet value",
providerConfig: providerConfig,
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testUserSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
{
Name: testUserSearchUsernameAttribute,
Values: []string{testUserSearchResultUsernameAttributeValue},
},
{
Name: testUserSearchUIDAttribute,
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
},
{
Name: pwdLastSetAttribute,
ByteValues: [][]byte{[]byte("132801740800000001")},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "validation for attribute \"pwdLastSet\" failed during upstream refresh: value for attribute \"pwdLastSet\" has changed since initial value at login",
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -1536,9 +1445,15 @@ func TestUpstreamRefresh(t *testing.T) {
return conn, nil return conn, nil
}) })
provider := New(*providerConfig) initialPwdLastSetEncoded := base64.RawURLEncoding.EncodeToString([]byte("132801740800000000"))
ldapProvider := New(*providerConfig)
subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU" subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU"
err := provider.PerformRefresh(context.Background(), testUserSearchResultDNValue, testUserSearchResultUsernameAttributeValue, subject) err := ldapProvider.PerformRefresh(context.Background(), provider.StoredRefreshAttributes{
Username: testUserSearchResultUsernameAttributeValue,
Subject: subject,
DN: testUserSearchResultDNValue,
AdditionalAttributes: map[string]string{pwdLastSetAttribute: initialPwdLastSetEncoded},
})
if tt.wantErr != "" { if tt.wantErr != "" {
require.Error(t, err) require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error()) require.Equal(t, tt.wantErr, err.Error())
@ -1846,73 +1761,76 @@ func TestRealTLSDialing(t *testing.T) {
} }
} }
func TestGetMicrosoftFormattedUUID(t *testing.T) { func TestAttributeUnchangedSinceLogin(t *testing.T) {
initialVal := "some-attribute-value"
changedVal := "some-different-attribute-value"
attributeName := "some-attribute-name"
tests := []struct { tests := []struct {
name string name string
binaryUUID []byte entry *ldap.Entry
wantString string wantResult bool
wantErr string wantErr string
}{ }{
{ {
name: "happy path", name: "happy path where value has not changed since login",
binaryUUID: []byte("\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16"), entry: &ldap.Entry{
wantString: "04030201-0605-0807-0910-111213141516", DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: attributeName,
Values: []string{initialVal},
ByteValues: [][]byte{[]byte(initialVal)},
},
},
},
}, },
{ {
name: "not the right length", name: "password has been reset since login",
binaryUUID: []byte("2\xf8\xb0\xaa\xb6V\xb1D\x8b(\xee"), entry: &ldap.Entry{
wantErr: "invalid UUID (got 11 bytes)", DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: attributeName,
Values: []string{changedVal},
ByteValues: [][]byte{[]byte(changedVal)},
},
},
},
wantErr: "value for attribute \"some-attribute-name\" has changed since initial value at login",
},
{
name: "no value for attribute attribute",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{},
},
wantErr: "expected to find 1 value for \"some-attribute-name\" attribute, but found 0",
},
{
name: "too many values for attribute",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: attributeName,
ByteValues: [][]byte{[]byte("val1"), []byte("val2")},
},
},
},
wantErr: "expected to find 1 value for \"some-attribute-name\" attribute, but found 2",
}, },
} }
for _, test := range tests { for _, test := range tests {
tt := test tt := test
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
actualUUIDString, err := microsoftUUIDFromBinary(tt.binaryUUID) initialValRawEncoded := base64.RawURLEncoding.EncodeToString([]byte(initialVal))
err := AttributeUnchangedSinceLogin(attributeName)(tt.entry, provider.StoredRefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}})
if tt.wantErr != "" { if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr) require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
} else { } else {
require.NoError(t, err) require.NoError(t, err)
} }
require.Equal(t, tt.wantString, actualUUIDString)
})
}
}
func TestGetDomainFromDistinguishedName(t *testing.T) {
tests := []struct {
name string
distinguishedName string
wantDomain string
wantErr string
}{
{
name: "happy path",
distinguishedName: "CN=Mammals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com",
wantDomain: "activedirectory.mycompany.example.com",
},
{
name: "lowercased happy path",
distinguishedName: "cn=Mammals,ou=Users,ou=pinniped-ad,dc=activedirectory,dc=mycompany,dc=example,dc=com",
wantDomain: "activedirectory.mycompany.example.com",
},
{
name: "no domain components",
distinguishedName: "not-a-dn",
wantErr: "did not find domain components in group dn: not-a-dn",
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
actualDomain, err := getDomainFromDistinguishedName(tt.distinguishedName)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantDomain, actualDomain)
}) })
} }
} }

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package upstreamoidc implements an abstraction of upstream OIDC provider interactions. // Package upstreamoidc implements an abstraction of upstream OIDC provider interactions.
@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc" coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -60,6 +61,19 @@ func (p *ProviderConfig) GetRevocationURL() *url.URL {
return p.RevocationURL return p.RevocationURL
} }
func (p *ProviderConfig) HasUserInfoURL() bool {
providerJSON := &struct {
UserInfoURL string `json:"userinfo_endpoint"`
}{}
if err := p.Provider.Claims(providerJSON); err != nil {
// This should never happen in practice because we should have already successfully
// parsed these claims when p.Provider was created.
return false
}
return len(providerJSON.UserInfoURL) > 0
}
func (p *ProviderConfig) GetAdditionalAuthcodeParams() map[string]string { func (p *ProviderConfig) GetAdditionalAuthcodeParams() map[string]string {
return p.AdditionalAuthcodeParams return p.AdditionalAuthcodeParams
} }
@ -112,7 +126,7 @@ func (p *ProviderConfig) PasswordCredentialsGrantAndValidateTokens(ctx context.C
// There is no nonce to validate for a resource owner password credentials grant because it skips using // There is no nonce to validate for a resource owner password credentials grant because it skips using
// the authorize endpoint and goes straight to the token endpoint. // the authorize endpoint and goes straight to the token endpoint.
const skipNonceValidation nonce.Nonce = "" const skipNonceValidation nonce.Nonce = ""
return p.ValidateToken(ctx, tok, skipNonceValidation) return p.ValidateTokenAndMergeWithUserInfo(ctx, tok, skipNonceValidation, true, false)
} }
func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce, redirectURI string) (*oidctypes.Token, error) { func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce, redirectURI string) (*oidctypes.Token, error) {
@ -126,7 +140,7 @@ func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context,
return nil, err return nil, err
} }
return p.ValidateToken(ctx, tok, expectedIDTokenNonce) return p.ValidateTokenAndMergeWithUserInfo(ctx, tok, expectedIDTokenNonce, true, false)
} }
func (p *ProviderConfig) PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) { func (p *ProviderConfig) PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) {
@ -259,36 +273,27 @@ func (p *ProviderConfig) tryRevokeToken(
} }
} }
// ValidateToken will validate the ID token. It will also merge the claims from the userinfo endpoint response, // ValidateTokenAndMergeWithUserInfo will validate the ID token. It will also merge the claims from the userinfo endpoint response,
// if the provider offers the userinfo endpoint. // if the provider offers the userinfo endpoint.
func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { func (p *ProviderConfig) ValidateTokenAndMergeWithUserInfo(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce, requireIDToken bool, requireUserInfo bool) (*oidctypes.Token, error) {
idTok, hasIDTok := tok.Extra("id_token").(string) var validatedClaims = make(map[string]interface{})
if !hasIDTok {
return nil, httperr.New(http.StatusBadRequest, "received response missing ID token") var idTokenExpiry time.Time
} // if we require the id token, make sure we have it.
validated, err := p.Provider.Verifier(&coreosoidc.Config{ClientID: p.GetClientID()}).Verify(coreosoidc.ClientContext(ctx, p.Client), idTok) // also, if it exists but wasn't required, still make sure it passes these checks.
idTokenExpiry, idTok, err := p.validateIDToken(ctx, tok, expectedIDTokenNonce, validatedClaims, requireIDToken)
if err != nil { if err != nil {
return nil, httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err) return nil, err
}
if validated.AccessTokenHash != "" {
if err := validated.VerifyAccessToken(tok.AccessToken); err != nil {
return nil, httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err)
}
}
if expectedIDTokenNonce != "" {
if err := expectedIDTokenNonce.Validate(validated); err != nil {
return nil, httperr.Wrap(http.StatusBadRequest, "received ID token with invalid nonce", err)
}
} }
var validatedClaims map[string]interface{} idTokenSubject, _ := validatedClaims[oidc.IDTokenSubjectClaim].(string)
if err := validated.Claims(&validatedClaims); err != nil {
return nil, httperr.Wrap(http.StatusInternalServerError, "could not unmarshal id token claims", err)
}
maybeLogClaims("claims from ID token", p.Name, validatedClaims)
if err := p.maybeFetchUserInfoAndMergeClaims(ctx, tok, validatedClaims); err != nil { if len(idTokenSubject) > 0 || !requireIDToken {
return nil, httperr.Wrap(http.StatusInternalServerError, "could not fetch user info claims", err) // only fetch userinfo if the ID token has a subject or if we are ignoring the id token completely.
// otherwise, defer to existing ID token validation
if err := p.maybeFetchUserInfoAndMergeClaims(ctx, tok, validatedClaims, requireIDToken, requireUserInfo); err != nil {
return nil, httperr.Wrap(http.StatusInternalServerError, "could not fetch user info claims", err)
}
} }
return &oidctypes.Token{ return &oidctypes.Token{
@ -302,58 +307,107 @@ func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, e
}, },
IDToken: &oidctypes.IDToken{ IDToken: &oidctypes.IDToken{
Token: idTok, Token: idTok,
Expiry: metav1.NewTime(validated.Expiry), Expiry: metav1.NewTime(idTokenExpiry),
Claims: validatedClaims, Claims: validatedClaims,
}, },
}, nil }, nil
} }
func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, tok *oauth2.Token, claims map[string]interface{}) error { func (p *ProviderConfig) validateIDToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce, validatedClaims map[string]interface{}, requireIDToken bool) (time.Time, string, error) {
idTok, hasIDTok := tok.Extra("id_token").(string)
if !hasIDTok && !requireIDToken {
return time.Time{}, "", nil // exit early
}
var idTokenExpiry time.Time
if !hasIDTok {
return time.Time{}, "", httperr.New(http.StatusBadRequest, "received response missing ID token")
}
validated, err := p.Provider.Verifier(&coreosoidc.Config{ClientID: p.GetClientID()}).Verify(coreosoidc.ClientContext(ctx, p.Client), idTok)
if err != nil {
return time.Time{}, "", httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err)
}
if validated.AccessTokenHash != "" {
if err := validated.VerifyAccessToken(tok.AccessToken); err != nil {
return time.Time{}, "", httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err)
}
}
if expectedIDTokenNonce != "" {
if err := expectedIDTokenNonce.Validate(validated); err != nil {
return time.Time{}, "", httperr.Wrap(http.StatusBadRequest, "received ID token with invalid nonce", err)
}
}
if err := validated.Claims(&validatedClaims); err != nil {
return time.Time{}, "", httperr.Wrap(http.StatusInternalServerError, "could not unmarshal id token claims", err)
}
maybeLogClaims("claims from ID token", p.Name, validatedClaims)
idTokenExpiry = validated.Expiry // keep track of the id token expiry if we have an id token. Otherwise, it'll just be the zero value.
return idTokenExpiry, idTok, nil
}
func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, tok *oauth2.Token, claims map[string]interface{}, requireIDToken bool, requireUserInfo bool) error {
idTokenSubject, _ := claims[oidc.IDTokenSubjectClaim].(string) idTokenSubject, _ := claims[oidc.IDTokenSubjectClaim].(string)
if len(idTokenSubject) == 0 {
return nil // defer to existing ID token validation
}
providerJSON := &struct { userInfo, err := p.maybeFetchUserInfo(ctx, tok, requireUserInfo)
UserInfoURL string `json:"userinfo_endpoint"` if err != nil {
}{} return err
if err := p.Provider.Claims(providerJSON); err != nil {
// this should never happen because we should have already parsed these claims at an earlier stage
return httperr.Wrap(http.StatusInternalServerError, "could not unmarshal discovery JSON", err)
} }
if userInfo == nil {
// implementing the user info endpoint is not required, skip this logic when it is absent
if len(providerJSON.UserInfoURL) == 0 {
return nil return nil
} }
userInfo, err := p.Provider.UserInfo(coreosoidc.ClientContext(ctx, p.Client), oauth2.StaticTokenSource(tok))
if err != nil {
return httperr.Wrap(http.StatusInternalServerError, "could not get user info", err)
}
// The sub (subject) Claim MUST always be returned in the UserInfo Response. // The sub (subject) Claim MUST always be returned in the UserInfo Response.
//
// NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not // NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not
// guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. The sub Claim in // guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. The sub Claim in
// the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, // the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match,
// the UserInfo Response values MUST NOT be used. // the UserInfo Response values MUST NOT be used.
// //
// http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
if len(userInfo.Subject) == 0 || userInfo.Subject != idTokenSubject { // If there is no ID token and it is not required, we must assume that the caller is performing other checks
// to ensure the subject is correct.
checkIDToken := requireIDToken || len(idTokenSubject) > 0
if checkIDToken && (len(userInfo.Subject) == 0 || userInfo.Subject != idTokenSubject) {
return httperr.Newf(http.StatusUnprocessableEntity, "userinfo 'sub' claim (%s) did not match id_token 'sub' claim (%s)", userInfo.Subject, idTokenSubject) return httperr.Newf(http.StatusUnprocessableEntity, "userinfo 'sub' claim (%s) did not match id_token 'sub' claim (%s)", userInfo.Subject, idTokenSubject)
} }
// keep track of the issuer from the ID token
idTokenIssuer := claims["iss"]
// merge existing claims with user info claims // merge existing claims with user info claims
if err := userInfo.Claims(&claims); err != nil { if err := userInfo.Claims(&claims); err != nil {
return httperr.Wrap(http.StatusInternalServerError, "could not unmarshal user info claims", err) return httperr.Wrap(http.StatusInternalServerError, "could not unmarshal user info claims", err)
} }
// The OIDC spec for the UserInfo response does not make any guarantees about the iss claim's existence or validity:
// "If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value SHOULD be the OP's Issuer Identifier URL."
// See https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
// So we just ignore it and use it the version from the id token, which has stronger guarantees.
delete(claims, "iss")
if idTokenIssuer != nil {
claims["iss"] = idTokenIssuer
}
maybeLogClaims("claims from ID token and userinfo", p.Name, claims) maybeLogClaims("claims from ID token and userinfo", p.Name, claims)
return nil return nil
} }
func (p *ProviderConfig) maybeFetchUserInfo(ctx context.Context, tok *oauth2.Token, requireUserInfo bool) (*coreosoidc.UserInfo, error) {
// implementing the user info endpoint is not required by the OIDC spec, but we may require it in certain situations.
if !p.HasUserInfoURL() {
if requireUserInfo {
// TODO should these all be http errors?
return nil, httperr.New(http.StatusInternalServerError, "userinfo endpoint not found, but is required")
}
return nil, nil
}
userInfo, err := p.Provider.UserInfo(coreosoidc.ClientContext(ctx, p.Client), oauth2.StaticTokenSource(tok))
if err != nil {
return nil, httperr.Wrap(http.StatusInternalServerError, "could not get user info", err)
}
return userInfo, nil
}
func maybeLogClaims(msg, name string, claims map[string]interface{}) { func maybeLogClaims(msg, name string, claims map[string]interface{}) {
if plog.Enabled(plog.LevelAll) { // log keys and values at all level if plog.Enabled(plog.LevelAll) { // log keys and values at all level
data, _ := json.Marshal(claims) // nothing we can do if it fails, but it really never should data, _ := json.Marshal(claims) // nothing we can do if it fails, but it really never should

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package upstreamoidc package upstreamoidc
@ -41,6 +41,9 @@ func TestProviderConfig(t *testing.T) {
Endpoint: oauth2.Endpoint{AuthURL: "https://example.com"}, Endpoint: oauth2.Endpoint{AuthURL: "https://example.com"},
Scopes: []string{"scope1", "scope2"}, Scopes: []string{"scope1", "scope2"},
}, },
Provider: &mockProvider{
rawClaims: []byte(`{"userinfo_endpoint": "https://example.com/userinfo"}`),
},
} }
require.Equal(t, "test-name", p.GetName()) require.Equal(t, "test-name", p.GetName())
require.Equal(t, "test-client-id", p.GetClientID()) require.Equal(t, "test-client-id", p.GetClientID())
@ -55,6 +58,16 @@ func TestProviderConfig(t *testing.T) {
require.True(t, p.AllowsPasswordGrant()) require.True(t, p.AllowsPasswordGrant())
p.AllowPasswordGrant = false p.AllowPasswordGrant = false
require.False(t, p.AllowsPasswordGrant()) require.False(t, p.AllowsPasswordGrant())
require.True(t, p.HasUserInfoURL())
p.Provider = &mockProvider{
rawClaims: []byte(`{"some_other_endpoint": "https://example.com/blah"}`),
}
require.False(t, p.HasUserInfoURL())
p.Provider = &mockProvider{
rawClaims: []byte(`{`),
}
require.False(t, p.HasUserInfoURL())
}) })
const ( const (
@ -707,6 +720,399 @@ func TestProviderConfig(t *testing.T) {
} }
}) })
t.Run("ValidateTokenAndMergeWithUserInfo", func(t *testing.T) {
expiryTime := time.Now().Add(42 * time.Second)
testTokenWithoutIDToken := &oauth2.Token{
AccessToken: "test-access-token",
// the library sets the original refresh token into the result, even though the server did not return that
RefreshToken: "test-initial-refresh-token",
TokenType: "test-token-type",
Expiry: expiryTime,
}
// generated from jwt.io
// sub: some-subject
// iss: some-issuer
// nonce: some-nonce
goodIDToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzb21lLXN1YmplY3QiLCJub25jZSI6InNvbWUtbm9uY2UiLCJpc3MiOiJzb21lLWlzc3VlciJ9.eGvzOihLUqzn3M4k6fHsToedgy7Fu89_Xu_u4mwMgRlIyRWZqmEMV76RVLnZd9Ihm9j_VpvrpirIkaj4JM9eRNfLX1n328cmBivBwnTKAzHuTm17dUKO5EvdTmQzmwnN0WZ8nWk4GfR7RzcvE1V8G9tIiWD8FkO3Dr-NR_zTun3N37onAazVLCmF0SDtATDfUH1ETqviHEp8xGx5HD5mv5T3HEjOuer5gxTEnfncef0LurBH3po-C0tXHKu74PD8x88CMJ1DLsRdCalnctwa850slKPkBSTP-ssh0JVg7cdMXoosVpwiXtKYaBkrhu8VS018aFP-cBbW0mYwsHmt3g" //nolint:gosec
tests := []struct {
name string
tok *oauth2.Token
nonce nonce.Nonce
requireIDToken bool
requireUserInfo bool
userInfo *oidc.UserInfo
rawClaims []byte
userInfoErr error
wantErr string
wantMergedTokens *oidctypes.Token
}{
{
name: "token with id, access and refresh tokens, valid nonce, and no userinfo",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: goodIDToken,
Claims: map[string]interface{}{
"iss": "some-issuer",
"nonce": "some-nonce",
"sub": "some-subject",
},
},
},
},
{
name: "id token not required but is provided",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: false,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "sub": "some-subject"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: goodIDToken,
Claims: map[string]interface{}{
"iss": "some-issuer",
"nonce": "some-nonce",
"sub": "some-subject",
"name": "Pinny TheSeal",
},
},
},
},
{
name: "token with id, access and refresh tokens, valid nonce, and userinfo with a value that doesn't exist in the id token",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "sub": "some-subject"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: goodIDToken,
Claims: map[string]interface{}{
"iss": "some-issuer",
"nonce": "some-nonce",
"sub": "some-subject",
"name": "Pinny TheSeal",
},
},
},
},
{
name: "userinfo is required, token with id, access and refresh tokens, valid nonce, and userinfo with a value that doesn't exist in the id token",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: true,
requireUserInfo: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "sub": "some-subject"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: goodIDToken,
Claims: map[string]interface{}{
"iss": "some-issuer",
"nonce": "some-nonce",
"sub": "some-subject",
"name": "Pinny TheSeal",
},
},
},
},
{
name: "claims from userinfo override id token claims",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzb21lLXN1YmplY3QiLCJuYW1lIjoiSm9obiBEb2UiLCJpc3MiOiJzb21lLWlzc3VlciIsIm5vbmNlIjoic29tZS1ub25jZSJ9.sBWi3_4cfGwrmMFZWkCghw4uvCnHN35h9xNX1gkwOtj6Oz_yKqpj7wfO4AqeWsRyrDGnkmIZbVuhAAJqPSi4GlNzN4NU8zh53PGDUpFlpDI1dvqDjIRb9iIEJpRIj34--Sz41H0ooxviIzvUdZFvQlaSzLOqgjR3ddHe2urhbtUuz_DsabP84AWo2DSg0y3ull6DRvk_DvzC6HNN8JwVi08fFvvV9BVq8kjdVeob7gajJkuGSTjsxNZGs5rbBuxBx0MZTQ8boR1fDNdG70GoIb4SsCoBSs7pZxtmGZPHInteY1SilHDDDmpQuE-LvSmvvPN_Cyk1d3eS-IR7hBbCAA"}),
nonce: "some-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "sub": "some-subject"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzb21lLXN1YmplY3QiLCJuYW1lIjoiSm9obiBEb2UiLCJpc3MiOiJzb21lLWlzc3VlciIsIm5vbmNlIjoic29tZS1ub25jZSJ9.sBWi3_4cfGwrmMFZWkCghw4uvCnHN35h9xNX1gkwOtj6Oz_yKqpj7wfO4AqeWsRyrDGnkmIZbVuhAAJqPSi4GlNzN4NU8zh53PGDUpFlpDI1dvqDjIRb9iIEJpRIj34--Sz41H0ooxviIzvUdZFvQlaSzLOqgjR3ddHe2urhbtUuz_DsabP84AWo2DSg0y3ull6DRvk_DvzC6HNN8JwVi08fFvvV9BVq8kjdVeob7gajJkuGSTjsxNZGs5rbBuxBx0MZTQ8boR1fDNdG70GoIb4SsCoBSs7pZxtmGZPHInteY1SilHDDDmpQuE-LvSmvvPN_Cyk1d3eS-IR7hBbCAA",
Claims: map[string]interface{}{
"iss": "some-issuer", // takes the issuer from the ID token, since the userinfo one is unreliable.
"nonce": "some-nonce",
"sub": "some-subject",
"name": "Pinny TheSeal",
},
},
},
},
{
name: "token with id, access and refresh tokens and valid nonce, but userinfo has a different issuer",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "iss": "some-other-issuer", "sub": "some-subject"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: goodIDToken,
Claims: map[string]interface{}{
"iss": "some-issuer", // takes the issuer from the ID token, since the userinfo one is unreliable.
"nonce": "some-nonce",
"sub": "some-subject",
"name": "Pinny TheSeal",
},
},
},
},
{
name: "token with id, access and refresh tokens and valid nonce, but no userinfo endpoint from discovery and it's not required",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: true,
requireUserInfo: false,
rawClaims: []byte(`{"not_the_userinfo_endpoint": "some-other-endpoint"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: goodIDToken,
Claims: map[string]interface{}{
"iss": "some-issuer",
"nonce": "some-nonce",
"sub": "some-subject",
},
},
},
},
{
name: "token with no id token but valid userinfo",
tok: testTokenWithoutIDToken,
nonce: "",
requireIDToken: false,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "iss": "some-other-issuer", "sub": "some-subject"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: "",
Claims: map[string]interface{}{
"sub": "some-subject",
"name": "Pinny TheSeal",
},
},
},
},
{
name: "token with neither id token nor userinfo",
tok: testTokenWithoutIDToken,
nonce: "",
requireIDToken: false,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{},
},
},
},
{
name: "token with id, access and refresh tokens, valid nonce, and userinfo subject that doesn't match",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-other-subject", `{"name": "Pinny TheSeal", "sub": "some-other-subject"}`),
wantErr: "could not fetch user info claims: userinfo 'sub' claim (some-other-subject) did not match id_token 'sub' claim (some-subject)",
},
{
name: "id token not required but is provided, and subjects don't match",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-nonce",
requireIDToken: false,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-other-subject", `{"name": "Pinny TheSeal", "sub": "some-other-subject"}`),
wantErr: "could not fetch user info claims: userinfo 'sub' claim (some-other-subject) did not match id_token 'sub' claim (some-subject)",
},
{
name: "invalid id token",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": "not-an-id-token"}),
nonce: "some-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-other-subject", `{"name": "Pinny TheSeal", "sub": "some-other-subject"}`),
wantErr: "received invalid ID token: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts",
},
{
name: "invalid nonce",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": goodIDToken}),
nonce: "some-other-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-other-subject", `{"name": "Pinny TheSeal", "sub": "some-other-subject"}`),
wantErr: "received ID token with invalid nonce: invalid nonce (expected \"some-other-nonce\", got \"some-nonce\")",
},
{
name: "expected to have id token, but doesn't",
tok: testTokenWithoutIDToken,
nonce: "some-other-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "sub": "some-subject"}`),
wantErr: "received response missing ID token",
},
{
name: "expected to have userinfo, but doesn't",
tok: testTokenWithoutIDToken,
nonce: "some-other-nonce",
requireUserInfo: true,
rawClaims: []byte(`{}`),
wantErr: "could not fetch user info claims: userinfo endpoint not found, but is required",
},
{
name: "expected to have id token and userinfo, but doesn't have either",
tok: testTokenWithoutIDToken,
nonce: "some-other-nonce",
requireUserInfo: true,
requireIDToken: true,
rawClaims: []byte(`{}`),
wantErr: "received response missing ID token",
},
{
name: "mismatched access token hash",
tok: testTokenWithoutIDToken,
nonce: "some-other-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-subject", `{"name": "Pinny TheSeal", "sub": "some-subject"}`),
wantErr: "received response missing ID token",
},
{
name: "id token missing subject, skip userinfo check",
tok: testTokenWithoutIDToken.WithExtra(map[string]interface{}{"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpc3MiOiJzb21lLWlzc3VlciIsIm5vbmNlIjoic29tZS1ub25jZSJ9.aIhrhikAnQ4Mb1g6RAT08qqflT2LLLi2yj4F2S4zud8nYad4tfEd2ITVJ4Njdjf70ubqyzZ6XxojtC4OqaWbDaQOcd95sd3PW58SYrf4NMvEStFkcMG0HMhJEZLVGnuJQstuq3G9h5Z5bFCkx4mFNo5ho_isBWyHpk-uF14duXXlIDB10SnyZ9dRbcmu-3mMOq0g4oCUPEDiHWkv-Rf70Mk0harL2xvcpxlSMLK4glDfiiki5gl6IReIo4rTVosXAqv3JmjLDeVLtJQRG6F8YcIlDCIfUEUfk0GeYacBVjoDIO570ywVJy1LGvyUuvgXNQUjq2JgzCfb8HWGp7iJdQ"}),
nonce: "some-nonce",
requireIDToken: true,
rawClaims: []byte(`{"userinfo_endpoint": "not-empty"}`),
userInfo: forceUserInfoWithClaims("some-other-subject", `{"name": "Pinny TheSeal", "sub": "some-other-subject"}`),
wantMergedTokens: &oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Type: "test-token-type",
Expiry: metav1.NewTime(expiryTime),
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-initial-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpc3MiOiJzb21lLWlzc3VlciIsIm5vbmNlIjoic29tZS1ub25jZSJ9.aIhrhikAnQ4Mb1g6RAT08qqflT2LLLi2yj4F2S4zud8nYad4tfEd2ITVJ4Njdjf70ubqyzZ6XxojtC4OqaWbDaQOcd95sd3PW58SYrf4NMvEStFkcMG0HMhJEZLVGnuJQstuq3G9h5Z5bFCkx4mFNo5ho_isBWyHpk-uF14duXXlIDB10SnyZ9dRbcmu-3mMOq0g4oCUPEDiHWkv-Rf70Mk0harL2xvcpxlSMLK4glDfiiki5gl6IReIo4rTVosXAqv3JmjLDeVLtJQRG6F8YcIlDCIfUEUfk0GeYacBVjoDIO570ywVJy1LGvyUuvgXNQUjq2JgzCfb8HWGp7iJdQ",
Claims: map[string]interface{}{
"iss": "some-issuer",
"name": "John Doe",
"nonce": "some-nonce",
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
p := ProviderConfig{
Name: "test-name",
UsernameClaim: "test-username-claim",
GroupsClaim: "test-groups-claim",
Config: &oauth2.Config{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: "https://example.com",
TokenURL: "https://example.com",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: []string{"scope1", "scope2"},
},
Provider: &mockProvider{
rawClaims: tt.rawClaims,
userInfo: tt.userInfo,
userInfoErr: tt.userInfoErr,
},
}
gotTok, err := p.ValidateTokenAndMergeWithUserInfo(context.Background(), tt.tok, tt.nonce, tt.requireIDToken, tt.requireUserInfo)
if tt.wantErr != "" {
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
} else {
require.NoError(t, err)
require.Equal(t, tt.wantMergedTokens, gotTok)
}
})
}
})
t.Run("ExchangeAuthcodeAndValidateTokens", func(t *testing.T) { t.Run("ExchangeAuthcodeAndValidateTokens", func(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -779,6 +1185,36 @@ func TestProviderConfig(t *testing.T) {
rawClaims: []byte(`{}`), // user info not supported rawClaims: []byte(`{}`), // user info not supported
wantUserInfoCalled: false, wantUserInfoCalled: false,
}, },
{
name: "valid but userinfo endpoint could not be found due to parse error",
authCode: "valid",
returnIDTok: validIDToken,
wantToken: oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Expiry: metav1.Time{},
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: validIDToken,
Expiry: metav1.Time{},
Claims: map[string]interface{}{
"foo": "bar",
"bat": "baz",
"aud": "test-client-id",
"iat": 1.606768593e+09,
"jti": "test-jti",
"nbf": 1.606768593e+09,
"sub": "test-user",
},
},
},
// cannot be parsed as json, but note that in this case constructing a real provider would have failed
rawClaims: []byte(`{`),
wantUserInfoCalled: false,
},
{ {
name: "valid", name: "valid",
authCode: "valid", authCode: "valid",
@ -808,13 +1244,6 @@ func TestProviderConfig(t *testing.T) {
rawClaims: []byte(`{}`), // user info not supported rawClaims: []byte(`{}`), // user info not supported
wantUserInfoCalled: false, wantUserInfoCalled: false,
}, },
{
name: "user info discovery parse error",
authCode: "valid",
returnIDTok: validIDToken,
rawClaims: []byte(`junk`), // user info discovery fails
wantErr: "could not fetch user info claims: could not unmarshal discovery JSON: invalid character 'j' looking for beginning of value",
},
{ {
name: "user info fetch error", name: "user info fetch error",
authCode: "valid", authCode: "valid",

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package oidcclient implements a CLI OIDC login flow. // Package oidcclient implements a CLI OIDC login flow.
@ -435,7 +435,9 @@ func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (
authorizeURL := h.oauth2Config.AuthCodeURL(h.state.String(), *authorizeOptions...) authorizeURL := h.oauth2Config.AuthCodeURL(h.state.String(), *authorizeOptions...)
// Don't follow redirects automatically because we want to handle redirects here. // Don't follow redirects automatically because we want to handle redirects here.
var sawRedirect bool
h.httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { h.httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
sawRedirect = true
return http.ErrUseLastResponse return http.ErrUseLastResponse
} }
@ -454,8 +456,8 @@ func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (
} }
_ = authRes.Body.Close() // don't need the response body, and okay if it fails to close _ = authRes.Body.Close() // don't need the response body, and okay if it fails to close
// A successful authorization always results in a 302. // A successful authorization always results in a redirect (we are flexible on the exact status code).
if authRes.StatusCode != http.StatusFound { if !sawRedirect {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"error getting authorization: expected to be redirected, but response status was %s", authRes.Status) "error getting authorization: expected to be redirected, but response status was %s", authRes.Status)
} }
@ -820,7 +822,7 @@ func (h *handlerState) handleRefresh(ctx context.Context, refreshToken *oidctype
// The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at least // The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at least
// some providers do not include one, so we skip the nonce validation here (but not other validations). // some providers do not include one, so we skip the nonce validation here (but not other validations).
return upstreamOIDCIdentityProvider.ValidateToken(ctx, refreshed, "") return upstreamOIDCIdentityProvider.ValidateTokenAndMergeWithUserInfo(ctx, refreshed, "", true, false)
} }
func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Request) (err error) { func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Request) (err error) {

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package oidcclient package oidcclient
@ -19,8 +19,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-logr/stdr"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -408,7 +406,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI {
mock := mockUpstream(t) mock := mockUpstream(t)
mock.EXPECT(). mock.EXPECT().
ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")). ValidateTokenAndMergeWithUserInfo(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce(""), true, false).
Return(&testToken, nil) Return(&testToken, nil)
mock.EXPECT(). mock.EXPECT().
PerformRefresh(gomock.Any(), testToken.RefreshToken.Token). PerformRefresh(gomock.Any(), testToken.RefreshToken.Token).
@ -455,7 +453,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI {
mock := mockUpstream(t) mock := mockUpstream(t)
mock.EXPECT(). mock.EXPECT().
ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")). ValidateTokenAndMergeWithUserInfo(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce(""), true, false).
Return(nil, fmt.Errorf("some validation error")) Return(nil, fmt.Errorf("some validation error"))
mock.EXPECT(). mock.EXPECT().
PerformRefresh(gomock.Any(), "test-refresh-token-returning-invalid-id-token"). PerformRefresh(gomock.Any(), "test-refresh-token-returning-invalid-id-token").
@ -902,7 +900,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
`/authorize?access_type=offline&client_id=test-client-id&code_challenge=VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g&code_challenge_method=S256&nonce=test-nonce&pinniped_idp_name=some-upstream-name&pinniped_idp_type=ldap&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state": some error fetching authorize endpoint`, `/authorize?access_type=offline&client_id=test-client-id&code_challenge=VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g&code_challenge_method=S256&nonce=test-nonce&pinniped_idp_name=some-upstream-name&pinniped_idp_type=ldap&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state": some error fetching authorize endpoint`,
}, },
{ {
name: "ldap login when the OIDC provider authorization endpoint returns something other than a 302 redirect", name: "ldap login when the OIDC provider authorization endpoint returns something other than a redirect",
clientID: "test-client-id", clientID: "test-client-id",
opt: func(t *testing.T) Option { opt: func(t *testing.T) Option {
return func(h *handlerState) error { return func(h *handlerState) error {
@ -1217,6 +1215,117 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
}, },
wantToken: &testToken, wantToken: &testToken,
}, },
{
name: "successful ldap login with env vars for username and password, http.StatusSeeOther redirect",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
fakeAuthCode := "test-authcode-value"
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
mock := mockUpstream(t)
mock.EXPECT().
ExchangeAuthcodeAndValidateTokens(
gomock.Any(), fakeAuthCode, pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), "http://127.0.0.1:0/callback").
Return(&testToken, nil)
return mock
}
h.generateState = func() (state.State, error) { return "test-state", nil }
h.generatePKCE = func() (pkce.Code, error) { return "test-pkce", nil }
h.generateNonce = func() (nonce.Nonce, error) { return "test-nonce", nil }
h.getEnv = func(key string) string {
switch key {
case "PINNIPED_USERNAME":
return "some-upstream-username"
case "PINNIPED_PASSWORD":
return "some-upstream-password"
default:
return "" // all other env vars are treated as if they are unset
}
}
h.promptForValue = func(_ context.Context, promptLabel string) (string, error) {
require.FailNow(t, fmt.Sprintf("saw unexpected prompt from the CLI: %q", promptLabel))
return "", nil
}
h.promptForSecret = func(promptLabel string) (string, error) {
require.FailNow(t, fmt.Sprintf("saw unexpected prompt from the CLI: %q", promptLabel))
return "", nil
}
cache := &mockSessionCache{t: t, getReturnsToken: nil}
cacheKey := SessionCacheKey{
Issuer: successServer.URL,
ClientID: "test-client-id",
Scopes: []string{"test-scope"},
RedirectURI: "http://localhost:0/callback",
}
t.Cleanup(func() {
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys)
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys)
require.Equal(t, []*oidctypes.Token{&testToken}, cache.sawPutTokens)
})
require.NoError(t, WithSessionCache(cache)(h))
require.NoError(t, WithCLISendingCredentials()(h))
require.NoError(t, WithUpstreamIdentityProvider("some-upstream-name", "ldap")(h))
discoveryRequestWasMade := false
authorizeRequestWasMade := false
t.Cleanup(func() {
require.True(t, discoveryRequestWasMade, "should have made an discovery request")
require.True(t, authorizeRequestWasMade, "should have made an authorize request")
})
require.NoError(t, WithClient(&http.Client{
Transport: roundtripper.Func(func(req *http.Request) (*http.Response, error) {
switch req.URL.Scheme + "://" + req.URL.Host + req.URL.Path {
case "http://" + successServer.Listener.Addr().String() + "/.well-known/openid-configuration":
discoveryRequestWasMade = true
return defaultDiscoveryResponse(req)
case "http://" + successServer.Listener.Addr().String() + "/authorize":
authorizeRequestWasMade = true
require.Equal(t, "some-upstream-username", req.Header.Get("Pinniped-Username"))
require.Equal(t, "some-upstream-password", req.Header.Get("Pinniped-Password"))
require.Equal(t, url.Values{
// This is the PKCE challenge which is calculated as base64(sha256("test-pkce")). For example:
// $ echo -n test-pkce | shasum -a 256 | cut -d" " -f1 | xxd -r -p | base64 | cut -d"=" -f1
// VVaezYqum7reIhoavCHD1n2d+piN3r/mywoYj7fCR7g
"code_challenge": []string{"VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g"},
"code_challenge_method": []string{"S256"},
"response_type": []string{"code"},
"scope": []string{"test-scope"},
"nonce": []string{"test-nonce"},
"state": []string{"test-state"},
"access_type": []string{"offline"},
"client_id": []string{"test-client-id"},
"redirect_uri": []string{"http://127.0.0.1:0/callback"},
"pinniped_idp_name": []string{"some-upstream-name"},
"pinniped_idp_type": []string{"ldap"},
}, req.URL.Query())
return &http.Response{
StatusCode: http.StatusSeeOther,
Header: http.Header{"Location": []string{
fmt.Sprintf("http://127.0.0.1:0/callback?code=%s&state=test-state", fakeAuthCode),
}},
}, nil
default:
// Note that "/token" requests should not be made. They are mocked by mocking calls to ExchangeAuthcodeAndValidateTokens().
require.FailNow(t, fmt.Sprintf("saw unexpected http call from the CLI: %s", req.URL.String()))
return nil, nil
}
}),
})(h))
return nil
}
},
issuer: successServer.URL,
wantLogs: []string{
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"",
"\"level\"=4 \"msg\"=\"Pinniped: Read username from environment variable\" \"name\"=\"PINNIPED_USERNAME\"",
"\"level\"=4 \"msg\"=\"Pinniped: Read password from environment variable\" \"name\"=\"PINNIPED_PASSWORD\"",
},
wantToken: &testToken,
},
{ {
name: "with requested audience, session cache hit with valid token, but discovery fails", name: "with requested audience, session cache hit with valid token, but discovery fails",
clientID: "test-client-id", clientID: "test-client-id",
@ -1539,7 +1648,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI {
mock := mockUpstream(t) mock := mockUpstream(t)
mock.EXPECT(). mock.EXPECT().
ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")). ValidateTokenAndMergeWithUserInfo(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce(""), true, false).
Return(&testToken, nil) Return(&testToken, nil)
mock.EXPECT(). mock.EXPECT().
PerformRefresh(gomock.Any(), testToken.RefreshToken.Token). PerformRefresh(gomock.Any(), testToken.RefreshToken.Token).
@ -1571,9 +1680,8 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
testLogger := testlogger.New(t) testLogger := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
klog.SetLogger(testLogger) klog.SetLogger(testLogger.Logger)
stdr.SetVerbosity(debugLogLevel) // set stdr's global log level to debug so the test logger will send output.
tok, err := Login(tt.issuer, tt.clientID, tok, err := Login(tt.issuer, tt.clientID,
WithContext(context.Background()), WithContext(context.Background()),
@ -1581,7 +1689,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
WithScopes([]string{"test-scope"}), WithScopes([]string{"test-scope"}),
WithSkipBrowserOpen(), WithSkipBrowserOpen(),
tt.opt(t), tt.opt(t),
WithLogger(testLogger), WithLogger(testLogger.Logger),
) )
testLogger.Expect(tt.wantLogs) testLogger.Expect(tt.wantLogs)
if tt.wantErr != "" { if tt.wantErr != "" {

View File

@ -35,9 +35,9 @@ layout: section
</div> </div>
<div class="content"> <div class="content">
<h3><a href="{{< param "community_url" >}}">Community Meetings</a></h3> <h3><a href="{{< param "community_url" >}}">Community Meetings</a></h3>
<p>Pinniped Community Meetings are held every first and third Thursday of the month at 9 AM PT / 12 PM ET</p> <p>Pinniped <a href="https://go.pinniped.dev/community/agenda">Community Meetings</a> are on hold until early 2022. Stay tuned for more details on our return. Happy Holidays!</p>
<p>Join our <a href="https://groups.google.com/u/1/g/project-pinniped">Google Group</a> to receive invites to the meeting</p> <p>Join our <a href="https://groups.google.com/u/1/g/project-pinniped">Google Group</a> to receive invites to the meeting</p>
<p>Watch previous community meetings on our <a href="https://www.youtube.com/playlist?list=PL7bmigfV0EqQ8qYn8ornuJnuGvCt0belt">YouTube playlist</a></p> <p>Watch previous community meetings on our <a href="https://go.pinniped.dev/community/youtube">YouTube playlist</a></p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -12,31 +12,6 @@ layout: section
<h2>Resources about Pinniped, such as videos, podcasts, and community articles</h2> <h2>Resources about Pinniped, such as videos, podcasts, and community articles</h2>
<div class="grid three"> <div class="grid three">
<div class="col">
<div class="embed-responsive">
<iframe class="embed-responsive-item"
src="https://www.youtube-nocookie.com/embed/YGeO1jKfgac?start=138"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>
<div class="content">
<p>
<a href="https://www.cncf.io/online-programs/cncf-live-webinar-easy-secure-kubernetes-authentication-with-pinniped/"
target="_blank">
CNCF Live Webinar: Easy, Secure Kubernetes Authentication With Pinniped - August 24, 2021
[VIDEO]
</a>
<a href="https://docs.google.com/presentation/d/1euA62C7SHQpHewPqPaxTvNEKdvyNOdU9MDWh3YN3NvY/edit?usp=sharing"
target="_blank">
[SLIDES]
</a>
</p>
</div>
</div>
<div class="col"> <div class="col">
<a href="https://github.com/vmware-tanzu/pinniped"> <a href="https://github.com/vmware-tanzu/pinniped">
<div class="icon"> <div class="icon">
@ -49,5 +24,57 @@ layout: section
</a> </a>
</div> </div>
<div class="col">
<div class="icon">
<img src="/img/logo.svg" />
</div>
<div class="content">
<p class="strong">Community Meetings and Demos</p>
<p>We have a YouTube playlist for our Pinniped community meetings and demos, <a href="https://go.pinniped.dev/community/youtube">check it out here</a>.</p>
</div>
</div>
<div class="col">
<div class="embed-responsive">
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/OCkTnElNE9M"></iframe>
</div>
<div class="content">
<p><a href="https://www.youtube.com/watch?v=OCkTnElNE9M&feature=emb_logo">KubeCon + CloudNativeCon North America 2021: Everything Wrong with K8s Authentication and How We Worked Around It with Mo Khan & Margo Crawford</a></p>
</div>
</div>
<div class="col">
<div class="embed-responsive">
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/2fI_XOGEoIU"></iframe>
</div>
<div class="content">
<p><a href="https://www.youtube.com/watch?v=2fI_XOGEoIU&feature=emb_logo">Pinniped: A Unified Framework for User Authentication to Kubernetes Cluster with Mo Khan & Anjali Telang</a></p>
</div>
</div>
<div class="col">
<div class="embed-responsive">
<iframe class="embed-responsive-item"
src="https://www.youtube-nocookie.com/embed/YGeO1jKfgac?start=138"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>
<div class="content">
<p>
<a href="https://www.cncf.io/online-programs/cncf-live-webinar-easy-secure-kubernetes-authentication-with-pinniped/"
target="_blank">
CNCF Live Webinar: Easy, Secure Kubernetes Authentication With Pinniped - August 24, 2021
[VIDEO]
</a>
<a href="https://docs.google.com/presentation/d/1euA62C7SHQpHewPqPaxTvNEKdvyNOdU9MDWh3YN3NvY/edit?usp=sharing"
target="_blank">
[SLIDES]
</a>
</p>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -12,7 +12,7 @@
[[redirects]] [[redirects]]
from = "/community/agenda" from = "/community/agenda"
to = "https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view" to = "https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ"
status = 302 status = 302
force = true force = true

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package integration package integration
@ -83,7 +83,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(nil)), provider: upstreamldap.New(*providerConfig(nil)),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -95,7 +97,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.ConnectionProtocol = upstreamldap.StartTLS p.ConnectionProtocol = upstreamldap.StartTLS
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -104,7 +108,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -113,7 +119,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -125,7 +133,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.Filter = "cn={}" p.UserSearch.Filter = "cn={}"
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -136,7 +146,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.Filter = "(|(cn={})(mail={}))" p.UserSearch.Filter = "(|(cn={})(mail={}))"
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -147,7 +159,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.Filter = "(|(cn={})(mail={}))" p.UserSearch.Filter = "(|(cn={})(mail={}))"
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -156,7 +170,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("cn=pinny,ou=users,dc=pinniped,dc=dev"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("cn=pinny,ou=users,dc=pinniped,dc=dev"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -165,7 +181,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("Seal"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("Seal"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -174,7 +192,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "Seal", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", // note that the final answer has case preserved from the entry User: &user.DefaultInfo{Name: "Seal", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", // note that the final answer has case preserved from the entry
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -187,7 +207,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.UIDAttribute = "givenName" p.UserSearch.UIDAttribute = "givenName"
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: b64("Pinny the 🦭"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: b64("Pinny the 🦭"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -199,7 +221,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.UsernameAttribute = "cn" p.UserSearch.UsernameAttribute = "cn"
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -220,7 +244,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.GroupSearch.Base = "" p.GroupSearch.Base = ""
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: nil},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -231,7 +257,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.GroupSearch.Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree p.GroupSearch.Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: nil},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -245,7 +273,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{ User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{
"cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev", "cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev",
"cn=seals,ou=groups,dc=pinniped,dc=dev", "cn=seals,ou=groups,dc=pinniped,dc=dev",
}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", }},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -259,7 +289,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{ User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{
"cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev", "cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev",
"cn=seals,ou=groups,dc=pinniped,dc=dev", "cn=seals,ou=groups,dc=pinniped,dc=dev",
}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", }},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -270,7 +302,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.GroupSearch.GroupNameAttribute = "objectClass" // silly example, but still a meaningful test p.GroupSearch.GroupNameAttribute = "objectClass" // silly example, but still a meaningful test
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"groupOfNames", "groupOfNames"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"groupOfNames", "groupOfNames"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -281,7 +315,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.GroupSearch.Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))" p.GroupSearch.Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))"
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -292,7 +328,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.GroupSearch.Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema p.GroupSearch.Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema
})), })),
wantAuthResponse: &authenticators.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: nil},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, },
}, },
{ {
@ -671,8 +709,9 @@ func TestSimultaneousLDAPRequestsOnSingleProvider(t *testing.T) {
assert.NoError(t, result.err) assert.NoError(t, result.err)
assert.True(t, result.authenticated, "expected the user to be authenticated, but they were not") assert.True(t, result.authenticated, "expected the user to be authenticated, but they were not")
assert.Equal(t, &authenticators.Response{ assert.Equal(t, &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
ExtraRefreshAttributes: map[string]string{},
}, result.response) }, result.response)
} }
} }

View File

@ -1,15 +1,20 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package integration package integration
import ( import (
"context" "context"
"crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"math/big"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -19,15 +24,18 @@ import (
"time" "time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc" coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/text/encoding/unicode"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/crypto/ptls"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil"
@ -38,24 +46,26 @@ import (
"go.pinniped.dev/test/testlib/browsertest" "go.pinniped.dev/test/testlib/browsertest"
) )
// nolint:gocyclo
func TestSupervisorLogin(t *testing.T) { func TestSupervisorLogin(t *testing.T) {
env := testlib.IntegrationEnv(t) env := testlib.IntegrationEnv(t)
tests := []struct { tests := []struct {
name string name string
maybeSkip func(t *testing.T) maybeSkip func(t *testing.T)
createTestUser func(t *testing.T) (string, string)
deleteTestUser func(t *testing.T, username string)
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client)
createIDP func(t *testing.T) string createIDP func(t *testing.T) string
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client)
wantDownstreamIDTokenSubjectToMatch string wantDownstreamIDTokenSubjectToMatch string
wantDownstreamIDTokenUsernameToMatch string wantDownstreamIDTokenUsernameToMatch func(username string) string
wantDownstreamIDTokenGroups []string wantDownstreamIDTokenGroups []string
wantErrorDescription string wantErrorDescription string
wantErrorType string wantErrorType string
// We don't necessarily have any way to revoke the user's session on the upstream provider, // Either revoke the user's session on the upstream provider, or manipulate the user's session
// so to cause the upstream refresh to fail we can cheat by manipulating the user's session
// data in such a way that it should cause the next upstream refresh attempt to fail. // data in such a way that it should cause the next upstream refresh attempt to fail.
breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName string) breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string)
}{ }{
{ {
name: "oidc with default username and groups claim settings", name: "oidc with default username and groups claim settings",
@ -76,16 +86,14 @@ func TestSupervisorLogin(t *testing.T) {
return oidcIDP.Name return oidcIDP.Name
}, },
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom pinnipedSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) pinnipedSessionData.OIDC.UpstreamIssuer = "wrong-issuer"
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
}, },
// the ID token Subject should include the upstream user ID after the upstream issuer name // the ID token Subject should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
// the ID token Username should include the upstream user ID after the upstream issuer name // the ID token Username should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
}, },
{ {
name: "oidc with custom username and groups claim settings", name: "oidc with custom username and groups claim settings",
@ -113,14 +121,46 @@ func TestSupervisorLogin(t *testing.T) {
return oidcIDP.Name return oidcIDP.Name
}, },
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom fositeSessionData := pinnipedSession.Fosite
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) fositeSessionData.Claims.Extra["username"] = "some-incorrect-username"
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
}, },
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
},
{
name: "oidc without refresh token",
maybeSkip: func(t *testing.T) {
// never need to skip this test
},
createIDP: func(t *testing.T) string {
t.Helper()
oidcIDP := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
Claims: idpv1alpha1.OIDCClaims{
Username: env.SupervisorUpstreamOIDC.UsernameClaim,
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{
AdditionalScopes: []string{"email"}, // does not ask for offline_access.
},
}, idpv1alpha1.PhaseReady)
return oidcIDP.Name
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Extra["username"] = "some-incorrect-username"
},
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
}, },
{ {
@ -144,7 +184,7 @@ func TestSupervisorLogin(t *testing.T) {
}, idpv1alpha1.PhaseReady) }, idpv1alpha1.PhaseReady)
return oidcIDP.Name return oidcIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamOIDC.Username, // username to present to server during login env.SupervisorUpstreamOIDC.Username, // username to present to server during login
@ -153,7 +193,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
@ -162,7 +202,7 @@ func TestSupervisorLogin(t *testing.T) {
// the ID token Subject should include the upstream user ID after the upstream issuer name // the ID token Subject should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
// the ID token Username should include the upstream user ID after the upstream issuer name // the ID token Username should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
}, },
{ {
name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS", name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS",
@ -212,7 +252,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
return ldapIDP.Name return ldapIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -221,7 +261,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN) require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -235,8 +275,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
}, },
{ {
name: "ldap with CN as username and group names as CNs and using an LDAP provider which only supports StartTLS", // try another variation of configuration options name: "ldap with CN as username and group names as CNs and using an LDAP provider which only supports StartTLS", // try another variation of configuration options
@ -286,7 +328,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
return ldapIDP.Name return ldapIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login
@ -295,7 +337,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN) require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -309,7 +351,7 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$" },
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs, wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs,
}, },
{ {
@ -360,7 +402,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
return ldapIDP.Name return ldapIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -438,7 +480,7 @@ func TestSupervisorLogin(t *testing.T) {
}, time.Minute, 500*time.Millisecond) }, time.Minute, 500*time.Millisecond)
return ldapIDP.Name return ldapIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -447,7 +489,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN) require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -460,8 +502,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
}, },
{ {
name: "ldap login still works after deleting and recreating the bind secret", name: "ldap login still works after deleting and recreating the bind secret",
@ -543,7 +587,7 @@ func TestSupervisorLogin(t *testing.T) {
}, time.Minute, 500*time.Millisecond) }, time.Minute, 500*time.Millisecond)
return ldapIDP.Name return ldapIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -552,7 +596,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN) require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -565,8 +609,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
}, },
{ {
name: "activedirectory with all default options", name: "activedirectory with all default options",
@ -604,7 +650,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
return adIDP.Name return adIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
@ -613,7 +659,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -627,8 +673,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
}, { }, {
name: "activedirectory with custom options", name: "activedirectory with custom options",
maybeSkip: func(t *testing.T) { maybeSkip: func(t *testing.T) {
@ -679,7 +727,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
return adIDP.Name return adIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue, // username to present to server during login
@ -688,7 +736,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -702,8 +750,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs,
}, },
{ {
name: "active directory login still works after updating bind secret", name: "active directory login still works after updating bind secret",
@ -759,7 +809,7 @@ func TestSupervisorLogin(t *testing.T) {
}, time.Minute, 500*time.Millisecond) }, time.Minute, 500*time.Millisecond)
return adIDP.Name return adIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
@ -768,7 +818,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -781,8 +831,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
}, },
{ {
name: "active directory login still works after deleting and recreating bind secret", name: "active directory login still works after deleting and recreating bind secret",
@ -853,7 +905,7 @@ func TestSupervisorLogin(t *testing.T) {
}, time.Minute, 500*time.Millisecond) }, time.Minute, 500*time.Millisecond)
return adIDP.Name return adIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
@ -862,7 +914,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -875,8 +927,196 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
},
{
name: "active directory login fails after the user password is changed",
maybeSkip: func(t *testing.T) {
t.Helper()
if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) {
t.Skip("LDAP integration test requires connectivity to an LDAP server")
}
if env.SupervisorUpstreamActiveDirectory.Host == "" {
t.Skip("Active Directory hostname not specified")
}
},
createIDP: func(t *testing.T) string {
t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
map[string]string{
v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername,
v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword,
},
)
adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{
Host: env.SupervisorUpstreamActiveDirectory.Host,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)),
},
Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{
SecretName: secret.Name,
},
}, idpv1alpha1.ActiveDirectoryPhaseReady)
expectedMsg := fmt.Sprintf(
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername,
secret.Name, secret.ResourceVersion,
)
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
return adIDP.Name
},
createTestUser: func(t *testing.T) (string, string) {
return createFreshADTestUser(t, env)
},
deleteTestUser: func(t *testing.T, username string) {
deleteTestADUser(t, env, username)
},
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL,
testUserName, // username to present to server during login
testUserPassword, // password to present to server during login
httpClient,
false,
)
},
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
changeADTestUserPassword(t, env, username)
},
// we can't know the subject ahead of time because we created a new user and don't know their uid,
// so skip wantDownstreamIDTokenSubjectToMatch
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: func(username string) string {
return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$"
},
wantDownstreamIDTokenGroups: []string{}, // none for now.
},
{
name: "active directory login fails after the user is deactivated",
maybeSkip: func(t *testing.T) {
t.Helper()
if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) {
t.Skip("LDAP integration test requires connectivity to an LDAP server")
}
if env.SupervisorUpstreamActiveDirectory.Host == "" {
t.Skip("Active Directory hostname not specified")
}
},
createIDP: func(t *testing.T) string {
t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
map[string]string{
v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername,
v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword,
},
)
adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{
Host: env.SupervisorUpstreamActiveDirectory.Host,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)),
},
Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{
SecretName: secret.Name,
},
}, idpv1alpha1.ActiveDirectoryPhaseReady)
expectedMsg := fmt.Sprintf(
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername,
secret.Name, secret.ResourceVersion,
)
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
return adIDP.Name
},
createTestUser: func(t *testing.T) (string, string) {
return createFreshADTestUser(t, env)
},
deleteTestUser: func(t *testing.T, username string) {
deleteTestADUser(t, env, username)
},
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL,
testUserName, // username to present to server during login
testUserPassword, // password to present to server during login
httpClient,
false,
)
},
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
deactivateADTestUser(t, env, username)
},
// we can't know the subject ahead of time because we created a new user and don't know their uid,
// so skip wantDownstreamIDTokenSubjectToMatch
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: func(username string) string {
return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$"
},
wantDownstreamIDTokenGroups: []string{}, // none for now.
},
{
name: "active directory login fails after the user is locked",
maybeSkip: func(t *testing.T) {
t.Helper()
if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) {
t.Skip("LDAP integration test requires connectivity to an LDAP server")
}
if env.SupervisorUpstreamActiveDirectory.Host == "" {
t.Skip("Active Directory hostname not specified")
}
},
createIDP: func(t *testing.T) string {
t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
map[string]string{
v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername,
v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword,
},
)
adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{
Host: env.SupervisorUpstreamActiveDirectory.Host,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)),
},
Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{
SecretName: secret.Name,
},
}, idpv1alpha1.ActiveDirectoryPhaseReady)
expectedMsg := fmt.Sprintf(
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername,
secret.Name, secret.ResourceVersion,
)
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
return adIDP.Name
},
createTestUser: func(t *testing.T) (string, string) {
return createFreshADTestUser(t, env)
},
deleteTestUser: func(t *testing.T, username string) {
deleteTestADUser(t, env, username)
},
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL,
testUserName, // username to present to server during login
testUserPassword, // password to present to server during login
httpClient,
false,
)
},
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
lockADTestUser(t, env, username)
},
// we can't know the subject ahead of time because we created a new user and don't know their uid,
// so skip wantDownstreamIDTokenSubjectToMatch
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: func(username string) string {
return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$"
},
wantDownstreamIDTokenGroups: []string{},
}, },
{ {
name: "logging in to activedirectory with a deactivated user fails", name: "logging in to activedirectory with a deactivated user fails",
@ -914,7 +1154,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
return adIDP.Name return adIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login
@ -975,7 +1215,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
return ldapIDP.Name return ldapIDP.Name
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -984,7 +1224,7 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, _ string) {
// get the idp, update the config. // get the idp, update the config.
client := testlib.NewSupervisorClientset(t) client := testlib.NewSupervisorClientset(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
@ -1007,8 +1247,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$", ) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
}, },
} }
for _, test := range tests { for _, test := range tests {
@ -1020,6 +1262,8 @@ func TestSupervisorLogin(t *testing.T) {
tt.createIDP, tt.createIDP,
tt.requestAuthorization, tt.requestAuthorization,
tt.breakRefreshSessionData, tt.breakRefreshSessionData,
tt.createTestUser,
tt.deleteTestUser,
tt.wantDownstreamIDTokenSubjectToMatch, tt.wantDownstreamIDTokenSubjectToMatch,
tt.wantDownstreamIDTokenUsernameToMatch, tt.wantDownstreamIDTokenUsernameToMatch,
tt.wantDownstreamIDTokenGroups, tt.wantDownstreamIDTokenGroups,
@ -1150,10 +1394,15 @@ func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *tes
func testSupervisorLogin( func testSupervisorLogin(
t *testing.T, t *testing.T,
createIDP func(t *testing.T) string, createIDP func(t *testing.T) string,
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client), requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client),
breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string), breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string),
wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string, createTestUser func(t *testing.T) (string, string),
wantErrorDescription string, wantErrorType string, deleteTestUser func(t *testing.T, username string),
wantDownstreamIDTokenSubjectToMatch string,
wantDownstreamIDTokenUsernameToMatch func(username string) string,
wantDownstreamIDTokenGroups []string,
wantErrorDescription string,
wantErrorType string,
) { ) {
env := testlib.IntegrationEnv(t) env := testlib.IntegrationEnv(t)
@ -1241,6 +1490,12 @@ func testSupervisorLogin(
// Create upstream IDP and wait for it to become ready. // Create upstream IDP and wait for it to become ready.
idpName := createIDP(t) idpName := createIDP(t)
username, password := "", ""
if createTestUser != nil {
username, password = createTestUser(t)
defer deleteTestUser(t, username)
}
// Perform OIDC discovery for our downstream. // Perform OIDC discovery for our downstream.
var discovery *coreosoidc.Provider var discovery *coreosoidc.Provider
testlib.RequireEventually(t, func(requireEventually *require.Assertions) { testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
@ -1276,7 +1531,7 @@ func testSupervisorLogin(
) )
// Perform parameterized auth code acquisition. // Perform parameterized auth code acquisition.
requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, httpClient) requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient)
// Expect that our callback handler was invoked. // Expect that our callback handler was invoked.
callback := localCallbackServer.waitForCallback(10 * time.Second) callback := localCallbackServer.waitForCallback(10 * time.Second)
@ -1294,7 +1549,7 @@ func testSupervisorLogin(
expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username", "groups"} expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username", "groups"}
verifyTokenResponse(t, verifyTokenResponse(t,
tokenResponse, discovery, downstreamOAuth2Config, nonceParam, tokenResponse, discovery, downstreamOAuth2Config, nonceParam,
expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups)
// token exchange on the original token // token exchange on the original token
doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery) doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery)
@ -1308,7 +1563,7 @@ func testSupervisorLogin(
expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "groups", "at_hash"} expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "groups", "at_hash"}
verifyTokenResponse(t, verifyTokenResponse(t,
refreshedTokenResponse, discovery, downstreamOAuth2Config, "", refreshedTokenResponse, discovery, downstreamOAuth2Config, "",
expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups)
require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken)
require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken)
@ -1333,7 +1588,7 @@ func testSupervisorLogin(
// Next mutate the part of the session that is used during upstream refresh. // Next mutate the part of the session that is used during upstream refresh.
pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession)
require.True(t, ok, "should have been able to cast session data to PinnipedSession") require.True(t, ok, "should have been able to cast session data to PinnipedSession")
breakRefreshSessionData(t, pinnipedSession, idpName) breakRefreshSessionData(t, pinnipedSession, idpName, username)
// Then save the mutated Secret back to Kubernetes. // Then save the mutated Secret back to Kubernetes.
// There is no update function, so delete and create again at the same name. // There is no update function, so delete and create again at the same name.
@ -1423,7 +1678,7 @@ func verifyTokenResponse(
require.NotEmpty(t, tokenResponse.RefreshToken) require.NotEmpty(t, tokenResponse.RefreshToken)
} }
func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) { func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
t.Helper() t.Helper()
env := testlib.IntegrationEnv(t) env := testlib.IntegrationEnv(t)
@ -1595,3 +1850,153 @@ func expectSecurityHeaders(t *testing.T, response *http.Response, expectFositeTo
assert.Equal(t, "no-cache", h.Get("Pragma")) assert.Equal(t, "no-cache", h.Get("Pragma"))
assert.Equal(t, "0", h.Get("Expires")) assert.Equal(t, "0", h.Get("Expires"))
} }
// create a fresh test user in AD to use for this test.
func createFreshADTestUser(t *testing.T, env *testlib.TestEnv) (string, string) {
t.Helper()
// dial tls
conn := dialTLS(t, env)
// bind
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
require.NoError(t, err)
testUserName := "user-" + createRandomHexString(t, 7) // sAMAccountNames are limited to 20 characters, so this is as long as we can make it.
// create
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
a := ldap.NewAddRequest(userDN, []ldap.Control{})
a.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"})
a.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", testUserName, env.SupervisorUpstreamActiveDirectory.Domain)})
a.Attribute("sAMAccountName", []string{testUserName})
err = conn.Add(a)
require.NoError(t, err)
// modify password and enable account
testUserPassword := createRandomASCIIString(t, 20)
enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
encodedTestUserPassword, err := enc.String("\"" + testUserPassword + "\"")
require.NoError(t, err)
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
m.Replace("unicodePwd", []string{encodedTestUserPassword})
m.Replace("userAccountControl", []string{"512"})
err = conn.Modify(m)
require.NoError(t, err)
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
return testUserName, testUserPassword
}
// deactivate the test user.
func deactivateADTestUser(t *testing.T, env *testlib.TestEnv, testUserName string) {
conn := dialTLS(t, env)
// bind
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
require.NoError(t, err)
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
m.Replace("userAccountControl", []string{"514"}) // normal user, account disabled
err = conn.Modify(m)
require.NoError(t, err)
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
}
// lock the test user's account by entering the wrong password a bunch of times.
func lockADTestUser(t *testing.T, env *testlib.TestEnv, testUserName string) {
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
conn := dialTLS(t, env)
// our password policy allows 20 wrong attempts before locking the account, so do 21.
// these wrong password attempts could go to different domain controllers, but account
// lockout changes are urgently replicated, meaning that the domain controllers will be
// synced asap rather than in the usual 15 second interval.
// See https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc961787(v=technet.10)#urgent-replication-of-account-lockout-changes
for i := 0; i <= 21; i++ {
err := conn.Bind(userDN, "not-the-right-password-"+fmt.Sprint(i))
require.Error(t, err) // this should be an error
}
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
require.NoError(t, err)
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
}
// change the user's password to a new one.
func changeADTestUserPassword(t *testing.T, env *testlib.TestEnv, testUserName string) {
conn := dialTLS(t, env)
// bind
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
require.NoError(t, err)
newTestUserPassword := createRandomASCIIString(t, 20)
enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
encodedTestUserPassword, err := enc.String(`"` + newTestUserPassword + `"`)
require.NoError(t, err)
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
m.Replace("unicodePwd", []string{encodedTestUserPassword})
err = conn.Modify(m)
require.NoError(t, err)
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
// don't bother to return the new password... we won't be using it, just checking that it's changed.
}
// delete the test user created for this test.
func deleteTestADUser(t *testing.T, env *testlib.TestEnv, testUserName string) {
t.Helper()
conn := dialTLS(t, env)
// bind
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
require.NoError(t, err)
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
d := ldap.NewDelRequest(userDN, []ldap.Control{})
err = conn.Del(d)
require.NoError(t, err)
}
func dialTLS(t *testing.T, env *testlib.TestEnv) *ldap.Conn {
t.Helper()
// dial tls
rootCAs := x509.NewCertPool()
success := rootCAs.AppendCertsFromPEM([]byte(env.SupervisorUpstreamActiveDirectory.CABundle))
require.True(t, success)
tlsConfig := ptls.DefaultLDAP(rootCAs)
dialer := &tls.Dialer{NetDialer: &net.Dialer{Timeout: time.Minute}, Config: tlsConfig}
c, err := dialer.DialContext(context.Background(), "tcp", env.SupervisorUpstreamActiveDirectory.Host)
require.NoError(t, err)
conn := ldap.NewConn(c, true)
conn.Start()
return conn
}
func createRandomHexString(t *testing.T, length int) string {
t.Helper()
bytes := make([]byte, length)
_, err := rand.Read(bytes)
require.NoError(t, err)
randomString := hex.EncodeToString(bytes)
return randomString
}
func createRandomASCIIString(t *testing.T, length int) string {
result := ""
for {
if len(result) >= length {
return result
}
num, err := rand.Int(rand.Reader, big.NewInt(int64(127)))
require.NoError(t, err)
n := num.Int64()
// Make sure that the number/byte/letter is inside
// the range of printable ASCII characters (excluding space and DEL)
if n > 32 && n < 127 {
result += string(rune(n))
}
}
}

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package testlib package testlib
@ -84,6 +84,7 @@ type TestOIDCUpstream struct {
type TestLDAPUpstream struct { type TestLDAPUpstream struct {
Host string `json:"host"` Host string `json:"host"`
Domain string `json:"domain"`
StartTLSOnlyHost string `json:"startTLSOnlyHost"` StartTLSOnlyHost string `json:"startTLSOnlyHost"`
CABundle string `json:"caBundle"` CABundle string `json:"caBundle"`
BindUsername string `json:"bindUsername"` BindUsername string `json:"bindUsername"`
@ -279,6 +280,7 @@ func loadEnvVars(t *testing.T, result *TestEnv) {
result.SupervisorUpstreamActiveDirectory = TestLDAPUpstream{ result.SupervisorUpstreamActiveDirectory = TestLDAPUpstream{
Host: wantEnv("PINNIPED_TEST_AD_HOST", ""), Host: wantEnv("PINNIPED_TEST_AD_HOST", ""),
Domain: wantEnv("PINNIPED_TEST_AD_DOMAIN", ""),
CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")), CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")),
BindUsername: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""), BindUsername: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""),
BindPassword: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""), BindPassword: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""),