Merge branch 'main' into upstream_refresh_revocation_during_gc

This commit is contained in:
Ryan Richard 2021-11-10 15:35:42 -08:00
commit de79f15068
30 changed files with 1718 additions and 193 deletions

57
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: "CodeQL"
on:
push:
branches: [ main, release* ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main, release* ]
schedule:
- cron: '39 13 * * 2'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -18,8 +18,8 @@ The near-term and mid-term roadmap for the work planned for the project [maintai
Pinniped is better because of our contributors and [maintainers](MAINTAINERS.md). It is because of you that we can bring great Pinniped is better because of our contributors and [maintainers](MAINTAINERS.md). It is because of you that we can bring great
software to the community. Please join us during our online community meetings, software to the community. Please join us during our online community meetings,
occurring every first and third Thursday of the month at 9 AM PT / 12 PM PT. occurring every first and third Thursday of the month at 9 AM PT / 12 PM ET.
Use [this Zoom Link](https://vmware.zoom.us/j/93798188973?pwd=T3pIMWxReEQvcWljNm1admRoZTFSZz09) Use [this Zoom Link](https://go.pinniped.dev/community/zoom)
to attend and add any agenda items you wish to discuss to attend and add any agenda items you wish to discuss
to [the notes document](https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view). to [the notes document](https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view).
Join our [Google Group](https://groups.google.com/g/project-pinniped) to receive invites to this meeting. Join our [Google Group](https://groups.google.com/g/project-pinniped) to receive invites to this meeting.

View File

@ -3,7 +3,7 @@
# Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. # Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
FROM golang:1.17.2 as build-env FROM golang:1.17.3 as build-env
WORKDIR /work WORKDIR /work
COPY . . COPY . .
@ -17,14 +17,14 @@ RUN \
--mount=type=cache,target=/cache/gomodcache \ --mount=type=cache,target=/cache/gomodcache \
mkdir out && \ mkdir out && \
export GOCACHE=/cache/gocache GOMODCACHE=/cache/gomodcache CGO_ENABLED=0 GOOS=linux GOARCH=amd64 && \ export GOCACHE=/cache/gocache GOMODCACHE=/cache/gomodcache CGO_ENABLED=0 GOOS=linux GOARCH=amd64 && \
go build -v -ldflags "$(hack/get-ldflags.sh) -w -s" -o /usr/local/bin/pinniped-concierge-kube-cert-agent ./cmd/pinniped-concierge-kube-cert-agent/main.go && \ go build -v -trimpath -ldflags "$(hack/get-ldflags.sh) -w -s" -o /usr/local/bin/pinniped-concierge-kube-cert-agent ./cmd/pinniped-concierge-kube-cert-agent/... && \
go build -v -ldflags "$(hack/get-ldflags.sh) -w -s" -o /usr/local/bin/pinniped-server ./cmd/pinniped-server/main.go && \ go build -v -trimpath -ldflags "$(hack/get-ldflags.sh) -w -s" -o /usr/local/bin/pinniped-server ./cmd/pinniped-server/... && \
ln -s /usr/local/bin/pinniped-server /usr/local/bin/pinniped-concierge && \ ln -s /usr/local/bin/pinniped-server /usr/local/bin/pinniped-concierge && \
ln -s /usr/local/bin/pinniped-server /usr/local/bin/pinniped-supervisor && \ ln -s /usr/local/bin/pinniped-server /usr/local/bin/pinniped-supervisor && \
ln -s /usr/local/bin/pinniped-server /usr/local/bin/local-user-authenticator ln -s /usr/local/bin/pinniped-server /usr/local/bin/local-user-authenticator
# Use a distroless runtime image with CA certificates, timezone data, and not much else. # Use a distroless runtime image with CA certificates, timezone data, and not much else.
FROM gcr.io/distroless/static:nonroot@sha256:07869abb445859465749913267a8c7b3b02dc4236fbc896e29ae859e4b360851 FROM gcr.io/distroless/static:nonroot@sha256:bca3c203cdb36f5914ab8568e4c25165643ea9b711b41a8a58b42c80a51ed609
# Copy the server binary from the build-env stage. # Copy the server binary from the build-env stage.
COPY --from=build-env /usr/local/bin /usr/local/bin COPY --from=build-env /usr/local/bin /usr/local/bin
@ -33,7 +33,9 @@ COPY --from=build-env /usr/local/bin /usr/local/bin
EXPOSE 8080 8443 EXPOSE 8080 8443
# Run as non-root for security posture # Run as non-root for security posture
USER 1001:1001 # Use the same non-root user as https://github.com/GoogleContainerTools/distroless/blob/fc3c4eaceb0518900f886aae90407c43be0a42d9/base/base.bzl#L9
# This is a workaround for https://github.com/GoogleContainerTools/distroless/issues/718
USER 65532:65532
# Set the entrypoint # Set the entrypoint
ENTRYPOINT ["/usr/local/bin/pinniped-server"] ENTRYPOINT ["/usr/local/bin/pinniped-server"]

View File

@ -34,8 +34,8 @@ building and testing the code, submitting PRs, and other contributor topics.
Pinniped is better because of our contributors and [maintainers](MAINTAINERS.md). It is because of you that we can bring great Pinniped is better because of our contributors and [maintainers](MAINTAINERS.md). It is because of you that we can bring great
software to the community. Please join us during our online community meetings, software to the community. Please join us during our online community meetings,
occurring every first and third Thursday of the month at 9 AM PT / 12 PM PT. occurring every first and third Thursday of the month at 9 AM PT / 12 PM ET.
Use [this Zoom Link](https://vmware.zoom.us/j/93798188973?pwd=T3pIMWxReEQvcWljNm1admRoZTFSZz09) Use [this Zoom Link](https://go.pinniped.dev/community/zoom)
to attend and add any agenda items you wish to discuss to attend and add any agenda items you wish to discuss
to [the notes document](https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view). to [the notes document](https://hackmd.io/rd_kVJhjQfOvfAWzK8A3tQ?view).
Join our [Google Group](https://groups.google.com/g/project-pinniped) to receive invites to this meeting. Join our [Google Group](https://groups.google.com/g/project-pinniped) to receive invites to this meeting.

View File

@ -36,7 +36,7 @@ The following table includes the current roadmap for Pinniped. If you have any q
Last Updated: Sept 2021 Last Updated: Sept 2021
|Theme|Description|Timeline| |Theme|Description|Timeline|
|--|--|--| |--|--|--|
|Improving Security Posture|Supervisor token refresh fails when the upstream refresh token no longer works for OIDC |Oct 2021| |Improving Security Posture|Supervisor token refresh fails when the upstream refresh token no longer works for OIDC |Nov 2021|
|Improving Security Posture|Supervisor token refresh fails when the upstream user is in an invalid state for LDAP/AD |Nov 2021| |Improving Security Posture|Supervisor token refresh fails when the upstream user is in an invalid state for LDAP/AD |Nov 2021|
|Improving Security Posture|Set stricter default TLS versions and Ciphers |Nov 2021| |Improving Security Posture|Set stricter default TLS versions and Ciphers |Nov 2021|
|Improving Security Posture|Support FIPS compliant Boring crypto libraries |Dec 2021| |Improving Security Posture|Support FIPS compliant Boring crypto libraries |Dec 2021|

View File

@ -97,6 +97,7 @@ type getKubeconfigParams struct {
generatedNameSuffix string generatedNameSuffix string
credentialCachePath string credentialCachePath string
credentialCachePathSet bool credentialCachePathSet bool
installHint string
} }
func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command {
@ -147,6 +148,7 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command {
f.StringVarP(&flags.outputPath, "output", "o", "", "Output file path (default: stdout)") f.StringVarP(&flags.outputPath, "output", "o", "", "Output file path (default: stdout)")
f.StringVar(&flags.generatedNameSuffix, "generated-name-suffix", "-pinniped", "Suffix to append to generated cluster, context, user kubeconfig entries") f.StringVar(&flags.generatedNameSuffix, "generated-name-suffix", "-pinniped", "Suffix to append to generated cluster, context, user kubeconfig entries")
f.StringVar(&flags.credentialCachePath, "credential-cache", "", "Path to cluster-specific credentials cache") f.StringVar(&flags.credentialCachePath, "credential-cache", "", "Path to cluster-specific credentials cache")
f.StringVar(&flags.installHint, "install-hint", "The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli for more details", "This text is shown to the user when the pinniped CLI is not installed.")
mustMarkHidden(cmd, "oidc-debug-session-cache") mustMarkHidden(cmd, "oidc-debug-session-cache")
// --oidc-skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case. // --oidc-skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case.
@ -259,6 +261,7 @@ func newExecConfig(deps kubeconfigDeps, flags getKubeconfigParams) (*clientcmdap
ProvideClusterInfo: true, ProvideClusterInfo: true,
} }
execConfig.InstallHint = flags.installHint
var err error var err error
execConfig.Command, err = deps.getPathToSelf() execConfig.Command, err = deps.getPathToSelf()
if err != nil { if err != nil {

View File

@ -133,6 +133,7 @@ func TestGetKubeconfig(t *testing.T) {
--credential-cache string Path to cluster-specific credentials cache --credential-cache string Path to cluster-specific credentials cache
--generated-name-suffix string Suffix to append to generated cluster, context, user kubeconfig entries (default "-pinniped") --generated-name-suffix string Suffix to append to generated cluster, context, user kubeconfig entries (default "-pinniped")
-h, --help help for kubeconfig -h, --help help for kubeconfig
--install-hint string This text is shown to the user when the pinniped CLI is not installed. (default "The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli for more details")
--kubeconfig string Path to kubeconfig file --kubeconfig string Path to kubeconfig file
--kubeconfig-context string Kubeconfig context name (default: current active context) --kubeconfig-context string Kubeconfig context name (default: current active context)
--no-concierge Generate a configuration which does not use the Concierge, but sends the credential to the cluster directly --no-concierge Generate a configuration which does not use the Concierge, but sends the credential to the cluster directly
@ -1326,6 +1327,8 @@ func TestGetKubeconfig(t *testing.T) {
- --token=test-token - --token=test-token
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`) `)
}, },
@ -1389,6 +1392,8 @@ func TestGetKubeconfig(t *testing.T) {
- --token-env=TEST_TOKEN - --token-env=TEST_TOKEN
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`) `)
}, },
@ -1457,6 +1462,8 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -1541,6 +1548,8 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()),
@ -1652,6 +1661,8 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()),
@ -1759,6 +1770,8 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -1836,6 +1849,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-type=ldap - --upstream-identity-provider-type=ldap
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -1913,6 +1928,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-type=oidc - --upstream-identity-provider-type=oidc
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -1986,6 +2003,8 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2057,6 +2076,8 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2135,6 +2156,8 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2211,6 +2234,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-flow=foobar - --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2292,6 +2317,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-flow=foobar - --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2348,6 +2375,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-type=ldap - --upstream-identity-provider-type=ldap
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2407,6 +2436,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-type=ldap - --upstream-identity-provider-type=ldap
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2466,6 +2497,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-type=ldap - --upstream-identity-provider-type=ldap
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2526,6 +2559,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-type=ldap - --upstream-identity-provider-type=ldap
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2587,6 +2622,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-flow=foobar - --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2646,6 +2683,8 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-flow=cli_password - --upstream-identity-provider-flow=cli_password
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
@ -2704,12 +2743,77 @@ func TestGetKubeconfig(t *testing.T) {
- --upstream-identity-provider-flow=cli_password - --upstream-identity-provider-flow=cli_password
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli
for more details
provideClusterInfo: true provideClusterInfo: true
`, `,
issuerURL, issuerURL,
base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) base64.StdEncoding.EncodeToString([]byte(issuerCABundle)))
}, },
}, },
{
name: "user specified message for install-hint flag",
args: func(issuerCABundle string, issuerURL string) []string {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--install-hint", "Test installHint message",
"--static-token", "test-token",
"--skip-validation",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
return []runtime.Object{
credentialIssuer(),
&conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}},
}
},
wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
`"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`,
`"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`,
`"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`,
`"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`,
}
},
wantStdout: func(issuerCABundle string, issuerURL string) string {
return here.Doc(`
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
server: https://fake-server-url-value
name: kind-cluster-pinniped
contexts:
- context:
cluster: kind-cluster-pinniped
user: kind-user-pinniped
name: kind-context-pinniped
current-context: kind-context-pinniped
kind: Config
preferences: {}
users:
- name: kind-user-pinniped
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
args:
- login
- static
- --enable-concierge
- --concierge-api-group-suffix=pinniped.dev
- --concierge-authenticator-name=test-authenticator
- --concierge-authenticator-type=webhook
- --concierge-endpoint=https://fake-server-url-value
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --token=test-token
command: '.../path/to/pinniped'
env: []
installHint: Test installHint message
provideClusterInfo: true
`)
},
},
} }
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt

