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
# Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
# Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
FROM golang:1.17.4 as build-env
FROM golang:1.17.6 as build-env
WORKDIR /work
COPY . .
@ -24,7 +24,7 @@ RUN \
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.
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 --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
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,
occurring every first and third 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
to [the notes document](https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view).
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, occurring every first and third
Thursday of the month at 9 AM PT / 12 PM ET.
**Note:** Community meetings are currently paused until early 2022 as we wind down 2021!
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.
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
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
package cmd
@ -2850,7 +2850,7 @@ func TestGetKubeconfig(t *testing.T) {
})
issuerEndpointPtr = &issuerEndpoint
testLog := testlogger.New(t)
testLog := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
cmd := kubeconfigCommand(kubeconfigDeps{
getPathToSelf: func() (string, error) {
if tt.getPathToSelfErr != nil {
@ -2876,7 +2876,7 @@ func TestGetKubeconfig(t *testing.T) {
}
return fake, nil
},
log: testLog,
log: testLog.Logger,
})
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
package cmd
@ -358,8 +358,8 @@ func TestLoginOIDCCommand(t *testing.T) {
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testLogger := testlogger.New(t)
klog.SetLogger(testLogger)
testLogger := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
klog.SetLogger(testLogger.Logger)
var (
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
package cmd
@ -165,8 +165,8 @@ func TestLoginStaticCommand(t *testing.T) {
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testLogger := testlogger.New(t)
klog.SetLogger(testLogger)
testLogger := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
klog.SetLogger(testLogger.Logger)
cmd := staticLoginCommand(staticLoginDeps{
lookupEnv: func(s string) (string, bool) {
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
package cmd
@ -7,8 +7,6 @@ import (
"os"
"github.com/spf13/cobra"
"go.pinniped.dev/internal/plog"
)
//nolint: gochecknoglobals
@ -19,12 +17,6 @@ var rootCmd = &cobra.Command{
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.
// This is called by main.main(). It only needs to happen once to the rootCmd.
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
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
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
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
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
package v1alpha1

197
go.mod
View File

@ -2,152 +2,189 @@ module go.pinniped.dev
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 (
github.com/MakeNowJust/heredoc/v2 v2.0.1
github.com/coreos/go-oidc/v3 v3.0.0
github.com/creack/pty v1.1.14
github.com/coreos/go-oidc/v3 v3.1.0
github.com/creack/pty v1.1.17
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-logr/logr v0.4.0
github.com/go-logr/stdr v0.4.0
github.com/go-logr/logr v1.2.2
github.com/go-logr/stdr v1.2.2
github.com/gofrs/flock v0.8.1
github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.5.6
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/websocket v1.4.2
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
github.com/ory/fosite v0.40.2
github.com/ory/x v0.0.212
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4
github.com/ory/fosite v0.41.0
github.com/ory/x v0.0.331
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/pkg/errors v0.9.1
github.com/sclevine/agouti v3.0.0+incompatible
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/stretchr/testify v1.7.0
github.com/tdewolff/minify/v2 v2.9.21
go.uber.org/atomic v1.7.0
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602
github.com/tdewolff/minify/v2 v2.9.26
go.uber.org/atomic v1.9.0
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
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
k8s.io/api v0.22.2
k8s.io/apiextensions-apiserver v0.22.2
k8s.io/apimachinery v0.22.2
k8s.io/apiserver v0.22.2
k8s.io/client-go v0.22.2
k8s.io/component-base v0.22.2
k8s.io/gengo v0.0.0-20210203185629-de9496dff47b
k8s.io/klog/v2 v2.10.0
k8s.io/kube-aggregator v0.22.1
k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a
sigs.k8s.io/yaml v1.2.0
k8s.io/api v0.23.1
k8s.io/apiextensions-apiserver v0.23.1
k8s.io/apimachinery v0.23.1
k8s.io/apiserver v0.23.1
k8s.io/client-go v0.23.1
k8s.io/component-base v0.23.1
k8s.io/gengo v0.0.0-20211129171323-c02415ce4185
k8s.io/klog/v2 v2.40.1
k8s.io/kube-aggregator v0.23.1
k8s.io/utils v0.0.0-20211208161948-7d6a63dca704
sigs.k8s.io/yaml v1.3.0
)
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/autorest v0.11.18 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect
github.com/Azure/go-autorest/autorest v0.11.23 // 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/logger v0.2.1 // 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/PuerkitoBio/purell v1.1.1 // 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/blang/semver v3.5.1+incompatible // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/coreos/go-oidc v2.1.0+incompatible // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/coreos/go-oidc v2.2.1+incompatible // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/dgraph-io/ristretto v0.0.3 // indirect
github.com/emicklei/go-restful v2.9.5+incompatible // indirect
github.com/evanphx/json-patch v4.11.0+incompatible // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/emicklei/go-restful v2.15.0+incompatible // indirect
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
github.com/form3tech-oss/jwt-go v3.2.5+incompatible // indirect
github.com/fsnotify/fsnotify 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/jsonreference v0.19.5 // indirect
github.com/go-openapi/swag v0.19.14 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/swag v0.19.15 // 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/protobuf v1.5.2 // indirect
github.com/googleapis/gnostic v0.5.5 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.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/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/mailru/easyjson v0.7.6 // indirect
github.com/mattn/goveralls v0.0.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/goveralls v0.0.11 // 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/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/ory/go-acc v0.2.6 // indirect
github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/viper v1.7.5 // indirect
github.com/pborman/uuid v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.3 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.4 // 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_model v0.2.0 // indirect
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.3.2-0.20200723214538-8d17101741c8 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/afero v1.7.1 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/tdewolff/parse/v2 v2.5.19 // indirect
go.etcd.io/etcd/api/v3 v3.5.0 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.0 // indirect
go.etcd.io/etcd/client/v3 v3.5.0 // indirect
github.com/tdewolff/parse/v2 v2.5.26 // indirect
go.etcd.io/etcd/api/v3 v3.5.1 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.1 // indirect
go.etcd.io/etcd/client/v3 v3.5.1 // 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/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/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/metric v0.20.0 // indirect
go.opentelemetry.io/otel/trace v0.20.0 // indirect
go.opentelemetry.io/proto/otlp v0.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.17.0 // indirect
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect
golang.org/x/text v0.3.6 // indirect
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
golang.org/x/tools v0.1.2 // indirect
go.opentelemetry.io/otel/trace v1.2.0 // indirect
go.opentelemetry.io/proto/otlp v0.10.0 // indirect
go.uber.org/multierr v1.7.0 // indirect
go.uber.org/zap v1.19.1 // indirect
golang.org/x/mod v0.5.1 // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
golang.org/x/tools v0.1.8 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c // indirect
google.golang.org/grpc v1.38.0 // indirect
google.golang.org/protobuf v1.26.0 // indirect
google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb // indirect
google.golang.org/grpc v1.43.0 // indirect
google.golang.org/protobuf v1.27.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/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.22 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect
k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.27 // 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

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
// Package authenticators contains authenticator interfaces.
@ -35,6 +35,7 @@ type UserAuthenticator interface {
}
type Response struct {
User user.Info
DN string
User user.Info
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
package impersonator
@ -31,6 +31,7 @@ import (
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/sets"
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/audit/policy"
"k8s.io/apiserver/pkg/authentication/authenticator"
"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
serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil)
serverConfig.AuditPolicyRuleEvaluator = policy.NewFakePolicyRuleEvaluator(auditinternal.LevelMetadata, nil)
serverConfig.AuditBackend = &auditfake.Backend{}
// Probe the API server to figure out if anonymous auth is enabled.
@ -511,7 +512,7 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
return
}
ae := request.AuditEventFrom(r.Context())
ae := audit.AuditEventFrom(r.Context())
if ae == nil {
plog.Warning("aggregated API server logic did not set audit event but it is always supposed to do so",
"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
package impersonator
@ -27,6 +27,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/httpstream"
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/user"
@ -712,8 +713,8 @@ func TestImpersonator(t *testing.T) {
http.NotFound(w, r)
return
case "/apis/flowcontrol.apiserver.k8s.io/v1beta1/prioritylevelconfigurations",
"/apis/flowcontrol.apiserver.k8s.io/v1beta1/flowschemas":
case "/apis/flowcontrol.apiserver.k8s.io/v1beta2/prioritylevelconfigurations",
"/apis/flowcontrol.apiserver.k8s.io/v1beta2/flowschemas":
// ignore requests related to priority and fairness logic
require.Equal(t, http.MethodGet, r.Method)
http.NotFound(w, r)
@ -1125,7 +1126,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
Groups: testGroups,
Extra: testExtra,
}, nil, "")
ctx := request.WithAuditEvent(req.Context(), nil)
ctx := audit.WithAuditContext(req.Context(), nil)
req = req.WithContext(ctx)
return req
}(),
@ -1880,7 +1881,7 @@ func newRequest(t *testing.T, h http.Header, userInfo user.Info, event *auditint
if event != nil {
ae = event
}
ctx = request.WithAuditEvent(ctx, ae)
ctx = audit.WithAuditContext(ctx, &audit.AuditContext{Event: ae})
reqInfo := &request.RequestInfo{
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
// 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/issuer"
"go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/registry/credentialrequest"
)
@ -96,8 +95,6 @@ func addCommandlineFlagsToCommand(cmd *cobra.Command, app *App) {
"/etc/podinfo",
"path to Downward API volume mount",
)
plog.RemoveKlogGlobalFlags()
}
// 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
package apicerts
@ -251,13 +251,10 @@ func TestExpirerControllerSync(t *testing.T) {
0,
)
opts := &[]metav1.DeleteOptions{}
trackDeleteClient := testutil.NewDeleteOptionsRecorder(kubeAPIClient, opts)
c := NewCertsExpirerController(
namespace,
certsSecretResourceName,
trackDeleteClient,
kubeAPIClient,
kubeInformers.Core().V1().Secrets(),
controllerlib.WithInformer,
test.renewBefore,
@ -281,7 +278,7 @@ func TestExpirerControllerSync(t *testing.T) {
if test.wantDelete {
exActions = append(
exActions,
kubetesting.NewDeleteAction(
kubetesting.NewDeleteActionWithOptions(
schema.GroupVersionResource{
Group: "",
Version: "v1",
@ -289,18 +286,12 @@ func TestExpirerControllerSync(t *testing.T) {
},
namespace,
name,
testutil.NewPreconditions(testUID, testRV),
),
)
}
acActions := kubeAPIClient.Actions()
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
package cachecleaner
@ -143,11 +143,11 @@ func TestController(t *testing.T) {
if tt.initialCache != nil {
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()
jwtAuthenticators := informers.Authentication().V1alpha1().JWTAuthenticators()
controller := New(cache, webhooks, jwtAuthenticators, testLog)
controller := New(cache, webhooks, jwtAuthenticators, testLog.Logger)
ctx, cancel := context.WithCancel(context.Background())
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
package jwtcachefiller
@ -318,13 +318,13 @@ func TestController(t *testing.T) {
fakeClient := pinnipedfake.NewSimpleClientset(tt.jwtAuthenticators...)
informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
cache := authncache.New()
testLog := testlogger.New(t)
testLog := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
if tt.cache != nil {
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())
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
package webhookcachefiller
@ -88,9 +88,9 @@ func TestController(t *testing.T) {
fakeClient := pinnipedfake.NewSimpleClientset(tt.webhooks...)
informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
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())
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
package impersonatorconfig
@ -21,7 +21,6 @@ import (
"k8s.io/apimachinery/pkg/api/equality"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/apimachinery/pkg/util/errors"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/intstr"
@ -31,6 +30,7 @@ import (
corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
"k8s.io/utils/clock"
"go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
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
package impersonatorconfig
@ -29,12 +29,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/apimachinery/pkg/util/intstr"
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
kubernetesfake "k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake"
@ -95,7 +94,7 @@ func TestImpersonatorConfigControllerOptions(t *testing.T) {
nil,
caSignerName,
nil,
testLog,
testLog.Logger,
)
credIssuerInformerFilter = observableWithInformerOption.GetFilterForInformer(credIssuerInformer)
servicesInformerFilter = observableWithInformerOption.GetFilterForInformer(servicesInformer)
@ -270,8 +269,6 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
var subject controllerlib.Controller
var kubeAPIClient *kubernetesfake.Clientset
var deleteOptions *[]metav1.DeleteOptions
var deleteOptionsRecorder kubernetes.Interface
var pinnipedAPIClient *pinnipedfake.Clientset
var pinnipedInformerClient *pinnipedfake.Clientset
var pinnipedInformers pinnipedinformers.SharedInformerFactory
@ -550,7 +547,7 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
subject = NewImpersonatorConfigController(
installedInNamespace,
credentialIssuerResourceName,
deleteOptionsRecorder,
kubeAPIClient,
pinnipedAPIClient,
pinnipedInformers.Config().V1alpha1().CredentialIssuers(),
kubeInformers.Core().V1().Services(),
@ -562,11 +559,11 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
tlsSecretName,
caSecretName,
labels,
clock.NewFakeClock(frozenNow),
clocktesting.NewFakeClock(frozenNow),
impersonatorFunc,
caSignerName,
signingCertProvider,
testLog,
testLog.Logger,
)
controllerlib.TestWrap(t, subject, func(syncer controllerlib.Syncer) controllerlib.Syncer {
tlsServingCertDynamicCertProvider = syncer.(*impersonatorConfigController).tlsServingCertDynamicCertProvider
@ -1032,10 +1029,7 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
r.Equal("secrets", deleteAction.GetResource().Resource)
// validate that we set delete preconditions correctly
r.NotEmpty(*deleteOptions)
for _, opt := range *deleteOptions {
r.Equal(testutil.NewPreconditions("uid-1234", "rv-5678"), opt)
}
r.Equal(testutil.NewPreconditions("uid-1234", "rv-5678"), deleteAction.GetDeleteOptions())
}
var requireCASecretWasCreated = func(action coretesting.Action) []byte {
@ -1114,8 +1108,6 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
kubeinformers.WithNamespace(installedInNamespace),
)
kubeAPIClient = kubernetesfake.NewSimpleClientset()
deleteOptions = &[]metav1.DeleteOptions{}
deleteOptionsRecorder = testutil.NewDeleteOptionsRecorder(kubeAPIClient, deleteOptions)
pinnipedAPIClient = pinnipedfake.NewSimpleClientset()
frozenNow = time.Date(2021, time.March, 2, 7, 42, 0, 0, time.Local)
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
// 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"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/cache"
"k8s.io/apimachinery/pkg/util/clock"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
appsv1informers "k8s.io/client-go/informers/apps/v1"
corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2"
"k8s.io/klog/v2/klogr"
"k8s.io/utils/clock"
"k8s.io/utils/pointer"
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
package kubecertagent
@ -20,11 +20,11 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/cache"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
kubefake "k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"k8s.io/utils/pointer"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
@ -1027,17 +1027,14 @@ func TestAgentController(t *testing.T) {
tt.addKubeReactions(kubeClientset)
}
actualDeleteActionOpts := &[]metav1.DeleteOptions{}
trackDeleteKubeClient := testutil.NewDeleteOptionsRecorder(kubeClientset, actualDeleteActionOpts)
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)
defer ctrl.Finish()
mockExecutor := mocks.NewMockPodCommandExecutor(ctrl)
mockDynamicCert := mocks.NewMockDynamicCertPrivate(ctrl)
fakeClock := clock.NewFakeClock(now)
fakeClock := clocktesting.NewFakeClock(now)
execCache := cache.NewExpiringWithClock(fakeClock)
if tt.mocks != nil {
tt.mocks(t, mockExecutor.EXPECT(), mockDynamicCert.EXPECT(), execCache)
@ -1059,7 +1056,7 @@ func TestAgentController(t *testing.T) {
},
DiscoveryURLOverride: tt.discoveryURLOverride,
},
&kubeclient.Client{Kubernetes: trackDeleteKubeClient, PinnipedConcierge: conciergeClientset},
&kubeclient.Client{Kubernetes: kubeClientset, PinnipedConcierge: conciergeClientset},
kubeInformers.Core().V1().Pods(),
kubeInformers.Apps().V1().Deployments(),
kubeInformers.Core().V1().Pods(),
@ -1069,13 +1066,13 @@ func TestAgentController(t *testing.T) {
mockDynamicCert,
fakeClock,
execCache,
log,
log.Logger,
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
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)
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.
var actualDeploymentActionVerbs []string
var actualDeleteActionOpts []metav1.DeleteOptions
for _, a := range kubeClientset.Actions() {
if a.GetResource().Resource == "deployments" && a.GetVerb() != "get" { // ignore gets caused by hasDeploymentSynced
actualDeploymentActionVerbs = append(actualDeploymentActionVerbs, a.GetVerb())
if deleteAction, ok := a.(coretesting.DeleteAction); ok {
actualDeleteActionOpts = append(actualDeleteActionOpts, deleteAction.GetDeleteOptions())
}
}
}
if tt.wantDeploymentActionVerbs != nil {
assert.Equal(t, tt.wantDeploymentActionVerbs, actualDeploymentActionVerbs)
}
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.

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
package kubecertagent
@ -58,12 +58,10 @@ func TestLegacyPodCleanerController(t *testing.T) {
wantDistinctErrors []string
wantDistinctLogs []string
wantActions []coretesting.Action
wantDeleteOptions []metav1.DeleteOptions
}{
{
name: "no pods",
wantActions: []coretesting.Action{},
wantDeleteOptions: []metav1.DeleteOptions{},
name: "no pods",
wantActions: []coretesting.Action{},
},
{
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
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),
},
wantDeleteOptions: []metav1.DeleteOptions{
testutil.NewPreconditions("3", "4"),
},
},
{
name: "fail to delete",
@ -102,13 +97,9 @@ func TestLegacyPodCleanerController(t *testing.T) {
},
wantActions: []coretesting.Action{
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.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
},
wantDeleteOptions: []metav1.DeleteOptions{
testutil.NewPreconditions("3", "4"),
testutil.NewPreconditions("3", "4"),
coretesting.NewDeleteActionWithOptions(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name, testutil.NewPreconditions("3", "4")),
},
},
{
@ -126,10 +117,7 @@ func TestLegacyPodCleanerController(t *testing.T) {
wantDistinctErrors: []string{""},
wantActions: []coretesting.Action{
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
},
wantDeleteOptions: []metav1.DeleteOptions{
testutil.NewPreconditions("3", "4"),
coretesting.NewDeleteActionWithOptions(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name, testutil.NewPreconditions("3", "4")),
},
},
{
@ -148,7 +136,6 @@ func TestLegacyPodCleanerController(t *testing.T) {
wantActions: []coretesting.Action{
coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name),
},
wantDeleteOptions: []metav1.DeleteOptions{},
},
}
for _, tt := range tests {
@ -161,19 +148,16 @@ func TestLegacyPodCleanerController(t *testing.T) {
tt.addKubeReactions(kubeClientset)
}
opts := &[]metav1.DeleteOptions{}
trackDeleteClient := testutil.NewDeleteOptionsRecorder(kubeClientset, opts)
kubeInformers := informers.NewSharedInformerFactory(kubeClientset, 0)
log := testlogger.New(t)
log := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
controller := NewLegacyPodCleanerController(
AgentConfig{
Namespace: "concierge",
Labels: map[string]string{"extralabel": "labelvalue"},
},
&kubeclient.Client{Kubernetes: trackDeleteClient},
&kubeclient.Client{Kubernetes: kubeClientset},
kubeInformers.Core().V1().Pods(),
log,
log.Logger,
)
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.wantDistinctLogs, deduplicate(log.Lines()), "unexpected logs")
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
//

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
//

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
// Package activedirectoryupstreamwatcher implements a controller which watches ActiveDirectoryIdentityProviders.
@ -7,8 +7,12 @@ package activedirectoryupstreamwatcher
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"k8s.io/apimachinery/pkg/api/equality"
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.
// - perform nested group search by default.
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 {
@ -315,12 +334,21 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context,
Filter: adUpstreamImpl.Spec().GroupSearch().Filter(),
GroupNameAttribute: adUpstreamImpl.Spec().GroupSearch().GroupNameAttribute(),
},
Dialer: c.ldapDialer,
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")},
Dialer: c.ldapDialer,
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 == "" {
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)
@ -353,3 +381,84 @@ func (c *activeDirectoryWatcherController) updateStatus(ctx context.Context, ups
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
package activedirectoryupstreamwatcher
@ -220,7 +220,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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.
@ -536,7 +541,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -592,7 +602,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -651,7 +666,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -710,7 +730,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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(),
@ -768,7 +793,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -897,7 +927,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -1021,8 +1056,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234},
@ -1072,7 +1111,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -1271,8 +1315,13 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={}))",
GroupNameAttribute: "sAMAccountName",
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")},
GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": upstreamldap.GroupSAMAccountNameWithDomainSuffix},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
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{{
@ -1324,7 +1373,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -1380,7 +1434,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -1430,7 +1489,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -1626,7 +1690,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
Filter: testGroupSearchFilter,
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{{
@ -1753,6 +1822,16 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
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)
}
@ -1800,3 +1879,270 @@ func normalizeActiveDirectoryUpstreams(upstreams []v1alpha1.ActiveDirectoryIdent
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
package supervisorconfig
@ -11,10 +11,10 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/apimachinery/pkg/util/errors"
"k8s.io/client-go/util/retry"
"k8s.io/klog/v2"
"k8s.io/utils/clock"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
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
package supervisorconfig
@ -20,8 +20,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
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.
subject = NewFederationDomainWatcherController(
providersSetter,
clock.NewFakeClock(frozenNow),
clocktesting.NewFakeClock(frozenNow),
pinnipedAPIClient,
federationDomainInformers.Config().V1alpha1().FederationDomains(),
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
// 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)
if err != nil {
const klogLevelTrace = 6
@ -359,46 +364,35 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1
}
}
if additionalDiscoveryClaims.RevocationEndpoint != "" {
// Found a revocation URL. Try to parse it.
revocationURL, err := url.Parse(additionalDiscoveryClaims.RevocationEndpoint)
if err != nil {
return &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reasonInvalidResponse,
Message: fmt.Sprintf("failed to parse revocation endpoint URL: %v", err),
}
}
// 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),
}
// Found a revocation URL. Validate it.
revocationURL, revocationURLCondition := validateHTTPSURL(
additionalDiscoveryClaims.RevocationEndpoint,
"revocation endpoint",
reasonInvalidResponse,
)
if revocationURLCondition != nil {
return revocationURLCondition
}
// Remember the URL for later use.
result.RevocationURL = revocationURL
}
// Parse out and validate the discovered authorize endpoint.
authURL, err := url.Parse(discoveredProvider.Endpoint().AuthURL)
if err != nil {
return &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reasonInvalidResponse,
Message: fmt.Sprintf("failed to parse authorization endpoint URL: %v", err),
}
_, authorizeURLCondition := validateHTTPSURL(
discoveredProvider.Endpoint().AuthURL,
"authorization endpoint",
reasonInvalidResponse,
)
if authorizeURLCondition != nil {
return authorizeURLCondition
}
if authURL.Scheme != "https" {
return &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reasonInvalidResponse,
Message: fmt.Sprintf(`authorization endpoint URL scheme must be "https", not %q`, authURL.Scheme),
}
_, tokenURLCondition := validateHTTPSURL(
discoveredProvider.Endpoint().TokenURL,
"token endpoint",
reasonInvalidResponse,
)
if tokenURLCondition != nil {
return tokenURLCondition
}
// 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)
}
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
package oidcupstreamwatcher
@ -91,7 +91,7 @@ func TestOIDCUpstreamWatcherControllerFilterSecret(t *testing.T) {
nil,
pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(),
secretInformer,
testLog,
testLog.Logger,
withInformer.WithInformer,
)
@ -399,7 +399,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
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},
},
}},
@ -410,11 +410,10 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
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"="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 "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{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -435,8 +434,145 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Status: "False",
LastTransitionTime: now,
Reason: "Unreachable",
Message: `failed to perform OIDC discovery against "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee":
Get "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration": unsupported protocol [truncated 9 chars]`,
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: "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(),
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 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 "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{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -626,7 +762,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Status: "False",
LastTransitionTime: now,
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(),
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"="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 "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{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -673,7 +809,148 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Status: "False",
LastTransitionTime: now,
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)
fakeKubeClient := fake.NewSimpleClientset(tt.inputSecrets...)
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.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{
&upstreamoidc.ProviderConfig{Name: "initial-entry"},
@ -1134,7 +1411,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs
fakePinnipedClient,
pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(),
kubeInformers.Core().V1().Secrets(),
testLog,
testLog.Logger,
controllerlib.WithInformer,
)
@ -1253,6 +1530,7 @@ func newTestIssuer(t *testing.T) (string, string) {
Issuer: testURL,
AuthURL: "https://example.com/authorize",
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",
AuthURL: "https://example.com/authorize",
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) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/invalid",
AuthURL: "%",
Issuer: testURL + "/invalid",
AuthURL: "%",
TokenURL: "https://example.com/token",
})
})
@ -1282,6 +1562,7 @@ func newTestIssuer(t *testing.T) (string, string) {
Issuer: testURL + "/invalid-revocation-url",
AuthURL: "https://example.com/authorize",
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) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/insecure",
AuthURL: "http://example.com/authorize",
Issuer: testURL + "/insecure",
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) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/insecure-revocation-url",
AuthURL: "https://example.com/authorize",
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/",
AuthURL: "https://example.com/authorize",
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
package supervisorstorage
@ -13,9 +13,10 @@ import (
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/clock"
corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/utils/clock"
clocktesting "k8s.io/utils/clock/testing"
"k8s.io/utils/strings/slices"
pinnipedcontroller "go.pinniped.dev/internal/controller"
@ -88,7 +89,7 @@ func GarbageCollectorController(
func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error {
// 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
// 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
package supervisorstorage
@ -18,11 +18,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
kubernetesfake "k8s.io/client-go/kubernetes/fake"
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/fositestorage/accesstoken"
@ -127,13 +127,11 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
subject controllerlib.Controller
kubeInformerClient *kubernetesfake.Clientset
kubeClient *kubernetesfake.Clientset
deleteOptions *[]metav1.DeleteOptions
deleteOptionsRecorder kubernetes.Interface
kubeInformers kubeinformers.SharedInformerFactory
cancelContext context.Context
cancelContextCancelFunc context.CancelFunc
syncContext *controllerlib.Context
fakeClock *clock.FakeClock
fakeClock *clocktesting.FakeClock
frozenNow time.Time
)
@ -144,7 +142,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
subject = GarbageCollectorController(
idpCache,
fakeClock,
deleteOptionsRecorder,
kubeClient,
kubeInformers.Core().V1().Secrets(),
controllerlib.WithInformer,
)
@ -172,11 +170,9 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
kubeInformerClient = kubernetesfake.NewSimpleClientset()
kubeClient = kubernetesfake.NewSimpleClientset()
deleteOptions = &[]metav1.DeleteOptions{}
deleteOptionsRecorder = testutil.NewDeleteOptionsRecorder(kubeClient, deleteOptions)
kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0)
frozenNow = time.Now().UTC()
fakeClock = clock.NewFakeClock(frozenNow)
fakeClock = clocktesting.NewFakeClock(frozenNow)
unrelatedSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
@ -252,18 +248,11 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "first expired secret"),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "second expired secret"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "first expired secret", testutil.NewPreconditions("uid-123", "rv-456")),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "second expired secret", testutil.NewPreconditions("uid-789", "rv-555")),
},
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{})
r.NoError(err)
r.Len(list.Items, 2)
@ -514,18 +503,11 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// Both authcode session secrets are deleted.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession"),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "inactiveOIDCAuthcodeSession"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "inactiveOIDCAuthcodeSession", testutil.NewPreconditions("uid-456", "rv-456")),
},
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.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "invalidOIDCAuthcodeSession"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "invalidOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
},
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.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
},
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.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "wrongProviderNameOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
},
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.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "activeOIDCAuthcodeSession", testutil.NewPreconditions("uid-123", "rv-123")),
},
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.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "offlineAccessGrantedOIDCAccessTokenSession"),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "offlineAccessNotGrantedOIDCAccessTokenSession"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "offlineAccessGrantedOIDCAccessTokenSession", testutil.NewPreconditions("uid-123", "rv-123")),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "offlineAccessNotGrantedOIDCAccessTokenSession", testutil.NewPreconditions("uid-456", "rv-456")),
},
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.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "oidcRefreshSession"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "oidcRefreshSession", testutil.NewPreconditions("uid-123", "rv-123")),
},
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.
expiredSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "expired secret",
Namespace: installedInNamespace,
Name: "expired secret",
Namespace: installedInNamespace,
UID: "uid-747",
ResourceVersion: "rv-609",
Annotations: map[string]string{
"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.
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "expired secret"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "expired secret", testutil.NewPreconditions("uid-747", "rv-609")),
},
kubeClient.Actions(),
)
@ -1441,8 +1388,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.NoError(kubeClient.Tracker().Add(malformedSecret))
expiredSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "expired secret",
Namespace: installedInNamespace,
Name: "expired secret",
Namespace: installedInNamespace,
UID: "uid-748",
ResourceVersion: "rv-608",
Annotations: map[string]string{
"storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339),
},
@ -1458,7 +1407,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "expired secret"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "expired secret", testutil.NewPreconditions("uid-748", "rv-608")),
},
kubeClient.Actions(),
)
@ -1473,8 +1422,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
it.Before(func() {
erroringSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "erroring secret",
Namespace: installedInNamespace,
Name: "erroring secret",
Namespace: installedInNamespace,
UID: "uid-111",
ResourceVersion: "rv-222",
Annotations: map[string]string{
"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{
ObjectMeta: metav1.ObjectMeta{
Name: "expired secret",
Namespace: installedInNamespace,
Name: "expired secret",
Namespace: installedInNamespace,
UID: "uid-333",
ResourceVersion: "rv-444",
Annotations: map[string]string{
"storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339),
},
@ -1507,8 +1460,8 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.ElementsMatch(
[]kubetesting.Action{
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "erroring secret"),
kubetesting.NewDeleteAction(secretsGVR, installedInNamespace, "expired secret"),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "erroring secret", testutil.NewPreconditions("uid-111", "rv-222")),
kubetesting.NewDeleteActionWithOptions(secretsGVR, installedInNamespace, "expired secret", testutil.NewPreconditions("uid-333", "rv-444")),
},
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
// Package controllermanager provides an entrypoint into running all of the controllers that run as
@ -9,10 +9,10 @@ import (
"fmt"
"time"
"k8s.io/apimachinery/pkg/util/clock"
k8sinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2/klogr"
"k8s.io/utils/clock"
pinnipedclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned"
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
package crud
@ -18,9 +18,9 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
)
func TestStorage(t *testing.T) {
@ -62,7 +62,7 @@ func TestStorage(t *testing.T) {
name string
resource string
mocks func(*testing.T, mocker)
run func(*testing.T, Storage, *clock.FakeClock) error
run func(*testing.T, Storage, *clocktesting.FakeClock) error
wantActions []coretesting.Action
wantSecrets []corev1.Secret
wantErr string
@ -71,7 +71,7 @@ func TestStorage(t *testing.T) {
name: "get non-existent",
resource: "authcode",
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)
return err
},
@ -85,7 +85,7 @@ func TestStorage(t *testing.T) {
name: "delete non-existent",
resource: "tokens",
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")
},
wantActions: []coretesting.Action{
@ -98,7 +98,7 @@ func TestStorage(t *testing.T) {
name: "delete non-existent by label",
resource: "tokens",
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")
},
wantActions: []coretesting.Action{
@ -113,7 +113,7 @@ func TestStorage(t *testing.T) {
name: "create and get",
resource: "access-tokens",
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)
require.NotEmpty(t, signature)
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",
resource: "access-tokens",
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"}
rv1, err := storage.Create(ctx, "sig1", data, nil)
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",
resource: "access-tokens",
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)
require.NotEmpty(t, signature)
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)
},
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)
require.NotEmpty(t, signature)
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
})
},
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)
require.NotEmpty(t, signature)
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)
},
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)
require.NotEmpty(t, signature)
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",
}))
},
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")
},
wantActions: []coretesting.Action{
@ -696,7 +696,7 @@ func TestStorage(t *testing.T) {
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")
},
wantActions: []coretesting.Action{
@ -749,7 +749,7 @@ func TestStorage(t *testing.T) {
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")
},
wantActions: []coretesting.Action{
@ -783,7 +783,7 @@ func TestStorage(t *testing.T) {
})
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)
require.NotEmpty(t, signature)
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)
},
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)
require.NotEmpty(t, signature)
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)
},
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)
require.NotEmpty(t, signature)
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)
},
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)
require.NotEmpty(t, signature)
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)
}
secrets := client.CoreV1().Secrets(namespace)
fakeClock := clock.NewFakeClock(fakeNow)
fakeClock := clocktesting.NewFakeClock(fakeNow)
storage := New(tt.resource, secrets, fakeClock.Now, lifetime)
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
package accesstoken
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession"
@ -53,7 +53,7 @@ func TestAccessTokenStorage(t *testing.T) {
},
},
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"),
},
Type: "storage.pinniped.dev/access-token",
@ -122,7 +122,7 @@ func TestAccessTokenStorageRevocation(t *testing.T) {
},
},
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"),
},
Type: "storage.pinniped.dev/access-token",
@ -276,7 +276,7 @@ func TestCreateWithoutRequesterID(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, RevocationStorage) {
client := fake.NewSimpleClientset()
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) {

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

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
package openidconnect
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession"
@ -52,7 +52,7 @@ func TestOpenIdConnectStorage(t *testing.T) {
},
},
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"),
},
Type: "storage.pinniped.dev/oidc",
@ -100,7 +100,7 @@ func TestOpenIdConnectStorage(t *testing.T) {
require.NoError(t, err)
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)
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) {
client := fake.NewSimpleClientset()
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
package pkce
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession"
@ -52,7 +52,7 @@ func TestPKCEStorage(t *testing.T) {
},
},
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"),
},
Type: "storage.pinniped.dev/pkce",
@ -199,5 +199,5 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, pkce.PKCERequestStorage) {
client := fake.NewSimpleClientset()
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
package refreshtoken
@ -16,10 +16,10 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/clock"
"k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/psession"
@ -52,7 +52,7 @@ func TestRefreshTokenStorage(t *testing.T) {
},
},
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"),
},
Type: "storage.pinniped.dev/refresh-token",
@ -122,7 +122,7 @@ func TestRefreshTokenStorageRevocation(t *testing.T) {
},
},
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"),
},
Type: "storage.pinniped.dev/refresh-token",
@ -276,7 +276,7 @@ func TestCreateWithoutRequesterID(t *testing.T) {
func makeTestSubject() (context.Context, *fake.Clientset, corev1client.SecretInterface, RevocationStorage) {
client := fake.NewSimpleClientset()
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) {

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
package kubeclient
@ -8,8 +8,6 @@ import (
"crypto/x509"
"fmt"
"net/http"
"reflect"
"unsafe"
"github.com/google/go-cmp/cmp"
"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 {
// 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
@ -205,7 +203,7 @@ func AssertSecureConfig(kubeConfig *restclient.Config) error {
}
func AssertSecureTransport(rt http.RoundTripper) error {
tlsConfig, err := netTLSClientConfig(rt)
tlsConfig, err := net.TLSClientConfig(rt)
if err != nil {
return fmt.Errorf("failed to get TLS config: %w", err)
}
@ -224,33 +222,6 @@ func AssertSecureTransport(rt http.RoundTripper) error {
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) {
// 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

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
package kubeclient
@ -19,6 +19,7 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/net"
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
"k8s.io/client-go/rest"
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.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.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
//

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
//

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
//

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
//

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
//

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
//

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
//

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
//
@ -186,6 +186,20 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetUsernameClaim() *gom
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.
func (m *MockUpstreamOIDCIdentityProviderI) PasswordCredentialsGrantAndValidateTokens(arg0 context.Context, arg1, arg2 string) (*oidctypes.Token, error) {
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)
}
// ValidateToken mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) ValidateToken(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce) (*oidctypes.Token, error) {
// ValidateTokenAndMergeWithUserInfo mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) ValidateTokenAndMergeWithUserInfo(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce, arg3, arg4 bool) (*oidctypes.Token, error) {
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)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ValidateToken indicates an expected call of ValidateToken.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) ValidateToken(arg0, arg1, arg2 interface{}) *gomock.Call {
// ValidateTokenAndMergeWithUserInfo indicates an expected call of ValidateTokenAndMergeWithUserInfo.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) ValidateTokenAndMergeWithUserInfo(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call {
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
// Package auth provides a handler for the OIDC authorization endpoint.
@ -10,6 +10,7 @@ import (
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/felixge/httpsnoop"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
@ -89,7 +90,7 @@ func handleAuthRequestForLDAPUpstream(
ldapUpstream provider.UpstreamLDAPIdentityProviderI,
idpType psession.ProviderType,
) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper)
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, true)
if !created {
return nil
}
@ -106,7 +107,7 @@ func handleAuthRequestForLDAPUpstream(
}
if !authenticated {
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)
@ -122,12 +123,14 @@ func handleAuthRequestForLDAPUpstream(
if idpType == psession.ProviderTypeLDAP {
customSessionData.LDAP = &psession.LDAPSessionData{
UserDN: dn,
UserDN: dn,
ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes,
}
}
if idpType == psession.ProviderTypeActiveDirectory {
customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{
UserDN: dn,
UserDN: dn,
ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes,
}
}
@ -141,7 +144,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
oauthHelper fosite.OAuth2Provider,
oidcUpstream provider.UpstreamOIDCIdentityProviderI,
) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper)
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, true)
if !created {
return nil
}
@ -155,7 +158,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
// Return a user-friendly error for this case which is entirely within our control.
return writeAuthorizeError(w, oauthHelper, authorizeRequester,
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)
@ -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
// spec and returns something other than an "invalid_grant" error for bad resource owner credentials.)
return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithDebug(err.Error())) // 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."))
fosite.ErrAccessDenied.WithDebug(err.Error()), true) // WithDebug hides the error from the client
}
subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims)
if err != nil {
// Return a user-friendly error for this case which is entirely within our control.
return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()),
fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true,
)
}
customSessionData := &psession.CustomSessionData{
ProviderUID: oidcUpstream.GetResourceUID(),
ProviderName: oidcUpstream.GetName(),
ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: token.RefreshToken.Token,
},
customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token)
if err != nil {
return writeAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true,
)
}
return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups, customSessionData)
}
@ -212,7 +204,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
upstreamStateEncoder oidc.Encoder,
cookieCodec oidc.Codec,
) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper)
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false)
if !created {
return nil
}
@ -229,7 +221,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
},
})
if err != nil {
return writeAuthorizeError(w, oauthHelper, authorizeRequester, err)
return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, false)
}
csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE)
@ -272,7 +264,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
promptParam := r.Form.Get(promptParamName)
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() {
@ -293,13 +285,13 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
encodedStateParamValue,
authCodeOptions...,
),
302,
http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11
)
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) {
// When trace level logging is enabled, include the stack trace in the log message.
keysAndValues := oidc.FositeErrorForLog(err)
@ -312,6 +304,9 @@ func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provide
} else {
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).
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
@ -331,29 +326,53 @@ func makeDownstreamSessionAndReturnAuthcodeRedirect(
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil {
return writeAuthorizeError(w, oauthHelper, authorizeRequester, err)
return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, true)
}
w = rewriteStatusSeeOtherToStatusFoundForBrowserless(w)
oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder)
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) {
username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName)
password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName)
if username == "" || password == "" {
_ = 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 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)
if err != nil {
_ = writeAuthorizeError(w, oauthHelper, authorizeRequester, err)
_ = writeAuthorizeError(w, oauthHelper, authorizeRequester, err, isBrowserless)
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
package auth
@ -56,6 +56,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
oidcUpstreamUsernameClaim = "the-user-claim"
oidcUpstreamGroupsClaim = "the-groups-claim"
oidcPasswordGrantUpstreamRefreshToken = "some-opaque-token" //nolint: gosec
oidcUpstreamAccessToken = "some-access-token"
downstreamIssuer = "https://my-downstream-issuer.com/some-path"
downstreamRedirectURI = "http://127.0.0.1/callback"
@ -154,9 +155,15 @@ func TestAuthorizationEndpoint(t *testing.T) {
"state": happyState,
}
fositeAccessDeniedWithMissingRefreshTokenErrorQuery = map[string]string{
fositeAccessDeniedWithMissingAccessTokenErrorQuery = map[string]string{
"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,
}
@ -269,6 +276,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
happyLDAPUID := "some-ldap-uid"
happyLDAPUserDN := "cn=foo,dn=bar"
happyLDAPGroups := []string{"group1", "group2", "group3"}
happyLDAPExtraRefreshAttribute := "some-refresh-attribute"
happyLDAPExtraRefreshValue := "some-refresh-attribute-value"
parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL)
require.NoError(t, err)
@ -285,6 +294,9 @@ func TestAuthorizationEndpoint(t *testing.T) {
Groups: happyLDAPGroups,
},
DN: happyLDAPUserDN,
ExtraRefreshAttributes: map[string]string{
happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue,
},
}, true, nil
}
return nil, false, nil
@ -442,7 +454,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
OIDC: nil,
LDAP: nil,
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,
OIDC: nil,
LDAP: &psession.LDAPSessionData{
UserDN: happyLDAPUserDN,
UserDN: happyLDAPUserDN,
ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue},
},
ActiveDirectory: nil,
}
@ -463,6 +477,19 @@ func TestAuthorizationEndpoint(t *testing.T) {
ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{
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,
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil),
@ -608,7 +635,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
method: http.MethodGet,
path: happyGetRequestPath,
csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ",
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), nil),
wantUpstreamStateParamInLocationHeader: true,
@ -626,7 +653,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: "/some/path",
contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "",
wantBodyString: "",
wantCSRFValueInCookieHeader: happyCSRF,
@ -715,7 +742,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}),
contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyStringWithLocationInHref: true,
wantCSRFValueInCookieHeader: happyCSRF,
@ -734,7 +761,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}),
contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyStringWithLocationInHref: true,
wantCSRFValueInCookieHeader: happyCSRF,
@ -753,7 +780,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none"}),
contentType: "application/x-www-form-urlencoded",
body: encodeQuery(happyGetRequestQueryMap),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery),
wantBodyString: "",
@ -769,7 +796,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
method: http.MethodGet,
path: happyGetRequestPath,
csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped",
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
// Generated a new CSRF cookie and set it in the response.
wantCSRFValueInCookieHeader: happyCSRF,
@ -789,7 +816,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
path: modifiedHappyGetRequestPath(map[string]string{
"redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client
}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{
@ -855,7 +882,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid offline_access"}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{
@ -864,6 +891,50 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantUpstreamStateParamInLocationHeader: 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",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider),
@ -1006,8 +1077,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantBodyString: "",
},
{
name: "return an error when upstream IDP did not return a refresh token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().Build()),
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().WithAccessToken(oidcUpstreamAccessToken).WithoutUserInfoURL().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
@ -1015,12 +1086,12 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingRefreshTokenErrorQuery),
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUserInfoEndpointErrorQuery),
wantBodyString: "",
},
{
name: "return an error when upstream IDP did not return a refresh token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().Build()),
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().WithAccessToken(oidcUpstreamAccessToken).WithoutUserInfoURL().Build()),
method: http.MethodGet,
path: happyGetRequestPath,
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
@ -1028,7 +1099,59 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
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: "",
},
{
@ -1163,7 +1286,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "",
@ -1210,7 +1333,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid profile email tuna"}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery),
wantBodyString: "",
@ -1261,7 +1384,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "",
@ -1342,7 +1465,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery),
wantBodyString: "",
@ -1384,7 +1507,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
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",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery),
wantBodyString: "",
@ -1426,7 +1549,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "",
@ -1468,7 +1591,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "",
@ -1512,7 +1635,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery),
wantBodyString: "",
@ -1559,7 +1682,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
method: http.MethodGet,
// 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"}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(
@ -2042,7 +2165,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"state": "short"}),
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantContentType: "application/json; charset=utf-8",
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery),
wantBodyString: "",
@ -2301,8 +2424,16 @@ func TestAuthorizationEndpoint(t *testing.T) {
case test.wantBodyJSON != "":
require.JSONEq(t, test.wantBodyJSON, rsp.Body.String())
case test.wantBodyStringWithLocationInHref:
anchorTagWithLocationHref := fmt.Sprintf("<a href=\"%s\">Found</a>.\n\n", html.EscapeString(actualLocation))
require.Equal(t, anchorTagWithLocationHref, rsp.Body.String())
switch code := rsp.Code; code {
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:
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
// 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/formposthtml"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
)
func NewHandler(
@ -69,28 +68,17 @@ func NewHandler(
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)
if err != nil {
return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
}
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, &psession.CustomSessionData{
ProviderUID: upstreamIDPConfig.GetResourceUID(),
ProviderName: upstreamIDPConfig.GetName(),
ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: token.RefreshToken.Token,
},
})
customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(upstreamIDPConfig, token)
if err != nil {
return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
}
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData)
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
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
package callback
@ -31,6 +31,7 @@ const (
oidcUpstreamIssuer = "https://my-upstream-issuer.com"
oidcUpstreamRefreshToken = "test-refresh-token"
oidcUpstreamAccessToken = "test-access-token"
oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL
oidcUpstreamSubjectQueryEscaped = "abc123-some+guid"
oidcUpstreamUsername = "test-pinniped-username"
@ -77,7 +78,21 @@ var (
ProviderUID: happyUpstreamIDPResourceUID,
ProviderName: happyUpstreamIDPName,
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()),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -196,6 +211,29 @@ func TestCallbackEndpoint(t *testing.T) {
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",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
@ -204,7 +242,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -229,7 +267,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -256,7 +294,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -284,7 +322,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
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,
wantBody: "",
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",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().Build()),
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().WithAccessToken(oidcUpstreamAccessToken).WithoutUserInfoURL().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
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{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "return an error when upstream IDP returned an empty refresh token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().Build()),
name: "return an error when upstream IDP returned no refresh token and no access token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().WithoutAccessToken().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
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{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
@ -372,7 +452,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -397,7 +477,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -422,7 +502,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -575,7 +655,7 @@ func TestCallbackEndpoint(t *testing.T) {
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -601,7 +681,7 @@ func TestCallbackEndpoint(t *testing.T) {
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
@ -698,7 +778,7 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
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
// Package downstreamsession provides some shared helpers for creating downstream OIDC sessions.
package downstreamsession
import (
"errors"
"fmt"
"net/url"
"time"
@ -19,6 +20,7 @@ import (
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
)
const (
@ -58,6 +60,55 @@ func MakeDownstreamSession(subject string, username string, groups []string, cus
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.
func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) {
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID)
@ -89,11 +140,11 @@ func getSubjectAndUsernameFromUpstreamIDToken(
) (string, string, error) {
// The spec says the "sub" claim is only unique per issuer,
// 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 {
return "", "", err
}
upstreamSubject, err := extractStringClaimValue(oidc.IDTokenSubjectClaim, upstreamIDPConfig.GetName(), idTokenClaims)
upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil {
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 {
return "", "", err
}
@ -136,7 +187,7 @@ func getSubjectAndUsernameFromUpstreamIDToken(
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]
if !ok {
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
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 {
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
package provider
@ -40,6 +40,9 @@ type UpstreamOIDCIdentityProviderI interface {
// GetAuthorizationURL returns the Authorization Endpoint fetched from discovery.
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() []string
@ -83,10 +86,10 @@ type UpstreamOIDCIdentityProviderI interface {
// 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
// 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
// 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 {
@ -105,7 +108,14 @@ type UpstreamLDAPIdentityProviderI interface {
authenticators.UserAuthenticator
// 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 {

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
package manager
@ -121,7 +121,7 @@ func TestManager(t *testing.T) {
r.False(fallbackHandlerWasCalled)
// 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")
r.True(
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.
// 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")
r.True(
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
// Package token provides a handler for the OIDC token endpoint.
@ -6,16 +6,19 @@ package token
import (
"context"
"errors"
"net/http"
"github.com/ory/fosite"
"github.com/ory/x/errorsx"
"golang.org/x/oauth2"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
)
var (
@ -75,11 +78,6 @@ func NewHandler(
func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error {
session := accessRequest.GetSession().(*psession.PinnipedSession)
downstreamUsername, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
downstreamSubject := session.Fosite.Claims.Subject
customSessionData := session.Custom
if customSessionData == nil {
@ -93,18 +91,27 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester,
switch customSessionData.ProviderType {
case psession.ProviderTypeOIDC:
return upstreamOIDCRefresh(ctx, customSessionData, providerCache)
return upstreamOIDCRefresh(ctx, session, providerCache)
case psession.ProviderTypeLDAP:
return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject)
return upstreamLDAPRefresh(ctx, providerCache, session)
case psession.ProviderTypeActiveDirectory:
return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject)
return upstreamLDAPRefresh(ctx, providerCache, session)
default:
return errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
}
func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister) error {
if s.OIDC == nil || s.OIDC.UpstreamRefreshToken == "" {
func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession, providerCache oidc.UpstreamIdentityProvidersLister) error {
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)
}
@ -116,42 +123,98 @@ func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, pro
plog.Debug("attempting upstream refresh request",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
refreshedTokens, err := p.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken)
if err != nil {
return errorsx.WithStack(errUpstreamRefreshError.WithHint(
"Upstream refresh failed.",
).WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
var tokens *oauth2.Token
if refreshTokenStored {
tokens, err = p.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken)
if err != nil {
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:
// "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
_, hasIDTok := refreshedTokens.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
// least some providers do not include one, so we skip the nonce validation here (but not other validations).
_, err = p.ValidateToken(ctx, refreshedTokens, "")
if err != nil {
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))
}
} else {
plog.Debug("upstream refresh request did not return a new ID token",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
_, hasIDTok := tokens.Extra("id_token").(string)
// 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).
validatedTokens, err := p.ValidateTokenAndMergeWithUserInfo(ctx, tokens, "", hasIDTok, accessTokenStored)
if err != nil {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf(
"Upstream refresh returned an invalid ID token or UserInfo response.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
err = validateIdentityUnchangedSinceInitialLogin(validatedTokens, session, p.GetUsernameClaim())
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
// 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.
if refreshedTokens.RefreshToken != "" {
plog.Debug("upstream refresh request did not return a new refresh token",
if tokens.RefreshToken != "" {
plog.Debug("upstream refresh request returned a new refresh token",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
s.OIDC.UpstreamRefreshToken = refreshedTokens.RefreshToken
s.OIDC.UpstreamRefreshToken = tokens.RefreshToken
}
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(
s *psession.CustomSessionData,
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))
}
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
validLDAP := s.ProviderType == psession.ProviderTypeLDAP && s.LDAP != nil && s.LDAP.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)
}
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
p, dn, err := findLDAPProviderByNameAndValidateUID(s, providerCache)
if err != nil {
return err
}
if session.IDTokenClaims().AuthTime.IsZero() {
return errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
// 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 {
return errorsx.WithStack(errUpstreamRefreshError.WithHint(
"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
package token
@ -52,10 +52,12 @@ import (
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/oidctestutil"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
)
const (
goodIssuer = "https://some-issuer.com"
goodUpstreamSubject = "some-subject"
goodClient = "pinniped-cli"
goodRedirectURI = "http://127.0.0.1/callback"
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 {
performedByUpstreamName string
args *oidctestutil.ValidateTokenArgs
args *oidctestutil.ValidateTokenAndMergeWithUserInfoArgs
}
type tokenEndpointResponseExpectedValues struct {
@ -879,6 +881,7 @@ func TestRefreshGrant(t *testing.T) {
oidcUpstreamInitialRefreshToken = "initial-upstream-refresh-token"
oidcUpstreamRefreshedIDToken = "fake-refreshed-id-token"
oidcUpstreamRefreshedRefreshToken = "fake-refreshed-refresh-token"
oidcUpstreamAccessToken = "fake-upstream-access-token" //nolint:gosec
ldapUpstreamName = "some-ldap-idp"
ldapUpstreamResourceUID = "ldap-resource-uid"
@ -902,19 +905,34 @@ func TestRefreshGrant(t *testing.T) {
WithResourceUID(oidcUpstreamResourceUID)
}
initialUpstreamOIDCCustomSessionData := func() *psession.CustomSessionData {
initialUpstreamOIDCRefreshTokenCustomSessionData := func() *psession.CustomSessionData {
return &psession.CustomSessionData{
ProviderName: oidcUpstreamName,
ProviderUID: oidcUpstreamResourceUID,
ProviderType: oidcUpstreamType,
OIDC: &psession.OIDCSessionData{
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 {
sessionData := initialUpstreamOIDCCustomSessionData()
sessionData := initialUpstreamOIDCRefreshTokenCustomSessionData()
sessionData.OIDC.UpstreamRefreshToken = newRefreshToken
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{
performedByUpstreamName: oidcUpstreamName,
args: &oidctestutil.ValidateTokenArgs{
args: &oidctestutil.ValidateTokenAndMergeWithUserInfoArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
Tok: expectedTokens,
ExpectedIDTokenNonce: "", // always expect empty string
RequireUserInfo: false,
RequireIDToken: requireIDToken,
},
}
}
@ -981,9 +1001,8 @@ func TestRefreshGrant(t *testing.T) {
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
// Should always try to perform an upstream refresh.
want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall()
// Should only try to ValidateToken when there was an id token returned by the upstream refresh.
if expectToValidateToken != nil {
want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken)
want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true)
}
return want
}
@ -1046,11 +1065,17 @@ func TestRefreshGrant(t *testing.T) {
{
name: "happy path refresh grant with openid scope granted (id token returned)",
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{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
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)",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()),
upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(),
wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
},
},
refreshRequest: refreshRequestInputs{
@ -1081,7 +1175,7 @@ func TestRefreshGrant(t *testing.T) {
wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false),
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",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(
upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()),
upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{
IDToken: &oidctypes.IDToken{
Claims: map[string]interface{}{},
},
}).WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForOpenIDAndOfflineAccess(
upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
nil, // expect ValidateToken is *not* called
),
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
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",
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{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForOpenIDAndOfflineAccess(
initialUpstreamOIDCCustomSessionData(), // still has the initial refresh token stored
initialUpstreamOIDCRefreshTokenCustomSessionData(), // still has the initial refresh token stored
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",
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{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
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",
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{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access pinniped:request-audience") },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(),
wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
},
},
refreshRequest: refreshRequestInputs{
@ -1162,7 +1283,7 @@ func TestRefreshGrant(t *testing.T) {
wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
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",
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{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
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",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(),
wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
},
},
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",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(),
wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
},
},
refreshRequest: refreshRequestInputs{
@ -1238,14 +1365,14 @@ func TestRefreshGrant(t *testing.T) {
name: "when the wrong client ID is included in the refresh request",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"},
wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(),
wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(),
},
},
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()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: &psession.CustomSessionData{
@ -1417,7 +1544,8 @@ func TestRefreshGrant(t *testing.T) {
ProviderUID: oidcUpstreamResourceUID,
ProviderType: oidcUpstreamType,
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") },
@ -1428,6 +1556,7 @@ func TestRefreshGrant(t *testing.T) {
ProviderType: oidcUpstreamType,
OIDC: &psession.OIDCSessionData{
UpstreamRefreshToken: "", // this should not happen in practice
UpstreamAccessToken: "",
},
},
),
@ -1508,9 +1637,9 @@ func TestRefreshGrant(t *testing.T) {
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().
WithPerformRefreshError(errors.New("some upstream refresh error")).Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
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",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().
WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).
// This is the current format of the errors returned by the production code version of ValidateToken, see ValidateToken in upstreamoidc.go
WithValidateTokenError(httperr.Wrap(http.StatusBadRequest, "some validate error", errors.New("some validate cause"))).
// This is the current format of the errors returned by the production code version of ValidateTokenAndMergeWithUserInfo, see ValidateTokenAndMergeWithUserInfo in upstreamoidc.go
WithValidateTokenAndMergeWithUserInfoError(httperr.Wrap(http.StatusBadRequest, "some validate error", errors.New("some validate cause"))).
Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamOIDCCustomSessionData(),
customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(),
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"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 {
test := test
@ -2758,7 +3223,7 @@ func requireValidStoredRequest(
// 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.AuthenticationMethodsReference)
require.Empty(t, claims.AuthenticationMethodsReferences)
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
package plog
import (
"fmt"
"sync"
"github.com/spf13/pflag"
"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.
func KObj(obj klog.KMetadata) string {
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
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
// downstream refresh.
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.
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.
type ActiveDirectorySessionData struct {
UserDN string `json:"userDN"`
UserDN string `json:"userDN"`
ExtraRefreshAttributes map[string]string `json:"extraRefreshAttributes,omitempty"`
}
// 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
package credentialrequest
@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/go-logr/logr"
"github.com/golang/mock/gomock"
"github.com/sclevine/spec"
"github.com/stretchr/testify/require"
@ -71,11 +72,11 @@ func TestCreate(t *testing.T) {
r = require.New(t)
ctrl = gomock.NewController(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() {
klog.SetLogger(nil)
klog.ClearLogger()
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
// Package server defines the entrypoint for the Pinniped Supervisor server.
@ -20,7 +20,6 @@ import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/clock"
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
@ -29,6 +28,7 @@ import (
"k8s.io/component-base/logs"
"k8s.io/klog/v2"
"k8s.io/klog/v2/klogr"
"k8s.io/utils/clock"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
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
logs.InitLogs()
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("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
package testutil
import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"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 {
return metav1.DeleteOptions{
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
package oidctestutil
@ -76,12 +76,20 @@ type RevokeTokenArgs struct {
TokenType provider.RevocableTokenType
}
// ValidateTokenArgs is used to spy on calls to
// TestUpstreamOIDCIdentityProvider.ValidateTokenFunc().
type ValidateTokenArgs struct {
// ValidateTokenAndMergeWithUserInfoArgs is used to spy on calls to
// TestUpstreamOIDCIdentityProvider.ValidateTokenAndMergeWithUserInfoFunc().
type ValidateTokenAndMergeWithUserInfoArgs struct {
Ctx context.Context
Tok *oauth2.Token
ExpectedIDTokenNonce nonce.Nonce
RequireIDToken bool
RequireUserInfo bool
}
type ValidateRefreshArgs struct {
Ctx context.Context
Tok *oauth2.Token
StoredAttributes provider.StoredRefreshAttributes
}
type TestUpstreamLDAPIdentityProvider struct {
@ -112,16 +120,16 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.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 {
u.performRefreshArgs = make([]*PerformRefreshArgs, 0)
}
u.performRefreshCallCount++
u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{
Ctx: ctx,
DN: userDN,
ExpectedUsername: expectedUsername,
ExpectedSubject: expectedSubject,
DN: storedRefreshAttributes.DN,
ExpectedUsername: storedRefreshAttributes.Username,
ExpectedSubject: storedRefreshAttributes.Subject,
})
if u.PerformRefreshErr != nil {
return u.PerformRefreshErr
@ -145,6 +153,7 @@ type TestUpstreamOIDCIdentityProvider struct {
ClientID string
ResourceUID types.UID
AuthorizationURL url.URL
UserInfoURL bool
RevocationURL *url.URL
UsernameClaim string
GroupsClaim string
@ -169,7 +178,7 @@ type TestUpstreamOIDCIdentityProvider struct {
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
exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs
@ -179,8 +188,8 @@ type TestUpstreamOIDCIdentityProvider struct {
performRefreshArgs []*PerformRefreshArgs
revokeTokenCallCount int
revokeTokenArgs []*RevokeTokenArgs
validateTokenCallCount int
validateTokenArgs []*ValidateTokenArgs
validateTokenAndMergeWithUserInfoCallCount int
validateTokenAndMergeWithUserInfoArgs []*ValidateTokenAndMergeWithUserInfoArgs
}
var _ provider.UpstreamOIDCIdentityProviderI = &TestUpstreamOIDCIdentityProvider{}
@ -205,6 +214,10 @@ func (u *TestUpstreamOIDCIdentityProvider) GetAuthorizationURL() *url.URL {
return &u.AuthorizationURL
}
func (u *TestUpstreamOIDCIdentityProvider) HasUserInfoURL() bool {
return u.UserInfoURL
}
func (u *TestUpstreamOIDCIdentityProvider) GetRevocationURL() *url.URL {
return u.RevocationURL
}
@ -314,28 +327,30 @@ func (u *TestUpstreamOIDCIdentityProvider) RevokeTokenArgs(call int) *RevokeToke
return u.revokeTokenArgs[call]
}
func (u *TestUpstreamOIDCIdentityProvider) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
if u.validateTokenArgs == nil {
u.validateTokenArgs = make([]*ValidateTokenArgs, 0)
func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenAndMergeWithUserInfo(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce, requireIDToken bool, requireUserInfo bool) (*oidctypes.Token, error) {
if u.validateTokenAndMergeWithUserInfoArgs == nil {
u.validateTokenAndMergeWithUserInfoArgs = make([]*ValidateTokenAndMergeWithUserInfoArgs, 0)
}
u.validateTokenCallCount++
u.validateTokenArgs = append(u.validateTokenArgs, &ValidateTokenArgs{
u.validateTokenAndMergeWithUserInfoCallCount++
u.validateTokenAndMergeWithUserInfoArgs = append(u.validateTokenAndMergeWithUserInfoArgs, &ValidateTokenAndMergeWithUserInfoArgs{
Ctx: ctx,
Tok: tok,
ExpectedIDTokenNonce: expectedIDTokenNonce,
RequireIDToken: requireIDToken,
RequireUserInfo: requireUserInfo,
})
return u.ValidateTokenFunc(ctx, tok, expectedIDTokenNonce)
return u.ValidateTokenAndMergeWithUserInfoFunc(ctx, tok, expectedIDTokenNonce)
}
func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenCallCount() int {
return u.validateTokenCallCount
func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenAndMergeWithUserInfoCallCount() int {
return u.validateTokenAndMergeWithUserInfoCallCount
}
func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenArgs(call int) *ValidateTokenArgs {
if u.validateTokenArgs == nil {
u.validateTokenArgs = make([]*ValidateTokenArgs, 0)
func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenAndMergeWithUserInfoArgs(call int) *ValidateTokenAndMergeWithUserInfoArgs {
if u.validateTokenAndMergeWithUserInfoArgs == nil {
u.validateTokenAndMergeWithUserInfoArgs = make([]*ValidateTokenAndMergeWithUserInfoArgs, 0)
}
return u.validateTokenArgs[call]
return u.validateTokenAndMergeWithUserInfoArgs[call]
}
type UpstreamIDPListerBuilder struct {
@ -520,25 +535,25 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *te
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToValidateToken(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *ValidateTokenArgs,
expectedArgs *ValidateTokenAndMergeWithUserInfoArgs,
) {
t.Helper()
var actualArgs *ValidateTokenArgs
var actualArgs *ValidateTokenAndMergeWithUserInfoArgs
var actualNameOfUpstreamWhichMadeCall string
actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstreamOIDC.validateTokenCallCount
callCountOnThisUpstream := upstreamOIDC.validateTokenAndMergeWithUserInfoCallCount
actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name
actualArgs = upstreamOIDC.validateTokenArgs[0]
actualArgs = upstreamOIDC.validateTokenAndMergeWithUserInfoArgs[0]
}
}
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,
"ValidateToken() was called on the wrong OIDC upstream",
"ValidateTokenAndMergeWithUserInfo() was called on the wrong OIDC upstream",
)
require.Equal(t, expectedArgs, actualArgs)
}
@ -547,10 +562,10 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToValidateToken(t *tes
t.Helper()
actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.validateTokenCallCount
actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.validateTokenAndMergeWithUserInfoCallCount
}
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 {
name string
resourceUID types.UID
clientID string
scopes []string
idToken map[string]interface{}
refreshToken *oidctypes.RefreshToken
usernameClaim string
groupsClaim string
refreshedTokens *oauth2.Token
validatedTokens *oidctypes.Token
authorizationURL url.URL
additionalAuthcodeParams map[string]string
allowPasswordGrant bool
authcodeExchangeErr error
passwordGrantErr error
performRefreshErr error
revokeTokenErr error
validateTokenErr error
name string
resourceUID types.UID
clientID string
scopes []string
idToken map[string]interface{}
refreshToken *oidctypes.RefreshToken
accessToken *oidctypes.AccessToken
usernameClaim string
groupsClaim string
refreshedTokens *oauth2.Token
validatedAndMergedWithUserInfoTokens *oidctypes.Token
authorizationURL url.URL
hasUserInfoURL bool
additionalAuthcodeParams map[string]string
allowPasswordGrant bool
authcodeExchangeErr error
passwordGrantErr error
performRefreshErr error
revokeTokenErr error
validateTokenAndMergeWithUserInfoErr error
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithName(value string) *TestUpstreamOIDCIdentityProviderBuilder {
@ -636,6 +653,16 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAuthorizationURL(value url
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 {
u.allowPasswordGrant = value
return u
@ -699,6 +726,20 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutRefreshToken() *TestUps
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 {
u.authcodeExchangeErr = err
return u
@ -719,13 +760,13 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithPerformRefreshError(err er
return u
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidatedTokens(tokens *oidctypes.Token) *TestUpstreamOIDCIdentityProviderBuilder {
u.validatedTokens = tokens
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidatedAndMergedWithUserInfoTokens(tokens *oidctypes.Token) *TestUpstreamOIDCIdentityProviderBuilder {
u.validatedAndMergedWithUserInfoTokens = tokens
return u
}
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidateTokenError(err error) *TestUpstreamOIDCIdentityProviderBuilder {
u.validateTokenErr = err
func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidateTokenAndMergeWithUserInfoError(err error) *TestUpstreamOIDCIdentityProviderBuilder {
u.validateTokenAndMergeWithUserInfoErr = err
return u
}
@ -744,18 +785,19 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdent
Scopes: u.scopes,
AllowPasswordGrant: u.allowPasswordGrant,
AuthorizationURL: u.authorizationURL,
UserInfoURL: u.hasUserInfoURL,
AdditionalAuthcodeParams: u.additionalAuthcodeParams,
ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
if u.authcodeExchangeErr != nil {
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) {
if u.passwordGrantErr != nil {
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) {
if u.performRefreshErr != nil {
@ -766,11 +808,11 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdent
RevokeTokenFunc: func(ctx context.Context, refreshToken string, tokenType provider.RevocableTokenType) error {
return u.revokeTokenErr
},
ValidateTokenFunc: func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
if u.validateTokenErr != nil {
return nil, u.validateTokenErr
ValidateTokenAndMergeWithUserInfoFunc: func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
if u.validateTokenAndMergeWithUserInfoErr != nil {
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.AccessTokenHash)
require.Empty(t, actualClaims.AuthenticationContextClassReference)
require.Empty(t, actualClaims.AuthenticationMethodsReference)
require.Empty(t, actualClaims.AuthenticationMethodsReferences)
// Check that the custom Pinniped session data matches.
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
package testutil
@ -29,6 +29,8 @@ func NewFakePinnipedSession() *psession.PinnipedSession {
ProviderName: "fake-provider-name",
OIDC: &psession.OIDCSessionData{
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
// Package testlogger implements a logr.Logger suitable for writing test assertions.
// Package testlogger wraps logr.Logger to allow for writing test assertions.
package testlogger
import (
@ -17,20 +17,27 @@ import (
"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 {
logr.Logger
Logger logr.Logger
t *testing.T
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 {
res := Logger{t: t}
res.Logger = stdr.New(log.New(&res.buffer, "", 0))
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.
func (l *Logger) Lines() []string {
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
package testutil
@ -17,7 +17,7 @@ type TranscriptLogger struct {
transcript []TranscriptLogMessage
}
var _ logr.Logger = &TranscriptLogger{}
var _ logr.LogSink = &TranscriptLogger{}
type TranscriptLogMessage struct {
Level string
@ -36,7 +36,7 @@ func (log *TranscriptLogger) Transcript() []TranscriptLogMessage {
return result
}
func (log *TranscriptLogger) Info(msg string, keysAndValues ...interface{}) {
func (log *TranscriptLogger) Info(level int, msg string, keysAndValues ...interface{}) {
log.lock.Lock()
defer log.lock.Unlock()
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
}
func (log *TranscriptLogger) V(_ int) logr.Logger {
func (log *TranscriptLogger) V(_ int) logr.LogSink {
return log
}
func (log *TranscriptLogger) WithName(_ string) logr.Logger {
func (log *TranscriptLogger) WithName(_ string) logr.LogSink {
return log
}
func (log *TranscriptLogger) WithValues(_ ...interface{}) logr.Logger {
func (log *TranscriptLogger) WithValues(_ ...interface{}) logr.LogSink {
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
// Package upstreamldap implements an abstraction of upstream LDAP IDP interactions.
@ -13,13 +13,11 @@ import (
"fmt"
"net"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/utils/trace"
@ -39,7 +37,6 @@ const (
groupSearchPageSize = uint32(250)
defaultLDAPPort = uint16(389)
defaultLDAPSPort = uint16(636)
sAMAccountNameAttribute = "sAMAccountName"
)
// 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
// name when it comes out of LDAP.
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.
@ -170,9 +170,11 @@ func (p *Provider) GetConfig() ProviderConfig {
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()})
defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches
userDN := storedRefreshAttributes.DN
searchResult, err := p.performRefresh(ctx, userDN)
if err != nil {
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.
// we don't need to worry about logging this because we know it's a dn.
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),
)
}
userEntry := searchResult.Entries[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)
if err != nil {
return err
}
if newUsername != expectedUsername {
return fmt.Errorf(`searching for user "%s" returned a different username than the previous value. expected: "%s", actual: "%s"`,
userDN, expectedUsername, newUsername,
if newUsername != storedRefreshAttributes.Username {
return fmt.Errorf(`searching for user %q returned a different username than the previous value. expected: %q, actual: %q`,
userDN, storedRefreshAttributes.Username, newUsername,
)
}
@ -207,10 +209,15 @@ func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername,
return err
}
newSubject := downstreamsession.DownstreamLDAPSubject(newUID, *p.GetURL())
if newSubject != expectedSubject {
return fmt.Errorf(`searching for user "%s" produced a different subject than the previous value. expected: "%s", actual: "%s"`, userDN, expectedSubject, newSubject)
if newSubject != storedRefreshAttributes.Subject {
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.
return nil
}
@ -220,19 +227,19 @@ func (p *Provider) performRefresh(ctx context.Context, userDN string) (*ldap.Sea
conn, err := p.dial(ctx)
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()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
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)
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
}
@ -362,13 +369,13 @@ func (p *Provider) TestConnection(ctx context.Context) error {
conn, err := p.dial(ctx)
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()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
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
@ -413,14 +420,14 @@ func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bi
conn, err := p.dial(ctx)
if err != nil {
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()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
if err != nil {
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)
@ -448,7 +455,7 @@ func (p *Provider) searchGroupsForUserDN(conn Conn, userDN string) ([]string, er
groupAttributeName = distinguishedNameAttributeName
}
groups := []string{}
var groups []string
entries:
for _, groupEntry := range searchResult.Entries {
if len(groupEntry.DN) == 0 {
@ -488,14 +495,14 @@ func (p *Provider) SearchForDefaultNamingContext(ctx context.Context) (string, e
conn, err := p.dial(ctx)
if err != nil {
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()
err = conn.Bind(p.c.BindUsername, p.c.BindPassword)
if err != nil {
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())
@ -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
// someone's password mistakenly entered into the username field, so we can log it without concern.
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),
)
}
userEntry := searchResult.Entries[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)
@ -560,7 +567,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
return nil, err
}
mappedGroupNames := []string{}
var mappedGroupNames []string
if len(p.c.GroupSearch.Base) > 0 {
mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN)
if err != nil {
@ -569,6 +576,15 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
}
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!
err = bindFunc(conn, userEntry.DN)
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 {
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 {
@ -592,7 +608,8 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
UID: mappedUID,
Groups: mappedGroupNames,
},
DN: userEntry.DN,
DN: userEntry.DN,
ExtraRefreshAttributes: mappedRefreshAttributes,
}
return response, nil
@ -658,13 +675,16 @@ func (p *Provider) refreshUserSearchRequest(dn string) *ldap.SearchRequest {
}
func (p *Provider) userSearchRequestedAttributes() []string {
attributes := []string{}
attributes := make([]string, 0, len(p.c.RefreshAttributeChecks)+2)
if p.c.UserSearch.UsernameAttribute != distinguishedNameAttributeName {
attributes = append(attributes, p.c.UserSearch.UsernameAttribute)
}
if p.c.UserSearch.UIDAttribute != distinguishedNameAttributeName {
attributes = append(attributes, p.c.UserSearch.UIDAttribute)
}
for k := range p.c.RefreshAttributeChecks {
attributes = append(attributes, k)
}
return attributes
}
@ -716,14 +736,14 @@ func (p *Provider) getSearchResultAttributeRawValueEncoded(attributeName string,
attributeValues := entry.GetRawAttributeValues(attributeName)
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,
)
}
attributeValue := attributeValues[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,
)
}
@ -743,14 +763,14 @@ func (p *Provider) getSearchResultAttributeValue(attributeName string, entry *ld
attributeValues := entry.GetAttributeValues(attributeName)
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,
)
}
attributeValue := attributeValues[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,
)
}
@ -782,57 +802,18 @@ func (p *Provider) traceRefreshFailure(t *trace.Trace, err error) {
)
}
func MicrosoftUUIDFromBinary(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 AttributeUnchangedSinceLogin(attribute string) func(*ldap.Entry, provider.StoredRefreshAttributes) error {
return func(entry *ldap.Entry, storedAttributes provider.StoredRefreshAttributes) error {
prevAttributeValue := storedAttributes.AdditionalAttributes[attribute]
newValues := entry.GetRawAttributeValues(attribute)
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
package upstreamldap
@ -26,6 +26,7 @@ import (
"go.pinniped.dev/internal/crypto/ptls"
"go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/mocks/mockldapconn"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/tlsserver"
)
@ -154,16 +155,17 @@ func TestEndUserAuthentication(t *testing.T) {
}
// 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{
Name: testUserSearchResultUsernameAttributeValue,
UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2},
}
response := &authenticators.Response{User: u, DN: testUserSearchResultDNValue, ExtraRefreshAttributes: map[string]string{}}
if editFunc != nil {
editFunc(u)
editFunc(response)
}
return &authenticators.Response{User: u, DN: testUserSearchResultDNValue}
return response
}
tests := []struct {
@ -250,8 +252,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.Groups = []string{}
wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
info := r.User.(*user.DefaultInfo)
info.Groups = nil
}),
},
{
@ -282,8 +285,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.Name = testUserSearchResultDNValue
wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
info := r.User.(*user.DefaultInfo)
info.Name = testUserSearchResultDNValue
}),
},
{
@ -314,8 +318,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue))
wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
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) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2}
wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
info := r.User.(*user.DefaultInfo)
info.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2}
}),
},
{
@ -360,8 +366,9 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2}
wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
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)),
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,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){
"objectGUID": MicrosoftUUIDFromBinary("objectGUID"),
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{
"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) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
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{
Entries: []*ldap.Entry{
{
DN: testUserSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
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).
Return(exampleGroupSearchResult, nil).Times(1)
conn.EXPECT().Close().Times(1)
@ -541,172 +552,31 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
},
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.UID = "04030201-0605-0807-0910-111213141516"
wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) {
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,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){
"objectGUID": MicrosoftUUIDFromBinary("objectGUID"),
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{
"some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error {
return nil
},
}
}),
searchMocks: func(conn *mockldapconn.MockConn) {
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).
Return(exampleGroupSearchResult, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
bindEndUserMocks: func(conn *mockldapconn.MockConn) {
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",
wantError: "found 0 values for attribute \"some-attribute-to-check-during-refresh\" while searching for user \"some-upstream-username\", but expected 1 result",
},
{
name: "when dial fails",
@ -1162,9 +1032,9 @@ func TestEndUserAuthentication(t *testing.T) {
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)
switch {
case tt.wantError != "":
@ -1196,7 +1066,7 @@ func TestEndUserAuthentication(t *testing.T) {
}
// 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)
switch {
case tt.wantError != "":
@ -1217,6 +1087,7 @@ func TestEndUserAuthentication(t *testing.T) {
}
func TestUpstreamRefresh(t *testing.T) {
pwdLastSetAttribute := "pwdLastSet"
expectedUserSearch := &ldap.SearchRequest{
BaseDN: testUserSearchResultDNValue,
Scope: ldap.ScopeBaseObject,
@ -1225,7 +1096,7 @@ func TestUpstreamRefresh(t *testing.T) {
TimeLimit: 90,
TypesOnly: false,
Filter: "(objectClass=*)",
Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute},
Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, pwdLastSetAttribute},
Controls: nil, // don't need paging because we set the SizeLimit so small
}
@ -1242,6 +1113,11 @@ func TestUpstreamRefresh(t *testing.T) {
Name: testUserSearchUIDAttribute,
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
},
{
Name: pwdLastSetAttribute,
Values: []string{"132801740800000000"},
ByteValues: [][]byte{[]byte("132801740800000000")},
},
},
},
},
@ -1259,6 +1135,9 @@ func TestUpstreamRefresh(t *testing.T) {
UIDAttribute: testUserSearchUIDAttribute,
UsernameAttribute: testUserSearchUsernameAttribute,
},
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
pwdLastSetAttribute: AttributeUnchangedSinceLogin(pwdLastSetAttribute),
},
}
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",
},
{
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 {
@ -1536,9 +1445,15 @@ func TestUpstreamRefresh(t *testing.T) {
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"
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 != "" {
require.Error(t, err)
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 {
name string
binaryUUID []byte
wantString string
entry *ldap.Entry
wantResult bool
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: "happy path where value has not changed since login",
entry: &ldap.Entry{
DN: "some-dn",
Attributes: []*ldap.EntryAttribute{
{
Name: attributeName,
Values: []string{initialVal},
ByteValues: [][]byte{[]byte(initialVal)},
},
},
},
},
{
name: "not the right length",
binaryUUID: []byte("2\xf8\xb0\xaa\xb6V\xb1D\x8b(\xee"),
wantErr: "invalid UUID (got 11 bytes)",
name: "password has been reset since login",
entry: &ldap.Entry{
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 {
tt := test
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 != "" {
require.EqualError(t, err, tt.wantErr)
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
} 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)
})
}
}

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
// Package upstreamoidc implements an abstraction of upstream OIDC provider interactions.
@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@ -60,6 +61,19 @@ func (p *ProviderConfig) GetRevocationURL() *url.URL {
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 {
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
// the authorize endpoint and goes straight to the token endpoint.
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) {
@ -126,7 +140,7 @@ func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context,
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) {
@ -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.
func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
idTok, hasIDTok := tok.Extra("id_token").(string)
if !hasIDTok {
return nil, 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)
func (p *ProviderConfig) ValidateTokenAndMergeWithUserInfo(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce, requireIDToken bool, requireUserInfo bool) (*oidctypes.Token, error) {
var validatedClaims = make(map[string]interface{})
var idTokenExpiry time.Time
// if we require the id token, make sure we have it.
// 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 {
return nil, httperr.Wrap(http.StatusBadRequest, "received invalid ID token", 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)
}
return nil, err
}
var validatedClaims map[string]interface{}
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)
idTokenSubject, _ := validatedClaims[oidc.IDTokenSubjectClaim].(string)
if err := p.maybeFetchUserInfoAndMergeClaims(ctx, tok, validatedClaims); err != nil {
return nil, httperr.Wrap(http.StatusInternalServerError, "could not fetch user info claims", err)
if len(idTokenSubject) > 0 || !requireIDToken {
// 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{
@ -302,58 +307,107 @@ func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, e
},
IDToken: &oidctypes.IDToken{
Token: idTok,
Expiry: metav1.NewTime(validated.Expiry),
Expiry: metav1.NewTime(idTokenExpiry),
Claims: validatedClaims,
},
}, 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)
if len(idTokenSubject) == 0 {
return nil // defer to existing ID token validation
}
providerJSON := &struct {
UserInfoURL string `json:"userinfo_endpoint"`
}{}
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)
userInfo, err := p.maybeFetchUserInfo(ctx, tok, requireUserInfo)
if err != nil {
return err
}
// implementing the user info endpoint is not required, skip this logic when it is absent
if len(providerJSON.UserInfoURL) == 0 {
if userInfo == 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.
//
// 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
// 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.
//
// 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)
}
// keep track of the issuer from the ID token
idTokenIssuer := claims["iss"]
// merge existing claims with user info claims
if err := userInfo.Claims(&claims); err != nil {
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)
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{}) {
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

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
package upstreamoidc
@ -41,6 +41,9 @@ func TestProviderConfig(t *testing.T) {
Endpoint: oauth2.Endpoint{AuthURL: "https://example.com"},
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-client-id", p.GetClientID())
@ -55,6 +58,16 @@ func TestProviderConfig(t *testing.T) {
require.True(t, p.AllowsPasswordGrant())
p.AllowPasswordGrant = false
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 (
@ -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) {
tests := []struct {
name string
@ -779,6 +1185,36 @@ func TestProviderConfig(t *testing.T) {
rawClaims: []byte(`{}`), // user info not supported
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",
authCode: "valid",
@ -808,13 +1244,6 @@ func TestProviderConfig(t *testing.T) {
rawClaims: []byte(`{}`), // user info not supported
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",
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
// 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...)
// 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 {
sawRedirect = true
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
// A successful authorization always results in a 302.
if authRes.StatusCode != http.StatusFound {
// A successful authorization always results in a redirect (we are flexible on the exact status code).
if !sawRedirect {
return nil, fmt.Errorf(
"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
// 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) {

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
package oidcclient
@ -19,8 +19,6 @@ import (
"testing"
"time"
"github.com/go-logr/stdr"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang/mock/gomock"
"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 {
mock := mockUpstream(t)
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)
mock.EXPECT().
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 {
mock := mockUpstream(t)
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"))
mock.EXPECT().
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`,
},
{
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",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
@ -1217,6 +1215,117 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
},
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",
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 {
mock := mockUpstream(t)
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)
mock.EXPECT().
PerformRefresh(gomock.Any(), testToken.RefreshToken.Token).
@ -1571,9 +1680,8 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testLogger := testlogger.New(t)
klog.SetLogger(testLogger)
stdr.SetVerbosity(debugLogLevel) // set stdr's global log level to debug so the test logger will send output.
testLogger := testlogger.NewLegacy(t) //nolint: staticcheck // old test with lots of log statements
klog.SetLogger(testLogger.Logger)
tok, err := Login(tt.issuer, tt.clientID,
WithContext(context.Background()),
@ -1581,7 +1689,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
WithScopes([]string{"test-scope"}),
WithSkipBrowserOpen(),
tt.opt(t),
WithLogger(testLogger),
WithLogger(testLogger.Logger),
)
testLogger.Expect(tt.wantLogs)
if tt.wantErr != "" {

View File

@ -35,9 +35,9 @@ layout: section
</div>
<div class="content">
<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>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>

View File

@ -12,31 +12,6 @@ layout: section
<h2>Resources about Pinniped, such as videos, podcasts, and community articles</h2>
<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">
<a href="https://github.com/vmware-tanzu/pinniped">
<div class="icon">
@ -49,5 +24,57 @@ layout: section
</a>
</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>

View File

@ -12,7 +12,7 @@
[[redirects]]
from = "/community/agenda"
to = "https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view"
to = "https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ"
status = 302
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
package integration
@ -83,7 +83,9 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(nil)),
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
})),
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,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })),
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,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })),
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={}"
})),
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={}))"
})),
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={}))"
})),
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,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })),
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,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })),
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,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })),
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"
})),
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"
})),
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 = ""
})),
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
})),
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{
"cn=ball-game-players,ou=beach-groups,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{
"cn=ball-game-players,ou=beach-groups,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
})),
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))"
})),
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
})),
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.True(t, result.authenticated, "expected the user to be authenticated, but they were not")
assert.Equal(t, &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{},
}, 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
package integration
import (
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
@ -19,15 +24,18 @@ import (
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/text/encoding/unicode"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/crypto/ptls"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/testutil"
@ -38,24 +46,26 @@ import (
"go.pinniped.dev/test/testlib/browsertest"
)
// nolint:gocyclo
func TestSupervisorLogin(t *testing.T) {
env := testlib.IntegrationEnv(t)
tests := []struct {
name string
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
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client)
wantDownstreamIDTokenSubjectToMatch string
wantDownstreamIDTokenUsernameToMatch string
wantDownstreamIDTokenUsernameToMatch func(username string) string
wantDownstreamIDTokenGroups []string
wantErrorDescription string
wantErrorType string
// We don't necessarily have any way to revoke the user's session on the upstream provider,
// so to cause the upstream refresh to fail we can cheat by manipulating the user's session
// Either revoke the user's session on the upstream provider, or manipulate the user's session
// 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",
@ -76,16 +86,14 @@ func TestSupervisorLogin(t *testing.T) {
return oidcIDP.Name
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
pinnipedSessionData := pinnipedSession.Custom
pinnipedSessionData.OIDC.UpstreamIssuer = "wrong-issuer"
},
// the ID token Subject should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
// 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",
@ -113,14 +121,46 @@ func TestSupervisorLogin(t *testing.T) {
return oidcIDP.Name
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
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: "^" + 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,
},
{
@ -144,7 +184,7 @@ func TestSupervisorLogin(t *testing.T) {
}, idpv1alpha1.PhaseReady)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamOIDC.Username, // username to present to server during login
@ -153,7 +193,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
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
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
// 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",
@ -212,7 +252,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -221,7 +261,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -235,8 +275,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
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
@ -286,7 +328,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login
@ -295,7 +337,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -309,7 +351,7 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$",
// 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,
},
{
@ -360,7 +402,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -438,7 +480,7 @@ func TestSupervisorLogin(t *testing.T) {
}, time.Minute, 500*time.Millisecond)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -447,7 +489,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -460,8 +502,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
},
{
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)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -552,7 +596,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN)
@ -565,8 +609,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
},
{
name: "activedirectory with all default options",
@ -604,7 +650,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
@ -613,7 +659,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -627,8 +673,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
}, {
name: "activedirectory with custom options",
maybeSkip: func(t *testing.T) {
@ -679,7 +727,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue, // username to present to server during login
@ -688,7 +736,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -702,8 +750,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs,
},
{
name: "active directory login still works after updating bind secret",
@ -759,7 +809,7 @@ func TestSupervisorLogin(t *testing.T) {
}, time.Minute, 500*time.Millisecond)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
@ -768,7 +818,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -781,8 +831,10 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
},
{
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)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
@ -862,7 +914,7 @@ func TestSupervisorLogin(t *testing.T) {
false,
)
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
@ -875,8 +927,196 @@ func TestSupervisorLogin(t *testing.T) {
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
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",
@ -914,7 +1154,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login
@ -975,7 +1215,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
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,
downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
@ -984,7 +1224,7 @@ func TestSupervisorLogin(t *testing.T) {
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.
client := testlib.NewSupervisorClientset(t)
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)),
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$",
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
},
}
for _, test := range tests {
@ -1020,6 +1262,8 @@ func TestSupervisorLogin(t *testing.T) {
tt.createIDP,
tt.requestAuthorization,
tt.breakRefreshSessionData,
tt.createTestUser,
tt.deleteTestUser,
tt.wantDownstreamIDTokenSubjectToMatch,
tt.wantDownstreamIDTokenUsernameToMatch,
tt.wantDownstreamIDTokenGroups,
@ -1150,10 +1394,15 @@ func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *tes
func testSupervisorLogin(
t *testing.T,
createIDP func(t *testing.T) string,
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client),
breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string),
wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string,
wantErrorDescription string, wantErrorType string,
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client),
breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string),
createTestUser func(t *testing.T) (string, 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)
@ -1241,6 +1490,12 @@ func testSupervisorLogin(
// Create upstream IDP and wait for it to become ready.
idpName := createIDP(t)
username, password := "", ""
if createTestUser != nil {
username, password = createTestUser(t)
defer deleteTestUser(t, username)
}
// Perform OIDC discovery for our downstream.
var discovery *coreosoidc.Provider
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
@ -1276,7 +1531,7 @@ func testSupervisorLogin(
)
// 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.
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"}
verifyTokenResponse(t,
tokenResponse, discovery, downstreamOAuth2Config, nonceParam,
expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups)
expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups)
// token exchange on the original token
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"}
verifyTokenResponse(t,
refreshedTokenResponse, discovery, downstreamOAuth2Config, "",
expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups)
expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups)
require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken)
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.
pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.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.
// 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)
}
func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) {
func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
t.Helper()
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, "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
package testlib
@ -84,6 +84,7 @@ type TestOIDCUpstream struct {
type TestLDAPUpstream struct {
Host string `json:"host"`
Domain string `json:"domain"`
StartTLSOnlyHost string `json:"startTLSOnlyHost"`
CABundle string `json:"caBundle"`
BindUsername string `json:"bindUsername"`
@ -279,6 +280,7 @@ func loadEnvVars(t *testing.T, result *TestEnv) {
result.SupervisorUpstreamActiveDirectory = TestLDAPUpstream{
Host: wantEnv("PINNIPED_TEST_AD_HOST", ""),
Domain: wantEnv("PINNIPED_TEST_AD_DOMAIN", ""),
CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")),
BindUsername: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""),
BindPassword: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""),