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

@@ -68,6 +68,7 @@ type getKubeconfigOIDCParams struct {
requestAudience string
upstreamIDPName string
upstreamIDPType string
upstreamIDPFlow string
}
type getKubeconfigConciergeParams struct {
@@ -110,8 +111,9 @@ type supervisorIDPsDiscoveryResponseV1Alpha1 struct {
}
type pinnipedIDPResponse struct {
Name string `json:"name"`
Type string `json:"type"`
Name string `json:"name"`
Type string `json:"type"`
Flows []string `json:"flows,omitempty"`
}
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.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.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.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)")
@@ -243,8 +246,10 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f
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 len(flags.oidc.issuer) > 0 && (flags.oidc.upstreamIDPType == "" || flags.oidc.upstreamIDPName == "") {
// If there is an issuer, and if any upstream IDP flags are not already set, then try to discover Supervisor upstream IDP details.
// 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 {
return err
}
@@ -346,6 +351,9 @@ func newExecConfig(deps kubeconfigDeps, flags getKubeconfigParams) (*clientcmdap
if 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
}
@@ -758,21 +766,31 @@ func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigPara
return nil
}
upstreamIDPs, err := discoverAllAvailableSupervisorUpstreamIDPs(ctx, pinnipedIDPsEndpoint, httpClient)
discoveredUpstreamIDPs, err := discoverAllAvailableSupervisorUpstreamIDPs(ctx, pinnipedIDPsEndpoint, httpClient)
if err != nil {
return err
}
if len(upstreamIDPs) == 1 {
flags.oidc.upstreamIDPName = upstreamIDPs[0].Name
flags.oidc.upstreamIDPType = upstreamIDPs[0].Type
} else if len(upstreamIDPs) > 1 {
idpName, idpType, err := selectUpstreamIDP(upstreamIDPs, flags.oidc.upstreamIDPName, flags.oidc.upstreamIDPType)
if err != nil {
return err
}
flags.oidc.upstreamIDPName = idpName
flags.oidc.upstreamIDPType = idpType
if len(discoveredUpstreamIDPs) == 0 {
// Discovered that the Supervisor does not have any upstream IDPs defined. Continue without putting one into the
// kubeconfig. This kubeconfig will only work if the user defines one (and only one) OIDC IDP in the Supervisor
// 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 {
return err
}
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
}
@@ -840,53 +858,104 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP
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)
var discoveredFlows []string
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 := ""
for _, idp := range pinnipedIDPs {
if idp.Type == idpType {
if idp.Type == specifiedIDPType {
if discoveredName != "" {
return "", "", fmt.Errorf(
"multiple Supervisor upstream identity providers of type \"%s\" were found,"+
" so the --upstream-identity-provider-name flag must be specified. "+
return "", "", nil, fmt.Errorf(
"multiple Supervisor upstream identity providers of type %q were found, "+
"so the --upstream-identity-provider-name flag must be specified. "+
"Found these upstreams: %s",
idpType, pinnipedIDPsString)
specifiedIDPType, pinnipedIDPsString)
}
discoveredName = idp.Name
discoveredFlows = idp.Flows
}
}
if discoveredName == "" {
return "", "", fmt.Errorf(
"no Supervisor upstream identity providers of type \"%s\" were found."+
" Found these upstreams: %s", idpType, pinnipedIDPsString)
return "", "", nil, fmt.Errorf(
"no Supervisor upstream identity providers of type %q were found. "+
"Found these upstreams: %s", specifiedIDPType, pinnipedIDPsString)
}
return discoveredName, idpType, nil
case idpName != "":
return discoveredName, specifiedIDPType, discoveredFlows, nil
case specifiedIDPName != "":
// The user specified only a name, so check if there is only one of that name found.
discoveredType := ""
for _, idp := range pinnipedIDPs {
if idp.Name == idpName {
if idp.Name == specifiedIDPName {
if discoveredType != "" {
return "", "", fmt.Errorf(
"multiple Supervisor upstream identity providers with name \"%s\" were found,"+
" so the --upstream-identity-provider-type flag must be specified. Found these upstreams: %s",
idpName, pinnipedIDPsString)
return "", "", nil, fmt.Errorf(
"multiple Supervisor upstream identity providers with name %q were found, "+
"so the --upstream-identity-provider-type flag must be specified. Found these upstreams: %s",
specifiedIDPName, pinnipedIDPsString)
}
discoveredType = idp.Type
discoveredFlows = idp.Flows
}
}
if discoveredType == "" {
return "", "", fmt.Errorf(
"no Supervisor upstream identity providers with name \"%s\" were found."+
" Found these upstreams: %s", idpName, pinnipedIDPsString)
return "", "", nil, fmt.Errorf(
"no Supervisor upstream identity providers with name %q were found. "+
"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:
return "", "", fmt.Errorf(
"multiple Supervisor upstream identity providers were found,"+
" so the --upstream-identity-provider-name/--upstream-identity-provider-type flags must be specified."+
" Found these upstreams: %s",
// The user did not specify any name or type, and there is more than one found.
return "", "", nil, fmt.Errorf(
"multiple Supervisor upstream identity providers were found, "+
"so the --upstream-identity-provider-name/--upstream-identity-provider-type flags must be specified. "+
"Found these upstreams: %s",
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-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)
--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-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 {
return []string{
"--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"
},
},
{
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",
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 {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
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 {
f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle)
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"
},
},
{
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",
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 {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
@@ -1958,7 +2085,7 @@ func TestGetKubeconfig(t *testing.T) {
}
}`, 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 {
return []string{
`"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 {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--upstream-identity-provider-name=some-oidc-idp",
"--upstream-identity-provider-type=oidc",
"--upstream-identity-provider-flow=foobar",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
@@ -2030,7 +2158,7 @@ func TestGetKubeconfig(t *testing.T) {
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 {
return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
@@ -2080,6 +2208,7 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience
- --upstream-identity-provider-name=some-oidc-idp
- --upstream-identity-provider-type=oidc
- --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped'
env: []
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 {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
"--upstream-identity-provider-name=some-oidc-idp",
"--upstream-identity-provider-type=oidc",
"--upstream-identity-provider-flow=foobar",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
@@ -2159,6 +2289,7 @@ func TestGetKubeconfig(t *testing.T) {
- --request-audience=test-audience
- --upstream-identity-provider-name=some-oidc-idp
- --upstream-identity-provider-type=oidc
- --upstream-identity-provider-flow=foobar
command: '.../path/to/pinniped'
env: []
provideClusterInfo: true
@@ -2341,6 +2472,244 @@ func TestGetKubeconfig(t *testing.T) {
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 {
tt := tt

View File

@@ -14,6 +14,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
@@ -37,6 +38,13 @@ func init() {
loginCmd.AddCommand(oidcLoginCommand(oidcLoginCommandRealDeps()))
}
const (
idpTypeOIDC = "oidc"
idpTypeLDAP = "ldap"
idpFlowCLIPassword = "cli_password"
idpFlowBrowserAuthcode = "browser_authcode"
)
type oidcLoginCommandDeps struct {
lookupEnv func(string) (string, bool)
login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error)
@@ -74,6 +82,7 @@ type oidcLoginFlags struct {
credentialCachePath string
upstreamIdentityProviderName string
upstreamIdentityProviderType string
upstreamIdentityProviderFlow string
}
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.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.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.
mustMarkHidden(cmd, "skip-listen")
@@ -160,17 +170,11 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
flags.upstreamIdentityProviderName, flags.upstreamIdentityProviderType))
}
switch flags.upstreamIdentityProviderType {
case "oidc":
// this is the default, so don't need to do anything
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)
flowOpts, err := flowOptions(flags.upstreamIdentityProviderType, flags.upstreamIdentityProviderFlow)
if err != nil {
return err
}
opts = append(opts, flowOpts...)
var concierge *conciergeclient.Client
if flags.conciergeEnabled {
@@ -251,6 +255,38 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
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) {
pool := x509.NewCertPool()
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])
--session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml")
--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-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{
"--issuer", "test-issuer",
"--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",
},
{
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{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
@@ -173,6 +212,32 @@ func TestLoginOIDCCommand(t *testing.T) {
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 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",
args: []string{