View File

@ -55,8 +55,8 @@ api_serving_certificate_renew_before_seconds: 2160000
#! information), trace (timing information), all (kitchen sink). #! information), trace (timing information), all (kitchen sink).
log_level: #! By default, when this value is left unset, only warnings and errors are printed. There is no way to suppress warning and error logs. log_level: #! By default, when this value is left unset, only warnings and errors are printed. There is no way to suppress warning and error logs.
run_as_user: 1001 #! run_as_user specifies the user ID that will own the process run_as_user: 65532 #! run_as_user specifies the user ID that will own the process, see the Dockerfile for the reasoning behind this choice
run_as_group: 1001 #! run_as_group specifies the group ID that will own the process run_as_group: 65532 #! run_as_group specifies the group ID that will own the process, see the Dockerfile for the reasoning behind this choice
#! Specify the API group suffix for all Pinniped API groups. By default, this is set to #! Specify the API group suffix for all Pinniped API groups. By default, this is set to
#! pinniped.dev, so Pinniped API groups will look like foo.pinniped.dev, #! pinniped.dev, so Pinniped API groups will look like foo.pinniped.dev,

View File

@ -15,5 +15,5 @@ image_tag: latest
#! Optional. #! Optional.
image_pull_dockerconfigjson: #! e.g. {"auths":{"https://registry.example.com":{"username":"USERNAME","password":"PASSWORD","auth":"BASE64_ENCODED_USERNAME_COLON_PASSWORD"}}} image_pull_dockerconfigjson: #! e.g. {"auths":{"https://registry.example.com":{"username":"USERNAME","password":"PASSWORD","auth":"BASE64_ENCODED_USERNAME_COLON_PASSWORD"}}}
run_as_user: 1001 #! run_as_user specifies the user ID that will own the process run_as_user: 65532 #! run_as_user specifies the user ID that will own the process, see the Dockerfile for the reasoning behind this choice
run_as_group: 1001 #! run_as_group specifies the group ID that will own the process run_as_group: 65532 #! run_as_group specifies the group ID that will own the process, see the Dockerfile for the reasoning behind this choice

View File

@ -57,8 +57,8 @@ service_loadbalancer_ip: #! e.g. 1.2.3.4
#! information), trace (timing information), all (kitchen sink). #! information), trace (timing information), all (kitchen sink).
log_level: #! By default, when this value is left unset, only warnings and errors are printed. There is no way to suppress warning and error logs. log_level: #! By default, when this value is left unset, only warnings and errors are printed. There is no way to suppress warning and error logs.
run_as_user: 1001 #! run_as_user specifies the user ID that will own the process run_as_user: 65532 #! run_as_user specifies the user ID that will own the process, see the Dockerfile for the reasoning behind this choice
run_as_group: 1001 #! run_as_group specifies the group ID that will own the process run_as_group: 65532 #! run_as_group specifies the group ID that will own the process, see the Dockerfile for the reasoning behind this choice
#! Specify the API group suffix for all Pinniped API groups. By default, this is set to #! Specify the API group suffix for all Pinniped API groups. By default, this is set to
#! pinniped.dev, so Pinniped API groups will look like foo.pinniped.dev, #! pinniped.dev, so Pinniped API groups will look like foo.pinniped.dev,

View File

@ -92,6 +92,9 @@ while (("$#")); do
;; ;;
-*) -*)
log_error "Unsupported flag $1" >&2 log_error "Unsupported flag $1" >&2
if [[ "$1" == *"active-directory"* ]]; then
log_error "Did you mean --get-active-directory-vars?"
fi
exit 1 exit 1
;; ;;
*) *)

View File

@ -7,7 +7,7 @@ package authenticators
import ( import (
"context" "context"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user"
) )
// This interface is similar to the k8s token authenticator, but works with username/passwords instead // This interface is similar to the k8s token authenticator, but works with username/passwords instead
@ -31,5 +31,10 @@ import (
// See k8s.io/apiserver/pkg/authentication/authenticator/interfaces.go for the token authenticator // See k8s.io/apiserver/pkg/authentication/authenticator/interfaces.go for the token authenticator
// interface, as well as the Response type. // interface, as well as the Response type.
type UserAuthenticator interface { type UserAuthenticator interface {
AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) AuthenticateUser(ctx context.Context, username, password string) (*Response, bool, error)
}
type Response struct {
User user.Info
DN string
} }

View File

@ -346,14 +346,22 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
"providerType": "闣ʬ橳(ý綃ʃʚƟ覣k眐4Ĉt", "providerType": "闣ʬ橳(ý綃ʃʚƟ覣k眐4Ĉt",
"oidc": { "oidc": {
"upstreamRefreshToken": "嵽痊w©Ź榨Q|ôɵt毇妬" "upstreamRefreshToken": "嵽痊w©Ź榨Q|ôɵt毇妬"
},
"ldap": {
"userDN": "6鉢緋uƴŤȱʀļÂ?墖\u003cƬb獭潜Ʃ饾"
},
"activedirectory": {
"userDN": "|鬌R蜚蠣麹概÷驣7Ʀ澉1æɽ誮rʨ鷞"
} }
} }
}, },
"requestedAudience": [ "requestedAudience": [
"6鉢緋uƴŤȱʀļÂ?墖\u003cƬb獭潜Ʃ饾" "ŚB碠k9"
], ],
"grantedAudience": [ "grantedAudience": [
"|鬌R蜚蠣麹概÷驣7Ʀ澉1æɽ誮rʨ鷞" "ʘ赱",
"ď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ¡圔",
"墀jMʥ"
] ]
}, },
"version": "2" "version": "2"

View File

@ -15,9 +15,9 @@ import (
"github.com/ory/fosite/token/jwt" "github.com/ory/fosite/token/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"k8s.io/apiserver/pkg/authentication/authenticator"
supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc" supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
@ -112,6 +112,7 @@ func handleAuthRequestForLDAPUpstream(
subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse)
username = authenticateResponse.User.GetName() username = authenticateResponse.User.GetName()
groups := authenticateResponse.User.GetGroups() groups := authenticateResponse.User.GetGroups()
dn := authenticateResponse.DN
customSessionData := &psession.CustomSessionData{ customSessionData := &psession.CustomSessionData{
ProviderUID: ldapUpstream.GetResourceUID(), ProviderUID: ldapUpstream.GetResourceUID(),
@ -119,6 +120,17 @@ func handleAuthRequestForLDAPUpstream(
ProviderType: idpType, ProviderType: idpType,
} }
if idpType == psession.ProviderTypeLDAP {
customSessionData.LDAP = &psession.LDAPSessionData{
UserDN: dn,
}
}
if idpType == psession.ProviderTypeActiveDirectory {
customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{
UserDN: dn,
}
}
return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w,
oauthHelper, authorizeRequester, subject, username, groups, customSessionData) oauthHelper, authorizeRequester, subject, username, groups, customSessionData)
} }
@ -470,10 +482,7 @@ func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken
return nil return nil
} }
func downstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentityProviderI, authenticateResponse *authenticator.Response) string { func downstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentityProviderI, authenticateResponse *authenticators.Response) string {
ldapURL := *ldapUpstream.GetURL() ldapURL := *ldapUpstream.GetURL()
q := ldapURL.Query() return downstreamsession.DownstreamLDAPSubject(authenticateResponse.User.GetUID(), ldapURL)
q.Set(oidc.IDTokenSubjectClaim, authenticateResponse.User.GetUID())
ldapURL.RawQuery = q.Encode()
return ldapURL.String()
} }

View File

@ -19,12 +19,12 @@ import (
"github.com/ory/fosite" "github.com/ory/fosite"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
v1 "k8s.io/client-go/kubernetes/typed/core/v1" v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/utils/pointer" "k8s.io/utils/pointer"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/here" "go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/csrftoken"
@ -267,22 +267,24 @@ func TestAuthorizationEndpoint(t *testing.T) {
happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username" happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username"
happyLDAPPassword := "some-ldap-password" //nolint:gosec happyLDAPPassword := "some-ldap-password" //nolint:gosec
happyLDAPUID := "some-ldap-uid" happyLDAPUID := "some-ldap-uid"
happyLDAPUserDN := "cn=foo,dn=bar"
happyLDAPGroups := []string{"group1", "group2", "group3"} happyLDAPGroups := []string{"group1", "group2", "group3"}
parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL) parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL)
require.NoError(t, err) require.NoError(t, err)
ldapAuthenticateFunc := func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { ldapAuthenticateFunc := func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) {
if username == "" || password == "" { if username == "" || password == "" {
return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator") return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator")
} }
if username == happyLDAPUsername && password == happyLDAPPassword { if username == happyLDAPUsername && password == happyLDAPPassword {
return &authenticator.Response{ return &authenticators.Response{
User: &user.DefaultInfo{ User: &user.DefaultInfo{
Name: happyLDAPUsernameFromAuthenticator, Name: happyLDAPUsernameFromAuthenticator,
UID: happyLDAPUID, UID: happyLDAPUID,
Groups: happyLDAPGroups, Groups: happyLDAPGroups,
}, },
DN: happyLDAPUserDN,
}, true, nil }, true, nil
} }
return nil, false, nil return nil, false, nil
@ -305,7 +307,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
erroringUpstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ erroringUpstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: ldapUpstreamName, Name: ldapUpstreamName,
ResourceUID: ldapUpstreamResourceUID, ResourceUID: ldapUpstreamResourceUID,
AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) {
return nil, false, fmt.Errorf("some ldap upstream auth error") return nil, false, fmt.Errorf("some ldap upstream auth error")
}, },
} }
@ -438,6 +440,10 @@ func TestAuthorizationEndpoint(t *testing.T) {
ProviderName: activeDirectoryUpstreamName, ProviderName: activeDirectoryUpstreamName,
ProviderType: psession.ProviderTypeActiveDirectory, ProviderType: psession.ProviderTypeActiveDirectory,
OIDC: nil, OIDC: nil,
LDAP: nil,
ActiveDirectory: &psession.ActiveDirectorySessionData{
UserDN: happyLDAPUserDN,
},
} }
expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{ expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{
@ -445,6 +451,10 @@ func TestAuthorizationEndpoint(t *testing.T) {
ProviderName: ldapUpstreamName, ProviderName: ldapUpstreamName,
ProviderType: psession.ProviderTypeLDAP, ProviderType: psession.ProviderTypeLDAP,
OIDC: nil, OIDC: nil,
LDAP: &psession.LDAPSessionData{
UserDN: happyLDAPUserDN,
},
ActiveDirectory: nil,
} }
expectedHappyOIDCPasswordGrantCustomSession := &psession.CustomSessionData{ expectedHappyOIDCPasswordGrantCustomSession := &psession.CustomSessionData{

View File

@ -169,6 +169,13 @@ func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl
return valueAsString, nil return valueAsString, nil
} }
func DownstreamLDAPSubject(uid string, ldapURL url.URL) string {
q := ldapURL.Query()
q.Set(oidc.IDTokenSubjectClaim, uid)
ldapURL.RawQuery = q.Encode()
return ldapURL.String()
}
func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string { func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string {
return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject)) return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject))
} }

