Optionally allow OIDC password grant for CLI-based login experience

- Add `AllowPasswordGrant` boolean field to OIDCIdentityProvider's spec
- The oidc upstream watcher controller copies the value of
  `AllowPasswordGrant` into the configuration of the cached provider
- Add password grant to the UpstreamOIDCIdentityProviderI interface
  which is implemented by the cached provider instance for use in the
  authorization endpoint
- Enhance the IDP discovery endpoint to return the supported "flows"
  for each IDP ("cli_password" and/or "browser_authcode")
- Enhance `pinniped get kubeconfig` to help the user choose the desired
  flow for the selected IDP, and to write the flow into the resulting
  kubeconfg
- Enhance `pinniped login oidc` to have a flow flag to tell it which
  client-side flow it should use for auth (CLI-based or browser-based)
- In the Dex config, allow the resource owner password grant, which Dex
  implements to also return ID tokens, for use in integration tests
- Enhance the authorize endpoint to perform password grant when
  requested by the incoming headers. This commit does not include unit
  tests for the enhancements to the authorize endpoint, which will come
  in the next commit
- Extract some shared helpers from the callback endpoint to share the
  code with the authorize endpoint
- Add new integration tests
This commit is contained in:
Ryan Richard 2021-08-12 10:00:18 -07:00
parent 01ddc7ac36
commit 84c3c3aa9c
36 changed files with 2012 additions and 576 deletions

View File

@ -1,4 +1,4 @@
// Copyright 2020 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
package v1alpha1 package v1alpha1
@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct {
// request parameters. // request parameters.
type OIDCAuthorizationConfig struct { type OIDCAuthorizationConfig struct {
// AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization
// request flow with an OIDC identity provider. By default only the "openid" scope will be requested. // request flow with an OIDC identity provider.
// In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes
// in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field).
// By default, only the "openid" scope will be requested.
// +optional // +optional
AdditionalScopes []string `json:"additionalScopes,omitempty"` AdditionalScopes []string `json:"additionalScopes,omitempty"`
// AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant
// (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a
// username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow.
// The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be
// supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password
// Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose
// to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the
// cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be
// convenient for users, especially for identities from your OIDC provider which are not intended to represent a human
// actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it,
// you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this
// OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password
// Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords
// (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other
// web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins.
// AllowPasswordGrant defaults to false.
// +optional
AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"`
} }
// OIDCClaims provides a mapping from upstream claims into identities. // OIDCClaims provides a mapping from upstream claims into identities.

View File

@ -68,6 +68,7 @@ type getKubeconfigOIDCParams struct {
requestAudience string requestAudience string
upstreamIDPName string upstreamIDPName string
upstreamIDPType string upstreamIDPType string
upstreamIDPFlow string
} }
type getKubeconfigConciergeParams struct { type getKubeconfigConciergeParams struct {
@ -112,6 +113,7 @@ type supervisorIDPsDiscoveryResponseV1Alpha1 struct {
type pinnipedIDPResponse struct { type pinnipedIDPResponse struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Flows []string `json:"flows,omitempty"`
} }
func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command {
@ -154,6 +156,7 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command {
f.StringVar(&flags.oidc.requestAudience, "oidc-request-audience", "", "Request a token with an alternate audience using RFC8693 token exchange") f.StringVar(&flags.oidc.requestAudience, "oidc-request-audience", "", "Request a token with an alternate audience using RFC8693 token exchange")
f.StringVar(&flags.oidc.upstreamIDPName, "upstream-identity-provider-name", "", "The name of the upstream identity provider used during login with a Supervisor") f.StringVar(&flags.oidc.upstreamIDPName, "upstream-identity-provider-name", "", "The name of the upstream identity provider used during login with a Supervisor")
f.StringVar(&flags.oidc.upstreamIDPType, "upstream-identity-provider-type", "", "The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')") f.StringVar(&flags.oidc.upstreamIDPType, "upstream-identity-provider-type", "", "The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')")
f.StringVar(&flags.oidc.upstreamIDPFlow, "upstream-identity-provider-flow", "", "The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'cli_password', 'browser_authcode')")
f.StringVar(&flags.kubeconfigPath, "kubeconfig", os.Getenv("KUBECONFIG"), "Path to kubeconfig file") f.StringVar(&flags.kubeconfigPath, "kubeconfig", os.Getenv("KUBECONFIG"), "Path to kubeconfig file")
f.StringVar(&flags.kubeconfigContextOverride, "kubeconfig-context", "", "Kubeconfig context name (default: current active context)") f.StringVar(&flags.kubeconfigContextOverride, "kubeconfig-context", "", "Kubeconfig context name (default: current active context)")
f.BoolVar(&flags.skipValidate, "skip-validation", false, "Skip final validation of the kubeconfig (default: false)") f.BoolVar(&flags.skipValidate, "skip-validation", false, "Skip final validation of the kubeconfig (default: false)")
@ -243,8 +246,10 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f
cluster.CertificateAuthorityData = flags.concierge.caBundle cluster.CertificateAuthorityData = flags.concierge.caBundle
} }
// If there is an issuer, and if both upstream flags are not already set, then try to discover Supervisor upstream IDP. // If there is an issuer, and if any upstream IDP flags are not already set, then try to discover Supervisor upstream IDP details.
if len(flags.oidc.issuer) > 0 && (flags.oidc.upstreamIDPType == "" || flags.oidc.upstreamIDPName == "") { // When all the upstream IDP flags are set by the user, then skip discovery and don't validate their input. Maybe they know something
// that we can't know, like the name of an IDP that they are going to define in the future.
if len(flags.oidc.issuer) > 0 && (flags.oidc.upstreamIDPType == "" || flags.oidc.upstreamIDPName == "" || flags.oidc.upstreamIDPFlow == "") {
if err := discoverSupervisorUpstreamIDP(ctx, &flags); err != nil { if err := discoverSupervisorUpstreamIDP(ctx, &flags); err != nil {
return err return err
} }
@ -346,6 +351,9 @@ func newExecConfig(deps kubeconfigDeps, flags getKubeconfigParams) (*clientcmdap
if flags.oidc.upstreamIDPType != "" { if flags.oidc.upstreamIDPType != "" {
execConfig.Args = append(execConfig.Args, "--upstream-identity-provider-type="+flags.oidc.upstreamIDPType) execConfig.Args = append(execConfig.Args, "--upstream-identity-provider-type="+flags.oidc.upstreamIDPType)
} }
if flags.oidc.upstreamIDPFlow != "" {
execConfig.Args = append(execConfig.Args, "--upstream-identity-provider-flow="+flags.oidc.upstreamIDPFlow)
}
return execConfig, nil return execConfig, nil
} }
@ -758,21 +766,31 @@ func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigPara
return nil return nil
} }
upstreamIDPs, err := discoverAllAvailableSupervisorUpstreamIDPs(ctx, pinnipedIDPsEndpoint, httpClient) discoveredUpstreamIDPs, err := discoverAllAvailableSupervisorUpstreamIDPs(ctx, pinnipedIDPsEndpoint, httpClient)
if err != nil { if err != nil {
return err return err
} }
if len(upstreamIDPs) == 1 {
flags.oidc.upstreamIDPName = upstreamIDPs[0].Name if len(discoveredUpstreamIDPs) == 0 {
flags.oidc.upstreamIDPType = upstreamIDPs[0].Type // Discovered that the Supervisor does not have any upstream IDPs defined. Continue without putting one into the
} else if len(upstreamIDPs) > 1 { // kubeconfig. This kubeconfig will only work if the user defines one (and only one) OIDC IDP in the Supervisor
idpName, idpType, err := selectUpstreamIDP(upstreamIDPs, flags.oidc.upstreamIDPName, flags.oidc.upstreamIDPType) // later and wants to use the default client flow for OIDC (browser-based auth).
return nil
}
selectedIDPName, selectedIDPType, discoveredIDPFlows, err := selectUpstreamIDPNameAndType(discoveredUpstreamIDPs, flags.oidc.upstreamIDPName, flags.oidc.upstreamIDPType)
if err != nil { if err != nil {
return err return err
} }
flags.oidc.upstreamIDPName = idpName
flags.oidc.upstreamIDPType = idpType selectedIDPFlow, err := selectUpstreamIDPFlow(discoveredIDPFlows, selectedIDPName, selectedIDPType, flags.oidc.upstreamIDPFlow)
if err != nil {
return err
} }
flags.oidc.upstreamIDPName = selectedIDPName
flags.oidc.upstreamIDPType = selectedIDPType
flags.oidc.upstreamIDPFlow = selectedIDPFlow
return nil return nil
} }
@ -840,53 +858,104 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP
return body.PinnipedIDPs, nil return body.PinnipedIDPs, nil
} }
func selectUpstreamIDP(pinnipedIDPs []pinnipedIDPResponse, idpName, idpType string) (string, string, error) { func selectUpstreamIDPNameAndType(pinnipedIDPs []pinnipedIDPResponse, specifiedIDPName, specifiedIDPType string) (string, string, []string, error) {
pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs) pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs)
var discoveredFlows []string
switch { switch {
case idpType != "": case specifiedIDPName != "" && specifiedIDPType != "":
// The user specified both name and type, so check to see if there exists an exact match.
for _, idp := range pinnipedIDPs {
if idp.Name == specifiedIDPName && idp.Type == specifiedIDPType {
return specifiedIDPName, specifiedIDPType, idp.Flows, nil
}
}
return "", "", nil, fmt.Errorf(
"no Supervisor upstream identity providers with name %q of type %q were found. "+
"Found these upstreams: %s", specifiedIDPName, specifiedIDPType, pinnipedIDPsString)
case specifiedIDPType != "":
// The user specified only a type, so check if there is only one of that type found.
discoveredName := "" discoveredName := ""
for _, idp := range pinnipedIDPs { for _, idp := range pinnipedIDPs {
if idp.Type == idpType { if idp.Type == specifiedIDPType {
if discoveredName != "" { if discoveredName != "" {
return "", "", fmt.Errorf( return "", "", nil, fmt.Errorf(
"multiple Supervisor upstream identity providers of type \"%s\" were found,"+ "multiple Supervisor upstream identity providers of type %q were found, "+
" so the --upstream-identity-provider-name flag must be specified. "+ "so the --upstream-identity-provider-name flag must be specified. "+
"Found these upstreams: %s", "Found these upstreams: %s",
idpType, pinnipedIDPsString) specifiedIDPType, pinnipedIDPsString)
} }
discoveredName = idp.Name discoveredName = idp.Name
discoveredFlows = idp.Flows
} }
} }
if discoveredName == "" { if discoveredName == "" {
return "", "", fmt.Errorf( return "", "", nil, fmt.Errorf(
"no Supervisor upstream identity providers of type \"%s\" were found."+ "no Supervisor upstream identity providers of type %q were found. "+
" Found these upstreams: %s", idpType, pinnipedIDPsString) "Found these upstreams: %s", specifiedIDPType, pinnipedIDPsString)
} }
return discoveredName, idpType, nil return discoveredName, specifiedIDPType, discoveredFlows, nil
case idpName != "": case specifiedIDPName != "":
// The user specified only a name, so check if there is only one of that name found.
discoveredType := "" discoveredType := ""
for _, idp := range pinnipedIDPs { for _, idp := range pinnipedIDPs {
if idp.Name == idpName { if idp.Name == specifiedIDPName {
if discoveredType != "" { if discoveredType != "" {
return "", "", fmt.Errorf( return "", "", nil, fmt.Errorf(
"multiple Supervisor upstream identity providers with name \"%s\" were found,"+ "multiple Supervisor upstream identity providers with name %q were found, "+
" so the --upstream-identity-provider-type flag must be specified. Found these upstreams: %s", "so the --upstream-identity-provider-type flag must be specified. Found these upstreams: %s",
idpName, pinnipedIDPsString) specifiedIDPName, pinnipedIDPsString)
} }
discoveredType = idp.Type discoveredType = idp.Type
discoveredFlows = idp.Flows
} }
} }
if discoveredType == "" { if discoveredType == "" {
return "", "", fmt.Errorf( return "", "", nil, fmt.Errorf(
"no Supervisor upstream identity providers with name \"%s\" were found."+ "no Supervisor upstream identity providers with name %q were found. "+
" Found these upstreams: %s", idpName, pinnipedIDPsString) "Found these upstreams: %s", specifiedIDPName, pinnipedIDPsString)
} }
return idpName, discoveredType, nil return specifiedIDPName, discoveredType, discoveredFlows, nil
case len(pinnipedIDPs) == 1:
// The user did not specify any name or type, but there is only one found, so select it.
return pinnipedIDPs[0].Name, pinnipedIDPs[0].Type, pinnipedIDPs[0].Flows, nil
default: default:
return "", "", fmt.Errorf( // The user did not specify any name or type, and there is more than one found.
"multiple Supervisor upstream identity providers were found,"+ return "", "", nil, fmt.Errorf(
" so the --upstream-identity-provider-name/--upstream-identity-provider-type flags must be specified."+ "multiple Supervisor upstream identity providers were found, "+
" Found these upstreams: %s", "so the --upstream-identity-provider-name/--upstream-identity-provider-type flags must be specified. "+
"Found these upstreams: %s",
pinnipedIDPsString) pinnipedIDPsString)
} }
} }
func selectUpstreamIDPFlow(discoveredIDPFlows []string, selectedIDPName string, selectedIDPType string, specifiedFlow string) (string, error) {
switch {
case len(discoveredIDPFlows) == 0:
// No flows listed by discovery means that we are talking to an old Supervisor from before this feature existed.
// If the user specified a flow on the CLI flag then use it without validation, otherwise skip flow selection
// and return empty string.
return specifiedFlow, nil
case specifiedFlow != "":
// The user specified a flow, so validate that it is available for the selected IDP.
for _, flow := range discoveredIDPFlows {
if flow == specifiedFlow {
// Found it, so use it as specified by the user.
return specifiedFlow, nil
}
}
return "", fmt.Errorf(
"no client flow %q for Supervisor upstream identity provider %q of type %q were found. "+
"Found these flows: %v",
specifiedFlow, selectedIDPName, selectedIDPType, discoveredIDPFlows)
case len(discoveredIDPFlows) == 1:
// The user did not specify a flow, but there is only one found, so select it.
return discoveredIDPFlows[0], nil
default:
// The user did not specify a flow, and more than one was found.
return "", fmt.Errorf(
"multiple client flows for Supervisor upstream identity provider %q of type %q were found, "+
"so the --upstream-identity-provider-flow flag must be specified. "+
"Found these flows: %v",
selectedIDPName, selectedIDPType, discoveredIDPFlows)
}
}

