LDAP logins read from PINNIPED_USERNAME and PINNIPED_PASSWORD env vars

For CLI-based auth, such as with LDAP upstream identity providers, the
user may use these environment variables to avoid getting interactively
prompted for username and password.
This commit is contained in:
Ryan Richard 2021-07-19 16:20:59 -07:00
parent 4605846499
commit cac45fd999
2 changed files with 152 additions and 8 deletions

View File

@ -60,6 +60,11 @@ const (
defaultLDAPUsernamePrompt = "Username: "
defaultLDAPPasswordPrompt = "Password: "
// For CLI-based auth, such as with LDAP upstream identity providers, the user may use these environment variables
// to avoid getting interactively prompted for username and password.
defaultUsernameEnvVarName = "PINNIPED_USERNAME"
defaultPasswordEnvVarName = "PINNIPED_PASSWORD"
httpLocationHeaderName = "Location"
debugLogLevel = 4
@ -99,6 +104,7 @@ type handlerState struct {
generatePKCE func() (pkce.Code, error)
generateNonce func() (nonce.Nonce, error)
openURL func(string) error
getEnv func(key string) string
listen func(string, string) (net.Listener, error)
isTTY func(int) bool
getProvider func(*oauth2.Config, *oidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI
@ -275,6 +281,7 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er
generateNonce: nonce.Generate,
generatePKCE: pkce.Generate,
openURL: browser.OpenURL,
getEnv: os.Getenv,
listen: net.Listen,
isTTY: term.IsTerminal,
getProvider: upstreamoidc.New,
@ -402,14 +409,10 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
// Make a direct call to the authorize endpoint, including the user's username and password on custom http headers,
// 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) {
// Ask the user for their username and password.
username, err := h.promptForValue(h.ctx, defaultLDAPUsernamePrompt)
// Ask the user for their username and password, or get them from env vars.
username, password, err := h.getUsernameAndPassword()
if err != nil {
return nil, fmt.Errorf("error prompting for username: %w", err)
}
password, err := h.promptForSecret(h.ctx, defaultLDAPPasswordPrompt)
if err != nil {
return nil, fmt.Errorf("error prompting for password: %w", err)
return nil, err
}
// Make a callback URL even though we won't be listening on this port, because providing a redirect URL is
@ -499,6 +502,33 @@ func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (
return token, nil
}
// Prompt for the user's username and password, or read them from env vars if they are available.
func (h *handlerState) getUsernameAndPassword() (string, string, error) {
var err error
username := h.getEnv(defaultUsernameEnvVarName)
if username == "" {
username, err = h.promptForValue(h.ctx, defaultLDAPUsernamePrompt)
if err != nil {
return "", "", fmt.Errorf("error prompting for username: %w", err)
}
} else {
h.logger.V(debugLogLevel).Info("Pinniped: Read username from environment variable", "name", defaultUsernameEnvVarName)
}
password := h.getEnv(defaultPasswordEnvVarName)
if password == "" {
password, err = h.promptForSecret(h.ctx, defaultLDAPPasswordPrompt)
if err != nil {
return "", "", fmt.Errorf("error prompting for password: %w", err)
}
} else {
h.logger.V(debugLogLevel).Info("Pinniped: Read password from environment variable", "name", defaultPasswordEnvVarName)
}
return username, password, 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) {

View File

@ -993,7 +993,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
wantErr: "error during authorization code exchange: some authcode exchange or token validation error",
},
{
name: "successful ldap login",
name: "successful ldap login with prompts for username and password",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
@ -1011,6 +1011,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
h.generateState = func() (state.State, error) { return "test-state", nil }
h.generatePKCE = func() (pkce.Code, error) { return "test-pkce", nil }
h.generateNonce = func() (nonce.Nonce, error) { return "test-nonce", nil }
h.getEnv = func(_ string) string {
return "" // asking for any env var returns empty as if it were unset
}
h.promptForValue = func(_ context.Context, promptLabel string) (string, error) {
require.Equal(t, "Username: ", promptLabel)
return "some-upstream-username", nil
@ -1089,6 +1092,117 @@ func TestLogin(t *testing.T) { // nolint:gocyclo
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
wantToken: &testToken,
},
{
name: "successful ldap login with env vars for username and password",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
fakeAuthCode := "test-authcode-value"
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
mock := mockUpstream(t)
mock.EXPECT().
ExchangeAuthcodeAndValidateTokens(
gomock.Any(), fakeAuthCode, pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), "http://127.0.0.1:0/callback").
Return(&testToken, nil)
return mock
}
h.generateState = func() (state.State, error) { return "test-state", nil }
h.generatePKCE = func() (pkce.Code, error) { return "test-pkce", nil }
h.generateNonce = func() (nonce.Nonce, error) { return "test-nonce", nil }
h.getEnv = func(key string) string {
switch key {
case "PINNIPED_USERNAME":
return "some-upstream-username"
case "PINNIPED_PASSWORD":
return "some-upstream-password"
default:
return "" // all other env vars are treated as if they are unset
}
}
h.promptForValue = func(_ context.Context, promptLabel string) (string, error) {
require.FailNow(t, fmt.Sprintf("saw unexpected prompt from the CLI: %q", promptLabel))
return "", nil
}
h.promptForSecret = func(_ context.Context, promptLabel string) (string, error) {
require.FailNow(t, fmt.Sprintf("saw unexpected prompt from the CLI: %q", promptLabel))
return "", nil
}
cache := &mockSessionCache{t: t, getReturnsToken: nil}
cacheKey := SessionCacheKey{
Issuer: successServer.URL,
ClientID: "test-client-id",
Scopes: []string{"test-scope"},
RedirectURI: "http://localhost:0/callback",
}
t.Cleanup(func() {
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys)
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys)
require.Equal(t, []*oidctypes.Token{&testToken}, cache.sawPutTokens)
})
require.NoError(t, WithSessionCache(cache)(h))
require.NoError(t, WithCLISendingCredentials()(h))
require.NoError(t, WithUpstreamIdentityProvider("some-upstream-name", "ldap")(h))
discoveryRequestWasMade := false
authorizeRequestWasMade := false
t.Cleanup(func() {
require.True(t, discoveryRequestWasMade, "should have made an discovery request")
require.True(t, authorizeRequestWasMade, "should have made an authorize request")
})
require.NoError(t, WithClient(&http.Client{
Transport: roundtripper.Func(func(req *http.Request) (*http.Response, error) {
switch req.URL.Scheme + "://" + req.URL.Host + req.URL.Path {
case "http://" + successServer.Listener.Addr().String() + "/.well-known/openid-configuration":
discoveryRequestWasMade = true
return defaultDiscoveryResponse(req)
case "http://" + successServer.Listener.Addr().String() + "/authorize":
authorizeRequestWasMade = true
require.Equal(t, "some-upstream-username", req.Header.Get("Pinniped-Username"))
require.Equal(t, "some-upstream-password", req.Header.Get("Pinniped-Password"))
require.Equal(t, url.Values{
// This is the PKCE challenge which is calculated as base64(sha256("test-pkce")). For example:
// $ echo -n test-pkce | shasum -a 256 | cut -d" " -f1 | xxd -r -p | base64 | cut -d"=" -f1
// VVaezYqum7reIhoavCHD1n2d+piN3r/mywoYj7fCR7g
"code_challenge": []string{"VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g"},
"code_challenge_method": []string{"S256"},
"response_type": []string{"code"},
"scope": []string{"test-scope"},
"nonce": []string{"test-nonce"},
"state": []string{"test-state"},
"access_type": []string{"offline"},
"client_id": []string{"test-client-id"},
"redirect_uri": []string{"http://127.0.0.1:0/callback"},
"pinniped_idp_name": []string{"some-upstream-name"},
"pinniped_idp_type": []string{"ldap"},
}, req.URL.Query())
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{
fmt.Sprintf("http://127.0.0.1:0/callback?code=%s&state=test-state", fakeAuthCode),
}},
}, nil
default:
// Note that "/token" requests should not be made. They are mocked by mocking calls to ExchangeAuthcodeAndValidateTokens().
require.FailNow(t, fmt.Sprintf("saw unexpected http call from the CLI: %s", req.URL.String()))
return nil, nil
}
}),
})(h))
return nil
}
},
issuer: successServer.URL,
wantLogs: []string{
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"",
"\"level\"=4 \"msg\"=\"Pinniped: Read username from environment variable\" \"name\"=\"PINNIPED_USERNAME\"",
"\"level\"=4 \"msg\"=\"Pinniped: Read password from environment variable\" \"name\"=\"PINNIPED_PASSWORD\"",
},
wantToken: &testToken,
},
{
name: "with requested audience, session cache hit with valid token, but discovery fails",
clientID: "test-client-id",