View File

@ -91,6 +91,9 @@ type UpstreamLDAPIdentityProviderI interface {
// UserAuthenticator adds an interface method for performing user authentication against the upstream LDAP provider. // UserAuthenticator adds an interface method for performing user authentication against the upstream LDAP provider.
authenticators.UserAuthenticator authenticators.UserAuthenticator
// PerformRefresh performs a refresh against the upstream LDAP identity provider
PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error
} }
type DynamicUpstreamIDPProvider interface { type DynamicUpstreamIDPProvider interface {

View File

@ -75,6 +75,12 @@ func NewHandler(
func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error { func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error {
session := accessRequest.GetSession().(*psession.PinnipedSession) session := accessRequest.GetSession().(*psession.PinnipedSession)
downstreamUsername, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
downstreamSubject := session.Fosite.Claims.Subject
customSessionData := session.Custom customSessionData := session.Custom
if customSessionData == nil { if customSessionData == nil {
return errorsx.WithStack(errMissingUpstreamSessionInternalError) return errorsx.WithStack(errMissingUpstreamSessionInternalError)
@ -89,14 +95,12 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester,
case psession.ProviderTypeOIDC: case psession.ProviderTypeOIDC:
return upstreamOIDCRefresh(ctx, customSessionData, providerCache) return upstreamOIDCRefresh(ctx, customSessionData, providerCache)
case psession.ProviderTypeLDAP: case psession.ProviderTypeLDAP:
// upstream refresh not yet implemented for LDAP, so do nothing return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject)
case psession.ProviderTypeActiveDirectory: case psession.ProviderTypeActiveDirectory:
// upstream refresh not yet implemented for AD, so do nothing return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject)
default: default:
return errorsx.WithStack(errMissingUpstreamSessionInternalError) return errorsx.WithStack(errMissingUpstreamSessionInternalError)
} }
return nil
} }
func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister) error { func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister) error {
@ -114,9 +118,9 @@ func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, pro
refreshedTokens, err := p.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken) refreshedTokens, err := p.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken)
if err != nil { if err != nil {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf( return errorsx.WithStack(errUpstreamRefreshError.WithHint(
"Upstream refresh failed using provider %q of type %q.", "Upstream refresh failed.",
s.ProviderName, s.ProviderType).WithWrap(err)) ).WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
} }
// Upstream refresh may or may not return a new ID token. From the spec: // Upstream refresh may or may not return a new ID token. From the spec:
@ -129,8 +133,7 @@ func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, pro
_, err = p.ValidateToken(ctx, refreshedTokens, "") _, err = p.ValidateToken(ctx, refreshedTokens, "")
if err != nil { if err != nil {
return errorsx.WithStack(errUpstreamRefreshError.WithHintf( return errorsx.WithStack(errUpstreamRefreshError.WithHintf(
"Upstream refresh returned an invalid ID token using provider %q of type %q.", "Upstream refresh returned an invalid ID token.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
s.ProviderName, s.ProviderType).WithWrap(err))
} }
} else { } else {
plog.Debug("upstream refresh request did not return a new ID token", plog.Debug("upstream refresh request did not return a new ID token",
@ -163,5 +166,72 @@ func findOIDCProviderByNameAndValidateUID(
} }
} }
return nil, errorsx.WithStack(errUpstreamRefreshError. return nil, errorsx.WithStack(errUpstreamRefreshError.
WithHintf("Provider %q of type %q from upstream session data was not found.", s.ProviderName, s.ProviderType)) WithHint("Provider from upstream session data was not found.").WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
func upstreamLDAPRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister, username string, subject string) error {
// 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 != ""
if !(validLDAP || validAD) {
return errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
// get ldap/ad provider out of cache
p, dn, err := findLDAPProviderByNameAndValidateUID(s, providerCache)
if err != nil {
return err
}
// run PerformRefresh
err = p.PerformRefresh(ctx, dn, username, subject)
if err != nil {
return errorsx.WithStack(errUpstreamRefreshError.WithHint(
"Upstream refresh failed.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
return nil
}
func findLDAPProviderByNameAndValidateUID(
s *psession.CustomSessionData,
providerCache oidc.UpstreamIdentityProvidersLister,
) (provider.UpstreamLDAPIdentityProviderI, string, error) {
var providers []provider.UpstreamLDAPIdentityProviderI
var dn string
if s.ProviderType == psession.ProviderTypeLDAP {
providers = providerCache.GetLDAPIdentityProviders()
dn = s.LDAP.UserDN
} else if s.ProviderType == psession.ProviderTypeActiveDirectory {
providers = providerCache.GetActiveDirectoryIdentityProviders()
dn = s.ActiveDirectory.UserDN
}
for _, p := range providers {
if p.GetName() == s.ProviderName {
if p.GetResourceUID() != s.ProviderUID {
return nil, "", errorsx.WithStack(errUpstreamRefreshError.WithHint(
"Provider from upstream session data has changed its resource UID since authentication.").WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
return p, dn, nil
}
}
return nil, "", errorsx.WithStack(errUpstreamRefreshError.
WithHint("Provider from upstream session data was not found.").WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
func getDownstreamUsernameFromPinnipedSession(session *psession.PinnipedSession) (string, error) {
extra := session.Fosite.Claims.Extra
if extra == nil {
return "", errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
downstreamUsernameInterface := extra["username"]
if downstreamUsernameInterface == nil {
return "", errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
downstreamUsername, ok := downstreamUsernameInterface.(string)
if !ok || len(downstreamUsername) == 0 {
return "", errorsx.WithStack(errMissingUpstreamSessionInternalError)
}
return downstreamUsername, nil
} }

View File

@ -232,7 +232,7 @@ type tokenEndpointResponseExpectedValues struct {
wantErrorResponseBody string wantErrorResponseBody string
wantRequestedScopes []string wantRequestedScopes []string
wantGrantedScopes []string wantGrantedScopes []string
wantUpstreamOIDCRefreshCall *expectedUpstreamRefresh wantUpstreamRefreshCall *expectedUpstreamRefresh
wantUpstreamOIDCValidateTokenCall *expectedUpstreamValidateTokens wantUpstreamOIDCValidateTokenCall *expectedUpstreamValidateTokens
wantCustomSessionDataStored *psession.CustomSessionData wantCustomSessionDataStored *psession.CustomSessionData
} }
@ -879,8 +879,20 @@ func TestRefreshGrant(t *testing.T) {
oidcUpstreamInitialRefreshToken = "initial-upstream-refresh-token" oidcUpstreamInitialRefreshToken = "initial-upstream-refresh-token"
oidcUpstreamRefreshedIDToken = "fake-refreshed-id-token" oidcUpstreamRefreshedIDToken = "fake-refreshed-id-token"
oidcUpstreamRefreshedRefreshToken = "fake-refreshed-refresh-token" oidcUpstreamRefreshedRefreshToken = "fake-refreshed-refresh-token"
ldapUpstreamName = "some-ldap-idp"
ldapUpstreamResourceUID = "ldap-resource-uid"
ldapUpstreamType = "ldap"
ldapUpstreamDN = "some-ldap-user-dn"
activeDirectoryUpstreamName = "some-ad-idp"
activeDirectoryUpstreamResourceUID = "ad-resource-uid"
activeDirectoryUpstreamType = "activedirectory"
activeDirectoryUpstreamDN = "some-ad-user-dn"
) )
ldapUpstreamURL, _ := url.Parse("some-url")
// The below values are funcs so every test can have its own copy of the objects, to avoid data races // The below values are funcs so every test can have its own copy of the objects, to avoid data races
// in these parallel tests. // in these parallel tests.
@ -907,7 +919,7 @@ func TestRefreshGrant(t *testing.T) {
return sessionData return sessionData
} }
happyUpstreamRefreshCall := func() *expectedUpstreamRefresh { happyOIDCUpstreamRefreshCall := func() *expectedUpstreamRefresh {
return &expectedUpstreamRefresh{ return &expectedUpstreamRefresh{
performedByUpstreamName: oidcUpstreamName, performedByUpstreamName: oidcUpstreamName,
args: &oidctestutil.PerformRefreshArgs{ args: &oidctestutil.PerformRefreshArgs{
@ -917,6 +929,30 @@ func TestRefreshGrant(t *testing.T) {
} }
} }
happyLDAPUpstreamRefreshCall := func() *expectedUpstreamRefresh {
return &expectedUpstreamRefresh{
performedByUpstreamName: ldapUpstreamName,
args: &oidctestutil.PerformRefreshArgs{
Ctx: nil,
DN: ldapUpstreamDN,
ExpectedSubject: goodSubject,
ExpectedUsername: goodUsername,
},
}
}
happyActiveDirectoryUpstreamRefreshCall := func() *expectedUpstreamRefresh {
return &expectedUpstreamRefresh{
performedByUpstreamName: activeDirectoryUpstreamName,
args: &oidctestutil.PerformRefreshArgs{
Ctx: nil,
DN: activeDirectoryUpstreamDN,
ExpectedSubject: goodSubject,
ExpectedUsername: goodUsername,
},
}
}
happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token) *expectedUpstreamValidateTokens { happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token) *expectedUpstreamValidateTokens {
return &expectedUpstreamValidateTokens{ return &expectedUpstreamValidateTokens{
performedByUpstreamName: oidcUpstreamName, performedByUpstreamName: oidcUpstreamName,
@ -944,7 +980,7 @@ func TestRefreshGrant(t *testing.T) {
// same as the same values as the authcode exchange case. // same as the same values as the authcode exchange case.
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored) want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
// Should always try to perform an upstream refresh. // Should always try to perform an upstream refresh.
want.wantUpstreamOIDCRefreshCall = happyUpstreamRefreshCall() want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall()
// Should only try to ValidateToken when there was an id token returned by the upstream refresh. // Should only try to ValidateToken when there was an id token returned by the upstream refresh.
if expectToValidateToken != nil { if expectToValidateToken != nil {
want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken) want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken)
@ -952,6 +988,18 @@ func TestRefreshGrant(t *testing.T) {
return want return want
} }
happyRefreshTokenResponseForLDAP := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues {
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
want.wantUpstreamRefreshCall = happyLDAPUpstreamRefreshCall()
return want
}
happyRefreshTokenResponseForActiveDirectory := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues {
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
want.wantUpstreamRefreshCall = happyActiveDirectoryUpstreamRefreshCall()
return want
}
refreshedUpstreamTokensWithRefreshTokenWithoutIDToken := func() *oauth2.Token { refreshedUpstreamTokensWithRefreshTokenWithoutIDToken := func() *oauth2.Token {
return &oauth2.Token{ return &oauth2.Token{
AccessToken: "fake-refreshed-access-token", AccessToken: "fake-refreshed-access-token",
@ -972,11 +1020,28 @@ func TestRefreshGrant(t *testing.T) {
return tokens return tokens
} }
happyActiveDirectoryCustomSessionData := &psession.CustomSessionData{
ProviderUID: activeDirectoryUpstreamResourceUID,
ProviderName: activeDirectoryUpstreamName,
ProviderType: activeDirectoryUpstreamType,
ActiveDirectory: &psession.ActiveDirectorySessionData{
UserDN: activeDirectoryUpstreamDN,
},
}
happyLDAPCustomSessionData := &psession.CustomSessionData{
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: ldapUpstreamType,
LDAP: &psession.LDAPSessionData{
UserDN: ldapUpstreamDN,
},
}
tests := []struct { tests := []struct {
name string name string
idps *oidctestutil.UpstreamIDPListerBuilder idps *oidctestutil.UpstreamIDPListerBuilder
authcodeExchange authcodeExchangeInputs authcodeExchange authcodeExchangeInputs
refreshRequest refreshRequestInputs refreshRequest refreshRequestInputs
modifyRefreshTokenStorage func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string)
}{ }{
{ {
name: "happy path refresh grant with openid scope granted (id token returned)", name: "happy path refresh grant with openid scope granted (id token returned)",
@ -1015,7 +1080,7 @@ func TestRefreshGrant(t *testing.T) {
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"}, wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"},
wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
}, },
@ -1096,7 +1161,7 @@ func TestRefreshGrant(t *testing.T) {
wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
}, },
@ -1400,7 +1465,7 @@ func TestRefreshGrant(t *testing.T) {
wantErrorResponseBody: here.Doc(` wantErrorResponseBody: here.Doc(`
{ {
"error": "error", "error": "error",
"error_description": "Error during upstream refresh. Provider 'this-name-will-not-be-found' of type 'oidc' from upstream session data was not found." "error_description": "Error during upstream refresh. Provider from upstream session data was not found."
} }
`), `),
}, },
@ -1449,12 +1514,12 @@ func TestRefreshGrant(t *testing.T) {
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized, wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(` wantErrorResponseBody: here.Doc(`
{ {
"error": "error", "error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed using provider 'some-oidc-idp' of type 'oidc'." "error_description": "Error during upstream refresh. Upstream refresh failed."
} }
`), `),
}, },
@ -1474,13 +1539,520 @@ func TestRefreshGrant(t *testing.T) {
}, },
refreshRequest: refreshRequestInputs{ refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{ want: tokenEndpointResponseExpectedValues{
wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()),
wantStatus: http.StatusUnauthorized, wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(` wantErrorResponseBody: here.Doc(`
{ {
"error": "error", "error": "error",
"error_description": "Error during upstream refresh. Upstream refresh returned an invalid ID token using provider 'some-oidc-idp' of type 'oidc'." "error_description": "Error during upstream refresh. Upstream refresh returned an invalid ID token."
}
`),
},
},
},
{
name: "upstream ldap refresh happy path",
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,
),
},
refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForLDAP(
happyLDAPCustomSessionData,
),
},
},
{
name: "upstream active directory refresh happy path",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: activeDirectoryUpstreamName,
ResourceUID: activeDirectoryUpstreamResourceUID,
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyActiveDirectoryCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyActiveDirectoryCustomSessionData,
),
},
refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForActiveDirectory(
happyActiveDirectoryCustomSessionData,
),
},
},
{
name: "upstream ldap refresh when the LDAP session data is nil",
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: &psession.CustomSessionData{
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: ldapUpstreamType,
LDAP: nil,
},
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
&psession.CustomSessionData{
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: ldapUpstreamType,
LDAP: nil,
},
),
},
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: "upstream active directory refresh when the ad session data is nil",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: activeDirectoryUpstreamName,
ResourceUID: activeDirectoryUpstreamResourceUID,
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: &psession.CustomSessionData{
ProviderUID: activeDirectoryUpstreamResourceUID,
ProviderName: activeDirectoryUpstreamName,
ProviderType: activeDirectoryUpstreamType,
ActiveDirectory: nil,
},
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
&psession.CustomSessionData{
ProviderUID: activeDirectoryUpstreamResourceUID,
ProviderName: activeDirectoryUpstreamName,
ProviderType: activeDirectoryUpstreamType,
ActiveDirectory: nil,
},
),
},
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: "upstream ldap refresh when the LDAP session data does not contain dn",
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: &psession.CustomSessionData{
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: ldapUpstreamType,
LDAP: &psession.LDAPSessionData{
UserDN: "",
},
},
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
&psession.CustomSessionData{
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: ldapUpstreamType,
LDAP: &psession.LDAPSessionData{
UserDN: "",
},
},
),
},
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: "upstream active directory refresh when the active directory session data does not contain dn",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: activeDirectoryUpstreamName,
ResourceUID: activeDirectoryUpstreamResourceUID,
URL: ldapUpstreamURL,
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: &psession.CustomSessionData{
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: ldapUpstreamType,
ActiveDirectory: &psession.ActiveDirectorySessionData{
UserDN: "",
},
},
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
&psession.CustomSessionData{
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: ldapUpstreamType,
ActiveDirectory: &psession.ActiveDirectorySessionData{
UserDN: "",
},
},
),
},
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: "upstream ldap refresh returns an error",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: ldapUpstreamName,
ResourceUID: ldapUpstreamResourceUID,
URL: ldapUpstreamURL,
PerformRefreshErr: errors.New("Some error performing upstream refresh"),
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyLDAPCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyLDAPCustomSessionData,
),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
}
`),
},
},
},
{
name: "upstream active directory refresh returns an error",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: activeDirectoryUpstreamName,
ResourceUID: activeDirectoryUpstreamResourceUID,
URL: ldapUpstreamURL,
PerformRefreshErr: errors.New("Some error performing upstream refresh"),
}),
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") },
customSessionData: happyActiveDirectoryCustomSessionData,
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
happyActiveDirectoryCustomSessionData,
),
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyActiveDirectoryUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
}
`),
},
},
},
{
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,
//fositeSessionData: &openid.DefaultSession{},
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: "username in extra is not a string",
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,
//fositeSessionData: &openid.DefaultSession{},
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{}{"username": 123},
},
}
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 in extra is an empty string",
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,
//fositeSessionData: &openid.DefaultSession{},
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{}{"username": ""},
},
}
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."
} }
`), `),
}, },
@ -1493,6 +2065,8 @@ func TestRefreshGrant(t *testing.T) {
t.Parallel() t.Parallel()
// First exchange the authcode for tokens, including a refresh token. // First exchange the authcode for tokens, including a refresh token.
// its actually fine to use this function even when simulating ldap (which uses a different flow) because it's
// just populating a secret in storage.
subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.Build()) subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.Build())
var parsedAuthcodeExchangeResponseBody map[string]interface{} var parsedAuthcodeExchangeResponseBody map[string]interface{}
require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody))
@ -1511,6 +2085,10 @@ func TestRefreshGrant(t *testing.T) {
// Send the refresh token back and preform a refresh. // Send the refresh token back and preform a refresh.
firstRefreshToken := parsedAuthcodeExchangeResponseBody["refresh_token"].(string) firstRefreshToken := parsedAuthcodeExchangeResponseBody["refresh_token"].(string)
require.NotEmpty(t, firstRefreshToken) require.NotEmpty(t, firstRefreshToken)
if test.modifyRefreshTokenStorage != nil {
test.modifyRefreshTokenStorage(t, oauthStore, firstRefreshToken)
}
reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context")
req := httptest.NewRequest("POST", "/path/shouldn't/matter", req := httptest.NewRequest("POST", "/path/shouldn't/matter",
happyRefreshRequestBody(firstRefreshToken).ReadCloser()).WithContext(reqContext) happyRefreshRequestBody(firstRefreshToken).ReadCloser()).WithContext(reqContext)
@ -1525,11 +2103,11 @@ func TestRefreshGrant(t *testing.T) {
t.Logf("second response body: %q", refreshResponse.Body.String()) t.Logf("second response body: %q", refreshResponse.Body.String())
// Test that we did or did not make a call to the upstream OIDC provider interface to perform a token refresh. // Test that we did or did not make a call to the upstream OIDC provider interface to perform a token refresh.
if test.refreshRequest.want.wantUpstreamOIDCRefreshCall != nil { if test.refreshRequest.want.wantUpstreamRefreshCall != nil {
test.refreshRequest.want.wantUpstreamOIDCRefreshCall.args.Ctx = reqContext test.refreshRequest.want.wantUpstreamRefreshCall.args.Ctx = reqContext
test.idps.RequireExactlyOneCallToPerformRefresh(t, test.idps.RequireExactlyOneCallToPerformRefresh(t,
test.refreshRequest.want.wantUpstreamOIDCRefreshCall.performedByUpstreamName, test.refreshRequest.want.wantUpstreamRefreshCall.performedByUpstreamName,
test.refreshRequest.want.wantUpstreamOIDCRefreshCall.args, test.refreshRequest.want.wantUpstreamRefreshCall.args,
) )
} else { } else {
test.idps.RequireExactlyZeroCallsToPerformRefresh(t) test.idps.RequireExactlyZeroCallsToPerformRefresh(t)

View File

@ -45,6 +45,10 @@ type CustomSessionData struct {
// Only used when ProviderType == "oidc". // Only used when ProviderType == "oidc".
OIDC *OIDCSessionData `json:"oidc,omitempty"` OIDC *OIDCSessionData `json:"oidc,omitempty"`
LDAP *LDAPSessionData `json:"ldap,omitempty"`
ActiveDirectory *ActiveDirectorySessionData `json:"activedirectory,omitempty"`
} }
type ProviderType string type ProviderType string
@ -60,6 +64,16 @@ type OIDCSessionData struct {
UpstreamRefreshToken string `json:"upstreamRefreshToken"` UpstreamRefreshToken string `json:"upstreamRefreshToken"`
} }
// LDAPSessionData is the additional data needed by Pinniped when the upstream IDP is an LDAP provider.
type LDAPSessionData struct {
UserDN string `json:"userDN"`
}
// ActiveDirectorySessionData is the additional data needed by Pinniped when the upstream IDP is an Active Directory provider.
type ActiveDirectorySessionData struct {
UserDN string `json:"userDN"`
}
// NewPinnipedSession returns a new empty session. // NewPinnipedSession returns a new empty session.
func NewPinnipedSession() *PinnipedSession { func NewPinnipedSession() *PinnipedSession {
return &PinnipedSession{ return &PinnipedSession{

View File

@ -21,10 +21,10 @@ import (
"gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
v1 "k8s.io/client-go/kubernetes/typed/core/v1" v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect" "go.pinniped.dev/internal/fositestorage/openidconnect"
@ -61,8 +61,11 @@ type PasswordCredentialsGrantAndValidateTokensArgs struct {
// PerformRefreshArgs is used to spy on calls to // PerformRefreshArgs is used to spy on calls to
// TestUpstreamOIDCIdentityProvider.PerformRefreshFunc(). // TestUpstreamOIDCIdentityProvider.PerformRefreshFunc().
type PerformRefreshArgs struct { type PerformRefreshArgs struct {
Ctx context.Context Ctx context.Context
RefreshToken string RefreshToken string
DN string
ExpectedUsername string
ExpectedSubject string
} }
// RevokeRefreshTokenArgs is used to spy on calls to // RevokeRefreshTokenArgs is used to spy on calls to
@ -81,10 +84,13 @@ type ValidateTokenArgs struct {
} }
type TestUpstreamLDAPIdentityProvider struct { type TestUpstreamLDAPIdentityProvider struct {
Name string Name string
ResourceUID types.UID ResourceUID types.UID
URL *url.URL URL *url.URL
AuthenticateFunc func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) AuthenticateFunc func(ctx context.Context, username, password string) (*authenticators.Response, bool, error)
performRefreshCallCount int
performRefreshArgs []*PerformRefreshArgs
PerformRefreshErr error
} }
var _ provider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{} var _ provider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{}
@ -97,7 +103,7 @@ func (u *TestUpstreamLDAPIdentityProvider) GetName() string {
return u.Name return u.Name
} }
func (u *TestUpstreamLDAPIdentityProvider) AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { func (u *TestUpstreamLDAPIdentityProvider) AuthenticateUser(ctx context.Context, username, password string) (*authenticators.Response, bool, error) {
return u.AuthenticateFunc(ctx, username, password) return u.AuthenticateFunc(ctx, username, password)
} }
@ -105,6 +111,34 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL {
return u.URL return u.URL
} }
func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) 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,
})
if u.PerformRefreshErr != nil {
return u.PerformRefreshErr
}
return nil
}
func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshCallCount() int {
return u.performRefreshCallCount
}
func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshArgs(call int) *PerformRefreshArgs {
if u.performRefreshArgs == nil {
u.performRefreshArgs = make([]*PerformRefreshArgs, 0)
}
return u.performRefreshArgs[call]
}
type TestUpstreamOIDCIdentityProvider struct { type TestUpstreamOIDCIdentityProvider struct {
Name string Name string
ClientID string ClientID string
@ -429,31 +463,54 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToPerformRefresh(
t.Helper() t.Helper()
var actualArgs *PerformRefreshArgs var actualArgs *PerformRefreshArgs
var actualNameOfUpstreamWhichMadeCall string var actualNameOfUpstreamWhichMadeCall string
actualCallCountAcrossAllOIDCUpstreams := 0 actualCallCountAcrossAllUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstreamOIDC.performRefreshCallCount callCountOnThisUpstream := upstreamOIDC.performRefreshCallCount
actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream actualCallCountAcrossAllUpstreams += callCountOnThisUpstream
if callCountOnThisUpstream == 1 { if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name
actualArgs = upstreamOIDC.performRefreshArgs[0] actualArgs = upstreamOIDC.performRefreshArgs[0]
} }
} }
require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams, for _, upstreamLDAP := range b.upstreamLDAPIdentityProviders {
"should have been exactly one call to PerformRefresh() by all OIDC upstreams", callCountOnThisUpstream := upstreamLDAP.performRefreshCallCount
actualCallCountAcrossAllUpstreams += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamLDAP.Name
actualArgs = upstreamLDAP.performRefreshArgs[0]
}
}
for _, upstreamAD := range b.upstreamActiveDirectoryIdentityProviders {
callCountOnThisUpstream := upstreamAD.performRefreshCallCount
actualCallCountAcrossAllUpstreams += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamAD.Name
actualArgs = upstreamAD.performRefreshArgs[0]
}
}
require.Equal(t, 1, actualCallCountAcrossAllUpstreams,
"should have been exactly one call to PerformRefresh() by all upstreams",
) )
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"PerformRefresh() was called on the wrong OIDC upstream", "PerformRefresh() was called on the wrong upstream",
) )
require.Equal(t, expectedArgs, actualArgs) require.Equal(t, expectedArgs, actualArgs)
} }
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *testing.T) { func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *testing.T) {
t.Helper() t.Helper()
actualCallCountAcrossAllOIDCUpstreams := 0 actualCallCountAcrossAllUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.performRefreshCallCount actualCallCountAcrossAllUpstreams += upstreamOIDC.performRefreshCallCount
} }
require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams, for _, upstreamLDAP := range b.upstreamLDAPIdentityProviders {
actualCallCountAcrossAllUpstreams += upstreamLDAP.performRefreshCallCount
}
for _, upstreamActiveDirectory := range b.upstreamActiveDirectoryIdentityProviders {
actualCallCountAcrossAllUpstreams += upstreamActiveDirectory.performRefreshCallCount
}
require.Equal(t, 0, actualCallCountAcrossAllUpstreams,
"expected exactly zero calls to PerformRefresh()", "expected exactly zero calls to PerformRefresh()",
) )
} }

View File

@ -21,12 +21,12 @@ import (
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/google/uuid" "github.com/google/uuid"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/utils/trace" "k8s.io/utils/trace"
"go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/endpointaddr" "go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
) )
@ -169,6 +169,73 @@ func (p *Provider) GetConfig() ProviderConfig {
return p.c return p.c
} }
func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) 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
searchResult, err := p.performRefresh(ctx, userDN)
if err != nil {
p.traceRefreshFailure(t, err)
return err
}
// 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`,
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)
}
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,
)
}
newUID, err := p.getSearchResultAttributeRawValueEncoded(p.c.UserSearch.UIDAttribute, userEntry, userDN)
if err != nil {
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)
}
// we checked that the user still exists and their information is the same, so just return.
return nil
}
func (p *Provider) performRefresh(ctx context.Context, userDN string) (*ldap.SearchResult, error) {
search := p.refreshUserSearchRequest(userDN)
conn, err := p.dial(ctx)
if err != nil {
return nil, fmt.Errorf(`error dialing host "%s": %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)
}
searchResult, err := conn.Search(search)
if err != nil {
return nil, fmt.Errorf(`error searching for user "%s": %w`, userDN, err)
}
return searchResult, nil
}
func (p *Provider) dial(ctx context.Context) (Conn, error) { func (p *Provider) dial(ctx context.Context) (Conn, error) {
tlsAddr, err := endpointaddr.Parse(p.c.Host, defaultLDAPSPort) tlsAddr, err := endpointaddr.Parse(p.c.Host, defaultLDAPSPort)
if err != nil { if err != nil {
@ -310,7 +377,7 @@ func (p *Provider) TestConnection(ctx context.Context) error {
// authentication for a given end user's username. It runs the same logic as AuthenticateUser except it does // authentication for a given end user's username. It runs the same logic as AuthenticateUser except it does
// not bind as that user, so it does not test their password. It returns the same values that a real call to // not bind as that user, so it does not test their password. It returns the same values that a real call to
// AuthenticateUser with the correct password would return. // AuthenticateUser with the correct password would return.
func (p *Provider) DryRunAuthenticateUser(ctx context.Context, username string) (*authenticator.Response, bool, error) { func (p *Provider) DryRunAuthenticateUser(ctx context.Context, username string) (*authenticators.Response, bool, error) {
endUserBindFunc := func(conn Conn, foundUserDN string) error { endUserBindFunc := func(conn Conn, foundUserDN string) error {
// Act as if the end user bind always succeeds. // Act as if the end user bind always succeeds.
return nil return nil
@ -319,14 +386,14 @@ func (p *Provider) DryRunAuthenticateUser(ctx context.Context, username string)
} }
// Authenticate an end user and return their mapped username, groups, and UID. Implements authenticators.UserAuthenticator. // Authenticate an end user and return their mapped username, groups, and UID. Implements authenticators.UserAuthenticator.
func (p *Provider) AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { func (p *Provider) AuthenticateUser(ctx context.Context, username, password string) (*authenticators.Response, bool, error) {
endUserBindFunc := func(conn Conn, foundUserDN string) error { endUserBindFunc := func(conn Conn, foundUserDN string) error {
return conn.Bind(foundUserDN, password) return conn.Bind(foundUserDN, password)
} }
return p.authenticateUserImpl(ctx, username, endUserBindFunc) return p.authenticateUserImpl(ctx, username, endUserBindFunc)
} }
func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bindFunc func(conn Conn, foundUserDN string) error) (*authenticator.Response, bool, error) { func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bindFunc func(conn Conn, foundUserDN string) error) (*authenticators.Response, bool, error) {
t := trace.FromContext(ctx).Nest("slow ldap authenticate user attempt", trace.Field{Key: "providerName", Value: p.GetName()}) t := trace.FromContext(ctx).Nest("slow ldap authenticate user attempt", trace.Field{Key: "providerName", Value: p.GetName()})
defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches
@ -355,24 +422,16 @@ func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bi
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 "%s" before user search: %w`, p.c.BindUsername, err)
} }
mappedUsername, mappedUID, mappedGroupNames, err := p.searchAndBindUser(conn, username, bindFunc) response, err := p.searchAndBindUser(conn, username, bindFunc)
if err != nil { if err != nil {
p.traceAuthFailure(t, err) p.traceAuthFailure(t, err)
return nil, false, err return nil, false, err
} }
if len(mappedUsername) == 0 || len(mappedUID) == 0 { if response == nil {
// Couldn't find the username or couldn't bind using the password.
p.traceAuthFailure(t, fmt.Errorf("bad username or password")) p.traceAuthFailure(t, fmt.Errorf("bad username or password"))
return nil, false, nil return nil, false, nil
} }
response := &authenticator.Response{
User: &user.DefaultInfo{
Name: mappedUsername,
UID: mappedUID,
Groups: mappedGroupNames,
},
}
p.traceAuthSuccess(t) p.traceAuthSuccess(t)
return response, true, nil return response, true, nil
} }
@ -454,7 +513,7 @@ func (p *Provider) SearchForDefaultNamingContext(ctx context.Context) (string, e
return searchBase, nil return searchBase, nil
} }
func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(conn Conn, foundUserDN string) error) (string, string, []string, error) { func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(conn Conn, foundUserDN string) error) (*authenticators.Response, error) {
searchResult, err := conn.Search(p.userSearchRequest(username)) searchResult, err := conn.Search(p.userSearchRequest(username))
if err != nil { if err != nil {
plog.All(`error searching for user`, plog.All(`error searching for user`,
@ -462,7 +521,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
"username", username, "username", username,
"err", err, "err", err,
) )
return "", "", nil, fmt.Errorf(`error searching for user: %w`, err) return nil, fmt.Errorf(`error searching for user: %w`, err)
} }
if len(searchResult.Entries) == 0 { if len(searchResult.Entries) == 0 {
if plog.Enabled(plog.LevelAll) { if plog.Enabled(plog.LevelAll) {
@ -473,38 +532,38 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
} else { } else {
plog.Debug("error finding user: user not found (cowardly avoiding printing username because log level is not 'all')", "upstreamName", p.GetName()) plog.Debug("error finding user: user not found (cowardly avoiding printing username because log level is not 'all')", "upstreamName", p.GetName())
} }
return "", "", nil, nil return nil, nil
} }
// At this point, we have matched at least one entry, so we can be confident that the username is not actually // At this point, we have matched at least one entry, so we can be confident that the username is not actually
// someone's password mistakenly entered into the username field, so we can log it without concern. // someone's password mistakenly entered into the username field, so we can log it without concern.
if len(searchResult.Entries) > 1 { if len(searchResult.Entries) > 1 {
return "", "", nil, fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`, return nil, fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`,
username, len(searchResult.Entries), username, len(searchResult.Entries),
) )
} }
userEntry := searchResult.Entries[0] userEntry := searchResult.Entries[0]
if len(userEntry.DN) == 0 { if len(userEntry.DN) == 0 {
return "", "", nil, fmt.Errorf(`searching for user "%s" resulted in search result without DN`, username) return nil, fmt.Errorf(`searching for user "%s" resulted in search result without DN`, username)
} }
mappedUsername, err := p.getSearchResultAttributeValue(p.c.UserSearch.UsernameAttribute, userEntry, username) mappedUsername, err := p.getSearchResultAttributeValue(p.c.UserSearch.UsernameAttribute, userEntry, username)
if err != nil { if err != nil {
return "", "", nil, err return nil, err
} }
// We would like to support binary typed attributes for UIDs, so always read them as binary and encode them, // We would like to support binary typed attributes for UIDs, so always read them as binary and encode them,
// even when the attribute may not be binary. // even when the attribute may not be binary.
mappedUID, err := p.getSearchResultAttributeRawValueEncoded(p.c.UserSearch.UIDAttribute, userEntry, username) mappedUID, err := p.getSearchResultAttributeRawValueEncoded(p.c.UserSearch.UIDAttribute, userEntry, username)
if err != nil { if err != nil {
return "", "", nil, err return nil, err
} }
mappedGroupNames := []string{} mappedGroupNames := []string{}
if len(p.c.GroupSearch.Base) > 0 { if len(p.c.GroupSearch.Base) > 0 {
mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN) mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN)
if err != nil { if err != nil {
return "", "", nil, err return nil, err
} }
} }
sort.Strings(mappedGroupNames) sort.Strings(mappedGroupNames)
@ -516,12 +575,26 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
err, "upstreamName", p.GetName(), "username", username, "dn", userEntry.DN) err, "upstreamName", p.GetName(), "username", username, "dn", userEntry.DN)
ldapErr := &ldap.Error{} ldapErr := &ldap.Error{}
if errors.As(err, &ldapErr) && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials { if errors.As(err, &ldapErr) && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
return "", "", nil, nil return nil, nil
} }
return "", "", nil, fmt.Errorf(`error binding for user "%s" using provided password against DN "%s": %w`, username, userEntry.DN, err) return nil, fmt.Errorf(`error binding for user "%s" using provided password against DN "%s": %w`, username, userEntry.DN, err)
} }
return mappedUsername, mappedUID, mappedGroupNames, nil if len(mappedUsername) == 0 || len(mappedUID) == 0 {
// Couldn't find the username or couldn't bind using the password.
return nil, nil
}
response := &authenticators.Response{
User: &user.DefaultInfo{
Name: mappedUsername,
UID: mappedUID,
Groups: mappedGroupNames,
},
DN: userEntry.DN,
}
return response, nil
} }
func (p *Provider) defaultNamingContextRequest() *ldap.SearchRequest { func (p *Provider) defaultNamingContextRequest() *ldap.SearchRequest {
@ -568,6 +641,21 @@ func (p *Provider) groupSearchRequest(userDN string) *ldap.SearchRequest {
} }
} }
func (p *Provider) refreshUserSearchRequest(dn string) *ldap.SearchRequest {
// See https://ldap.com/the-ldap-search-operation for general documentation of LDAP search options.
return &ldap.SearchRequest{
BaseDN: dn,
Scope: ldap.ScopeBaseObject,
DerefAliases: ldap.NeverDerefAliases,
SizeLimit: 2,
TimeLimit: 90,
TypesOnly: false,
Filter: "(objectClass=*)", // we already have the dn, so the filter doesn't matter
Attributes: p.userSearchRequestedAttributes(),
Controls: nil, // this could be used to enable paging, but we're already limiting the result max size
}
}
func (p *Provider) userSearchRequestedAttributes() []string { func (p *Provider) userSearchRequestedAttributes() []string {
attributes := []string{} attributes := []string{}
if p.c.UserSearch.UsernameAttribute != distinguishedNameAttributeName { if p.c.UserSearch.UsernameAttribute != distinguishedNameAttributeName {
@ -687,6 +775,12 @@ func (p *Provider) traceSearchBaseDiscoveryFailure(t *trace.Trace, err error) {
trace.Field{Key: "reason", Value: err.Error()}) trace.Field{Key: "reason", Value: err.Error()})
} }
func (p *Provider) traceRefreshFailure(t *trace.Trace, err error) {
t.Step("refresh failed",
trace.Field{Key: "reason", Value: err.Error()},
)
}
func MicrosoftUUIDFromBinary(attributeName string) func(entry *ldap.Entry) (string, error) { func MicrosoftUUIDFromBinary(attributeName string) func(entry *ldap.Entry) (string, error) {
// validation has already been done so we can just get the attribute... // validation has already been done so we can just get the attribute...
return func(entry *ldap.Entry) (string, error) { return func(entry *ldap.Entry) (string, error) {

View File

@ -18,9 +18,9 @@ import (
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/endpointaddr" "go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/mocks/mockldapconn" "go.pinniped.dev/internal/mocks/mockldapconn"
@ -151,7 +151,7 @@ func TestEndUserAuthentication(t *testing.T) {
} }
// The auth response which matches the exampleUserSearchResult and exampleGroupSearchResult. // The auth response which matches the exampleUserSearchResult and exampleGroupSearchResult.
expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticator.Response { expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticators.Response {
u := &user.DefaultInfo{ u := &user.DefaultInfo{
Name: testUserSearchResultUsernameAttributeValue, Name: testUserSearchResultUsernameAttributeValue,
UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
@ -160,7 +160,7 @@ func TestEndUserAuthentication(t *testing.T) {
if editFunc != nil { if editFunc != nil {
editFunc(u) editFunc(u)
} }
return &authenticator.Response{User: u} return &authenticators.Response{User: u, DN: testUserSearchResultDNValue}
} }
tests := []struct { tests := []struct {
@ -173,7 +173,7 @@ func TestEndUserAuthentication(t *testing.T) {
dialError error dialError error
wantError string wantError string
wantToSkipDial bool wantToSkipDial bool
wantAuthResponse *authenticator.Response wantAuthResponse *authenticators.Response
wantUnauthenticated bool wantUnauthenticated bool
skipDryRunAuthenticateUser bool // tests about when the end user bind fails don't make sense for DryRunAuthenticateUser() skipDryRunAuthenticateUser bool // tests about when the end user bind fails don't make sense for DryRunAuthenticateUser()
}{ }{
@ -498,12 +498,13 @@ func TestEndUserAuthentication(t *testing.T) {
bindEndUserMocks: func(conn *mockldapconn.MockConn) { bindEndUserMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{ User: &user.DefaultInfo{
Name: testUserSearchResultUsernameAttributeValue, Name: testUserSearchResultUsernameAttributeValue,
UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
Groups: []string{"a", "b", "c"}, Groups: []string{"a", "b", "c"},
}, },
DN: testUserSearchResultDNValue,
}, },
}, },
{ {
@ -1212,6 +1213,340 @@ func TestEndUserAuthentication(t *testing.T) {
} }
} }
func TestUpstreamRefresh(t *testing.T) {
expectedUserSearch := &ldap.SearchRequest{
BaseDN: testUserSearchResultDNValue,
Scope: ldap.ScopeBaseObject,
DerefAliases: ldap.NeverDerefAliases,
SizeLimit: 2,
TimeLimit: 90,
TypesOnly: false,
Filter: "(objectClass=*)",
Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute},
Controls: nil, // don't need paging because we set the SizeLimit so small
}
happyPathUserSearchResult := &ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testUserSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
{
Name: testUserSearchUsernameAttribute,
Values: []string{testUserSearchResultUsernameAttributeValue},
},
{
Name: testUserSearchUIDAttribute,
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
},
},
},
},
}
providerConfig := &ProviderConfig{
Name: "some-provider-name",
Host: testHost,
CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test
ConnectionProtocol: TLS,
BindUsername: testBindUsername,
BindPassword: testBindPassword,
UserSearch: UserSearchConfig{
Base: testUserSearchBase,
UIDAttribute: testUserSearchUIDAttribute,
UsernameAttribute: testUserSearchUsernameAttribute,
},
}
tests := []struct {
name string
providerConfig *ProviderConfig
setupMocks func(conn *mockldapconn.MockConn)
dialError error
wantErr string
}{
{
name: "happy path where searching the dn returns a single entry",
providerConfig: providerConfig,
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch).Return(happyPathUserSearchResult, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
},
{
name: "error where dial fails",
providerConfig: providerConfig,
dialError: errors.New("some dial error"),
wantErr: "error dialing host \"ldap.example.com:8443\": some dial error",
},
{
name: "error binding",
providerConfig: providerConfig,
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "error binding as \"cn=some-bind-username,dc=pinniped,dc=dev\" before user search: some bind error",
},
{
name: "search result returns no entries",
providerConfig: providerConfig,
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "searching for user \"some-upstream-user-dn\" resulted in 0 search results, but expected 1 result",
},
{
name: "error searching",
providerConfig: providerConfig,
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch).Return(nil, errors.New("some search error"))
conn.EXPECT().Close().Times(1)
},
wantErr: "error searching for user \"some-upstream-user-dn\": some search error",
},
{
name: "search result returns more than one entry",
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{},
},
{
DN: "doesn't-matter",
Attributes: []*ldap.EntryAttribute{},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "searching for user \"some-upstream-user-dn\" resulted in 2 search results, but expected 1 result",
},
{
name: "search result has wrong uid",
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("wrong-uid")},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "searching for user \"some-upstream-user-dn\" produced a different subject than the previous value. expected: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU\", actual: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=d3JvbmctdWlk\"",
},
{
name: "search result has wrong username",
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{"wrong-username"},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "searching for user \"some-upstream-user-dn\" returned a different username than the previous value. expected: \"some-upstream-username-value\", actual: \"wrong-username\"",
},
{
name: "search result has no dn",
providerConfig: providerConfig,
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
Attributes: []*ldap.EntryAttribute{
{
Name: testUserSearchUsernameAttribute,
Values: []string{testUserSearchResultUsernameAttributeValue},
},
{
Name: testUserSearchUIDAttribute,
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "searching for user with original DN \"some-upstream-user-dn\" resulted in search result without DN",
},
{
name: "search result has 0 values for username attribute",
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{},
},
{
Name: testUserSearchUIDAttribute,
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "found 0 values for attribute \"some-upstream-username-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
},
{
name: "search result has more than one value for username attribute",
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, "something-else"},
},
{
Name: testUserSearchUIDAttribute,
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "found 2 values for attribute \"some-upstream-username-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
},
{
name: "search result has 0 values for uid attribute",
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{},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "found 0 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
},
{
name: "search result has 2 values for uid attribute",
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), []byte("other-uid-value")},
},
},
},
},
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantErr: "found 2 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
conn := mockldapconn.NewMockConn(ctrl)
if tt.setupMocks != nil {
tt.setupMocks(conn)
}
dialWasAttempted := false
providerConfig.Dialer = LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (Conn, error) {
dialWasAttempted = true
require.Equal(t, providerConfig.Host, addr.Endpoint())
if tt.dialError != nil {
return nil, tt.dialError
}
return conn, nil
})
provider := New(*providerConfig)
subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU"
err := provider.PerformRefresh(context.Background(), testUserSearchResultDNValue, testUserSearchResultUsernameAttributeValue, subject)
if tt.wantErr != "" {
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
} else {
require.NoError(t, err)
}
require.Equal(t, true, dialWasAttempted)
})
}
}
func TestTestConnection(t *testing.T) { func TestTestConnection(t *testing.T) {
providerConfig := func(editFunc func(p *ProviderConfig)) *ProviderConfig { providerConfig := func(editFunc func(p *ProviderConfig)) *ProviderConfig {
config := &ProviderConfig{ config := &ProviderConfig{

View File

@ -13,6 +13,10 @@ As a Kubernetes cluster administrator or user, you can learn how Pinniped works,
Have a question, comment, or idea? Please reach out via [GitHub Discussions](https://github.com/vmware-tanzu/pinniped/discussions) or [join the Pinniped community meetings]({{< ref "/community" >}}). Have a question, comment, or idea? Please reach out via [GitHub Discussions](https://github.com/vmware-tanzu/pinniped/discussions) or [join the Pinniped community meetings]({{< ref "/community" >}}).
## Background
{{< docsmenu "background" >}}
## Tutorials ## Tutorials
{{< docsmenu "tutorials" >}} {{< docsmenu "tutorials" >}}
@ -24,7 +28,3 @@ Have a question, comment, or idea? Please reach out via [GitHub Discussions](htt
## Reference ## Reference
{{< docsmenu "reference" >}} {{< docsmenu "reference" >}}
## Background
{{< docsmenu "background" >}}

View File

@ -6,7 +6,7 @@ menu:
docs: docs:
name: Background name: Background
identifier: background identifier: background
weight: 110 weight: 11
--- ---
{{< docsmenu "background" >}} {{< docsmenu "background" >}}

View File

@ -100,7 +100,7 @@ issue short-lived cluster certificates. (In the future, when the Kubernetes CSR
provides a way to issue short-lived certificates, then the Pinniped credential exchange API provides a way to issue short-lived certificates, then the Pinniped credential exchange API
will use that instead of using the cluster's signing keypair.) will use that instead of using the cluster's signing keypair.)
* Impersonation Proxy: Pinniped hosts an [impersonation](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation) * Impersonation Proxy: Pinniped hosts an [impersonation](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation)
proxy that sends requests to the Kubernetes API server with user information and permissions based on a token. proxy that sends requests to the Kubernetes API server with user information and permissions based on a token.
## kubectl Integration ## kubectl Integration

View File

@ -4,6 +4,12 @@
status = 302 status = 302
force = true force = true
[[redirects]]
from = "/cli"
to = "https://pinniped.dev/docs/howto/install-cli"
status = 302
force = true
[[redirects]] [[redirects]]
from = "/latest/*" from = "/latest/*"
to = "https://github.com/vmware-tanzu/pinniped/releases/latest/download/:splat" to = "https://github.com/vmware-tanzu/pinniped/releases/latest/download/:splat"

View File

@ -17,9 +17,9 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/upstreamldap" "go.pinniped.dev/internal/upstreamldap"
"go.pinniped.dev/test/testlib" "go.pinniped.dev/test/testlib"
) )
@ -74,7 +74,7 @@ func TestLDAPSearch_Parallel(t *testing.T) {
password string password string
provider *upstreamldap.Provider provider *upstreamldap.Provider
wantError string wantError string
wantAuthResponse *authenticator.Response wantAuthResponse *authenticators.Response
wantUnauthenticated bool wantUnauthenticated bool
}{ }{
{ {
@ -82,8 +82,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
username: "pinny", username: "pinny",
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(nil)), provider: upstreamldap.New(*providerConfig(nil)),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -94,8 +94,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.Host = "127.0.0.1:" + ldapLocalhostPort p.Host = "127.0.0.1:" + ldapLocalhostPort
p.ConnectionProtocol = upstreamldap.StartTLS p.ConnectionProtocol = upstreamldap.StartTLS
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -103,8 +103,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
username: "pinny", username: "pinny",
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -112,8 +112,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
username: "pinny", username: "pinny",
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -124,8 +124,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.UsernameAttribute = "dn" p.UserSearch.UsernameAttribute = "dn"
p.UserSearch.Filter = "cn={}" p.UserSearch.Filter = "cn={}"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, 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",
}, },
}, },
{ {
@ -135,8 +135,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.UserSearch.Filter = "(|(cn={})(mail={}))" p.UserSearch.Filter = "(|(cn={})(mail={}))"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -146,8 +146,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.UserSearch.Filter = "(|(cn={})(mail={}))" p.UserSearch.Filter = "(|(cn={})(mail={}))"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -155,8 +155,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
username: "pinny", username: "pinny",
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("cn=pinny,ou=users,dc=pinniped,dc=dev"), Groups: []string{"ball-game-players", "seals"}}, 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",
}, },
}, },
{ {
@ -164,8 +164,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
username: "pinny", username: "pinny",
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("Seal"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("Seal"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -173,8 +173,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
username: "seAl", // note that this is not case-sensitive! sn=Seal. The server decides which fields are compared case-sensitive. username: "seAl", // note that this is not case-sensitive! sn=Seal. The server decides which fields are compared case-sensitive.
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "Seal", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, // 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
}, },
}, },
{ {
@ -186,8 +186,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.UsernameAttribute = "givenName" p.UserSearch.UsernameAttribute = "givenName"
p.UserSearch.UIDAttribute = "givenName" p.UserSearch.UIDAttribute = "givenName"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: b64("Pinny the 🦭"), Groups: []string{"ball-game-players", "seals"}}, 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",
}, },
}, },
{ {
@ -198,8 +198,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
p.UserSearch.Filter = "givenName={}" p.UserSearch.Filter = "givenName={}"
p.UserSearch.UsernameAttribute = "cn" p.UserSearch.UsernameAttribute = "cn"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -219,8 +219,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.GroupSearch.Base = "" p.GroupSearch.Base = ""
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -230,8 +230,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.GroupSearch.Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree p.GroupSearch.Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -241,11 +241,11 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.GroupSearch.GroupNameAttribute = "dn" p.GroupSearch.GroupNameAttribute = "dn"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{ User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{
"cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev", "cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev",
"cn=seals,ou=groups,dc=pinniped,dc=dev", "cn=seals,ou=groups,dc=pinniped,dc=dev",
}}, }}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -255,11 +255,11 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.GroupSearch.GroupNameAttribute = "" p.GroupSearch.GroupNameAttribute = ""
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{ User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{
"cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev", "cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev",
"cn=seals,ou=groups,dc=pinniped,dc=dev", "cn=seals,ou=groups,dc=pinniped,dc=dev",
}}, }}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -269,8 +269,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.GroupSearch.GroupNameAttribute = "objectClass" // silly example, but still a meaningful test p.GroupSearch.GroupNameAttribute = "objectClass" // silly example, but still a meaningful test
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"groupOfNames", "groupOfNames"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"groupOfNames", "groupOfNames"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -280,8 +280,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.GroupSearch.Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))" p.GroupSearch.Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -291,8 +291,8 @@ func TestLDAPSearch_Parallel(t *testing.T) {
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.GroupSearch.Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema p.GroupSearch.Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, },
}, },
{ {
@ -670,14 +670,15 @@ func TestSimultaneousLDAPRequestsOnSingleProvider(t *testing.T) {
// Record failures but allow the test to keep running so that all the background goroutines have a chance to try. // Record failures but allow the test to keep running so that all the background goroutines have a chance to try.
assert.NoError(t, result.err) assert.NoError(t, result.err)
assert.True(t, result.authenticated, "expected the user to be authenticated, but they were not") assert.True(t, result.authenticated, "expected the user to be authenticated, but they were not")
assert.Equal(t, &authenticator.Response{ assert.Equal(t, &authenticators.Response{
User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
DN: "cn=pinny,ou=users,dc=pinniped,dc=dev",
}, result.response) }, result.response)
} }
} }
type authUserResult struct { type authUserResult struct {
response *authenticator.Response response *authenticators.Response
authenticated bool authenticated bool
err error err error
} }