View File

@ -149,6 +149,7 @@ func TestGetKubeconfig(t *testing.T) {
--static-token string Instead of doing an OIDC-based login, specify a static token --static-token string Instead of doing an OIDC-based login, specify a static token
--static-token-env string Instead of doing an OIDC-based login, read a static token from the environment --static-token-env string Instead of doing an OIDC-based login, read a static token from the environment
--timeout duration Timeout for autodiscovery and validation (default 10m0s) --timeout duration Timeout for autodiscovery and validation (default 10m0s)
--upstream-identity-provider-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'cli_password', 'browser_authcode')
--upstream-identity-provider-name string The name of the upstream identity provider used during login with a Supervisor --upstream-identity-provider-name string The name of the upstream identity provider used during login with a Supervisor
--upstream-identity-provider-type string The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap') --upstream-identity-provider-type string The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')
`) `)
@ -814,7 +815,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "when IDP discovery document contains multiple pinniped_idps and no name or type flags are given", name: "when IDP discovery document contains multiple IDPs and no name or type flags are given",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -1033,6 +1034,33 @@ func TestGetKubeconfig(t *testing.T) {
return `Error: while forming request to IDP discovery URL: parse "https%://illegal_url": first path segment in URL cannot contain colon` + "\n" return `Error: while forming request to IDP discovery URL: parse "https%://illegal_url": first path segment in URL cannot contain colon` + "\n"
}, },
}, },
{
name: "supervisor upstream IDP discovery does not find matching IDP when name and type are both specified",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-name", "does-not-exist-idp",
"--upstream-identity-provider-type", "ldap",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-other-ldap-idp", "type": "ldap"}
]
}`),
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: no Supervisor upstream identity providers with name "does-not-exist-idp" of type "ldap" were found.` +
` Found these upstreams: [{"name":"some-ldap-idp","type":"ldap"},{"name":"some-other-ldap-idp","type":"ldap"}]` + "\n"
},
},
{ {
name: "supervisor upstream IDP discovery fails to resolve ambiguity when type is specified but name is not", name: "supervisor upstream IDP discovery fails to resolve ambiguity when type is specified but name is not",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
@ -1091,7 +1119,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "supervisor upstream IDP discovery fails to find any matching idps when type is specified but name is not", name: "supervisor upstream IDP discovery fails to find any matching IDPs when type is specified but name is not",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{ return []string{
@ -1117,7 +1145,32 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "supervisor upstream IDP discovery fails to find any matching idps when name is specified but type is not", name: "supervisor upstream IDP discovery fails to find any matching IDPs when type is specified but name is not and there is only one IDP found",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-type", "ldap",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-oidc-idp", "type": "oidc"}
]
}`),
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: no Supervisor upstream identity providers of type "ldap" were found.` +
` Found these upstreams: [{"name":"some-oidc-idp","type":"oidc"}]` + "\n"
},
},
{
name: "supervisor upstream IDP discovery fails to find any matching IDPs when name is specified but type is not",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{ return []string{
@ -1142,6 +1195,80 @@ func TestGetKubeconfig(t *testing.T) {
` Found these upstreams: [{"name":"some-oidc-idp","type":"oidc"},{"name":"some-other-oidc-idp","type":"oidc"}]` + "\n" ` Found these upstreams: [{"name":"some-oidc-idp","type":"oidc"},{"name":"some-other-oidc-idp","type":"oidc"}]` + "\n"
}, },
}, },
{
name: "supervisor upstream IDP discovery fails to find any matching IDPs when name is specified but type is not and there is only one IDP found",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-name", "my-nonexistent-idp",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-oidc-idp", "type": "oidc"}
]
}`),
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: no Supervisor upstream identity providers with name "my-nonexistent-idp" were found.` +
` Found these upstreams: [{"name":"some-oidc-idp","type":"oidc"}]` + "\n"
},
},
{
name: "supervisor upstream IDP discovery when flow is specified but it does not match any flow returned by discovery",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-flow", "my-nonexistent-flow",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-oidc-idp", "type": "oidc", "flows": ["non-matching-flow-1", "non-matching-flow-2"]}
]
}`),
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: no client flow "my-nonexistent-flow" for Supervisor upstream identity provider "some-oidc-idp" of type "oidc" were found.` +
` Found these flows: [non-matching-flow-1 non-matching-flow-2]` + "\n"
},
},
{
name: "supervisor upstream IDP discovery when no flow is specified and more than one flow is returned by discovery",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-oidc-idp", "type": "oidc", "flows": ["flow1", "flow2"]}
]
}`),
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: multiple client flows for Supervisor upstream identity provider "some-oidc-idp" of type "oidc" were found, so the --upstream-identity-provider-flow flag must be specified.` +
` Found these flows: [flow1 flow2]` + "\n"
},
},
{ {
name: "valid static token", name: "valid static token",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
@ -1535,7 +1662,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "autodetect impersonation proxy with autodiscovered JWT authenticator", name: "autodetect impersonation proxy with auto-discovered JWT authenticator",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -1958,7 +2085,7 @@ func TestGetKubeconfig(t *testing.T) {
} }
}`, issuerURL) }`, issuerURL)
}, },
idpsDiscoveryStatusCode: http.StatusBadRequest, // IDPs endpoint shouldn't be called by this test idpsDiscoveryStatusCode: http.StatusBadRequest, // IDP discovery endpoint shouldn't be called by this test
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
@ -2015,13 +2142,14 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "when upstream idp related flags are sent, pass them through", name: "when all upstream IDP related flags are sent, pass them through without performing IDP discovery",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation", "--skip-validation",
"--upstream-identity-provider-name=some-oidc-idp", "--upstream-identity-provider-name=some-oidc-idp",
"--upstream-identity-provider-type=oidc", "--upstream-identity-provider-type=oidc",
"--upstream-identity-provider-flow=foobar",
} }
}, },
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
@ -2030,7 +2158,7 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
oidcDiscoveryStatusCode: http.StatusNotFound, oidcDiscoveryStatusCode: http.StatusNotFound, // should not get called by the client in this case
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
@ -2080,6 +2208,7 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
- --upstream-identity-provider-name=some-oidc-idp - --upstream-identity-provider-name=some-oidc-idp
- --upstream-identity-provider-type=oidc - --upstream-identity-provider-type=oidc
- --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
provideClusterInfo: true provideClusterInfo: true
@ -2089,13 +2218,14 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "when upstream IDP related flags are sent, pass them through even when IDP discovery shows a different IDP", name: "when all upstream IDP related flags are sent, pass them through even when IDP discovery shows a different IDP",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation", "--skip-validation",
"--upstream-identity-provider-name=some-oidc-idp", "--upstream-identity-provider-name=some-oidc-idp",
"--upstream-identity-provider-type=oidc", "--upstream-identity-provider-type=oidc",
"--upstream-identity-provider-flow=foobar",
} }
}, },
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
@ -2159,6 +2289,7 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience - --request-audience=test-audience
- --upstream-identity-provider-name=some-oidc-idp - --upstream-identity-provider-name=some-oidc-idp
- --upstream-identity-provider-type=oidc - --upstream-identity-provider-type=oidc
- --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped' command: '.../path/to/pinniped'
env: [] env: []
provideClusterInfo: true provideClusterInfo: true
@ -2341,6 +2472,244 @@ func TestGetKubeconfig(t *testing.T) {
base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) base64.StdEncoding.EncodeToString([]byte(issuerCABundle)))
}, },
}, },
{
name: "supervisor upstream IDP discovery when both name and type are specified but flow is not and a matching IDP is found",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-name", "some-ldap-idp",
"--upstream-identity-provider-type", "ldap",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-oidc-idp", "type": "oidc"},
{"name": "some-other-oidc-idp", "type": "oidc"}
]
}`),
wantStdout: func(issuerCABundle string, issuerURL string) string {
return here.Docf(`
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
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
command: '.../path/to/pinniped'
env: []
provideClusterInfo: true
`,
issuerURL,
base64.StdEncoding.EncodeToString([]byte(issuerCABundle)))
},
},
{
name: "supervisor upstream IDP discovery when flow is specified and no flows were returned by discovery uses the specified flow",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-flow", "foobar",
"--upstream-identity-provider-type", "ldap",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-oidc-idp", "type": "oidc"},
{"name": "some-other-oidc-idp", "type": "oidc"}
]
}`),
wantStdout: func(issuerCABundle string, issuerURL string) string {
return here.Docf(`
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
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
- --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped'
env: []
provideClusterInfo: true
`,
issuerURL,
base64.StdEncoding.EncodeToString([]byte(issuerCABundle)))
},
},
{
name: "supervisor upstream IDP discovery when flow is specified and it matches a flow returned by discovery uses the specified flow",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-flow", "cli_password",
"--upstream-identity-provider-type", "ldap",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap", "flows": ["some_flow", "cli_password", "some_other_flow"]}
]
}`),
wantStdout: func(issuerCABundle string, issuerURL string) string {
return here.Docf(`
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
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
- --upstream-identity-provider-flow=cli_password
command: '.../path/to/pinniped'
env: []
provideClusterInfo: true
`,
issuerURL,
base64.StdEncoding.EncodeToString([]byte(issuerCABundle)))
},
},
{
name: "supervisor upstream IDP discovery when no flow is specified but there is only one flow returned by discovery uses the discovered flow",
args: func(issuerCABundle string, issuerURL string) []string {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--no-concierge",
"--oidc-issuer", issuerURL,
"--oidc-ca-bundle", f.Name(),
"--upstream-identity-provider-type", "ldap",
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap", "flows": ["cli_password"]}
]
}`),
wantStdout: func(issuerCABundle string, issuerURL string) string {
return here.Docf(`
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
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
- --upstream-identity-provider-flow=cli_password
command: '.../path/to/pinniped'
env: []
provideClusterInfo: true
`,
issuerURL,
base64.StdEncoding.EncodeToString([]byte(issuerCABundle)))
},
},
} }
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt

View File

