Add new command-line flags to the login oidc command

- Also some light prefactoring in login.go to make room for LDAP-style
  login, which is not implemented yet in this commit. TODOs are added.
- And fix a test pollution problem in login_oidc_test.go where it was
  using a real on-disk CLI cache file, so the tests were polluted by
  the contents of that file and would sometimes cause each other to
  fail.
This commit is contained in:
Ryan Richard 2021-04-16 18:30:31 -07:00
parent e9d5743845
commit 4c2a0b4872
3 changed files with 188 additions and 45 deletions

View File

@ -67,6 +67,8 @@ type oidcLoginFlags struct {
conciergeCABundle string conciergeCABundle string
conciergeAPIGroupSuffix string conciergeAPIGroupSuffix string
credentialCachePath string credentialCachePath string
upstreamIdentityProviderName string
upstreamIdentityProviderType string
} }
func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
@ -98,6 +100,8 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the Concierge") cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the Concierge")
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.upstreamIdentityProviderType, "upstream-identity-provider-type", "oidc", "The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')")
mustMarkHidden(cmd, "debug-session-cache") mustMarkHidden(cmd, "debug-session-cache")
mustMarkRequired(cmd, "issuer") mustMarkRequired(cmd, "issuer")
@ -137,6 +141,23 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
opts = append(opts, oidcclient.WithRequestAudience(flags.requestAudience)) opts = append(opts, oidcclient.WithRequestAudience(flags.requestAudience))
} }
if flags.upstreamIdentityProviderName != "" {
opts = append(opts, oidcclient.WithUpstreamIdentityProvider(
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.WithLDAPUpstreamIdentityProvider())
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)
}
var concierge *conciergeclient.Client var concierge *conciergeclient.Client
if flags.conciergeEnabled { if flags.conciergeEnabled {
var err error var err error

View File

@ -73,6 +73,8 @@ 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-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")
`), `),
}, },
{ {
@ -134,11 +136,45 @@ func TestLoginOIDCCommand(t *testing.T) {
Error: invalid Concierge parameters: invalid API group suffix: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*') Error: invalid Concierge parameters: invalid API group suffix: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')
`), `),
}, },
{
name: "invalid upstream type",
args: []string{
"--issuer", "test-issuer",
"--upstream-identity-provider-type", "invalid",
},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-type value not recognized: invalid (supported values: oidc, ldap)
`),
},
{
name: "oidc upstream type is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 3,
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",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--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: "login error", name: "login error",
args: []string{ args: []string{
"--client-id", "test-client-id", "--client-id", "test-client-id",
"--issuer", "test-issuer", "--issuer", "test-issuer",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
}, },
loginErr: fmt.Errorf("some login error"), loginErr: fmt.Errorf("some login error"),
wantOptionsCount: 3, wantOptionsCount: 3,
@ -156,6 +192,7 @@ func TestLoginOIDCCommand(t *testing.T) {
"--concierge-authenticator-type", "jwt", "--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", "test-authenticator", "--concierge-authenticator-name", "test-authenticator",
"--concierge-endpoint", "https://127.0.0.1:1234/", "--concierge-endpoint", "https://127.0.0.1:1234/",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
}, },
conciergeErr: fmt.Errorf("some concierge error"), conciergeErr: fmt.Errorf("some concierge error"),
wantOptionsCount: 3, wantOptionsCount: 3,
@ -169,6 +206,7 @@ func TestLoginOIDCCommand(t *testing.T) {
args: []string{ args: []string{
"--client-id", "test-client-id", "--client-id", "test-client-id",
"--issuer", "test-issuer", "--issuer", "test-issuer",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
}, },
wantOptionsCount: 3, wantOptionsCount: 3,
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",
@ -190,9 +228,11 @@ func TestLoginOIDCCommand(t *testing.T) {
"--concierge-endpoint", "https://127.0.0.1:1234/", "--concierge-endpoint", "https://127.0.0.1:1234/",
"--concierge-ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()), "--concierge-ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()),
"--concierge-api-group-suffix", "some.suffix.com", "--concierge-api-group-suffix", "some.suffix.com",
"--credential-cache", testutil.TempDir(t) + "/credentials.yaml", "--credential-cache", testutil.TempDir(t) + "/credentials.yaml", // must specify --credential-cache or else the cache file on disk causes test pollution
"--upstream-identity-provider-name", "some-upstream-name",
"--upstream-identity-provider-type", "ldap",
}, },
wantOptionsCount: 7, wantOptionsCount: 9,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n", wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n",
}, },
} }

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 oidcclient implements a CLI OIDC login flow. // Package oidcclient implements a CLI OIDC login flow.
@ -54,6 +54,10 @@ type handlerState struct {
scopes []string scopes []string
cache SessionCache cache SessionCache
upstreamIdentityProviderName string
upstreamIdentityProviderType string
ldapUpstreamIdentityProvider bool
requestedAudience string requestedAudience string
httpClient *http.Client httpClient *http.Client
@ -167,6 +171,30 @@ func WithRequestAudience(audience string) Option {
} }
} }
// WithLDAPUpstreamIdentityProvider causes the login flow to use CLI 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
// HTTP headers. This is only intended to be used when the issuer is a Pinniped Supervisor and the upstream identity
// provider is an LDAP provider. It should never be used with non-Supervisor issuers because it will send the user's
// password as a custom header, which would be ignored but could potentially get logged somewhere by the issuer.
func WithLDAPUpstreamIdentityProvider() Option {
return func(h *handlerState) error {
h.ldapUpstreamIdentityProvider = true
return nil
}
}
// WithUpstreamIdentityProvider causes the specified name and type to be sent as custom query parameters to the
// issuer's authorize endpoint. This is only intended to be used when the issuer is a Pinniped Supervisor, in which
// case it provides a mechanism to choose among several upstream identity providers.
// Other issuers will ignore these custom query parameters.
func WithUpstreamIdentityProvider(upstreamName, upstreamType string) Option {
return func(h *handlerState) error {
h.upstreamIdentityProviderName = upstreamName
h.upstreamIdentityProviderType = upstreamType
return nil
}
}
// nopCache is a SessionCache that doesn't actually do anything. // nopCache is a SessionCache that doesn't actually do anything.
type nopCache struct{} type nopCache struct{}
@ -281,6 +309,63 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
} }
} }
// Prepare the common options for the authorization URL. We don't have the redirect URL yet though.
authorizeOptions := []oauth2.AuthCodeOption{
oauth2.AccessTypeOffline,
h.nonce.Param(),
h.pkce.Challenge(),
h.pkce.Method(),
}
if h.upstreamIdentityProviderName != "" {
authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam("upstream_name", h.upstreamIdentityProviderName))
authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam("upstream_type", h.upstreamIdentityProviderType))
}
// Choose the appropriate authorization and authcode exchange strategy.
var authFunc = h.webBrowserBasedAuth
if h.ldapUpstreamIdentityProvider {
authFunc = h.cliBasedAuth
}
// Perform the authorize request and authcode exchange to get back OIDC tokens.
token, err := authFunc(&authorizeOptions)
// If we got tokens, put them in the cache.
if err == nil {
h.cache.PutToken(cacheKey, token)
}
return token, err
}
// Make a direct call to the authorize endpoint and parse the authcode from the response.
// Exchange the authcode for tokens. Return the tokens or an error.
func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (*oidctypes.Token, error) {
// Make a callback URL even though we won't be listening on this port, because providing a redirect URL is
// required for OIDC authorize endpoints, and it must match the allowed redirect URL of the OIDC client
// registered on the server.
h.oauth2Config.RedirectURL = (&url.URL{
Scheme: "http",
Host: h.listenAddr,
Path: h.callbackPath,
}).String()
// Now that we have a redirect URL, we can build the authorize URL.
_ = h.oauth2Config.AuthCodeURL(h.state.String(), *authorizeOptions...)
// TODO prompt for username and password
// TODO request the authorizeURL directly using h.httpClient, with the custom username and password headers
// TODO error if the response is not a 302
// TODO error if the response Location does not include a code param (in this case it could have an error message query param to show)
// TODO check the response Location state param to see if it matches, similar to how it is done in handleAuthCodeCallback()
// TODO exchange the authcode, similar to how it is done in handleAuthCodeCallback()
// TODO return the token or any error encountered along the way
return nil, nil
}
// Open a web browser, or ask the user to open a web browser, to visit the authorize endpoint.
// Create a localhost callback listener which exchanges the authcode for tokens. Return the tokens or an error.
func (h *handlerState) webBrowserBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (*oidctypes.Token, error) {
// Open a TCP listener and update the OAuth2 redirect_uri to match (in case we are using an ephemeral port number). // Open a TCP listener and update the OAuth2 redirect_uri to match (in case we are using an ephemeral port number).
listener, err := net.Listen("tcp", h.listenAddr) listener, err := net.Listen("tcp", h.listenAddr)
if err != nil { if err != nil {
@ -292,18 +377,14 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
Path: h.callbackPath, Path: h.callbackPath,
}).String() }).String()
// Now that we have a redirect URL with the listener port, we can build the authorize URL.
authorizeURL := h.oauth2Config.AuthCodeURL(h.state.String(), *authorizeOptions...)
// Start a callback server in a background goroutine. // Start a callback server in a background goroutine.
shutdown := h.serve(listener) shutdown := h.serve(listener)
defer shutdown() defer shutdown()
// Open the authorize URL in the users browser. // Open the authorize URL in the users browser.
authorizeURL := h.oauth2Config.AuthCodeURL(
h.state.String(),
oauth2.AccessTypeOffline,
h.nonce.Param(),
h.pkce.Challenge(),
h.pkce.Method(),
)
if err := h.openURL(authorizeURL); err != nil { if err := h.openURL(authorizeURL); err != nil {
return nil, fmt.Errorf("could not open browser: %w", err) return nil, fmt.Errorf("could not open browser: %w", err)
} }
@ -316,7 +397,6 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
if callback.err != nil { if callback.err != nil {
return nil, fmt.Errorf("error handling callback: %w", callback.err) return nil, fmt.Errorf("error handling callback: %w", callback.err)
} }
h.cache.PutToken(cacheKey, callback.token)
return callback.token, nil return callback.token, nil
} }
} }
@ -447,6 +527,8 @@ func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Req
// Check for error response parameters. // Check for error response parameters.
if errorParam := params.Get("error"); errorParam != "" { if errorParam := params.Get("error"); errorParam != "" {
// TODO This should also show the value of the optional "error_description" param if it exists.
// See https://openid.net/specs/openid-connect-core-1_0.html#AuthError
return httperr.Newf(http.StatusBadRequest, "login failed with code %q", errorParam) return httperr.Newf(http.StatusBadRequest, "login failed with code %q", errorParam)
} }