Add Supervisor upstream LDAP login to the Pinniped CLI

- Also enhance prepare-supervisor-on-kind.sh to allow setup of
  a working LDAP upstream IDP.
This commit is contained in:
Ryan Richard 2021-04-19 17:59:46 -07:00
parent c79930f419
commit c176d15aa7
5 changed files with 634 additions and 30 deletions

1
go.mod
View File

@ -31,6 +31,7 @@ require (
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d
gopkg.in/square/go-jose.v2 v2.5.1
k8s.io/api v0.21.0
k8s.io/apimachinery v0.21.0

View File

@ -20,6 +20,34 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
use_oidc_upstream=no
use_ldap_upstream=no
while (("$#")); do
case "$1" in
--ldap)
use_ldap_upstream=yes
shift
;;
--oidc)
use_oidc_upstream=yes
shift
;;
-*)
log_error "Unsupported flag $1" >&2
exit 1
;;
*)
log_error "Unsupported positional arg $1" >&2
exit 1
;;
esac
done
if [[ "$use_oidc_upstream" == "no" && "$use_ldap_upstream" == "no" ]]; then
echo "Error: Please use --oidc or --ldap to specify which type of upstream identity provider(s) you would like"
exit 1
fi
# Read the env vars output by hack/prepare-for-integration-tests.sh
source /tmp/integration-test-env
@ -73,8 +101,9 @@ sleep 5
echo "Fetching FederationDomain discovery info..."
https_proxy="$PINNIPED_TEST_PROXY" curl -fLsS --cacert "$root_ca_crt_path" "$issuer/.well-known/openid-configuration" | jq .
# Make an OIDCIdentityProvider which uses Dex to provide identity.
cat <<EOF | kubectl apply --namespace "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" -f -
if [[ "$use_oidc_upstream" == "yes" ]]; then
# Make an OIDCIdentityProvider which uses Dex to provide identity.
cat <<EOF | kubectl apply --namespace "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" -f -
apiVersion: idp.supervisor.pinniped.dev/v1alpha1
kind: OIDCIdentityProvider
metadata:
@ -92,8 +121,8 @@ spec:
secretName: my-oidc-provider-client-secret
EOF
# Make a Secret for the above OIDCIdentityProvider to describe the OIDC client configured in Dex.
cat <<EOF | kubectl apply --namespace "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" -f -
# Make a Secret for the above OIDCIdentityProvider to describe the OIDC client configured in Dex.
cat <<EOF | kubectl apply --namespace "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" -f -
apiVersion: v1
kind: Secret
metadata:
@ -104,10 +133,51 @@ stringData:
type: "secrets.pinniped.dev/oidc-client"
EOF
# Grant the test user some RBAC permissions so we can play with kubectl as that user.
kubectl create clusterrolebinding test-user-can-view --clusterrole view \
# Grant the test user some RBAC permissions so we can play with kubectl as that user.
kubectl create clusterrolebinding oidc-test-user-can-view --clusterrole view \
--user "$PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME" \
--dry-run=client --output yaml | kubectl apply -f -
fi
if [[ "$use_ldap_upstream" == "yes" ]]; then
# Make an LDAPIdentityProvider which uses OpenLDAP to provide identity.
cat <<EOF | kubectl apply --namespace "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" -f -
apiVersion: idp.supervisor.pinniped.dev/v1alpha1
kind: LDAPIdentityProvider
metadata:
name: my-ldap-provider
spec:
host: "$PINNIPED_TEST_LDAP_HOST"
tls:
certificateAuthorityData: "$PINNIPED_TEST_LDAP_LDAPS_CA_BUNDLE"
bind:
secretName: my-ldap-service-account
userSearch:
base: "$PINNIPED_TEST_LDAP_USERS_SEARCH_BASE"
filter: "cn={}"
attributes:
uniqueID: "$PINNIPED_TEST_LDAP_USER_UNIQUE_ID_ATTRIBUTE_NAME"
username: "$PINNIPED_TEST_LDAP_USER_EMAIL_ATTRIBUTE_NAME"
dryRunAuthenticationUsername: "$PINNIPED_TEST_LDAP_USER_CN"
EOF
# Make a Secret for the above LDAPIdentityProvider to describe the bind account.
cat <<EOF | kubectl apply --namespace "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" -f -
apiVersion: v1
kind: Secret
metadata:
name: my-ldap-service-account
stringData:
username: "$PINNIPED_TEST_LDAP_BIND_ACCOUNT_USERNAME"
password: "$PINNIPED_TEST_LDAP_BIND_ACCOUNT_PASSWORD"
type: "kubernetes.io/basic-auth"
EOF
# Grant the test user some RBAC permissions so we can play with kubectl as that user.
kubectl create clusterrolebinding ldap-test-user-can-view --clusterrole view \
--user "$PINNIPED_TEST_LDAP_USER_EMAIL_ATTRIBUTE_VALUE" \
--dry-run=client --output yaml | kubectl apply -f -
fi
# Make a JWTAuthenticator which respects JWTs from the Supervisor's issuer.
# The issuer URL must be accessible from within the cluster for OIDC discovery.
@ -134,17 +204,30 @@ go build ./cmd/pinniped
./pinniped get kubeconfig --oidc-skip-browser >kubeconfig
# Clear the local CLI cache to ensure that the kubectl command below will need to perform a fresh login.
rm -f "$HOME"/.config/pinniped/sessions.yaml
rm -f "$HOME/.config/pinniped/sessions.yaml"
rm -f "$HOME/.config/pinniped/credentials.yaml"
echo
echo "Ready! 🚀"
echo "To be able to access the login URL shown below, start Chrome like this:"
echo " open -a \"Google Chrome\" --args --proxy-server=\"$PINNIPED_TEST_PROXY\""
echo "Then use these credentials at the Dex login page:"
echo " Username: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME"
echo " Password: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD"
# Perform a login using the kubectl plugin. This should print the URL to be followed for the Dex login page.
if [[ "$use_oidc_upstream" == "yes" ]]; then
echo
echo "To be able to access the login URL shown below, start Chrome like this:"
echo " open -a \"Google Chrome\" --args --proxy-server=\"$PINNIPED_TEST_PROXY\""
echo "Then use these credentials at the Dex login page:"
echo " Username: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME"
echo " Password: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD"
fi
if [[ "$use_ldap_upstream" == "yes" ]]; then
echo
echo "When prompted for username and password by the CLI, use these values:"
echo " Username: $PINNIPED_TEST_LDAP_USER_CN"
echo " Password: $PINNIPED_TEST_LDAP_USER_PASSWORD"
fi
# Perform a login using the kubectl plugin. This should print the URL to be followed for the Dex login page
# if using an OIDC upstream, or should prompt on the CLI for username/password if using an LDAP upstream.
echo
echo "Running: https_proxy=\"$PINNIPED_TEST_PROXY\" no_proxy=\"127.0.0.1\" kubectl --kubeconfig ./kubeconfig get pods -A"
https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" kubectl --kubeconfig ./kubeconfig get pods -A