@ -14,6 +14,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
@ -37,6 +38,13 @@ func init() {
loginCmd.AddCommand(oidcLoginCommand(oidcLoginCommandRealDeps())) loginCmd.AddCommand(oidcLoginCommand(oidcLoginCommandRealDeps()))
} }
const (
idpTypeOIDC = "oidc"
idpTypeLDAP = "ldap"
idpFlowCLIPassword = "cli_password"
idpFlowBrowserAuthcode = "browser_authcode"
)
type oidcLoginCommandDeps struct { type oidcLoginCommandDeps struct {
lookupEnv func(string) (string, bool) lookupEnv func(string) (string, bool)
login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error) login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error)
@ -74,6 +82,7 @@ type oidcLoginFlags struct {
credentialCachePath string credentialCachePath string
upstreamIdentityProviderName string upstreamIdentityProviderName string
upstreamIdentityProviderType string upstreamIdentityProviderType string
upstreamIdentityProviderFlow string
} }
func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
@ -107,7 +116,8 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix") cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix")
cmd.Flags().StringVar(&flags.credentialCachePath, "credential-cache", filepath.Join(mustGetConfigDir(), "credentials.yaml"), "Path to cluster-specific credentials cache (\"\" disables the cache)") cmd.Flags().StringVar(&flags.credentialCachePath, "credential-cache", filepath.Join(mustGetConfigDir(), "credentials.yaml"), "Path to cluster-specific credentials cache (\"\" disables the cache)")
cmd.Flags().StringVar(&flags.upstreamIdentityProviderName, "upstream-identity-provider-name", "", "The name of the upstream identity provider used during login with a Supervisor") cmd.Flags().StringVar(&flags.upstreamIdentityProviderName, "upstream-identity-provider-name", "", "The name of the upstream identity provider used during login with a Supervisor")
cmd.Flags().StringVar(&flags.upstreamIdentityProviderType, "upstream-identity-provider-type", "oidc", "The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')") cmd.Flags().StringVar(&flags.upstreamIdentityProviderType, "upstream-identity-provider-type", idpTypeOIDC, fmt.Sprintf("The type of the upstream identity provider used during login with a Supervisor (e.g. '%s', '%s')", idpTypeOIDC, idpTypeLDAP))
cmd.Flags().StringVar(&flags.upstreamIdentityProviderFlow, "upstream-identity-provider-flow", "", fmt.Sprintf("The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. '%s', '%s')", idpFlowBrowserAuthcode, idpFlowCLIPassword))
// --skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case. // --skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case.
mustMarkHidden(cmd, "skip-listen") mustMarkHidden(cmd, "skip-listen")
@ -160,17 +170,11 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
flags.upstreamIdentityProviderName, flags.upstreamIdentityProviderType)) flags.upstreamIdentityProviderName, flags.upstreamIdentityProviderType))
} }
switch flags.upstreamIdentityProviderType { flowOpts, err := flowOptions(flags.upstreamIdentityProviderType, flags.upstreamIdentityProviderFlow)
case "oidc": if err != nil {
// this is the default, so don't need to do anything return err
case "ldap":
opts = append(opts, oidcclient.WithCLISendingCredentials())
default:
// Surprisingly cobra does not support this kind of flag validation. See https://github.com/spf13/pflag/issues/236
return fmt.Errorf(
"--upstream-identity-provider-type value not recognized: %s (supported values: oidc, ldap)",
flags.upstreamIdentityProviderType)
} }
opts = append(opts, flowOpts...)
var concierge *conciergeclient.Client var concierge *conciergeclient.Client
if flags.conciergeEnabled { if flags.conciergeEnabled {
@ -251,6 +255,38 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
return json.NewEncoder(cmd.OutOrStdout()).Encode(cred) return json.NewEncoder(cmd.OutOrStdout()).Encode(cred)
} }
func flowOptions(requestedIDPType string, requestedFlow string) ([]oidcclient.Option, error) {
useCLIFlow := []oidcclient.Option{oidcclient.WithCLISendingCredentials()}
switch requestedIDPType {
case idpTypeOIDC:
switch requestedFlow {
case idpFlowCLIPassword:
return useCLIFlow, nil
case idpFlowBrowserAuthcode, "":
return nil, nil // browser authcode flow is the default Option, so don't need to return an Option here
default:
return nil, fmt.Errorf(
"--upstream-identity-provider-flow value not recognized for identity provider type %q: %s (supported values: %s)",
requestedIDPType, requestedFlow, strings.Join([]string{idpFlowBrowserAuthcode, idpFlowCLIPassword}, ", "))
}
case idpTypeLDAP:
switch requestedFlow {
case idpFlowCLIPassword, "":
return useCLIFlow, nil
default:
return nil, fmt.Errorf(
"--upstream-identity-provider-flow value not recognized for identity provider type %q: %s (supported values: %s)",
requestedIDPType, requestedFlow, []string{idpFlowCLIPassword})
}
default:
// Surprisingly cobra does not support this kind of flag validation. See https://github.com/spf13/pflag/issues/236
return nil, fmt.Errorf(
"--upstream-identity-provider-type value not recognized: %s (supported values: %s)",
requestedIDPType, strings.Join([]string{idpTypeOIDC, idpTypeLDAP}, ", "))
}
}
func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, error) { func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, error) {
pool := x509.NewCertPool() pool := x509.NewCertPool()
for _, p := range caBundlePaths { for _, p := range caBundlePaths {

View File

@ -77,6 +77,7 @@ func TestLoginOIDCCommand(t *testing.T) {
--scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience]) --scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience])
--session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml") --session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml")
--skip-browser Skip opening the browser (just print the URL) --skip-browser Skip opening the browser (just print the URL)
--upstream-identity-provider-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'browser_authcode', 'cli_password')
--upstream-identity-provider-name string The name of the upstream identity provider used during login with a Supervisor --upstream-identity-provider-name string The name of the upstream identity provider used during login with a Supervisor
--upstream-identity-provider-type string The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap') (default "oidc") --upstream-identity-provider-type string The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap') (default "oidc")
`), `),
@ -152,7 +153,7 @@ func TestLoginOIDCCommand(t *testing.T) {
`), `),
}, },
{ {
name: "oidc upstream type is allowed", name: "oidc upstream type with default flow is allowed",
args: []string{ args: []string{
"--issuer", "test-issuer", "--issuer", "test-issuer",
"--client-id", "test-client-id", "--client-id", "test-client-id",
@ -163,7 +164,45 @@ func TestLoginOIDCCommand(t *testing.T) {
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
}, },
{ {
name: "ldap upstream type is allowed", name: "oidc upstream type with CLI flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "oidc upstream type with browser flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "oidc upstream type with unsupported flow is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "foobar",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-flow value not recognized for identity provider type "oidc": foobar (supported values: browser_authcode, cli_password)
`),
},
{
name: "ldap upstream type with default flow is allowed",
args: []string{ args: []string{
"--issuer", "test-issuer", "--issuer", "test-issuer",
"--client-id", "test-client-id", "--client-id", "test-client-id",
@ -173,6 +212,32 @@ func TestLoginOIDCCommand(t *testing.T) {
wantOptionsCount: 5, wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
}, },
{
name: "ldap upstream type with CLI flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "ldap upstream type with unsupported flow is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "browser_authcode", // "browser_authcode" is only supported for OIDC upstreams
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-flow value not recognized for identity provider type "ldap": browser_authcode (supported values: [cli_password])
`),
},
{ {
name: "login error", name: "login error",
args: []string{ args: []string{

View File

@ -59,11 +59,44 @@ spec:
additionalScopes: additionalScopes:
description: AdditionalScopes are the scopes in addition to "openid" description: AdditionalScopes are the scopes in addition to "openid"
that will be requested as part of the authorization request that will be requested as part of the authorization request
flow with an OIDC identity provider. By default only the "openid" flow with an OIDC identity provider. In the case of a Resource
scope will be requested. Owner Password Credentials Grant flow, AdditionalScopes are
the scopes in addition to "openid" that will be requested as
part of the token request (see also the AllowPasswordGrant field).
By default, only the "openid" scope will be requested.
items: items:
type: string type: string
type: array type: array
allowPasswordGrant:
description: AllowPasswordGrant, when true, will allow the use
of OAuth 2.0's Resource Owner Password Credentials Grant (see
https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to
authenticate to the OIDC provider using a username and password
without a web browser, in addition to the usual browser-based
OIDC Authorization Code Flow. The Resource Owner Password Credentials
Grant is not officially part of the OIDC specification, so it
may not be supported by your OIDC provider. If your OIDC provider
supports returning ID tokens from a Resource Owner Password
Credentials Grant token request, then you can choose to set
this field to true. This will allow end users to choose to present
their username and password to the kubectl CLI (using the Pinniped
plugin) to authenticate to the cluster, without using a web
browser to log in as is customary in OIDC Authorization Code
Flow. This may be convenient for users, especially for identities
from your OIDC provider which are not intended to represent
a human actor, such as service accounts performing actions in
a CI/CD environment. Even if your OIDC provider supports it,
you may wish to disable this behavior by setting this field
to false when you prefer to only allow users of this OIDCIdentityProvider
to log in via the browser-based OIDC Authorization Code Flow.
Using the Resource Owner Password Credentials Grant means that
the Pinniped CLI and Pinniped Supervisor will directly handle
your end users' passwords (similar to LDAPIdentityProvider),
and you will not be able to require multi-factor authentication
or use the other web-based login features of your OIDC provider
during Resource Owner Password Credentials Grant logins. AllowPasswordGrant
defaults to false.
type: boolean
type: object type: object
claims: claims:
description: Claims provides the names of token claims that will be description: Claims provides the names of token claims that will be

View File

@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author
[cols="25a,75a", options="header"] [cols="25a,75a", options="header"]
|=== |===
| Field | Description | Field | Description
| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. | *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested.
| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false.
|=== |===

View File

@ -1,4 +1,4 @@
// Copyright 2020 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
package v1alpha1 package v1alpha1
@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct {
// request parameters. // request parameters.
type OIDCAuthorizationConfig struct { type OIDCAuthorizationConfig struct {
// AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization
// request flow with an OIDC identity provider. By default only the "openid" scope will be requested. // request flow with an OIDC identity provider.
// In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes
// in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field).
// By default, only the "openid" scope will be requested.
// +optional // +optional
AdditionalScopes []string `json:"additionalScopes,omitempty"` AdditionalScopes []string `json:"additionalScopes,omitempty"`
// AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant
// (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a
// username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow.
// The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be
// supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password
// Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose
// to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the
// cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be
// convenient for users, especially for identities from your OIDC provider which are not intended to represent a human
// actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it,
// you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this
// OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password
// Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords
// (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other
// web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins.
// AllowPasswordGrant defaults to false.
// +optional
AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"`
} }
// OIDCClaims provides a mapping from upstream claims into identities. // OIDCClaims provides a mapping from upstream claims into identities.

View File

@ -59,11 +59,44 @@ spec:
additionalScopes: additionalScopes:
description: AdditionalScopes are the scopes in addition to "openid" description: AdditionalScopes are the scopes in addition to "openid"
that will be requested as part of the authorization request that will be requested as part of the authorization request
flow with an OIDC identity provider. By default only the "openid" flow with an OIDC identity provider. In the case of a Resource
scope will be requested. Owner Password Credentials Grant flow, AdditionalScopes are
the scopes in addition to "openid" that will be requested as
part of the token request (see also the AllowPasswordGrant field).
By default, only the "openid" scope will be requested.
items: items:
type: string type: string
type: array type: array
allowPasswordGrant:
description: AllowPasswordGrant, when true, will allow the use
of OAuth 2.0's Resource Owner Password Credentials Grant (see
https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to
authenticate to the OIDC provider using a username and password
without a web browser, in addition to the usual browser-based
OIDC Authorization Code Flow. The Resource Owner Password Credentials
Grant is not officially part of the OIDC specification, so it
may not be supported by your OIDC provider. If your OIDC provider
supports returning ID tokens from a Resource Owner Password
Credentials Grant token request, then you can choose to set
this field to true. This will allow end users to choose to present
their username and password to the kubectl CLI (using the Pinniped
plugin) to authenticate to the cluster, without using a web
browser to log in as is customary in OIDC Authorization Code
Flow. This may be convenient for users, especially for identities
from your OIDC provider which are not intended to represent
a human actor, such as service accounts performing actions in
a CI/CD environment. Even if your OIDC provider supports it,
you may wish to disable this behavior by setting this field
to false when you prefer to only allow users of this OIDCIdentityProvider
to log in via the browser-based OIDC Authorization Code Flow.
Using the Resource Owner Password Credentials Grant means that
the Pinniped CLI and Pinniped Supervisor will directly handle
your end users' passwords (similar to LDAPIdentityProvider),
and you will not be able to require multi-factor authentication
or use the other web-based login features of your OIDC provider
during Resource Owner Password Credentials Grant logins. AllowPasswordGrant
defaults to false.
type: boolean
type: object type: object
claims: claims:
description: Claims provides the names of token claims that will be description: Claims provides the names of token claims that will be

View File

@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author
[cols="25a,75a", options="header"] [cols="25a,75a", options="header"]
|=== |===
| Field | Description | Field | Description
| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. | *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested.
| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false.
|=== |===

View File

@ -1,4 +1,4 @@
// Copyright 2020 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
package v1alpha1 package v1alpha1
@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct {
// request parameters. // request parameters.
type OIDCAuthorizationConfig struct { type OIDCAuthorizationConfig struct {
// AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization
// request flow with an OIDC identity provider. By default only the "openid" scope will be requested. // request flow with an OIDC identity provider.
// In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes
// in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field).
// By default, only the "openid" scope will be requested.
// +optional // +optional
AdditionalScopes []string `json:"additionalScopes,omitempty"` AdditionalScopes []string `json:"additionalScopes,omitempty"`
// AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant
// (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a
// username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow.
// The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be
// supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password
// Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose
// to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the
// cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be
// convenient for users, especially for identities from your OIDC provider which are not intended to represent a human
// actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it,
// you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this
// OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password
// Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords
// (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other
// web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins.
// AllowPasswordGrant defaults to false.
// +optional
AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"`
} }
// OIDCClaims provides a mapping from upstream claims into identities. // OIDCClaims provides a mapping from upstream claims into identities.

View File

@ -59,11 +59,44 @@ spec:
additionalScopes: additionalScopes:
description: AdditionalScopes are the scopes in addition to "openid" description: AdditionalScopes are the scopes in addition to "openid"
that will be requested as part of the authorization request that will be requested as part of the authorization request
flow with an OIDC identity provider. By default only the "openid" flow with an OIDC identity provider. In the case of a Resource
scope will be requested. Owner Password Credentials Grant flow, AdditionalScopes are
the scopes in addition to "openid" that will be requested as
part of the token request (see also the AllowPasswordGrant field).
By default, only the "openid" scope will be requested.
items: items:
type: string type: string
type: array type: array
allowPasswordGrant:
description: AllowPasswordGrant, when true, will allow the use
of OAuth 2.0's Resource Owner Password Credentials Grant (see
https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to
authenticate to the OIDC provider using a username and password
without a web browser, in addition to the usual browser-based
OIDC Authorization Code Flow. The Resource Owner Password Credentials
Grant is not officially part of the OIDC specification, so it
may not be supported by your OIDC provider. If your OIDC provider
supports returning ID tokens from a Resource Owner Password
Credentials Grant token request, then you can choose to set
this field to true. This will allow end users to choose to present
their username and password to the kubectl CLI (using the Pinniped
plugin) to authenticate to the cluster, without using a web
browser to log in as is customary in OIDC Authorization Code
Flow. This may be convenient for users, especially for identities
from your OIDC provider which are not intended to represent
a human actor, such as service accounts performing actions in
a CI/CD environment. Even if your OIDC provider supports it,
you may wish to disable this behavior by setting this field
to false when you prefer to only allow users of this OIDCIdentityProvider
to log in via the browser-based OIDC Authorization Code Flow.
Using the Resource Owner Password Credentials Grant means that
the Pinniped CLI and Pinniped Supervisor will directly handle
your end users' passwords (similar to LDAPIdentityProvider),
and you will not be able to require multi-factor authentication
or use the other web-based login features of your OIDC provider
during Resource Owner Password Credentials Grant logins. AllowPasswordGrant
defaults to false.
type: boolean
type: object type: object
claims: claims:
description: Claims provides the names of token claims that will be description: Claims provides the names of token claims that will be

View File

@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author
[cols="25a,75a", options="header"] [cols="25a,75a", options="header"]
|=== |===
| Field | Description | Field | Description
| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. | *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested.
| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false.
|=== |===

View File

@ -1,4 +1,4 @@
// Copyright 2020 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
package v1alpha1 package v1alpha1
@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct {
// request parameters. // request parameters.
type OIDCAuthorizationConfig struct { type OIDCAuthorizationConfig struct {
// AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization
// request flow with an OIDC identity provider. By default only the "openid" scope will be requested. // request flow with an OIDC identity provider.
// In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes
// in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field).
// By default, only the "openid" scope will be requested.
// +optional // +optional
AdditionalScopes []string `json:"additionalScopes,omitempty"` AdditionalScopes []string `json:"additionalScopes,omitempty"`
// AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant
// (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a
// username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow.
// The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be
// supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password
// Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose
// to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the
// cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be
// convenient for users, especially for identities from your OIDC provider which are not intended to represent a human
// actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it,
// you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this
// OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password
// Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords
// (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other
// web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins.
// AllowPasswordGrant defaults to false.
// +optional
AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"`
} }
// OIDCClaims provides a mapping from upstream claims into identities. // OIDCClaims provides a mapping from upstream claims into identities.

View File

@ -59,11 +59,44 @@ spec:
additionalScopes: additionalScopes:
description: AdditionalScopes are the scopes in addition to "openid" description: AdditionalScopes are the scopes in addition to "openid"
that will be requested as part of the authorization request that will be requested as part of the authorization request
flow with an OIDC identity provider. By default only the "openid" flow with an OIDC identity provider. In the case of a Resource
scope will be requested. Owner Password Credentials Grant flow, AdditionalScopes are
the scopes in addition to "openid" that will be requested as
part of the token request (see also the AllowPasswordGrant field).
By default, only the "openid" scope will be requested.
items: items:
type: string type: string
type: array type: array
allowPasswordGrant:
description: AllowPasswordGrant, when true, will allow the use
of OAuth 2.0's Resource Owner Password Credentials Grant (see
https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to
authenticate to the OIDC provider using a username and password
without a web browser, in addition to the usual browser-based
OIDC Authorization Code Flow. The Resource Owner Password Credentials
Grant is not officially part of the OIDC specification, so it
may not be supported by your OIDC provider. If your OIDC provider
supports returning ID tokens from a Resource Owner Password
Credentials Grant token request, then you can choose to set
this field to true. This will allow end users to choose to present
their username and password to the kubectl CLI (using the Pinniped
plugin) to authenticate to the cluster, without using a web
browser to log in as is customary in OIDC Authorization Code
Flow. This may be convenient for users, especially for identities
from your OIDC provider which are not intended to represent
a human actor, such as service accounts performing actions in
a CI/CD environment. Even if your OIDC provider supports it,
you may wish to disable this behavior by setting this field
to false when you prefer to only allow users of this OIDCIdentityProvider
to log in via the browser-based OIDC Authorization Code Flow.
Using the Resource Owner Password Credentials Grant means that
the Pinniped CLI and Pinniped Supervisor will directly handle
your end users' passwords (similar to LDAPIdentityProvider),
and you will not be able to require multi-factor authentication
or use the other web-based login features of your OIDC provider
during Resource Owner Password Credentials Grant logins. AllowPasswordGrant
defaults to false.
type: boolean
type: object type: object
claims: claims:
description: Claims provides the names of token claims that will be description: Claims provides the names of token claims that will be

View File

@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author
[cols="25a,75a", options="header"] [cols="25a,75a", options="header"]
|=== |===
| Field | Description | Field | Description
| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. | *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested.
| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false.
|=== |===

View File

@ -1,4 +1,4 @@
// Copyright 2020 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
package v1alpha1 package v1alpha1
@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct {
// request parameters. // request parameters.
type OIDCAuthorizationConfig struct { type OIDCAuthorizationConfig struct {
// AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization
// request flow with an OIDC identity provider. By default only the "openid" scope will be requested. // request flow with an OIDC identity provider.
// In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes
// in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field).
// By default, only the "openid" scope will be requested.
// +optional // +optional
AdditionalScopes []string `json:"additionalScopes,omitempty"` AdditionalScopes []string `json:"additionalScopes,omitempty"`
// AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant
// (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a
// username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow.
// The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be
// supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password
// Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose
// to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the
// cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be
// convenient for users, especially for identities from your OIDC provider which are not intended to represent a human
// actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it,
// you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this
// OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password
// Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords
// (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other
// web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins.
// AllowPasswordGrant defaults to false.
// +optional
AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"`
} }
// OIDCClaims provides a mapping from upstream claims into identities. // OIDCClaims provides a mapping from upstream claims into identities.

View File

@ -59,11 +59,44 @@ spec:
additionalScopes: additionalScopes:
description: AdditionalScopes are the scopes in addition to "openid" description: AdditionalScopes are the scopes in addition to "openid"
that will be requested as part of the authorization request that will be requested as part of the authorization request
flow with an OIDC identity provider. By default only the "openid" flow with an OIDC identity provider. In the case of a Resource
scope will be requested. Owner Password Credentials Grant flow, AdditionalScopes are
the scopes in addition to "openid" that will be requested as
part of the token request (see also the AllowPasswordGrant field).
By default, only the "openid" scope will be requested.
items: items:
type: string type: string
type: array type: array
allowPasswordGrant:
description: AllowPasswordGrant, when true, will allow the use
of OAuth 2.0's Resource Owner Password Credentials Grant (see
https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to
authenticate to the OIDC provider using a username and password
without a web browser, in addition to the usual browser-based
OIDC Authorization Code Flow. The Resource Owner Password Credentials
Grant is not officially part of the OIDC specification, so it
may not be supported by your OIDC provider. If your OIDC provider
supports returning ID tokens from a Resource Owner Password
Credentials Grant token request, then you can choose to set
this field to true. This will allow end users to choose to present
their username and password to the kubectl CLI (using the Pinniped
plugin) to authenticate to the cluster, without using a web
browser to log in as is customary in OIDC Authorization Code
Flow. This may be convenient for users, especially for identities
from your OIDC provider which are not intended to represent
a human actor, such as service accounts performing actions in
a CI/CD environment. Even if your OIDC provider supports it,
you may wish to disable this behavior by setting this field
to false when you prefer to only allow users of this OIDCIdentityProvider
to log in via the browser-based OIDC Authorization Code Flow.
Using the Resource Owner Password Credentials Grant means that
the Pinniped CLI and Pinniped Supervisor will directly handle
your end users' passwords (similar to LDAPIdentityProvider),
and you will not be able to require multi-factor authentication
or use the other web-based login features of your OIDC provider
during Resource Owner Password Credentials Grant logins. AllowPasswordGrant
defaults to false.
type: boolean
type: object type: object
claims: claims:
description: Claims provides the names of token claims that will be description: Claims provides the names of token claims that will be

View File

@ -1,4 +1,4 @@
// Copyright 2020 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
package v1alpha1 package v1alpha1
@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct {
// request parameters. // request parameters.
type OIDCAuthorizationConfig struct { type OIDCAuthorizationConfig struct {
// AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization
// request flow with an OIDC identity provider. By default only the "openid" scope will be requested. // request flow with an OIDC identity provider.
// In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes
// in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field).
// By default, only the "openid" scope will be requested.
// +optional // +optional
AdditionalScopes []string `json:"additionalScopes,omitempty"` AdditionalScopes []string `json:"additionalScopes,omitempty"`
// AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant
// (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a
// username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow.
// The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be
// supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password
// Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose
// to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the
// cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be
// convenient for users, especially for identities from your OIDC provider which are not intended to represent a human
// actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it,
// you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this
// OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password
// Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords
// (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other
// web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins.
// AllowPasswordGrant defaults to false.
// +optional
AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"`
} }
// OIDCClaims provides a mapping from upstream claims into identities. // OIDCClaims provides a mapping from upstream claims into identities.

View File

@ -174,6 +174,7 @@ func (c *oidcWatcherController) validateUpstream(ctx controllerlib.Context, upst
}, },
UsernameClaim: upstream.Spec.Claims.Username, UsernameClaim: upstream.Spec.Claims.Username,
GroupsClaim: upstream.Spec.Claims.Groups, GroupsClaim: upstream.Spec.Claims.Groups,
AllowPasswordGrant: upstream.Spec.AuthorizationConfig.AllowPasswordGrant,
} }
conditions := []*v1alpha1.Condition{ conditions := []*v1alpha1.Condition{
c.validateSecret(upstream, &result), c.validateSecret(upstream, &result),

View File

@ -506,14 +506,17 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u
}}, }},
}, },
{ {
name: "upstream becomes valid", name: "upstream with error becomes valid",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name"}, ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name"},
Spec: v1alpha1.OIDCIdentityProviderSpec{ Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL, Issuer: testIssuerURL,
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName}, Client: v1alpha1.OIDCClient{SecretName: testSecretName},
AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: append(testAdditionalScopes, "xyz", "openid")}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{
AdditionalScopes: append(testAdditionalScopes, "xyz", "openid"),
AllowPasswordGrant: true,
},
Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim},
}, },
Status: v1alpha1.OIDCIdentityProviderStatus{ Status: v1alpha1.OIDCIdentityProviderStatus{
@ -541,6 +544,7 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u
Scopes: append(testExpectedScopes, "xyz"), Scopes: append(testExpectedScopes, "xyz"),
UsernameClaim: testUsernameClaim, UsernameClaim: testUsernameClaim,
GroupsClaim: testGroupsClaim, GroupsClaim: testGroupsClaim,
AllowPasswordGrant: true,
}, },
}, },
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -562,7 +566,10 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u
Issuer: testIssuerURL, Issuer: testIssuerURL,
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName}, Client: v1alpha1.OIDCClient{SecretName: testSecretName},
AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{
AdditionalScopes: testAdditionalScopes,
AllowPasswordGrant: false,
},
Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim},
}, },
Status: v1alpha1.OIDCIdentityProviderStatus{ Status: v1alpha1.OIDCIdentityProviderStatus{
@ -590,6 +597,7 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u
Scopes: testExpectedScopes, Scopes: testExpectedScopes,
UsernameClaim: testUsernameClaim, UsernameClaim: testUsernameClaim,
GroupsClaim: testGroupsClaim, GroupsClaim: testGroupsClaim,
AllowPasswordGrant: false,
}, },
}, },
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -639,6 +647,7 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u
Scopes: testExpectedScopes, Scopes: testExpectedScopes,
UsernameClaim: testUsernameClaim, UsernameClaim: testUsernameClaim,
GroupsClaim: testGroupsClaim, GroupsClaim: testGroupsClaim,
AllowPasswordGrant: false,
}, },
}, },
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@ -797,6 +806,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs
require.Equal(t, tt.wantResultingCache[i].GetAuthorizationURL().String(), actualIDP.GetAuthorizationURL().String()) require.Equal(t, tt.wantResultingCache[i].GetAuthorizationURL().String(), actualIDP.GetAuthorizationURL().String())
require.Equal(t, tt.wantResultingCache[i].GetUsernameClaim(), actualIDP.GetUsernameClaim()) require.Equal(t, tt.wantResultingCache[i].GetUsernameClaim(), actualIDP.GetUsernameClaim())
require.Equal(t, tt.wantResultingCache[i].GetGroupsClaim(), actualIDP.GetGroupsClaim()) require.Equal(t, tt.wantResultingCache[i].GetGroupsClaim(), actualIDP.GetGroupsClaim())
require.Equal(t, tt.wantResultingCache[i].AllowsPasswordGrant(), actualIDP.AllowsPasswordGrant())
require.ElementsMatch(t, tt.wantResultingCache[i].GetScopes(), actualIDP.GetScopes()) require.ElementsMatch(t, tt.wantResultingCache[i].GetScopes(), actualIDP.GetScopes())
// We always want to use the proxy from env on these clients, so although the following assertions // We always want to use the proxy from env on these clients, so although the following assertions

