cac45fd999
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.
1930 lines
77 KiB
Go
1930 lines
77 KiB
Go
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package oidcclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-logr/stdr"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/golang/mock/gomock"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/oauth2"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/klog/v2"
|
|
|
|
"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"
|
|
"go.pinniped.dev/internal/testutil/testlogger"
|
|
"go.pinniped.dev/pkg/oidcclient/nonce"
|
|
"go.pinniped.dev/pkg/oidcclient/oidctypes"
|
|
"go.pinniped.dev/pkg/oidcclient/pkce"
|
|
"go.pinniped.dev/pkg/oidcclient/state"
|
|
)
|
|
|
|
// mockSessionCache exists to avoid an import cycle if we generate mocks into another package.
|
|
type mockSessionCache struct {
|
|
t *testing.T
|
|
getReturnsToken *oidctypes.Token
|
|
sawGetKeys []SessionCacheKey
|
|
sawPutKeys []SessionCacheKey
|
|
sawPutTokens []*oidctypes.Token
|
|
}
|
|
|
|
func (m *mockSessionCache) GetToken(key SessionCacheKey) *oidctypes.Token {
|
|
m.t.Logf("saw mock session cache GetToken() with client ID %s", key.ClientID)
|
|
m.sawGetKeys = append(m.sawGetKeys, key)
|
|
return m.getReturnsToken
|
|
}
|
|
|
|
func (m *mockSessionCache) PutToken(key SessionCacheKey, token *oidctypes.Token) {
|
|
m.t.Logf("saw mock session cache PutToken() with client ID %s and ID token %s", key.ClientID, token.IDToken.Token)
|
|
m.sawPutKeys = append(m.sawPutKeys, key)
|
|
m.sawPutTokens = append(m.sawPutTokens, token)
|
|
}
|
|
|
|
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())
|
|
|
|
testToken := oidctypes.Token{
|
|
AccessToken: &oidctypes.AccessToken{Token: "test-access-token", Expiry: metav1.NewTime(time1.Add(1 * time.Minute))},
|
|
RefreshToken: &oidctypes.RefreshToken{Token: "test-refresh-token"},
|
|
IDToken: &oidctypes.IDToken{Token: "test-id-token", Expiry: metav1.NewTime(time1.Add(2 * time.Minute))},
|
|
}
|
|
|
|
testExchangedToken := oidctypes.Token{
|
|
IDToken: &oidctypes.IDToken{Token: "test-id-token-with-requested-audience", Expiry: metav1.NewTime(time1.Add(3 * time.Minute))},
|
|
}
|
|
|
|
// Start a test server that returns 500 errors
|
|
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "some discovery error", http.StatusInternalServerError)
|
|
}))
|
|
t.Cleanup(errorServer.Close)
|
|
|
|
// Start a test server that returns discovery data with a broken response_modes_supported value.
|
|
brokenResponseModeMux := http.NewServeMux()
|
|
brokenResponseModeServer := httptest.NewServer(brokenResponseModeMux)
|
|
brokenResponseModeMux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("content-type", "application/json")
|
|
type providerJSON struct {
|
|
Issuer string `json:"issuer"`
|
|
ResponseModesSupported string `json:"response_modes_supported"` // Wrong type (should be []string).
|
|
}
|
|
_ = json.NewEncoder(w).Encode(&providerJSON{
|
|
Issuer: brokenResponseModeServer.URL,
|
|
ResponseModesSupported: "invalid",
|
|
})
|
|
})
|
|
t.Cleanup(brokenResponseModeServer.Close)
|
|
|
|
// Start a test server that returns discovery data with a broken token URL
|
|
brokenTokenURLMux := http.NewServeMux()
|
|
brokenTokenURLServer := httptest.NewServer(brokenTokenURLMux)
|
|
brokenTokenURLMux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("content-type", "application/json")
|
|
type providerJSON struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthURL string `json:"authorization_endpoint"`
|
|
TokenURL string `json:"token_endpoint"`
|
|
JWKSURL string `json:"jwks_uri"`
|
|
}
|
|
_ = json.NewEncoder(w).Encode(&providerJSON{
|
|
Issuer: brokenTokenURLServer.URL,
|
|
AuthURL: brokenTokenURLServer.URL + "/authorize",
|
|
TokenURL: "%",
|
|
JWKSURL: brokenTokenURLServer.URL + "/keys",
|
|
})
|
|
})
|
|
t.Cleanup(brokenTokenURLServer.Close)
|
|
|
|
discoveryHandler := func(server *httptest.Server, responseModes []string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("content-type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(&struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthURL string `json:"authorization_endpoint"`
|
|
TokenURL string `json:"token_endpoint"`
|
|
JWKSURL string `json:"jwks_uri"`
|
|
ResponseModesSupported []string `json:"response_modes_supported,omitempty"`
|
|
}{
|
|
Issuer: server.URL,
|
|
AuthURL: server.URL + "/authorize",
|
|
TokenURL: server.URL + "/token",
|
|
JWKSURL: server.URL + "/keys",
|
|
ResponseModesSupported: responseModes,
|
|
})
|
|
}
|
|
}
|
|
tokenHandler := func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var response struct {
|
|
oauth2.Token
|
|
IDToken string `json:"id_token,omitempty"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
IssuedTokenType string `json:"issued_token_type,omitempty"`
|
|
}
|
|
|
|
switch r.Form.Get("grant_type") {
|
|
case "refresh_token":
|
|
if r.Form.Get("client_id") != "test-client-id" {
|
|
http.Error(w, "expected client_id 'test-client-id'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
response.AccessToken = testToken.AccessToken.Token
|
|
response.ExpiresIn = int64(time.Until(testToken.AccessToken.Expiry.Time).Seconds())
|
|
response.RefreshToken = testToken.RefreshToken.Token
|
|
response.IDToken = testToken.IDToken.Token
|
|
|
|
if r.Form.Get("refresh_token") == "test-refresh-token-returning-invalid-id-token" {
|
|
response.IDToken = "not a valid JWT"
|
|
} else if r.Form.Get("refresh_token") != "test-refresh-token" {
|
|
http.Error(w, "expected refresh_token to be 'test-refresh-token'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
case "urn:ietf:params:oauth:grant-type:token-exchange":
|
|
if r.Form.Get("client_id") != "test-client-id" {
|
|
http.Error(w, "bad client_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Form.Get("audience") {
|
|
case "test-audience-produce-invalid-http-response":
|
|
http.Redirect(w, r, "%", http.StatusTemporaryRedirect)
|
|
return
|
|
case "test-audience-produce-http-400":
|
|
http.Error(w, "some server error", http.StatusBadRequest)
|
|
return
|
|
case "test-audience-produce-invalid-content-type":
|
|
w.Header().Set("content-type", "invalid/invalid;=")
|
|
return
|
|
case "test-audience-produce-wrong-content-type":
|
|
w.Header().Set("content-type", "invalid")
|
|
return
|
|
case "test-audience-produce-invalid-json":
|
|
w.Header().Set("content-type", "application/json;charset=UTF-8")
|
|
_, _ = w.Write([]byte(`{`))
|
|
return
|
|
case "test-audience-produce-invalid-tokentype":
|
|
response.TokenType = "invalid"
|
|
case "test-audience-produce-invalid-issuedtokentype":
|
|
response.TokenType = "N_A"
|
|
response.IssuedTokenType = "invalid"
|
|
case "test-audience-produce-invalid-jwt":
|
|
response.TokenType = "N_A"
|
|
response.IssuedTokenType = "urn:ietf:params:oauth:token-type:jwt"
|
|
response.AccessToken = "not-a-valid-jwt"
|
|
default:
|
|
response.TokenType = "N_A"
|
|
response.IssuedTokenType = "urn:ietf:params:oauth:token-type:jwt"
|
|
response.AccessToken = testExchangedToken.IDToken.Token
|
|
}
|
|
|
|
default:
|
|
http.Error(w, fmt.Sprintf("invalid grant_type %q", r.Form.Get("grant_type")), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("content-type", "application/json")
|
|
require.NoError(t, json.NewEncoder(w).Encode(&response))
|
|
}
|
|
|
|
// Start a test server that returns a real discovery document and answers refresh requests.
|
|
providerMux := http.NewServeMux()
|
|
successServer := httptest.NewServer(providerMux)
|
|
t.Cleanup(successServer.Close)
|
|
providerMux.HandleFunc("/.well-known/openid-configuration", discoveryHandler(successServer, nil))
|
|
providerMux.HandleFunc("/token", tokenHandler)
|
|
|
|
// Start a test server that returns a real discovery document and answers refresh requests, _and_ supports form_mode=post.
|
|
formPostProviderMux := http.NewServeMux()
|
|
formPostSuccessServer := httptest.NewServer(formPostProviderMux)
|
|
t.Cleanup(formPostSuccessServer.Close)
|
|
formPostProviderMux.HandleFunc("/.well-known/openid-configuration", discoveryHandler(formPostSuccessServer, []string{"query", "form_post"}))
|
|
formPostProviderMux.HandleFunc("/token", tokenHandler)
|
|
|
|
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(_ context.Context, promptLabel string) (string, error) { return "some-upstream-username", nil }
|
|
h.promptForSecret = func(_ context.Context, _ 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, WithCLISendingCredentials()(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
|
|
issuer string
|
|
clientID string
|
|
wantErr string
|
|
wantToken *oidctypes.Token
|
|
wantLogs []string
|
|
}{
|
|
{
|
|
name: "option error",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
return fmt.Errorf("some option error")
|
|
}
|
|
},
|
|
wantErr: "some option error",
|
|
},
|
|
{
|
|
name: "error generating state",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.generateState = func() (state.State, error) { return "", fmt.Errorf("some error generating state") }
|
|
return nil
|
|
}
|
|
},
|
|
wantErr: "some error generating state",
|
|
},
|
|
{
|
|
name: "error generating nonce",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.generateNonce = func() (nonce.Nonce, error) { return "", fmt.Errorf("some error generating nonce") }
|
|
return nil
|
|
}
|
|
},
|
|
wantErr: "some error generating nonce",
|
|
},
|
|
{
|
|
name: "error generating PKCE",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.generatePKCE = func() (pkce.Code, error) { return "", fmt.Errorf("some error generating PKCE") }
|
|
return nil
|
|
}
|
|
},
|
|
wantErr: "some error generating PKCE",
|
|
},
|
|
{
|
|
name: "session cache hit but token expired",
|
|
issuer: "test-issuer",
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &oidctypes.Token{
|
|
IDToken: &oidctypes.IDToken{
|
|
Token: "test-id-token",
|
|
Expiry: metav1.NewTime(time.Now()), // less than Now() + minIDTokenValidity
|
|
},
|
|
}}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: "test-issuer",
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
return WithSessionCache(cache)(h)
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"test-issuer\""},
|
|
wantErr: `could not perform OIDC discovery for "test-issuer": Get "test-issuer/.well-known/openid-configuration": unsupported protocol scheme ""`,
|
|
},
|
|
{
|
|
name: "session cache hit with valid token",
|
|
issuer: "test-issuer",
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: "test-issuer",
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
return WithSessionCache(cache)(h)
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\""},
|
|
wantToken: &testToken,
|
|
},
|
|
{
|
|
name: "discovery failure due to 500 error",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error { return nil }
|
|
},
|
|
issuer: errorServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + errorServer.URL + "\""},
|
|
wantErr: fmt.Sprintf("could not perform OIDC discovery for %q: 500 Internal Server Error: some discovery error\n", errorServer.URL),
|
|
},
|
|
{
|
|
name: "discovery failure due to invalid response_modes_supported",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error { return nil }
|
|
},
|
|
issuer: brokenResponseModeServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + brokenResponseModeServer.URL + "\""},
|
|
wantErr: fmt.Sprintf("could not decode response_modes_supported in OIDC discovery from %q: json: cannot unmarshal string into Go struct field .response_modes_supported of type []string", brokenResponseModeServer.URL),
|
|
},
|
|
{
|
|
name: "session cache hit with refreshable token",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")).
|
|
Return(&testToken, nil)
|
|
return mock
|
|
}
|
|
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &oidctypes.Token{
|
|
IDToken: &oidctypes.IDToken{
|
|
Token: "expired-test-id-token",
|
|
Expiry: metav1.Now(), // less than Now() + minIDTokenValidity
|
|
},
|
|
RefreshToken: &oidctypes.RefreshToken{Token: "test-refresh-token"},
|
|
}}
|
|
t.Cleanup(func() {
|
|
cacheKey := SessionCacheKey{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}
|
|
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys)
|
|
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys)
|
|
require.Len(t, cache.sawPutTokens, 1)
|
|
require.Equal(t, testToken.IDToken.Token, cache.sawPutTokens[0].IDToken.Token)
|
|
})
|
|
h.cache = cache
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Refreshing cached token.\""},
|
|
wantToken: &testToken,
|
|
},
|
|
{
|
|
name: "session cache hit but refresh returns invalid token",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")).
|
|
Return(nil, fmt.Errorf("some validation error"))
|
|
return mock
|
|
}
|
|
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &oidctypes.Token{
|
|
IDToken: &oidctypes.IDToken{
|
|
Token: "expired-test-id-token",
|
|
Expiry: metav1.Now(), // less than Now() + minIDTokenValidity
|
|
},
|
|
RefreshToken: &oidctypes.RefreshToken{Token: "test-refresh-token-returning-invalid-id-token"},
|
|
}}
|
|
t.Cleanup(func() {
|
|
require.Empty(t, cache.sawPutKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
h.cache = cache
|
|
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Refreshing cached token.\""},
|
|
wantErr: "some validation error",
|
|
},
|
|
{
|
|
name: "session cache hit but refresh fails",
|
|
issuer: successServer.URL,
|
|
clientID: "not-the-test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &oidctypes.Token{
|
|
IDToken: &oidctypes.IDToken{
|
|
Token: "expired-test-id-token",
|
|
Expiry: metav1.Now(), // less than Now() + minIDTokenValidity
|
|
},
|
|
RefreshToken: &oidctypes.RefreshToken{Token: "test-refresh-token"},
|
|
}}
|
|
t.Cleanup(func() {
|
|
require.Empty(t, cache.sawPutKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
h.cache = cache
|
|
|
|
h.listen = func(string, string) (net.Listener, error) { return nil, fmt.Errorf("some listen error") }
|
|
h.isTTY = func(int) bool { return false }
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{
|
|
`"level"=4 "msg"="Pinniped: Performing OIDC discovery" "issuer"="` + successServer.URL + `"`,
|
|
`"level"=4 "msg"="Pinniped: Refreshing cached token."`,
|
|
`"msg"="could not open callback listener" "error"="some listen error"`,
|
|
},
|
|
// Expect this to fall through to the authorization code flow, so it fails here.
|
|
wantErr: "login failed: must have either a localhost listener or stdin must be a TTY",
|
|
},
|
|
{
|
|
name: "listen failure and non-tty stdin",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.listen = func(net string, addr string) (net.Listener, error) {
|
|
assert.Equal(t, "tcp", net)
|
|
assert.Equal(t, "localhost:0", addr)
|
|
return nil, fmt.Errorf("some listen error")
|
|
}
|
|
h.isTTY = func(fd int) bool {
|
|
assert.Equal(t, fd, syscall.Stdin)
|
|
return false
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
issuer: successServer.URL,
|
|
wantLogs: []string{
|
|
`"level"=4 "msg"="Pinniped: Performing OIDC discovery" "issuer"="` + successServer.URL + `"`,
|
|
`"msg"="could not open callback listener" "error"="some listen error"`,
|
|
},
|
|
wantErr: "login failed: must have either a localhost listener or stdin must be a TTY",
|
|
},
|
|
{
|
|
name: "listening disabled and manual prompt fails",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
require.NoError(t, WithSkipListen()(h))
|
|
h.isTTY = func(fd int) bool { return true }
|
|
h.openURL = func(authorizeURL string) error {
|
|
parsed, err := url.Parse(authorizeURL)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "http://127.0.0.1:0/callback", parsed.Query().Get("redirect_uri"))
|
|
require.Equal(t, "form_post", parsed.Query().Get("response_mode"))
|
|
return fmt.Errorf("some browser open error")
|
|
}
|
|
h.promptForSecret = func(ctx context.Context, promptLabel string) (string, error) {
|
|
return "", fmt.Errorf("some prompt error")
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
issuer: formPostSuccessServer.URL,
|
|
wantLogs: []string{
|
|
`"level"=4 "msg"="Pinniped: Performing OIDC discovery" "issuer"="` + formPostSuccessServer.URL + `"`,
|
|
`"msg"="could not open browser" "error"="some browser open error"`,
|
|
},
|
|
wantErr: "error handling callback: failed to prompt for manual authorization code: some prompt error",
|
|
},
|
|
{
|
|
name: "listen success and manual prompt succeeds",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.listen = func(string, string) (net.Listener, error) { return nil, fmt.Errorf("some listen error") }
|
|
h.isTTY = func(fd int) bool { return true }
|
|
h.openURL = func(authorizeURL string) error {
|
|
parsed, err := url.Parse(authorizeURL)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "http://127.0.0.1:0/callback", parsed.Query().Get("redirect_uri"))
|
|
require.Equal(t, "form_post", parsed.Query().Get("response_mode"))
|
|
return nil
|
|
}
|
|
h.promptForSecret = func(ctx context.Context, promptLabel string) (string, error) {
|
|
return "", fmt.Errorf("some prompt error")
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
issuer: formPostSuccessServer.URL,
|
|
wantLogs: []string{
|
|
`"level"=4 "msg"="Pinniped: Performing OIDC discovery" "issuer"="` + formPostSuccessServer.URL + `"`,
|
|
`"msg"="could not open callback listener" "error"="some listen error"`,
|
|
},
|
|
wantErr: "error handling callback: failed to prompt for manual authorization code: some prompt error",
|
|
},
|
|
{
|
|
name: "timeout waiting for callback",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
ctx, cancel := context.WithCancel(h.ctx)
|
|
h.ctx = ctx
|
|
|
|
h.openURL = func(_ string) error {
|
|
cancel()
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
issuer: successServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: "timed out waiting for token callback: context canceled",
|
|
},
|
|
{
|
|
name: "callback returns error",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.openURL = func(_ string) error {
|
|
go func() {
|
|
h.callbacks <- callbackResult{err: fmt.Errorf("some callback error")}
|
|
}()
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
issuer: successServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: "error handling callback: some callback error",
|
|
},
|
|
{
|
|
name: "callback returns success",
|
|
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))
|
|
|
|
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"},
|
|
}, 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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantToken: &testToken,
|
|
},
|
|
{
|
|
name: "callback returns success with request_mode=form_post",
|
|
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: formPostSuccessServer.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))
|
|
|
|
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"},
|
|
"response_mode": []string{"form_post"},
|
|
"scope": []string{"test-scope"},
|
|
"nonce": []string{"test-nonce"},
|
|
"state": []string{"test-state"},
|
|
"access_type": []string{"offline"},
|
|
"client_id": []string{"test-client-id"},
|
|
}, actualParams)
|
|
|
|
parsedActualURL.RawQuery = ""
|
|
require.Equal(t, formPostSuccessServer.URL+"/authorize", parsedActualURL.String())
|
|
|
|
go func() {
|
|
h.callbacks <- callbackResult{token: &testToken}
|
|
}()
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
issuer: formPostSuccessServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + formPostSuccessServer.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"},
|
|
"pinniped_idp_name": []string{"some-upstream-name"},
|
|
"pinniped_idp_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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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(_ context.Context, promptLabel string) (string, error) {
|
|
require.Equal(t, "Username: ", promptLabel)
|
|
return "", errors.New("some prompt error")
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
issuer: successServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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(_ context.Context, _ string) (string, error) { return "", errors.New("some prompt error") }
|
|
return nil
|
|
}
|
|
},
|
|
issuer: successServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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&pinniped_idp_name=some-upstream-name&pinniped_idp_type=ldap&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state": 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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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&pinniped_idp_name=some-upstream-name&pinniped_idp_type=ldap&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state": 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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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&state=test-state",
|
|
}},
|
|
}, nil)
|
|
}
|
|
},
|
|
issuer: successServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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&state=test-state",
|
|
}},
|
|
}, nil)
|
|
}
|
|
},
|
|
issuer: successServer.URL,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"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,
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: "error during authorization code exchange: some authcode exchange or token validation error",
|
|
},
|
|
{
|
|
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 {
|
|
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(_ 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
|
|
}
|
|
h.promptForSecret = func(_ context.Context, 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, 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 + "\""},
|
|
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",
|
|
issuer: errorServer.URL,
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: errorServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("cluster-1234")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"cluster-1234\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + errorServer.URL + "\""},
|
|
wantErr: fmt.Sprintf("failed to exchange token: could not perform OIDC discovery for %q: 500 Internal Server Error: some discovery error\n", errorServer.URL),
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token URL is invalid",
|
|
issuer: brokenTokenURLServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: brokenTokenURLServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("cluster-1234")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"cluster-1234\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + brokenTokenURLServer.URL + "\""},
|
|
wantErr: `failed to exchange token: could not build RFC8693 request: parse "%": invalid URL escape "%"`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request fails",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-invalid-http-response")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-http-response\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: fmt.Sprintf(`failed to exchange token: Post "%s/token": failed to parse Location header "%%": parse "%%": invalid URL escape "%%"`, successServer.URL),
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request returns non-200",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-http-400")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-http-400\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: `failed to exchange token: unexpected HTTP response status 400`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request returns invalid content-type header",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-invalid-content-type")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-content-type\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: `failed to exchange token: failed to decode content-type header: mime: invalid media parameter`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request returns wrong content-type",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-wrong-content-type")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-wrong-content-type\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: `failed to exchange token: unexpected HTTP response content type "invalid"`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request returns invalid JSON",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-invalid-json")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-json\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: `failed to exchange token: failed to decode response: unexpected EOF`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request returns invalid token_type",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-invalid-tokentype")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-tokentype\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: `failed to exchange token: got unexpected token_type "invalid"`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request returns invalid issued_token_type",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-invalid-issuedtokentype")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-issuedtokentype\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: `failed to exchange token: got unexpected issued_token_type "invalid"`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, but token exchange request returns invalid JWT",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience-produce-invalid-jwt")(h))
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-jwt\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantErr: `failed to exchange token: received invalid JWT: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts`,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid token, and token exchange request succeeds",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
|
t.Cleanup(func() {
|
|
require.Equal(t, []SessionCacheKey{{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}}, cache.sawGetKeys)
|
|
require.Empty(t, cache.sawPutTokens)
|
|
})
|
|
require.NoError(t, WithSessionCache(cache)(h))
|
|
require.NoError(t, WithRequestAudience("test-audience")(h))
|
|
|
|
h.validateIDToken = func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) {
|
|
require.Equal(t, "test-audience", audience)
|
|
require.Equal(t, "test-id-token-with-requested-audience", token)
|
|
return &oidc.IDToken{Expiry: testExchangedToken.IDToken.Expiry.Time}, nil
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""},
|
|
wantToken: &testExchangedToken,
|
|
},
|
|
{
|
|
name: "with requested audience, session cache hit with valid refresh token, and token exchange request succeeds",
|
|
issuer: successServer.URL,
|
|
clientID: "test-client-id",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
cache := &mockSessionCache{t: t, getReturnsToken: &oidctypes.Token{
|
|
IDToken: &oidctypes.IDToken{
|
|
Token: "expired-test-id-token",
|
|
Expiry: metav1.Now(), // less than Now() + minIDTokenValidity
|
|
},
|
|
RefreshToken: &oidctypes.RefreshToken{Token: "test-refresh-token"},
|
|
}}
|
|
t.Cleanup(func() {
|
|
cacheKey := SessionCacheKey{
|
|
Issuer: successServer.URL,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"test-scope"},
|
|
RedirectURI: "http://localhost:0/callback",
|
|
}
|
|
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys)
|
|
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys)
|
|
require.Len(t, cache.sawPutTokens, 1)
|
|
require.Equal(t, testToken.IDToken.Token, cache.sawPutTokens[0].IDToken.Token)
|
|
})
|
|
h.cache = cache
|
|
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")).
|
|
Return(&testToken, nil)
|
|
return mock
|
|
}
|
|
|
|
require.NoError(t, WithRequestAudience("test-audience")(h))
|
|
|
|
h.validateIDToken = func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) {
|
|
require.Equal(t, "test-audience", audience)
|
|
require.Equal(t, "test-id-token-with-requested-audience", token)
|
|
return &oidc.IDToken{Expiry: testExchangedToken.IDToken.Expiry.Time}, nil
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
wantLogs: []string{
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Refreshing cached token.\"",
|
|
"\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience\"",
|
|
},
|
|
wantToken: &testExchangedToken,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testLogger := testlogger.New(t)
|
|
klog.SetLogger(testLogger)
|
|
stdr.SetVerbosity(debugLogLevel) // set stdr's global log level to debug so the test logger will send output.
|
|
|
|
tok, err := Login(tt.issuer, tt.clientID,
|
|
WithContext(context.Background()),
|
|
WithListenPort(0),
|
|
WithScopes([]string{"test-scope"}),
|
|
WithSkipBrowserOpen(),
|
|
tt.opt(t),
|
|
WithLogger(testLogger),
|
|
)
|
|
testLogger.Expect(tt.wantLogs)
|
|
if tt.wantErr != "" {
|
|
require.EqualError(t, err, tt.wantErr)
|
|
require.Nil(t, tok)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
if tt.wantToken == nil {
|
|
require.Nil(t, tok)
|
|
return
|
|
}
|
|
require.NotNil(t, tok)
|
|
|
|
if want := tt.wantToken.AccessToken; want != nil {
|
|
require.NotNil(t, tok.AccessToken)
|
|
require.Equal(t, want.Token, tok.AccessToken.Token)
|
|
require.Equal(t, want.Type, tok.AccessToken.Type)
|
|
testutil.RequireTimeInDelta(t, want.Expiry.Time, tok.AccessToken.Expiry.Time, 5*time.Second)
|
|
} else {
|
|
assert.Nil(t, tok.AccessToken)
|
|
}
|
|
require.Equal(t, tt.wantToken.RefreshToken, tok.RefreshToken)
|
|
if want := tt.wantToken.IDToken; want != nil {
|
|
require.NotNil(t, tok.IDToken)
|
|
require.Equal(t, want.Token, tok.IDToken.Token)
|
|
testutil.RequireTimeInDelta(t, want.Expiry.Time, tok.IDToken.Expiry.Time, 5*time.Second)
|
|
} else {
|
|
assert.Nil(t, tok.IDToken)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlePasteCallback(t *testing.T) {
|
|
const testRedirectURI = "http://127.0.0.1:12324/callback"
|
|
|
|
tests := []struct {
|
|
name string
|
|
opt func(t *testing.T) Option
|
|
wantCallback *callbackResult
|
|
}{
|
|
{
|
|
name: "no stdin available",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.isTTY = func(fd int) bool {
|
|
require.Equal(t, syscall.Stdin, fd)
|
|
return false
|
|
}
|
|
h.useFormPost = true
|
|
return nil
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "no form_post mode available",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.isTTY = func(fd int) bool { return true }
|
|
h.useFormPost = false
|
|
return nil
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "prompt fails",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.isTTY = func(fd int) bool { return true }
|
|
h.useFormPost = true
|
|
h.promptForSecret = func(ctx context.Context, promptLabel string) (string, error) {
|
|
assert.Equal(t, " If automatic login fails, paste your authorization code to login manually: ", promptLabel)
|
|
return "", fmt.Errorf("some prompt error")
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
wantCallback: &callbackResult{
|
|
err: fmt.Errorf("failed to prompt for manual authorization code: some prompt error"),
|
|
},
|
|
},
|
|
{
|
|
name: "redeeming code fails",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.isTTY = func(fd int) bool { return true }
|
|
h.useFormPost = true
|
|
h.promptForSecret = func(ctx context.Context, promptLabel string) (string, error) {
|
|
return "invalid", nil
|
|
}
|
|
h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI}
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ExchangeAuthcodeAndValidateTokens(gomock.Any(), "invalid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI).
|
|
Return(nil, fmt.Errorf("some exchange error"))
|
|
return mock
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
wantCallback: &callbackResult{
|
|
err: fmt.Errorf("some exchange error"),
|
|
},
|
|
},
|
|
{
|
|
name: "success",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.isTTY = func(fd int) bool { return true }
|
|
h.useFormPost = true
|
|
h.promptForSecret = func(ctx context.Context, promptLabel string) (string, error) {
|
|
return "valid", nil
|
|
}
|
|
h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI}
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ExchangeAuthcodeAndValidateTokens(gomock.Any(), "valid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI).
|
|
Return(&oidctypes.Token{IDToken: &oidctypes.IDToken{Token: "test-id-token"}}, nil)
|
|
return mock
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
wantCallback: &callbackResult{
|
|
token: &oidctypes.Token{IDToken: &oidctypes.IDToken{Token: "test-id-token"}},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := &handlerState{
|
|
callbacks: make(chan callbackResult, 1),
|
|
state: state.State("test-state"),
|
|
pkce: pkce.Code("test-pkce"),
|
|
nonce: nonce.Nonce("test-nonce"),
|
|
}
|
|
if tt.opt != nil {
|
|
require.NoError(t, tt.opt(t)(h))
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
var buf bytes.Buffer
|
|
h.promptForWebLogin(ctx, "https://test-authorize-url/", &buf)
|
|
require.Equal(t,
|
|
"Log in by visiting this link:\n\n https://test-authorize-url/\n\n",
|
|
buf.String(),
|
|
)
|
|
|
|
if tt.wantCallback != nil {
|
|
select {
|
|
case <-time.After(1 * time.Second):
|
|
require.Fail(t, "timed out waiting to receive from callbacks channel")
|
|
case result := <-h.callbacks:
|
|
require.Equal(t, *tt.wantCallback, result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleAuthCodeCallback(t *testing.T) {
|
|
const testRedirectURI = "http://127.0.0.1:12324/callback"
|
|
|
|
withFormPostMode := func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.useFormPost = true
|
|
return nil
|
|
}
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
query string
|
|
body []byte
|
|
contentType string
|
|
opt func(t *testing.T) Option
|
|
wantErr string
|
|
wantHTTPStatus int
|
|
}{
|
|
{
|
|
name: "wrong method",
|
|
method: "POST",
|
|
query: "",
|
|
wantErr: "wanted GET",
|
|
wantHTTPStatus: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
name: "wrong method for form_post",
|
|
method: "GET",
|
|
query: "",
|
|
opt: withFormPostMode,
|
|
wantErr: "wanted POST",
|
|
wantHTTPStatus: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
name: "invalid form for form_post",
|
|
method: "POST",
|
|
query: "",
|
|
contentType: "application/x-www-form-urlencoded",
|
|
body: []byte(`%`),
|
|
opt: withFormPostMode,
|
|
wantErr: `invalid form: invalid URL escape "%"`,
|
|
wantHTTPStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid state",
|
|
query: "state=invalid",
|
|
wantErr: "missing or invalid state parameter",
|
|
wantHTTPStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "error code from provider",
|
|
query: "state=test-state&error=some_error",
|
|
wantErr: `login failed with code "some_error"`,
|
|
wantHTTPStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "error code with a description from provider",
|
|
query: "state=test-state&error=some_error&error_description=optional%20error%20description",
|
|
wantErr: `login failed with code "some_error": optional error description`,
|
|
wantHTTPStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid code",
|
|
query: "state=test-state&code=invalid",
|
|
wantErr: "could not complete code exchange: some exchange error",
|
|
wantHTTPStatus: http.StatusBadRequest,
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI}
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ExchangeAuthcodeAndValidateTokens(gomock.Any(), "invalid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI).
|
|
Return(nil, fmt.Errorf("some exchange error"))
|
|
return mock
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "valid",
|
|
query: "state=test-state&code=valid",
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI}
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ExchangeAuthcodeAndValidateTokens(gomock.Any(), "valid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI).
|
|
Return(&oidctypes.Token{IDToken: &oidctypes.IDToken{Token: "test-id-token"}}, nil)
|
|
return mock
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "valid form_post",
|
|
method: http.MethodPost,
|
|
contentType: "application/x-www-form-urlencoded",
|
|
body: []byte(`state=test-state&code=valid`),
|
|
opt: func(t *testing.T) Option {
|
|
return func(h *handlerState) error {
|
|
h.useFormPost = true
|
|
h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI}
|
|
h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI {
|
|
mock := mockUpstream(t)
|
|
mock.EXPECT().
|
|
ExchangeAuthcodeAndValidateTokens(gomock.Any(), "valid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI).
|
|
Return(&oidctypes.Token{IDToken: &oidctypes.IDToken{Token: "test-id-token"}}, nil)
|
|
return mock
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := &handlerState{
|
|
callbacks: make(chan callbackResult, 1),
|
|
state: state.State("test-state"),
|
|
pkce: pkce.Code("test-pkce"),
|
|
nonce: nonce.Nonce("test-nonce"),
|
|
}
|
|
if tt.opt != nil {
|
|
require.NoError(t, tt.opt(t)(h))
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
resp := httptest.NewRecorder()
|
|
req, err := http.NewRequestWithContext(ctx, "GET", "/test-callback", bytes.NewBuffer(tt.body))
|
|
require.NoError(t, err)
|
|
req.URL.RawQuery = tt.query
|
|
if tt.method != "" {
|
|
req.Method = tt.method
|
|
}
|
|
if tt.contentType != "" {
|
|
req.Header.Set("Content-Type", tt.contentType)
|
|
}
|
|
|
|
err = h.handleAuthCodeCallback(resp, req)
|
|
if tt.wantErr != "" {
|
|
require.EqualError(t, err, tt.wantErr)
|
|
if tt.wantHTTPStatus != 0 {
|
|
rec := httptest.NewRecorder()
|
|
err.(httperr.Responder).Respond(rec)
|
|
require.Equal(t, tt.wantHTTPStatus, rec.Code)
|
|
}
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
select {
|
|
case <-time.After(1 * time.Second):
|
|
require.Fail(t, "timed out waiting to receive from callbacks channel")
|
|
case result := <-h.callbacks:
|
|
if tt.wantErr != "" {
|
|
require.EqualError(t, result.err, tt.wantErr)
|
|
return
|
|
}
|
|
require.NoError(t, result.err)
|
|
require.NotNil(t, result.token)
|
|
require.Equal(t, result.token.IDToken.Token, "test-id-token")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mockUpstream(t *testing.T) *mockupstreamoidcidentityprovider.MockUpstreamOIDCIdentityProviderI {
|
|
t.Helper()
|
|
ctrl := gomock.NewController(t)
|
|
t.Cleanup(ctrl.Finish)
|
|
return mockupstreamoidcidentityprovider.NewMockUpstreamOIDCIdentityProviderI(ctrl)
|
|
}
|
|
|
|
// hasAccessTokenMatcher is a gomock.Matcher that expects an *oauth2.Token with a particular access token.
|
|
type hasAccessTokenMatcher struct{ expected string }
|
|
|
|
func (m hasAccessTokenMatcher) Matches(arg interface{}) bool {
|
|
return arg.(*oauth2.Token).AccessToken == m.expected
|
|
}
|
|
|
|
func (m hasAccessTokenMatcher) Got(got interface{}) string {
|
|
return got.(*oauth2.Token).AccessToken
|
|
}
|
|
|
|
func (m hasAccessTokenMatcher) String() string {
|
|
return m.expected
|
|
}
|
|
|
|
func HasAccessToken(expected string) gomock.Matcher {
|
|
return hasAccessTokenMatcher{expected: expected}
|
|
}
|