View File

@ -89,6 +89,7 @@ func handleAuthRequestForLDAPUpstream(
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
}
@ -102,6 +103,7 @@ func handleAuthRequestForLDAPUpstream(
plog.Debug("failed upstream LDAP authentication", "upstreamName", ldapUpstream.GetName())
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
err = errors.WithStack(fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider."))
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}

View File

@ -5,13 +5,16 @@
package oidcclient
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"mime"
"net"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
@ -19,6 +22,7 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"github.com/pkg/browser"
"golang.org/x/oauth2"
"golang.org/x/term"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/internal/httputil/httperr"
@ -44,6 +48,16 @@ const (
// overallTimeout is the overall time that a login is allowed to take. This includes several user interactions, so
// we set this to be relatively long.
overallTimeout = 90 * time.Minute
supervisorAuthorizeUpstreamNameParam = "upstream_name"
supervisorAuthorizeUpstreamTypeParam = "upstream_type"
supervisorAuthorizeUpstreamUsernameHeader = "X-Pinniped-Upstream-Username"
supervisorAuthorizeUpstreamPasswordHeader = "X-Pinniped-Upstream-Password" // nolint:gosec // this is not a credential
defaultLDAPUsernamePrompt = "Username: "
defaultLDAPPasswordPrompt = "Password: "
httpLocationHeaderName = "Location"
)
type handlerState struct {
@ -80,6 +94,8 @@ type handlerState struct {
openURL func(string) error
getProvider func(*oauth2.Config, *oidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI
validateIDToken func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error)
promptForValue func(promptLabel string) (string, error)
promptForSecret func(promptLabel string) (string, error)
callbacks chan callbackResult
}
@ -103,7 +119,7 @@ func WithContext(ctx context.Context) Option {
// WithListenPort specifies a TCP listen port on localhost, which will be used for the redirect_uri and to handle the
// authorization code callback. By default, a random high port will be chosen which requires the authorization server
// to support wildcard port numbers as described by https://tools.ietf.org/html/rfc8252:
// to support wildcard port numbers as described by https://tools.ietf.org/html/rfc8252#section-7.3:
//
// The authorization server MUST allow any port to be specified at the
// time of the request for loopback IP redirect URIs, to accommodate
@ -223,6 +239,8 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er
validateIDToken: func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) {
return provider.Verifier(&oidc.Config{ClientID: audience}).Verify(ctx, token)
},
promptForValue: promptForValue,
promptForSecret: promptForSecret,
}
for _, opt := range opts {
if err := opt(&h); err != nil {
@ -317,8 +335,8 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
h.pkce.Method(),
}
if h.upstreamIdentityProviderName != "" {
authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam("upstream_name", h.upstreamIdentityProviderName))
authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam("upstream_type", h.upstreamIdentityProviderType))
authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam(supervisorAuthorizeUpstreamNameParam, h.upstreamIdentityProviderName))
authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam(supervisorAuthorizeUpstreamTypeParam, h.upstreamIdentityProviderType))
}
// Choose the appropriate authorization and authcode exchange strategy.
@ -341,26 +359,103 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
// 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) {
// Ask the user for their username and password.
username, err := h.promptForValue(defaultLDAPUsernamePrompt)
if err != nil {
return nil, fmt.Errorf("error prompting for username: %w", err)
}
password, err := h.promptForSecret(defaultLDAPPasswordPrompt)
if err != nil {
return nil, fmt.Errorf("error prompting for password: %w", err)
}
// 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.
// registered on the server. The Supervisor oauth client does not have "localhost" in the allowed redirect
// URI list, so use 127.0.0.1.
localhostAddr := strings.ReplaceAll(h.listenAddr, "localhost", "127.0.0.1")
h.oauth2Config.RedirectURL = (&url.URL{
Scheme: "http",
Host: h.listenAddr,
Host: localhostAddr,
Path: h.callbackPath,
}).String()
// Now that we have a redirect URL, we can build the authorize URL.
_ = h.oauth2Config.AuthCodeURL(h.state.String(), *authorizeOptions...)
authorizeURL := 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
// Don't follow redirects automatically because we want to handle redirects here.
h.httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
// Send an authorize request.
authCtx, authorizeCtxCancelFunc := context.WithTimeout(context.Background(), httpRequestTimeout)
defer authorizeCtxCancelFunc()
authReq, err := http.NewRequestWithContext(authCtx, http.MethodGet, authorizeURL, nil)
if err != nil {
return nil, fmt.Errorf("could not build authorize request: %w", err)
}
authReq.Header.Set(supervisorAuthorizeUpstreamUsernameHeader, username)
authReq.Header.Set(supervisorAuthorizeUpstreamPasswordHeader, password)
authRes, err := h.httpClient.Do(authReq)
if err != nil {
return nil, fmt.Errorf("authorization response error: %w", err)
}
err = authRes.Body.Close() // don't need the response body
if err != nil {
return nil, fmt.Errorf("could not close authorize response body: %w", err)
}
// A successful authorization always results in a 302.
if authRes.StatusCode != http.StatusFound {
return nil, fmt.Errorf(
"error getting authorization: expected to be redirected, but response status was %s", authRes.Status)
}
rawLocation := authRes.Header.Get(httpLocationHeaderName)
location, err := url.Parse(rawLocation)
if err != nil {
// This shouldn't be possible in practice because httpClient.Do() already parses the Location header.
return nil, fmt.Errorf("error getting authorization: could not parse redirect location: %w", err)
}
// Check that the redirect was to the expected location.
if location.Scheme != "http" || location.Host != localhostAddr || location.Path != h.callbackPath {
return nil, fmt.Errorf("error getting authorization: redirected to the wrong location: %s", rawLocation)
}
// Get the auth code or return the error from the server.
authCode := location.Query().Get("code")
if authCode == "" {
requiredErrorCode := location.Query().Get("error")
optionalErrorDescription := location.Query().Get("error_description")
if optionalErrorDescription == "" {
return nil, fmt.Errorf("login failed with code %q", requiredErrorCode)
}
return nil, fmt.Errorf("login failed with code %q: %s", requiredErrorCode, optionalErrorDescription)
}
// Validate OAuth2 state and fail if it's incorrect (to block CSRF).
if err := h.state.Validate(location.Query().Get("state")); err != nil {
return nil, fmt.Errorf("missing or invalid state parameter in authorization response: %s", rawLocation)
}
// Exchange the authorization code for access, ID, and refresh tokens and perform required
// validations on the returned ID token.
tokenCtx, tokenCtxCancelFunc := context.WithTimeout(context.Background(), httpRequestTimeout)
defer tokenCtxCancelFunc()
token, err := h.getProvider(h.oauth2Config, h.provider, h.httpClient).
ExchangeAuthcodeAndValidateTokens(
tokenCtx,
authCode,
h.pkce,
h.nonce,
h.oauth2Config.RedirectURL,
)
if err != nil {
return nil, fmt.Errorf("error during authorization code exchange: %w", err)
}
return token, nil
}
// Open a web browser, or ask the user to open a web browser, to visit the authorize endpoint.
@ -401,6 +496,41 @@ func (h *handlerState) webBrowserBasedAuth(authorizeOptions *[]oauth2.AuthCodeOp
}
}
func promptForValue(promptLabel string) (string, error) {
if !term.IsTerminal(0) {
return "", errors.New("stdin is not connected to a terminal")
}
_, err := fmt.Fprint(os.Stderr, promptLabel)
if err != nil {
return "", fmt.Errorf("could not print prompt to stderr: %w", err)
}
text, _ := bufio.NewReader(os.Stdin).ReadString('\n')
text = strings.ReplaceAll(text, "\n", "")
return text, nil
}
func promptForSecret(promptLabel string) (string, error) {
if !term.IsTerminal(0) {
return "", errors.New("stdin is not connected to a terminal")
}
_, err := fmt.Fprint(os.Stderr, promptLabel)
if err != nil {
return "", fmt.Errorf("could not print prompt to stderr: %w", err)
}
password, err := term.ReadPassword(0)
if err != nil {
return "", fmt.Errorf("could not read password: %w", err)
}
// term.ReadPassword swallows the newline that was typed by the user, so to
// avoid the next line of output from happening on same line as the password
// prompt, we need to print a newline.
_, err = fmt.Fprint(os.Stderr, "\n")
if err != nil {
return "", fmt.Errorf("could not print newline to stderr: %w", err)
}
return string(password), err
}
func (h *handlerState) initOIDCDiscovery() error {
// Make this method idempotent so it can be called in multiple cases with no extra network requests.
if h.provider != nil {

View File

@ -6,10 +6,13 @@ package oidcclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@ -21,6 +24,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/roundtripper"
"go.pinniped.dev/internal/mocks/mockupstreamoidcidentityprovider"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil"
@ -51,7 +55,7 @@ func (m *mockSessionCache) PutToken(key SessionCacheKey, token *oidctypes.Token)
m.sawPutTokens = append(m.sawPutTokens, token)
}
func TestLogin(t *testing.T) {
func TestLogin(t *testing.T) { // nolint:gocyclo
time1 := time.Date(2035, 10, 12, 13, 14, 15, 16, time.UTC)
time1Unix := int64(2075807775)
require.Equal(t, time1Unix, time1.Add(2*time.Minute).Unix())
@ -198,6 +202,51 @@ func TestLogin(t *testing.T) {
require.NoError(t, json.NewEncoder(w).Encode(&response))
})
defaultDiscoveryResponse := func(req *http.Request) (*http.Response, error) { // nolint:unparam
// Call the handler function from the test server to calculate the response.
handler, _ := providerMux.Handler(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
return recorder.Result(), nil
}
defaultLDAPTestOpts := func(t *testing.T, h *handlerState, authResponse *http.Response, authError error) error { // nolint:unparam
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.promptForValue = func(promptLabel string) (string, error) { return "some-upstream-username", nil }
h.promptForSecret = func(promptLabel string) (string, error) { return "some-upstream-password", 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.NoError(t, WithSessionCache(cache)(h))
require.NoError(t, WithLDAPUpstreamIdentityProvider()(h))
require.NoError(t, WithUpstreamIdentityProvider("some-upstream-name", "ldap")(h))
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":
return defaultDiscoveryResponse(req)
case "http://" + successServer.Listener.Addr().String() + "/authorize":
return authResponse, authError
default:
require.FailNow(t, fmt.Sprintf("saw unexpected http call from the CLI: %s", req.URL.String()))
return nil, nil
}
}),
})(h))
return nil
}
tests := []struct {
name string
opt func(t *testing.T) Option
@ -512,6 +561,345 @@ func TestLogin(t *testing.T) {
issuer: successServer.URL,
wantToken: &testToken,
},
{
name: "upstream name and type are included in authorize request if upstream name is provided",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
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 }
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, WithClient(&http.Client{Timeout: 10 * time.Second})(h))
require.NoError(t, WithUpstreamIdentityProvider("some-upstream-name", "oidc")(h))
h.openURL = func(actualURL string) error {
parsedActualURL, err := url.Parse(actualURL)
require.NoError(t, err)
actualParams := parsedActualURL.Query()
require.Contains(t, actualParams.Get("redirect_uri"), "http://127.0.0.1:")
actualParams.Del("redirect_uri")
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"},
"upstream_name": []string{"some-upstream-name"},
"upstream_type": []string{"oidc"},
}, actualParams)
parsedActualURL.RawQuery = ""
require.Equal(t, successServer.URL+"/authorize", parsedActualURL.String())
go func() {
h.callbacks <- callbackResult{token: &testToken}
}()
return nil
}
return nil
}
},
issuer: successServer.URL,
wantToken: &testToken,
},
{
name: "ldap login when prompting for username returns an error",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
_ = defaultLDAPTestOpts(t, h, nil, nil)
h.promptForValue = func(promptLabel string) (string, error) {
require.Equal(t, "Username: ", promptLabel)
return "", errors.New("some prompt error")
}
return nil
}
},
issuer: successServer.URL,
wantErr: "error prompting for username: some prompt error",
},
{
name: "ldap login when prompting for password returns an error",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
_ = defaultLDAPTestOpts(t, h, nil, nil)
h.promptForSecret = func(promptLabel string) (string, error) { return "", errors.New("some prompt error") }
return nil
}
},
issuer: successServer.URL,
wantErr: "error prompting for password: some prompt error",
},
{
name: "ldap login when there is a problem with parsing the authorize URL",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
_ = defaultLDAPTestOpts(t, h, nil, nil)
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":
type providerJSON struct {
Issuer string `json:"issuer"`
AuthURL string `json:"authorization_endpoint"`
TokenURL string `json:"token_endpoint"`
JWKSURL string `json:"jwks_uri"`
}
jsonResponseBody, err := json.Marshal(&providerJSON{
Issuer: successServer.URL,
AuthURL: "%", // this is not a legal URL!
TokenURL: successServer.URL + "/token",
JWKSURL: successServer.URL + "/keys",
})
require.NoError(t, err)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"content-type": []string{"application/json"}},
Body: ioutil.NopCloser(strings.NewReader(string(jsonResponseBody))),
}, nil
default:
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,
wantErr: `could not build authorize request: parse "%?access_type=offline&client_id=test-client-id&code_challenge=VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g&code_challenge_method=S256&nonce=test-nonce&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state&upstream_name=some-upstream-name&upstream_type=ldap": invalid URL escape "%"`,
},
{
name: "ldap login when there is an error calling the authorization endpoint",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
return defaultLDAPTestOpts(t, h, nil, errors.New("some error fetching authorize endpoint"))
}
},
issuer: successServer.URL,
wantErr: `authorization response error: Get "http://` + successServer.Listener.Addr().String() +
`/authorize?access_type=offline&client_id=test-client-id&code_challenge=VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g&code_challenge_method=S256&nonce=test-nonce&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state&upstream_name=some-upstream-name&upstream_type=ldap": some error fetching authorize endpoint`,
},
{
name: "ldap login when the OIDC provider authorization endpoint returns something other than a 302 redirect",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
return defaultLDAPTestOpts(t, h, &http.Response{StatusCode: http.StatusBadGateway, Status: "502 Bad Gateway"}, nil)
}
},
issuer: successServer.URL,
wantErr: `error getting authorization: expected to be redirected, but response status was 502 Bad Gateway`,
},
{
name: "ldap login when the OIDC provider authorization endpoint redirect has an error and error description",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
return defaultLDAPTestOpts(t, h, &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{
"http://127.0.0.1:0/callback?error=access_denied&error_description=optional-error-description",
}},
}, nil)
}
},
issuer: successServer.URL,
wantErr: `login failed with code "access_denied": optional-error-description`,
},
{
name: "ldap login when the OIDC provider authorization endpoint redirects us to a different server",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
return defaultLDAPTestOpts(t, h, &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{
"http://other-server.example.com/callback?code=foo&state=test-state",
}},
}, nil)
}
},
issuer: successServer.URL,
wantErr: `error getting authorization: redirected to the wrong location: http://other-server.example.com/callback?code=foo&state=test-state`,
},
{
name: "ldap login when the OIDC provider authorization endpoint redirect has an error but no error description",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
return defaultLDAPTestOpts(t, h, &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{
"http://127.0.0.1:0/callback?error=access_denied",
}},
}, nil)
}
},
issuer: successServer.URL,
wantErr: `login failed with code "access_denied"`,
},
{
name: "ldap login when the OIDC provider authorization endpoint redirect has the wrong state value",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
return defaultLDAPTestOpts(t, h, &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{"http://127.0.0.1:0/callback?code=foo&state=wrong-state"}},
}, nil)
}
},
issuer: successServer.URL,
wantErr: `missing or invalid state parameter in authorization response: http://127.0.0.1:0/callback?code=foo&state=wrong-state`,
},
{
name: "ldap login when there is an error exchanging the authcode or validating the tokens",
clientID: "test-client-id",
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
fakeAuthCode := "test-authcode-value"
_ = defaultLDAPTestOpts(t, h, &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)
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(nil, errors.New("some authcode exchange or token validation error"))
return mock
}
return nil
}
},
issuer: successServer.URL,
wantErr: "error during authorization code exchange: some authcode exchange or token validation error",
},
{
name: "successful ldap login",
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.promptForValue = func(promptLabel string) (string, error) {
require.Equal(t, "Username: ", promptLabel)
return "some-upstream-username", nil
}
h.promptForSecret = func(promptLabel string) (string, error) {
require.Equal(t, "Password: ", promptLabel)
return "some-upstream-password", 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, WithLDAPUpstreamIdentityProvider()(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("X-Pinniped-Upstream-Username"))
require.Equal(t, "some-upstream-password", req.Header.Get("X-Pinniped-Upstream-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"},
"upstream_name": []string{"some-upstream-name"},
"upstream_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,
wantToken: &testToken,
},
{
name: "with requested audience, session cache hit with valid token, but discovery fails",
clientID: "test-client-id",