View File

@ -43,6 +43,20 @@ func (m *MockUpstreamOIDCIdentityProviderI) EXPECT() *MockUpstreamOIDCIdentityPr
return m.recorder return m.recorder
} }
// AllowsPasswordGrant mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) AllowsPasswordGrant() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AllowsPasswordGrant")
ret0, _ := ret[0].(bool)
return ret0
}
// AllowsPasswordGrant indicates an expected call of AllowsPasswordGrant.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) AllowsPasswordGrant() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllowsPasswordGrant", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).AllowsPasswordGrant))
}
// ExchangeAuthcodeAndValidateTokens mocks base method. // ExchangeAuthcodeAndValidateTokens mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) ExchangeAuthcodeAndValidateTokens(arg0 context.Context, arg1 string, arg2 pkce.Code, arg3 nonce.Nonce, arg4 string) (*oidctypes.Token, error) { func (m *MockUpstreamOIDCIdentityProviderI) ExchangeAuthcodeAndValidateTokens(arg0 context.Context, arg1 string, arg2 pkce.Code, arg3 nonce.Nonce, arg4 string) (*oidctypes.Token, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -142,6 +156,21 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetUsernameClaim() *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsernameClaim", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetUsernameClaim)) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsernameClaim", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetUsernameClaim))
} }
// PasswordCredentialsGrantAndValidateTokens mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) PasswordCredentialsGrantAndValidateTokens(arg0 context.Context, arg1, arg2 string) (*oidctypes.Token, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PasswordCredentialsGrantAndValidateTokens", arg0, arg1, arg2)
ret0, _ := ret[0].(*oidctypes.Token)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PasswordCredentialsGrantAndValidateTokens indicates an expected call of PasswordCredentialsGrantAndValidateTokens.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) PasswordCredentialsGrantAndValidateTokens(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCredentialsGrantAndValidateTokens", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).PasswordCredentialsGrantAndValidateTokens), arg0, arg1, arg2)
}
// ValidateToken mocks base method. // ValidateToken mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) ValidateToken(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce) (*oidctypes.Token, error) { func (m *MockUpstreamOIDCIdentityProviderI) ValidateToken(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce) (*oidctypes.Token, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@ -59,7 +59,12 @@ func NewHandler(
} }
if oidcUpstream != nil { if oidcUpstream != nil {
return handleAuthRequestForOIDCUpstream(r, w, if len(r.Header.Values(CustomUsernameHeaderName)) > 0 {
// The client set a username header, so they are trying to log in with a username/password.
// TODO unit test this
return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream)
}
return handleAuthRequestForOIDCUpstreamAuthcodeGrant(r, w,
oauthHelperWithoutStorage, oauthHelperWithoutStorage,
generateCSRF, generateNonce, generatePKCE, generateCSRF, generateNonce, generatePKCE,
oidcUpstream, oidcUpstream,
@ -86,13 +91,8 @@ func handleAuthRequestForLDAPUpstream(
return nil return nil
} }
username := r.Header.Get(CustomUsernameHeaderName) username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester)
password := r.Header.Get(CustomPasswordHeaderName) if !hadUsernamePasswordValues {
if username == "" || password == "" {
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
err := errors.WithStack(fosite.ErrAccessDenied.WithHintf("Missing or blank username or password."))
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil return nil
} }
@ -128,7 +128,69 @@ func handleAuthRequestForLDAPUpstream(
return nil return nil
} }
func handleAuthRequestForOIDCUpstream( func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) {
username := r.Header.Get(CustomUsernameHeaderName)
password := r.Header.Get(CustomPasswordHeaderName)
if username == "" || password == "" {
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
err := errors.WithStack(fosite.ErrAccessDenied.WithHintf("Missing or blank username or password."))
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return "", "", false
}
return username, password, true
}
func handleAuthRequestForOIDCUpstreamPasswordGrant(
r *http.Request,
w http.ResponseWriter,
oauthHelper fosite.OAuth2Provider,
oidcUpstream provider.UpstreamOIDCIdentityProviderI,
) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper)
if !created {
return nil
}
username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester)
if !hadUsernamePasswordValues {
return nil
}
token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password)
if err != nil {
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
// TODO do not return the full details of the error to the client, but let them know if it is because password grants are disallowed
err := errors.WithStack(fosite.ErrAccessDenied.WithHintf(err.Error()))
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
subject, username, err := downstreamsession.GetSubjectAndUsernameFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims)
if err != nil {
return err
}
groups, err := downstreamsession.GetGroupsFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims)
if err != nil {
return err
}
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups)
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil {
plog.WarningErr("error while generating and saving authcode", err, "upstreamName", oidcUpstream.GetName())
return httperr.Wrap(http.StatusInternalServerError, "error while generating and saving authcode", err)
}
oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder)
return nil
}
func handleAuthRequestForOIDCUpstreamAuthcodeGrant(
r *http.Request, r *http.Request,
w http.ResponseWriter, w http.ResponseWriter,
oauthHelper fosite.OAuth2Provider, oauthHelper fosite.OAuth2Provider,

View File

@ -6,7 +6,6 @@ package callback
import ( import (
"crypto/subtle" "crypto/subtle"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -22,14 +21,6 @@ import (
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
) )
const (
// The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailClaimName = "email"
// The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailVerifiedClaimName = "email_verified"
)
func NewHandler( func NewHandler(
upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister, upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister,
oauthHelper fosite.OAuth2Provider, oauthHelper fosite.OAuth2Provider,
@ -77,12 +68,12 @@ func NewHandler(
return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens") return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens")
} }
subject, username, err := getSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) subject, username, err := downstreamsession.GetSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims)
if err != nil { if err != nil {
return err return err
} }
groups, err := getGroupsFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) groups, err := downstreamsession.GetGroupsFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims)
if err != nil { if err != nil {
return err return err
} }
@ -182,166 +173,3 @@ func readState(r *http.Request, stateDecoder oidc.Decoder) (*oidc.UpstreamStateP
return &state, nil return &state, nil
} }
func getSubjectAndUsernameFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) (string, string, error) {
// The spec says the "sub" claim is only unique per issuer,
// so we will prepend the issuer string to make it globally unique.
upstreamIssuer := idTokenClaims[oidc.IDTokenIssuerClaim]
if upstreamIssuer == "" {
plog.Warning(
"issuer claim in upstream ID token missing",
"upstreamName", upstreamIDPConfig.GetName(),
"issClaim", upstreamIssuer,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token missing")
}
upstreamIssuerAsString, ok := upstreamIssuer.(string)
if !ok {
plog.Warning(
"issuer claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"issClaim", upstreamIssuer,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token has invalid format")
}
subjectAsInterface, ok := idTokenClaims[oidc.IDTokenSubjectClaim]
if !ok {
plog.Warning(
"no subject claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "no subject claim in upstream ID token")
}
upstreamSubject, ok := subjectAsInterface.(string)
if !ok {
plog.Warning(
"subject claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "subject claim in upstream ID token has invalid format")
}
subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString, upstreamSubject)
usernameClaimName := upstreamIDPConfig.GetUsernameClaim()
if usernameClaimName == "" {
return subject, subject, nil
}
// If the upstream username claim is configured to be the special "email" claim and the upstream "email_verified"
// claim is present, then validate that the "email_verified" claim is true.
emailVerifiedAsInterface, ok := idTokenClaims[emailVerifiedClaimName]
if usernameClaimName == emailClaimName && ok {
emailVerified, ok := emailVerifiedAsInterface.(bool)
if !ok {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim is not a boolean",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
"emailVerifiedClaim", emailVerifiedAsInterface,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has invalid format")
}
if !emailVerified {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim has false value",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has false value")
}
}
usernameAsInterface, ok := idTokenClaims[usernameClaimName]
if !ok {
plog.Warning(
"no username claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token")
}
username, ok := usernameAsInterface.(string)
if !ok {
plog.Warning(
"username claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format")
}
return subject, username, nil
}
func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string {
return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject))
}
func getGroupsFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) ([]string, error) {
groupsClaimName := upstreamIDPConfig.GetGroupsClaim()
if groupsClaimName == "" {
return nil, nil
}
groupsAsInterface, ok := idTokenClaims[groupsClaimName]
if !ok {
plog.Warning(
"no groups claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, nil // the upstream IDP may have omitted the claim if the user has no groups
}
groupsAsArray, okAsArray := extractGroups(groupsAsInterface)
if !okAsArray {
plog.Warning(
"groups claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format")
}
return groupsAsArray, nil
}
func extractGroups(groupsAsInterface interface{}) ([]string, bool) {
groupsAsString, okAsString := groupsAsInterface.(string)
if okAsString {
return []string{groupsAsString}, true
}
groupsAsStringArray, okAsStringArray := groupsAsInterface.([]string)
if okAsStringArray {
return groupsAsStringArray, true
}
groupsAsInterfaceArray, okAsArray := groupsAsInterface.([]interface{})
if !okAsArray {
return nil, false
}
var groupsAsStrings []string
for _, groupAsInterface := range groupsAsInterfaceArray {
groupAsString, okAsString := groupAsInterface.(string)
if !okAsString {
return nil, false
}
if groupAsString != "" {
groupsAsStrings = append(groupsAsStrings, groupAsString)
}
}
return groupsAsStrings, true
}

View File

@ -5,6 +5,9 @@
package downstreamsession package downstreamsession
import ( import (
"fmt"
"net/http"
"net/url"
"time" "time"
oidc2 "github.com/coreos/go-oidc/v3/oidc" oidc2 "github.com/coreos/go-oidc/v3/oidc"
@ -12,7 +15,18 @@ import (
"github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt" "github.com/ory/fosite/token/jwt"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
)
const (
// The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailClaimName = "email"
// The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailVerifiedClaimName = "email_verified"
) )
// MakeDownstreamSession creates a downstream OIDC session. // MakeDownstreamSession creates a downstream OIDC session.
@ -41,3 +55,166 @@ func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) {
oidc.GrantScopeIfRequested(authorizeRequester, oidc2.ScopeOfflineAccess) oidc.GrantScopeIfRequested(authorizeRequester, oidc2.ScopeOfflineAccess)
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience") oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience")
} }
func GetSubjectAndUsernameFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) (string, string, error) {
// The spec says the "sub" claim is only unique per issuer,
// so we will prepend the issuer string to make it globally unique.
upstreamIssuer := idTokenClaims[oidc.IDTokenIssuerClaim]
if upstreamIssuer == "" {
plog.Warning(
"issuer claim in upstream ID token missing",
"upstreamName", upstreamIDPConfig.GetName(),
"issClaim", upstreamIssuer,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token missing")
}
upstreamIssuerAsString, ok := upstreamIssuer.(string)
if !ok {
plog.Warning(
"issuer claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"issClaim", upstreamIssuer,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token has invalid format")
}
subjectAsInterface, ok := idTokenClaims[oidc.IDTokenSubjectClaim]
if !ok {
plog.Warning(
"no subject claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "no subject claim in upstream ID token")
}
upstreamSubject, ok := subjectAsInterface.(string)
if !ok {
plog.Warning(
"subject claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "subject claim in upstream ID token has invalid format")
}
subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString, upstreamSubject)
usernameClaimName := upstreamIDPConfig.GetUsernameClaim()
if usernameClaimName == "" {
return subject, subject, nil
}
// If the upstream username claim is configured to be the special "email" claim and the upstream "email_verified"
// claim is present, then validate that the "email_verified" claim is true.
emailVerifiedAsInterface, ok := idTokenClaims[emailVerifiedClaimName]
if usernameClaimName == emailClaimName && ok {
emailVerified, ok := emailVerifiedAsInterface.(bool)
if !ok {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim is not a boolean",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
"emailVerifiedClaim", emailVerifiedAsInterface,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has invalid format")
}
if !emailVerified {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim has false value",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has false value")
}
}
usernameAsInterface, ok := idTokenClaims[usernameClaimName]
if !ok {
plog.Warning(
"no username claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token")
}
username, ok := usernameAsInterface.(string)
if !ok {
plog.Warning(
"username claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format")
}
return subject, username, nil
}
func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string {
return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject))
}
func GetGroupsFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) ([]string, error) {
groupsClaimName := upstreamIDPConfig.GetGroupsClaim()
if groupsClaimName == "" {
return nil, nil
}
groupsAsInterface, ok := idTokenClaims[groupsClaimName]
if !ok {
plog.Warning(
"no groups claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, nil // the upstream IDP may have omitted the claim if the user has no groups
}
groupsAsArray, okAsArray := extractGroups(groupsAsInterface)
if !okAsArray {
plog.Warning(
"groups claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format")
}
return groupsAsArray, nil
}
func extractGroups(groupsAsInterface interface{}) ([]string, bool) {
groupsAsString, okAsString := groupsAsInterface.(string)
if okAsString {
return []string{groupsAsString}, true
}
groupsAsStringArray, okAsStringArray := groupsAsInterface.([]string)
if okAsStringArray {
return groupsAsStringArray, true
}
groupsAsInterfaceArray, okAsArray := groupsAsInterface.([]interface{})
if !okAsArray {
return nil, false
}
var groupsAsStrings []string
for _, groupAsInterface := range groupsAsInterfaceArray {
groupAsString, okAsString := groupAsInterface.(string)
if !okAsString {
return nil, false
}
if groupAsString != "" {
groupsAsStrings = append(groupsAsStrings, groupAsString)
}
}
return groupsAsStrings, true
}

View File

@ -16,6 +16,9 @@ import (
const ( const (
idpDiscoveryTypeLDAP = "ldap" idpDiscoveryTypeLDAP = "ldap"
idpDiscoveryTypeOIDC = "oidc" idpDiscoveryTypeOIDC = "oidc"
flowOIDCBrowser = "browser_authcode"
flowCLIPassword = "cli_password"
) )
type response struct { type response struct {
@ -25,6 +28,7 @@ type response struct {
type identityProviderResponse struct { type identityProviderResponse struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Flows []string `json:"flows"`
} }
// NewHandler returns an http.Handler that serves the upstream IDP discovery endpoint. // NewHandler returns an http.Handler that serves the upstream IDP discovery endpoint.
@ -56,10 +60,22 @@ func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte,
// The cache of IDPs could change at any time, so always recalculate the list. // The cache of IDPs could change at any time, so always recalculate the list.
for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() { for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() {
r.IDPs = append(r.IDPs, identityProviderResponse{Name: provider.GetName(), Type: idpDiscoveryTypeLDAP}) r.IDPs = append(r.IDPs, identityProviderResponse{
Name: provider.GetName(),
Type: idpDiscoveryTypeLDAP,
Flows: []string{flowCLIPassword},
})
} }
for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() { for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() {
r.IDPs = append(r.IDPs, identityProviderResponse{Name: provider.GetName(), Type: idpDiscoveryTypeOIDC}) flows := []string{flowOIDCBrowser}
if provider.AllowsPasswordGrant() {
flows = append(flows, flowCLIPassword)
}
r.IDPs = append(r.IDPs, identityProviderResponse{
Name: provider.GetName(),
Type: idpDiscoveryTypeOIDC,
Flows: flows,
})
} }
// Nobody like an API that changes the results unnecessarily. :) // Nobody like an API that changes the results unnecessarily. :)

View File

@ -37,20 +37,20 @@ func TestIDPDiscovery(t *testing.T) {
wantContentType: "application/json", wantContentType: "application/json",
wantFirstResponseBodyJSON: &response{ wantFirstResponseBodyJSON: &response{
IDPs: []identityProviderResponse{ IDPs: []identityProviderResponse{
{Name: "a-some-ldap-idp", Type: "ldap"}, {Name: "a-some-ldap-idp", Type: "ldap", Flows: []string{"cli_password"}},
{Name: "a-some-oidc-idp", Type: "oidc"}, {Name: "a-some-oidc-idp", Type: "oidc", Flows: []string{"browser_authcode"}},
{Name: "x-some-idp", Type: "ldap"}, {Name: "x-some-idp", Type: "ldap", Flows: []string{"cli_password"}},
{Name: "x-some-idp", Type: "oidc"}, {Name: "x-some-idp", Type: "oidc", Flows: []string{"browser_authcode"}},
{Name: "z-some-ldap-idp", Type: "ldap"}, {Name: "z-some-ldap-idp", Type: "ldap", Flows: []string{"cli_password"}},
{Name: "z-some-oidc-idp", Type: "oidc"}, {Name: "z-some-oidc-idp", Type: "oidc", Flows: []string{"browser_authcode", "cli_password"}},
}, },
}, },
wantSecondResponseBodyJSON: &response{ wantSecondResponseBodyJSON: &response{
IDPs: []identityProviderResponse{ IDPs: []identityProviderResponse{
{Name: "some-other-ldap-idp-1", Type: "ldap"}, {Name: "some-other-ldap-idp-1", Type: "ldap", Flows: []string{"cli_password"}},
{Name: "some-other-ldap-idp-2", Type: "ldap"}, {Name: "some-other-ldap-idp-2", Type: "ldap", Flows: []string{"cli_password"}},
{Name: "some-other-oidc-idp-1", Type: "oidc"}, {Name: "some-other-oidc-idp-1", Type: "oidc", Flows: []string{"browser_authcode", "cli_password"}},
{Name: "some-other-oidc-idp-2", Type: "oidc"}, {Name: "some-other-oidc-idp-2", Type: "oidc", Flows: []string{"browser_authcode"}},
}, },
}, },
}, },
@ -67,7 +67,7 @@ func TestIDPDiscovery(t *testing.T) {
test := test test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
idpLister := oidctestutil.NewUpstreamIDPListerBuilder(). idpLister := oidctestutil.NewUpstreamIDPListerBuilder().
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "z-some-oidc-idp"}). WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "z-some-oidc-idp", AllowPasswordGrant: true}).
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "x-some-idp"}). WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "x-some-idp"}).
WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "a-some-ldap-idp"}). WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "a-some-ldap-idp"}).
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "a-some-oidc-idp"}). WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "a-some-oidc-idp"}).
@ -100,7 +100,7 @@ func TestIDPDiscovery(t *testing.T) {
&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-2"}, &oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-2"},
}) })
idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{
&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-1"}, &oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-1", AllowPasswordGrant: true},
&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-2"}, &oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-2"},
}) })

