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:
parent
4605846499
commit
cac45fd999
@ -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) {
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user