View File

@ -44,7 +44,7 @@ func TestSupervisorLogin(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
maybeSkip func(t *testing.T) maybeSkip func(t *testing.T)
createIDP func(t *testing.T) createIDP func(t *testing.T) string
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client)
wantDownstreamIDTokenSubjectToMatch string wantDownstreamIDTokenSubjectToMatch string
wantDownstreamIDTokenUsernameToMatch string wantDownstreamIDTokenUsernameToMatch string
@ -55,16 +55,16 @@ func TestSupervisorLogin(t *testing.T) {
// We don't necessarily have any way to revoke the user's session on the upstream provider, // 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 // so to cause the upstream refresh to fail we can cheat by manipulating the user's session
// data in such a way that it should cause the next upstream refresh attempt to fail. // data in such a way that it should cause the next upstream refresh attempt to fail.
breakRefreshSessionData func(t *testing.T, customSessionData *psession.CustomSessionData) breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName string)
}{ }{
{ {
name: "oidc with default username and groups claim settings", name: "oidc with default username and groups claim settings",
maybeSkip: func(t *testing.T) { maybeSkip: func(t *testing.T) {
// never need to skip this test // never need to skip this test
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ oidcIDP := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer, Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{ TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
@ -73,9 +73,11 @@ func TestSupervisorLogin(t *testing.T) {
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
}, },
}, idpv1alpha1.PhaseReady) }, idpv1alpha1.PhaseReady)
return oidcIDP.Name
}, },
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
breakRefreshSessionData: func(t *testing.T, customSessionData *psession.CustomSessionData) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token" customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
@ -90,9 +92,9 @@ func TestSupervisorLogin(t *testing.T) {
maybeSkip: func(t *testing.T) { maybeSkip: func(t *testing.T) {
// never need to skip this test // never need to skip this test
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ oidcIDP := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer, Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{ TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
@ -108,9 +110,11 @@ func TestSupervisorLogin(t *testing.T) {
AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes, AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes,
}, },
}, idpv1alpha1.PhaseReady) }, idpv1alpha1.PhaseReady)
return oidcIDP.Name
}, },
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
breakRefreshSessionData: func(t *testing.T, customSessionData *psession.CustomSessionData) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token" customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
@ -124,9 +128,9 @@ func TestSupervisorLogin(t *testing.T) {
maybeSkip: func(t *testing.T) { maybeSkip: func(t *testing.T) {
// never need to skip this test // never need to skip this test
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ oidcIDP := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer, Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{ TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
@ -138,6 +142,7 @@ func TestSupervisorLogin(t *testing.T) {
AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider
}, },
}, idpv1alpha1.PhaseReady) }, 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -148,7 +153,8 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: func(t *testing.T, customSessionData *psession.CustomSessionData) { breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token" customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
@ -166,7 +172,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("LDAP integration test requires connectivity to an LDAP server") t.Skip("LDAP integration test requires connectivity to an LDAP server")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth,
map[string]string{ map[string]string{
@ -204,6 +210,7 @@ func TestSupervisorLogin(t *testing.T) {
secret.Name, secret.ResourceVersion, secret.Name, secret.ResourceVersion,
) )
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -214,7 +221,13 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Subject = "not-right"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.Host+ "ldaps://"+env.SupervisorUpstreamLDAP.Host+
@ -233,7 +246,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("LDAP integration test requires connectivity to an LDAP server") t.Skip("LDAP integration test requires connectivity to an LDAP server")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth,
map[string]string{ map[string]string{
@ -271,6 +284,7 @@ func TestSupervisorLogin(t *testing.T) {
secret.Name, secret.ResourceVersion, secret.Name, secret.ResourceVersion,
) )
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -281,7 +295,13 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Extra["username"] = "not-the-same"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.StartTLSOnlyHost+ "ldaps://"+env.SupervisorUpstreamLDAP.StartTLSOnlyHost+
@ -300,7 +320,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("LDAP integration test requires connectivity to an LDAP server") t.Skip("LDAP integration test requires connectivity to an LDAP server")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth,
map[string]string{ map[string]string{
@ -338,6 +358,7 @@ func TestSupervisorLogin(t *testing.T) {
secret.Name, secret.ResourceVersion, secret.Name, secret.ResourceVersion,
) )
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -348,9 +369,8 @@ func TestSupervisorLogin(t *testing.T) {
true, true,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type wantErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.",
wantErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", wantErrorType: "access_denied",
wantErrorType: "access_denied",
}, },
{ {
name: "ldap login still works after updating bind secret", name: "ldap login still works after updating bind secret",
@ -360,7 +380,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("LDAP integration test requires connectivity to an LDAP server") t.Skip("LDAP integration test requires connectivity to an LDAP server")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth,
@ -416,6 +436,7 @@ func TestSupervisorLogin(t *testing.T) {
requireEventually.NoError(err) requireEventually.NoError(err)
requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, ldapIDP, expectedMsg) requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, ldapIDP, expectedMsg)
}, time.Minute, 500*time.Millisecond) }, 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -426,7 +447,12 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
customSessionData.LDAP.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.Host+ "ldaps://"+env.SupervisorUpstreamLDAP.Host+
@ -445,7 +471,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("LDAP integration test requires connectivity to an LDAP server") t.Skip("LDAP integration test requires connectivity to an LDAP server")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth,
@ -515,6 +541,7 @@ func TestSupervisorLogin(t *testing.T) {
requireEventually.NoError(err) requireEventually.NoError(err)
requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, ldapIDP, expectedMsg) requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, ldapIDP, expectedMsg)
}, time.Minute, 500*time.Millisecond) }, 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -525,7 +552,12 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
customSessionData.LDAP.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.Host+ "ldaps://"+env.SupervisorUpstreamLDAP.Host+
@ -547,7 +579,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("Active Directory hostname not specified") t.Skip("Active Directory hostname not specified")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
map[string]string{ map[string]string{
@ -570,6 +602,7 @@ func TestSupervisorLogin(t *testing.T) {
secret.Name, secret.ResourceVersion, secret.Name, secret.ResourceVersion,
) )
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -580,7 +613,13 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Extra["username"] = "not-the-same"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
@ -601,7 +640,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("Active Directory hostname not specified") t.Skip("Active Directory hostname not specified")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
map[string]string{ map[string]string{
@ -638,6 +677,7 @@ func TestSupervisorLogin(t *testing.T) {
secret.Name, secret.ResourceVersion, secret.Name, secret.ResourceVersion,
) )
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -648,7 +688,13 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Subject = "not-right"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
@ -670,7 +716,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("Active Directory hostname not specified") t.Skip("Active Directory hostname not specified")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
@ -711,6 +757,7 @@ func TestSupervisorLogin(t *testing.T) {
requireEventually.NoError(err) requireEventually.NoError(err)
requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, adIDP, expectedMsg) requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, adIDP, expectedMsg)
}, time.Minute, 500*time.Millisecond) }, 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -721,7 +768,12 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
customSessionData.ActiveDirectory.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
@ -743,7 +795,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("Active Directory hostname not specified") t.Skip("Active Directory hostname not specified")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
@ -799,6 +851,7 @@ func TestSupervisorLogin(t *testing.T) {
requireEventually.NoError(err) requireEventually.NoError(err)
requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, adIDP, expectedMsg) requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, adIDP, expectedMsg)
}, time.Minute, 500*time.Millisecond) }, 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -809,7 +862,12 @@ func TestSupervisorLogin(t *testing.T) {
false, false,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type 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)
customSessionData.ActiveDirectory.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
@ -831,7 +889,7 @@ func TestSupervisorLogin(t *testing.T) {
t.Skip("Active Directory hostname not specified") t.Skip("Active Directory hostname not specified")
} }
}, },
createIDP: func(t *testing.T) { createIDP: func(t *testing.T) string {
t.Helper() t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth,
map[string]string{ map[string]string{
@ -854,6 +912,7 @@ func TestSupervisorLogin(t *testing.T) {
secret.Name, secret.ResourceVersion, secret.Name, secret.ResourceVersion,
) )
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) 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, requestAuthorizationUsingCLIPasswordFlow(t,
@ -864,10 +923,93 @@ func TestSupervisorLogin(t *testing.T) {
true, true,
) )
}, },
breakRefreshSessionData: nil, // upstream refresh not yet implemented for this IDP type breakRefreshSessionData: nil,
wantErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", wantErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.",
wantErrorType: "access_denied", wantErrorType: "access_denied",
}, },
{
name: "ldap refresh fails when username changes from email as username to dn as username",
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")
}
},
createIDP: func(t *testing.T) string {
t.Helper()
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth,
map[string]string{
v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername,
v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword,
},
)
ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{
Host: env.SupervisorUpstreamLDAP.Host,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)),
},
Bind: idpv1alpha1.LDAPIdentityProviderBind{
SecretName: secret.Name,
},
UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{
Base: env.SupervisorUpstreamLDAP.UserSearchBase,
Filter: "",
Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{
Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName,
UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName,
},
},
GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{
Base: env.SupervisorUpstreamLDAP.GroupSearchBase,
Filter: "",
Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{
GroupName: "dn",
},
},
}, idpv1alpha1.LDAPPhaseReady)
expectedMsg := fmt.Sprintf(
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername,
secret.Name, secret.ResourceVersion,
)
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
return ldapIDP.Name
},
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
httpClient,
false,
)
},
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)
defer cancel()
// Create the LDAPIdentityProvider using GenerateName to get a random name.
upstreams := client.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace)
ldapIDP, err := upstreams.Get(ctx, idpName, metav1.GetOptions{})
require.NoError(t, err)
ldapIDP.Spec.UserSearch.Attributes.Username = "dn"
_, err = upstreams.Update(ctx, ldapIDP, metav1.UpdateOptions{})
require.NoError(t, err)
time.Sleep(10 * time.Second) // wait for controllers to pick up the change
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
"&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,
},
} }
for _, test := range tests { for _, test := range tests {
tt := test tt := test
@ -1007,9 +1149,9 @@ func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *tes
func testSupervisorLogin( func testSupervisorLogin(
t *testing.T, t *testing.T,
createIDP func(t *testing.T), createIDP func(t *testing.T) string,
requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client), requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client),
breakRefreshSessionData func(t *testing.T, customSessionData *psession.CustomSessionData), breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string),
wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string,
wantErrorDescription string, wantErrorType string, wantErrorDescription string, wantErrorType string,
) { ) {
@ -1097,7 +1239,7 @@ func testSupervisorLogin(
}, 30*time.Second, 200*time.Millisecond) }, 30*time.Second, 200*time.Millisecond)
// Create upstream IDP and wait for it to become ready. // Create upstream IDP and wait for it to become ready.
createIDP(t) idpName := createIDP(t)
// Perform OIDC discovery for our downstream. // Perform OIDC discovery for our downstream.
var discovery *coreosoidc.Provider var discovery *coreosoidc.Provider
@ -1191,7 +1333,7 @@ func testSupervisorLogin(
// Next mutate the part of the session that is used during upstream refresh. // Next mutate the part of the session that is used during upstream refresh.
pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession)
require.True(t, ok, "should have been able to cast session data to PinnipedSession") require.True(t, ok, "should have been able to cast session data to PinnipedSession")
breakRefreshSessionData(t, pinnipedSession.Custom) breakRefreshSessionData(t, pinnipedSession, idpName)
// Then save the mutated Secret back to Kubernetes. // Then save the mutated Secret back to Kubernetes.
// There is no update function, so delete and create again at the same name. // There is no update function, so delete and create again at the same name.
@ -1204,9 +1346,8 @@ func testSupervisorLogin(
require.Error(t, err) require.Error(t, err)
require.Regexp(t, require.Regexp(t,
regexp.QuoteMeta("oauth2: cannot fetch token: 401 Unauthorized\n")+ regexp.QuoteMeta("oauth2: cannot fetch token: 401 Unauthorized\n")+
regexp.QuoteMeta(`Response: {"error":"error","error_description":"Error during upstream refresh. Upstream refresh failed using provider '`)+ regexp.QuoteMeta(`Response: {"error":"error","error_description":"Error during upstream refresh. Upstream refresh failed`)+
"[^']+"+ // this would be the name of the identity provider CR "[^']+",
regexp.QuoteMeta(fmt.Sprintf(`' of type '%s'."`, pinnipedSession.Custom.ProviderType)),
err.Error(), err.Error(),
) )
} }