View File

@ -17,26 +17,36 @@ import (
) )
type UpstreamOIDCIdentityProviderI interface { type UpstreamOIDCIdentityProviderI interface {
// A name for this upstream provider, which will be used as a component of the path for the callback endpoint // GetName returns a name for this upstream provider, which will be used as a component of the path for the
// hosted by the Supervisor. // callback endpoint hosted by the Supervisor.
GetName() string GetName() string
// The Oauth client ID registered with the upstream provider to be used in the authorization code flow. // GetClientID returns the OAuth client ID registered with the upstream provider to be used in the authorization code flow.
GetClientID() string GetClientID() string
// The Authorization Endpoint fetched from discovery. // GetAuthorizationURL returns the Authorization Endpoint fetched from discovery.
GetAuthorizationURL() *url.URL GetAuthorizationURL() *url.URL
// Scopes to request in authorization flow. // GetScopes returns the scopes to request in authorization (authcode or password grant) flow.
GetScopes() []string GetScopes() []string
// ID Token username claim name. May return empty string, in which case we will use some reasonable defaults. // GetUsernameClaim returns the ID Token username claim name. May return empty string, in which case we
// will use some reasonable defaults.
GetUsernameClaim() string GetUsernameClaim() string
// ID Token groups claim name. May return empty string, in which case we won't try to read groups from the upstream provider. // GetGroupsClaim returns the ID Token groups claim name. May return empty string, in which case we won't
// try to read groups from the upstream provider.
GetGroupsClaim() string GetGroupsClaim() string
// Performs upstream OIDC authorization code exchange and token validation. // AllowsPasswordGrant returns true if a client should be allowed to use the resource owner password credentials grant
// flow with this upstream provider. When false, it should not be allowed.
AllowsPasswordGrant() bool
// PasswordCredentialsGrantAndValidateTokens performs upstream OIDC resource owner password credentials grant and
// token validation. Returns the validated raw tokens as well as the parsed claims of the ID token.
PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error)
// ExchangeAuthcodeAndValidateTokens performs upstream OIDC authorization code exchange and token validation.
// Returns the validated raw tokens as well as the parsed claims of the ID token. // Returns the validated raw tokens as well as the parsed claims of the ID token.
ExchangeAuthcodeAndValidateTokens( ExchangeAuthcodeAndValidateTokens(
ctx context.Context, ctx context.Context,
@ -50,15 +60,15 @@ type UpstreamOIDCIdentityProviderI interface {
} }
type UpstreamLDAPIdentityProviderI interface { type UpstreamLDAPIdentityProviderI interface {
// A name for this upstream provider. // GetName returns a name for this upstream provider.
GetName() string GetName() string
// Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234". // GetURL returns a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234".
// This URL is not used for connecting to the provider, but rather is used for creating a globally unique user // This URL is not used for connecting to the provider, but rather is used for creating a globally unique user
// identifier by being combined with the user's UID, since user UIDs are only unique within one provider. // identifier by being combined with the user's UID, since user UIDs are only unique within one provider.
GetURL() *url.URL GetURL() *url.URL
// A 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
} }

View File

@ -61,6 +61,10 @@ func TestManager(t *testing.T) {
downstreamPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements" downstreamPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements"
) )
var (
upstreamIDPFlows = []string{"browser_authcode"}
)
newGetRequest := func(url string) *http.Request { newGetRequest := func(url string) *http.Request {
return httptest.NewRequest(http.MethodGet, url, nil) return httptest.NewRequest(http.MethodGet, url, nil)
} }
@ -89,19 +93,22 @@ func TestManager(t *testing.T) {
r.Equal(parsedDiscoveryResult.SupervisorDiscovery.PinnipedIDPsEndpoint, expectedIssuer+oidc.PinnipedIDPsPathV1Alpha1) r.Equal(parsedDiscoveryResult.SupervisorDiscovery.PinnipedIDPsEndpoint, expectedIssuer+oidc.PinnipedIDPsPathV1Alpha1)
} }
requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIDPName, expectedIDPType string) { requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIDPName, expectedIDPType string, expectedFlows []string) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPathV1Alpha1+requestURLSuffix)) subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPathV1Alpha1+requestURLSuffix))
r.False(fallbackHandlerWasCalled) r.False(fallbackHandlerWasCalled)
expectedFlowsJSON, err := json.Marshal(expectedFlows)
require.NoError(t, err)
// Minimal check to ensure that the right IDP discovery endpoint was called // Minimal check to ensure that the right IDP discovery endpoint was called
r.Equal(http.StatusOK, recorder.Code) r.Equal(http.StatusOK, recorder.Code)
responseBody, err := ioutil.ReadAll(recorder.Body) responseBody, err := ioutil.ReadAll(recorder.Body)
r.NoError(err) r.NoError(err)
r.Equal( r.Equal(
fmt.Sprintf(`{"pinniped_identity_providers":[{"name":"%s","type":"%s"}]}`+"\n", expectedIDPName, expectedIDPType), fmt.Sprintf(`{"pinniped_identity_providers":[{"name":"%s","type":"%s","flows":%s}]}`+"\n", expectedIDPName, expectedIDPType, expectedFlowsJSON),
string(responseBody), string(responseBody),
) )
} }
@ -314,14 +321,14 @@ func TestManager(t *testing.T) {
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2) requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2)
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2) requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1, "", upstreamIDPName, upstreamIDPType) requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "", upstreamIDPName, upstreamIDPType) requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "?some=query", upstreamIDPName, upstreamIDPType) requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "?some=query", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
// Hostnames are case-insensitive, so test that we can handle that. // Hostnames are case-insensitive, so test that we can handle that.
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType) requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType) requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", upstreamIDPName, upstreamIDPType) requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID) issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID)
issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID) issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID)

View File

@ -76,6 +76,7 @@ type TestUpstreamOIDCIdentityProvider struct {
UsernameClaim string UsernameClaim string
GroupsClaim string GroupsClaim string
Scopes []string Scopes []string
AllowPasswordGrant bool
ExchangeAuthcodeAndValidateTokensFunc func( ExchangeAuthcodeAndValidateTokensFunc func(
ctx context.Context, ctx context.Context,
authcode string, authcode string,
@ -111,6 +112,15 @@ func (u *TestUpstreamOIDCIdentityProvider) GetGroupsClaim() string {
return u.GroupsClaim return u.GroupsClaim
} }
func (u *TestUpstreamOIDCIdentityProvider) AllowsPasswordGrant() bool {
return u.AllowPasswordGrant
}
func (u *TestUpstreamOIDCIdentityProvider) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) {
// TODO implement this unit test helper
return nil, nil
}
func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens(
ctx context.Context, ctx context.Context,
authcode string, authcode string,

View File

@ -6,6 +6,7 @@ package upstreamoidc
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -32,11 +33,12 @@ type ProviderConfig struct {
UsernameClaim string UsernameClaim string
GroupsClaim string GroupsClaim string
Config *oauth2.Config Config *oauth2.Config
Client *http.Client
AllowPasswordGrant bool
Provider interface { Provider interface {
Verifier(*coreosoidc.Config) *coreosoidc.IDTokenVerifier Verifier(*coreosoidc.Config) *coreosoidc.IDTokenVerifier
UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*coreosoidc.UserInfo, error) UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*coreosoidc.UserInfo, error)
} }
Client *http.Client
} }
func (p *ProviderConfig) GetName() string { func (p *ProviderConfig) GetName() string {
@ -64,6 +66,32 @@ func (p *ProviderConfig) GetGroupsClaim() string {
return p.GroupsClaim return p.GroupsClaim
} }
func (p *ProviderConfig) AllowsPasswordGrant() bool {
return p.AllowPasswordGrant
}
func (p *ProviderConfig) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) {
// Disallow this grant when requested.
if !p.AllowPasswordGrant {
return nil, fmt.Errorf("resource owner password grant is not allowed for this upstream provider according to its configuration")
}
// Note that this implicitly uses the scopes from p.Config.Scopes.
tok, err := p.Config.PasswordCredentialsToken(
coreosoidc.ClientContext(ctx, p.Client),
username,
password,
)
if err != nil {
return nil, err
}
// There is no nonce to validate for a resource owner password credentials grant because it skips using
// the authorize endpoint and goes straight to the token endpoint.
skipNonceValidation := nonce.Nonce("")
return p.ValidateToken(ctx, tok, skipNonceValidation)
}
func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce, redirectURI string) (*oidctypes.Token, error) { func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce, redirectURI string) (*oidctypes.Token, error) {
tok, err := p.Config.Exchange( tok, err := p.Config.Exchange(
coreosoidc.ClientContext(ctx, p.Client), coreosoidc.ClientContext(ctx, p.Client),

View File

@ -1,4 +1,4 @@
// Copyright 2020 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
package upstreamoidc package upstreamoidc
@ -45,6 +45,13 @@ func TestProviderConfig(t *testing.T) {
require.ElementsMatch(t, []string{"scope1", "scope2"}, p.GetScopes()) require.ElementsMatch(t, []string{"scope1", "scope2"}, p.GetScopes())
require.Equal(t, "test-username-claim", p.GetUsernameClaim()) require.Equal(t, "test-username-claim", p.GetUsernameClaim())
require.Equal(t, "test-groups-claim", p.GetGroupsClaim()) require.Equal(t, "test-groups-claim", p.GetGroupsClaim())
// AllowPasswordGrant defaults to false.
require.False(t, p.AllowsPasswordGrant())
p.AllowPasswordGrant = true
require.True(t, p.AllowsPasswordGrant())
p.AllowPasswordGrant = false
require.False(t, p.AllowsPasswordGrant())
}) })
const ( const (
@ -66,6 +73,218 @@ func TestProviderConfig(t *testing.T) {
// if the error string for unsupported user info changes, this will hopefully catch it // if the error string for unsupported user info changes, this will hopefully catch it
_, userInfoNotSupported := (&oidc.Provider{}).UserInfo(context.Background(), nil) _, userInfoNotSupported := (&oidc.Provider{}).UserInfo(context.Background(), nil)
t.Run("PasswordCredentialsGrantAndValidateTokens", func(t *testing.T) {
tests := []struct {
name string
disallowPasswordGrant bool
returnIDTok string
tokenStatusCode int
wantErr string
wantToken oidctypes.Token
userInfo *oidc.UserInfo
userInfoErr error
wantUserInfoCalled bool
}{
{
name: "valid",
returnIDTok: validIDToken,
wantToken: oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Expiry: metav1.Time{},
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: validIDToken,
Expiry: metav1.Time{},
Claims: map[string]interface{}{
"foo": "bar",
"bat": "baz",
"aud": "test-client-id",
"iat": 1.606768593e+09,
"jti": "test-jti",
"nbf": 1.606768593e+09,
"sub": "test-user",
},
},
},
userInfoErr: userInfoNotSupported,
wantUserInfoCalled: true,
},
{
name: "valid with userinfo",
returnIDTok: validIDToken,
wantToken: oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Expiry: metav1.Time{},
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: validIDToken,
Expiry: metav1.Time{},
Claims: map[string]interface{}{
"foo": "awesomeness", // overwrite existing claim
"bat": "baz",
"aud": "test-client-id",
"iat": 1.606768593e+09,
"jti": "test-jti",
"nbf": 1.606768593e+09,
"sub": "test-user",
"groups": "fancy-group", // add a new claim
},
},
},
// claims is private field so we have to use hacks to set it
userInfo: forceUserInfoWithClaims("test-user", `{"foo":"awesomeness","groups":"fancy-group"}`),
wantUserInfoCalled: true,
},
{
name: "password grant not allowed",
disallowPasswordGrant: true, // password grant is not allowed in this ProviderConfig
wantErr: "resource owner password grant is not allowed for this upstream provider according to its configuration",
},
{
name: "token request fails with http error",
tokenStatusCode: http.StatusForbidden,
wantErr: "oauth2: cannot fetch token: 403 Forbidden\nResponse: fake error\n",
},
{
name: "missing ID token",
wantErr: "received response missing ID token",
},
{
name: "invalid ID token",
returnIDTok: "invalid-jwt",
wantErr: "received invalid ID token: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts",
},
{
name: "invalid access token hash",
returnIDTok: invalidAccessTokenHashIDToken,
wantErr: "received invalid ID token: access token hash does not match value in ID token",
},
{
name: "user info fetch error",
returnIDTok: validIDToken,
wantErr: "could not fetch user info claims: could not get user info: some network error",
userInfoErr: errors.New("some network error"),
},
{
name: "user info sub error",
returnIDTok: validIDToken,
wantErr: "could not fetch user info claims: userinfo 'sub' claim (test-user-2) did not match id_token 'sub' claim (test-user)",
userInfo: &oidc.UserInfo{Subject: "test-user-2"},
},
{
name: "user info is not json",
returnIDTok: validIDToken,
wantErr: "could not fetch user info claims: could not unmarshal user info claims: invalid character 'i' looking for beginning of value",
// claims is private field so we have to use hacks to set it
userInfo: forceUserInfoWithClaims("test-user", `invalid-json-data`),
},
{
name: "invalid sub claim",
returnIDTok: invalidSubClaim,
wantToken: oidctypes.Token{
AccessToken: &oidctypes.AccessToken{
Token: "test-access-token",
Expiry: metav1.Time{},
},
RefreshToken: &oidctypes.RefreshToken{
Token: "test-refresh-token",
},
IDToken: &oidctypes.IDToken{
Token: invalidSubClaim,
Expiry: metav1.Time{},
Claims: map[string]interface{}{
"foo": "bar",
"bat": "baz",
"aud": "test-client-id",
"iat": 1.61021969e+09,
"jti": "test-jti",
"nbf": 1.61021969e+09,
// no sub claim
},
},
},
wantUserInfoCalled: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.NoError(t, r.ParseForm())
require.Equal(t, 6, len(r.Form))
require.Equal(t, "password", r.Form.Get("grant_type"))
require.Equal(t, "test-client-id", r.Form.Get("client_id"))
require.Equal(t, "test-client-secret", r.Form.Get("client_secret"))
require.Equal(t, "test-username", r.Form.Get("username"))
require.Equal(t, "test-password", r.Form.Get("password"))
require.Equal(t, "scope1 scope2", r.Form.Get("scope"))
if tt.tokenStatusCode != 0 {
http.Error(w, "fake error", http.StatusForbidden)
return
}
var response struct {
oauth2.Token
IDToken string `json:"id_token,omitempty"`
}
response.AccessToken = "test-access-token"
response.RefreshToken = "test-refresh-token"
response.Expiry = time.Now().Add(time.Hour)
response.IDToken = tt.returnIDTok
w.Header().Set("content-type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(&response))
}))
t.Cleanup(tokenServer.Close)
p := ProviderConfig{
Name: "test-name",
UsernameClaim: "test-username-claim",
GroupsClaim: "test-groups-claim",
Config: &oauth2.Config{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
Endpoint: oauth2.Endpoint{
AuthURL: "https://example.com",
TokenURL: tokenServer.URL,
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: []string{"scope1", "scope2"},
},
Provider: &mockProvider{
userInfo: tt.userInfo,
userInfoErr: tt.userInfoErr,
},
AllowPasswordGrant: !tt.disallowPasswordGrant,
}
tok, err := p.PasswordCredentialsGrantAndValidateTokens(
context.Background(),
"test-username",
"test-password",
)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
require.Nil(t, tok)
return
}
require.NoError(t, err)
require.Equal(t, &tt.wantToken, tok)
require.Equal(t, tt.wantUserInfoCalled, p.Provider.(*mockProvider).called)
})
}
})
t.Run("ExchangeAuthcodeAndValidateTokens", func(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
authCode string authCode string
@ -253,9 +472,12 @@ func TestProviderConfig(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method) require.Equal(t, http.MethodPost, r.Method)
require.NoError(t, r.ParseForm()) require.NoError(t, r.ParseForm())
require.Len(t, r.Form, 6)
require.Equal(t, "test-client-id", r.Form.Get("client_id")) require.Equal(t, "test-client-id", r.Form.Get("client_id"))
require.Equal(t, "test-client-secret", r.Form.Get("client_secret"))
require.Equal(t, "test-pkce", r.Form.Get("code_verifier")) require.Equal(t, "test-pkce", r.Form.Get("code_verifier"))
require.Equal(t, "authorization_code", r.Form.Get("grant_type")) require.Equal(t, "authorization_code", r.Form.Get("grant_type"))
require.Equal(t, "https://example.com/callback", r.Form.Get("redirect_uri"))
require.NotEmpty(t, r.Form.Get("code")) require.NotEmpty(t, r.Form.Get("code"))
if r.Form.Get("code") != "valid" { if r.Form.Get("code") != "valid" {
http.Error(w, "invalid authorization code", http.StatusForbidden) http.Error(w, "invalid authorization code", http.StatusForbidden)
@ -280,6 +502,7 @@ func TestProviderConfig(t *testing.T) {
GroupsClaim: "test-groups-claim", GroupsClaim: "test-groups-claim",
Config: &oauth2.Config{ Config: &oauth2.Config{
ClientID: "test-client-id", ClientID: "test-client-id",
ClientSecret: "test-client-secret",
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: "https://example.com", AuthURL: "https://example.com",
TokenURL: tokenServer.URL, TokenURL: tokenServer.URL,
@ -293,9 +516,14 @@ func TestProviderConfig(t *testing.T) {
}, },
} }
ctx := context.Background() tok, err := p.ExchangeAuthcodeAndValidateTokens(
context.Background(),
tt.authCode,
"test-pkce",
tt.expectNonce,
"https://example.com/callback",
)
tok, err := p.ExchangeAuthcodeAndValidateTokens(ctx, tt.authCode, "test-pkce", tt.expectNonce, "https://example.com/callback")
if tt.wantErr != "" { if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr) require.EqualError(t, err, tt.wantErr)
require.Nil(t, tok) require.Nil(t, tok)
@ -306,6 +534,7 @@ func TestProviderConfig(t *testing.T) {
require.Equal(t, tt.wantUserInfoCalled, p.Provider.(*mockProvider).called) require.Equal(t, tt.wantUserInfoCalled, p.Provider.(*mockProvider).called)
}) })
} }
})
} }
// mockVerifier returns an *oidc.IDTokenVerifier that validates any correctly serialized JWT without doing much else. // mockVerifier returns an *oidc.IDTokenVerifier that validates any correctly serialized JWT without doing much else.
@ -350,7 +579,7 @@ func (m *mockProvider) UserInfo(_ context.Context, tokenSource oauth2.TokenSourc
return m.userInfo, m.userInfoErr return m.userInfo, m.userInfoErr
} }
func forceUserInfoWithClaims(subject string, claims string) *oidc.UserInfo { func forceUserInfoWithClaims(subject string, claims string) *oidc.UserInfo { //nolint:unparam
userInfo := &oidc.UserInfo{Subject: subject} userInfo := &oidc.UserInfo{Subject: subject}
// this is some dark magic to set a private field // this is some dark magic to set a private field

View File

@ -237,7 +237,8 @@ func WithRequestAudience(audience string) Option {
// WithCLISendingCredentials causes the login flow to use CLI-based prompts for username and password and causes the // WithCLISendingCredentials causes the login flow to use CLI-based prompts for username and password and causes the
// call to the Issuer's authorize endpoint to be made directly (no web browser) with the username and password on custom // call to the Issuer's authorize endpoint to be made directly (no web browser) with the username and password on custom
// HTTP headers. This is only intended to be used when the issuer is a Pinniped Supervisor and the upstream identity // HTTP headers. This is only intended to be used when the issuer is a Pinniped Supervisor and the upstream identity
// provider type supports this style of authentication. Currently this is supported by LDAPIdentityProviders. // provider type supports this style of authentication. Currently, this is supported by LDAPIdentityProviders
// and by OIDCIdentityProviders which optionally enable the resource owner password credentials grant flow.
// This should never be used with non-Supervisor issuers because it will send the user's password to the authorization // This should never be used with non-Supervisor issuers because it will send the user's password to the authorization
// endpoint as a custom header, which would be ignored but could potentially get logged somewhere by the issuer. // endpoint as a custom header, which would be ignored but could potentially get logged somewhere by the issuer.
func WithCLISendingCredentials() Option { func WithCLISendingCredentials() Option {

View File

@ -17,6 +17,8 @@ web:
tlsKey: /var/certs/dex-key.pem tlsKey: /var/certs/dex-key.pem
oauth2: oauth2:
skipApprovalScreen: true skipApprovalScreen: true
#! Allow the resource owner password grant, which Dex implements to also return ID tokens.
passwordConnector: local
staticClients: staticClients:
- id: pinniped-cli - id: pinniped-cli
name: 'Pinniped CLI' name: 'Pinniped CLI'

View File

@ -271,7 +271,7 @@ func TestE2EFullIntegration(t *testing.T) {
) )
}) })
t.Run("with Supervisor OIDC upstream IDP and manual flow", func(t *testing.T) { t.Run("with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) {
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file. // Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
page := browsertest.Open(t) page := browsertest.Open(t)
@ -365,7 +365,7 @@ func TestE2EFullIntegration(t *testing.T) {
// Read all of the remaining output from the subprocess until EOF. // Read all of the remaining output from the subprocess until EOF.
t.Logf("waiting for kubectl to output namespace list") t.Logf("waiting for kubectl to output namespace list")
// Read all of the output from the subprocess until EOF. // Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux. // Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
@ -382,6 +382,159 @@ func TestE2EFullIntegration(t *testing.T) {
) )
}) })
t.Run("with Supervisor OIDC upstream IDP and CLI password flow without web browser", func(t *testing.T) {
expectedUsername := env.SupervisorUpstreamOIDC.Username
expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups
// Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
testlib.CreateTestClusterRoleBinding(t,
rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername},
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
)
testlib.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
Verb: "get",
Group: "",
Version: "v1",
Resource: "namespaces",
})
// Create upstream OIDC provider and wait for it to become ready.
testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
},
AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{
AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes,
AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider
},
Claims: idpv1alpha1.OIDCClaims{
Username: env.SupervisorUpstreamOIDC.UsernameClaim,
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/oidc-test-sessions-password-grant.yaml"
kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
"get", "kubeconfig",
"--concierge-api-group-suffix", env.APIGroupSuffix,
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-skip-browser",
"--oidc-skip-listen",
"--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
})
// Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin.
start := time.Now()
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
ptyFile, err := pty.Start(kubectlCmd)
require.NoError(t, err)
// Wait for the subprocess to print the username prompt, then type the user's username.
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
_, err = ptyFile.WriteString(expectedUsername + "\n")
require.NoError(t, err)
// Wait for the subprocess to print the password prompt, then type the user's password.
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamOIDC.Password + "\n")
require.NoError(t, err)
// Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env,
downstream,
kubeconfigPath,
sessionCachePath,
pinnipedExe,
expectedUsername,
expectedGroups,
)
})
t.Run("with Supervisor OIDC upstream IDP and CLI password flow when OIDCIdentityProvider disallows it", func(t *testing.T) {
// Create upstream OIDC provider and wait for it to become ready.
oidcIdentityProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
},
AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{
AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes,
AllowPasswordGrant: false, // disallow the CLI password flow for this OIDCIdentityProvider!
},
Claims: idpv1alpha1.OIDCClaims{
Username: env.SupervisorUpstreamOIDC.UsernameClaim,
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/oidc-test-sessions-password-grant-negative-test.yaml"
kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
"get", "kubeconfig",
"--concierge-api-group-suffix", env.APIGroupSuffix,
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-skip-browser",
"--oidc-skip-listen",
// Create a kubeconfig configured to use the cli_password flow. By specifying all
// available --upstream-identity-provider-* options the CLI should skip IDP discovery
// and use the provided values without validating them. "cli_password" will not show
// up in the list of available flows for this IDP in the discovery response.
"--upstream-identity-provider-name", oidcIdentityProvider.Name,
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "cli_password",
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
})
// Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin.
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
ptyFile, err := pty.Start(kubectlCmd)
require.NoError(t, err)
// Wait for the subprocess to print the username prompt, then type the user's username.
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamOIDC.Username + "\n")
require.NoError(t, err)
// Wait for the subprocess to print the password prompt, then type the user's password.
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamOIDC.Password + "\n")
require.NoError(t, err)
// Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
kubectlOutput := string(kubectlOutputBytes)
// The output should look like an authentication failure, because the OIDCIdentityProvider disallows password grants.
t.Log("kubectl command output (expecting a login failed error):\n", kubectlOutput)
require.Contains(t, kubectlOutput,
`Error: could not complete Pinniped login: login failed with code "access_denied": `+
`The resource owner or authorization server denied the request. `+
`resource owner password grant is not allowed for this upstream provider according to its configuration`,
)
})
// Add an LDAP upstream IDP and try using it to authenticate during kubectl commands // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands
// by interacting with the CLI's username and password prompts. // by interacting with the CLI's username and password prompts.
t.Run("with Supervisor LDAP upstream IDP using username and password prompts", func(t *testing.T) { t.Run("with Supervisor LDAP upstream IDP using username and password prompts", func(t *testing.T) {
@ -422,7 +575,7 @@ func TestE2EFullIntegration(t *testing.T) {
_, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n") _, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n")
require.NoError(t, err) require.NoError(t, err)
// Read all of the output from the subprocess until EOF. // Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux. // Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
@ -487,7 +640,7 @@ func TestE2EFullIntegration(t *testing.T) {
ptyFile, err := pty.Start(kubectlCmd) ptyFile, err := pty.Start(kubectlCmd)
require.NoError(t, err) require.NoError(t, err)
// Read all of the output from the subprocess until EOF. // Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux. // Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))

View File

@ -65,7 +65,7 @@ func TestSupervisorLogin(t *testing.T) {
}, },
}, idpv1alpha1.PhaseReady) }, idpv1alpha1.PhaseReady)
}, },
requestAuthorization: requestAuthorizationUsingOIDCIdentityProvider, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
// the ID token Subject should include the upstream user ID after the upstream issuer name // the ID token Subject should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
// the ID token Username should include the upstream user ID after the upstream issuer name // the ID token Username should include the upstream user ID after the upstream issuer name
@ -95,11 +95,44 @@ func TestSupervisorLogin(t *testing.T) {
}, },
}, idpv1alpha1.PhaseReady) }, idpv1alpha1.PhaseReady)
}, },
requestAuthorization: requestAuthorizationUsingOIDCIdentityProvider, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username), wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username),
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
}, },
{
name: "oidc with CLI password flow",
maybeSkip: func(t *testing.T) {
// never need to skip this test
},
createIDP: func(t *testing.T) {
t.Helper()
testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{
AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider
},
}, idpv1alpha1.PhaseReady)
},
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL,
env.SupervisorUpstreamOIDC.Username, // username to present to server during login
env.SupervisorUpstreamOIDC.Password, // password to present to server during login
httpClient,
)
},
// the ID token Subject should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
// the ID token Username should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
},
{ {
name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS", name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS",
maybeSkip: func(t *testing.T) { maybeSkip: func(t *testing.T) {
@ -148,7 +181,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) {
requestAuthorizationUsingLDAPIdentityProvider(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
@ -213,7 +246,7 @@ func TestSupervisorLogin(t *testing.T) {
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
}, },
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) {
requestAuthorizationUsingLDAPIdentityProvider(t, requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL, downstreamAuthorizeURL,
env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
@ -495,7 +528,7 @@ func verifyTokenResponse(
require.NotEmpty(t, tokenResponse.RefreshToken) require.NotEmpty(t, tokenResponse.RefreshToken)
} }
func requestAuthorizationUsingOIDCIdentityProvider(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) { func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) {
t.Helper() t.Helper()
env := testlib.IntegrationEnv(t) env := testlib.IntegrationEnv(t)
@ -524,7 +557,7 @@ func requestAuthorizationUsingOIDCIdentityProvider(t *testing.T, downstreamAutho
browsertest.WaitForURL(t, page, callbackURLPattern) browsertest.WaitForURL(t, page, callbackURLPattern)
} }
func requestAuthorizationUsingLDAPIdentityProvider(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client) { func requestAuthorizationUsingCLIPasswordFlow(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client) {
t.Helper() t.Helper()
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)