View File

@ -40,6 +40,12 @@ func TestSupervisorUpstreamOIDCDiscovery(t *testing.T) {
Message: `failed to perform OIDC discovery against "https://127.0.0.1:444444/invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee": Message: `failed to perform OIDC discovery against "https://127.0.0.1:444444/invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee":
Get "https://127.0.0.1:444444/invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration": dial tcp: address 444444: in [truncated 10 chars]`, Get "https://127.0.0.1:444444/invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration": dial tcp: address 444444: in [truncated 10 chars]`,
}, },
{
Type: "AdditionalAuthorizeParametersValid",
Status: "True",
Reason: "Success",
Message: "additionalAuthorizeParameters parameter names are allowed",
},
}) })
}) })
@ -72,6 +78,12 @@ Get "https://127.0.0.1:444444/invalid-url-that-is-really-really-long-nananananan
Message: `failed to perform OIDC discovery against "` + env.SupervisorUpstreamOIDC.Issuer + `/": Message: `failed to perform OIDC discovery against "` + env.SupervisorUpstreamOIDC.Issuer + `/":
oidc: issuer did not match the issuer returned by provider, expected "` + env.SupervisorUpstreamOIDC.Issuer + `/" got "` + env.SupervisorUpstreamOIDC.Issuer + `"`, oidc: issuer did not match the issuer returned by provider, expected "` + env.SupervisorUpstreamOIDC.Issuer + `/" got "` + env.SupervisorUpstreamOIDC.Issuer + `"`,
}, },
{
Type: "AdditionalAuthorizeParametersValid",
Status: "True",
Reason: "Success",
Message: "additionalAuthorizeParameters parameter names are allowed",
},
}) })
}) })
@ -103,6 +115,12 @@ oidc: issuer did not match the issuer returned by provider, expected "` + env.Su
Reason: "Success", Reason: "Success",
Message: "discovered issuer configuration", Message: "discovered issuer configuration",
}, },
{
Type: "AdditionalAuthorizeParametersValid",
Status: "True",
Reason: "Success",
Message: "additionalAuthorizeParameters parameter names are allowed",
},
}) })
}) })
} }