Compare commits

...

22 Commits

Author SHA1 Message Date
Mo Khan
59be3008fd Merge pull request #985 from microwavables/update-docs
updated search functionality of docs on site
2022-02-09 12:01:59 -05:00
Nanci Lancaster
d728c89ba6 updated search functionality of docs on site
Signed-off-by: Nanci Lancaster <nancil@vmware.com>
2022-02-09 11:01:37 -05:00
Mo Khan
863aadd9ea Merge pull request #989 from vmware-tanzu/chrome_cors2
Followup for CORS request handling to CLI's localhost listener
2022-02-09 10:27:00 -05:00
Ryan Richard
5d79d4b9dc Fix form_post.js mistake from recent commit; Better CORS on callback 2022-02-08 17:30:48 -08:00
Ryan Richard
f6f188565b Merge pull request #987 from vmware-tanzu/chrome_cors
Add CORS request handling to CLI's localhost listener
2022-02-08 14:31:45 -08:00
Mo Khan
29368e8242 Make the linter happy 2022-02-08 16:31:04 -05:00
Ryan Richard
cd825c5e51 Use "-v6" for kubectl for an e2e test so we can get more failure output 2022-02-08 13:00:49 -08:00
Mo Khan
874b567974 Merge pull request #988 from enj/enj/t/e2e_hung
e2e_test: handle hung go routines and readers
2022-02-08 12:57:54 -05:00
Monis Khan
8ee461ae8a e2e_test: handle hung go routines and readers
Signed-off-by: Monis Khan <mok@vmware.com>
2022-02-08 11:40:10 -05:00
Mo Khan
1388183bf1 TestE2EFullIntegration: reduce timeout
This causes the test to timeout before concourse terminates the entire test run.
2022-02-07 20:53:03 -05:00
Ryan Richard
f1962ccf86 Merge branch 'main' into chrome_cors 2022-02-07 16:35:44 -08:00
Ryan Richard
0431a072ae Remove an unnecessary nolint comment 2022-02-07 16:26:39 -08:00
Ryan Richard
6781bfd7d8 Fix JS bug: form post UI shows manual copy/paste UI upon failed callback
When the POST to the CLI's localhost callback endpoint results in a
non-2XX status code, then treat that as a failed login attempt and
automatically show the manual copy/paste UI.
2022-02-07 16:21:23 -08:00
Ryan Richard
aa56f174db Capture and print the full kubectl output in an e2e test upon failure 2022-02-07 16:17:38 -08:00
Ryan Richard
3c7e387137 Keep the CLI localhost listener running after requests with wrong verb
Just in case some future browser change sends some new kind of request
to our CLI, just ignore them by returning StatusMethodNotAllowed and
continuing to listen.
2022-02-07 13:32:31 -08:00
Ryan Richard
2b93fdf357 Fix a bug in the e2e tests
When the test was going to fail, a goroutine would accidentally block
on writing to an unbuffered channel, and the spawnTestGoroutine helper
would wait for that goroutine to end on cleanup, causing the test to
hang forever while it was trying to fail.
2022-02-07 11:57:54 -08:00
Ryan Richard
7b97f1533e Add CORS request handling to CLI's localhost listener
This is to support the new changes in Google Chrome v98 which now
performs CORS preflight requests for the Javascript form submission
on the Supervisor's login page, even though the form is being submitted
to a localhost listener.
2022-02-04 16:57:37 -08:00
anjalitelang
7c246784dc Update ROADMAP.md
Updated roadmap to reflect changes planned for v0.14 release and beyond.
2022-02-03 08:57:47 -05:00
anjalitelang
0dd3b40694 Update ROADMAP.md 2022-01-31 12:13:18 -05:00
Margo Crawford
3b1153cd91 Update latest version to v0.13.0 2022-01-21 15:19:40 -08:00
anjalitelang
6590230bcd Merge pull request #954 from anjaltelang/main
Blog for v0.13.0
2022-01-21 15:17:18 -08:00
Pinny
4f06cd3c2e Update CLI docs for v0.13.0 release 2022-01-21 23:12:12 +00:00
14 changed files with 563 additions and 149 deletions

View File

@@ -33,16 +33,16 @@ The following table includes the current roadmap for Pinniped. If you have any q
Last Updated: Sept 2021
Last Updated: Jan 2022
|Theme|Description|Timeline|
|--|--|--|
|Improving Security Posture|Supervisor token refresh fails when the upstream refresh token no longer works for OIDC |Jan 2022|
|Improving Security Posture|Supervisor token refresh fails when the upstream user is in an invalid state for LDAP/AD |Jan 2022|
|Improving Security Posture|Set stricter default TLS versions and Ciphers |Jan 2022|
|Improving Security Posture|Support FIPS compliant Boring crypto libraries |Feb 2022|
|Improving Security Posture|Support for refreshing LDAP/AD Group information |Feb 2022|
|Improving Documentation|Documentation updates for HowTo guides and Workspace ONE IDP |Feb/March 2022|
|Improving Security Posture|Support FIPS compliant Boring crypto libraries |Feb/March 2022|
|Multiple IDP support|Support multiple IDPs configured on a single Supervisor|March/April 2022|
|Improving Security Posture|TLS hardening |March/April 2022|
|Improving Security Posture|Support Audit logging of security events related to Authentication |April/May 2022|
|Improving Usability|Support for integrating with UI/Dashboards |June/July 2022|
|Improving Security Posture|mTLS for Supervisor sessions |Exploring/Ongoing|
|Improving Security Posture|Key management/rotation for Pinniped components with minimal downtime |Exploring/Ongoing|
|Improving Security Posture|Support for Session Logout |Exploring/Ongoing|

View File

@@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
window.onload = () => {
@@ -44,11 +44,22 @@ window.onload = () => {
responseParams['redirect_uri'].value,
{
method: 'POST',
mode: 'no-cors',
mode: 'no-cors', // in the future, we could change this to "cors" (see comment below)
headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'},
body: responseParams['encoded_params'].value,
})
.then(() => clearTimeout(timeout))
.then(() => transitionToState('success'))
.then(response => {
clearTimeout(timeout);
// Requests made using "no-cors" mode will hide the real response.status by making it 0
// and the real response.ok by making it false.
// If the real response was success, then we would like to show the success state.
// If the real response was an error, then we wish we could show the manual
// state, but we have no way to know that, as long as we are making "no-cors" requests.
// For now, show the success status for all responses.
// In the future, we could make this request in "cors" mode once old versions of our CLI
// which did not handle CORS are upgraded out by our users. That would allow us to use
// a conditional statement based on response.ok here to decide which state to transition into.
transitionToState('success');
})
.catch(() => transitionToState('manual'));
};

View File

@@ -30,7 +30,7 @@ var (
<head>
<meta charset="UTF-8">
<style>body{font-family:metropolis-light,Helvetica,sans-serif}h1{font-size:20px}.state{position:absolute;top:100px;left:50%;width:400px;height:80px;margin-top:-40px;margin-left:-200px;font-size:14px;line-height:24px}button{margin:-10px;padding:10px;text-align:left;width:100%;display:inline;border:none;background:0 0;cursor:pointer;transition:all .1s}button:hover{background-color:#eee;transform:scale(1.01)}button:active{background-color:#ddd;transform:scale(.99)}code{display:block;word-wrap:break-word;word-break:break-all;font-size:12px;font-family:monospace;color:#333}.copy-icon{float:left;width:36px;height:36px;margin-top:-3px;margin-right:10px;background-size:contain;background-repeat:no-repeat;background-image:url("data:image/svg+xml,%3Csvg width=%2236%22 height=%2236%22 viewBox=%220 0 36 36%22 xmlns=%22http://www.w3.org/2000/svg%22 xmlns:xlink=%22http://www.w3.org/1999/xlink%22%3E%3Ctitle%3Ecopy-to-clipboard-line%3C/title%3E%3Cpath d=%22M22.6 4H21.55a3.89 3.89.0 00-7.31.0H13.4A2.41 2.41.0 0011 6.4V10H25V6.4A2.41 2.41.0 0022.6 4zM23 8H13V6.25A.25.25.0 0113.25 6h2.69l.12-1.11A1.24 1.24.0 0116.61 4a2 2 0 013.15 1.18l.09.84h2.9a.25.25.0 01.25.25z%22 class=%22clr-i-outline clr-i-outline-path-1%22/%3E%3Cpath d=%22M33.25 18.06H21.33l2.84-2.83a1 1 0 10-1.42-1.42L17.5 19.06l5.25 5.25a1 1 0 00.71.29 1 1 0 00.71-1.7l-2.84-2.84H33.25a1 1 0 000-2z%22 class=%22clr-i-outline clr-i-outline-path-2%22/%3E%3Cpath d=%22M29 16h2V6.68A1.66 1.66.0 0029.35 5H27.08V7H29z%22 class=%22clr-i-outline clr-i-outline-path-3%22/%3E%3Cpath d=%22M29 31H7V7H9V5H6.64A1.66 1.66.0 005 6.67V31.32A1.66 1.66.0 006.65 33H29.36A1.66 1.66.0 0031 31.33V22.06H29z%22 class=%22clr-i-outline clr-i-outline-path-4%22/%3E%3Crect x=%220%22 y=%220%22 width=%2236%22 height=%2236%22 fill-opacity=%220%22/%3E%3C/svg%3E")}@keyframes loader{to{transform:rotate(360deg)}}#loading{content:'';box-sizing:border-box;width:80px;height:80px;margin-top:-40px;margin-left:-40px;border-radius:50%;border:2px solid #fff;border-top-color:#1b3951;animation:loader .6s linear infinite}</style>
<script>window.onload=()=>{const e=t=>{Array.from(document.querySelectorAll('.state')).forEach(e=>e.hidden=!0);const e=document.getElementById(t);e.hidden=!1,document.title=e.dataset.title,document.getElementById('favicon').setAttribute('href','data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>'+e.dataset.favicon+'</text></svg>')};e('loading'),window.history.replaceState(null,'','./'),document.getElementById('manual-copy-button').onclick=()=>{const e=document.getElementById('manual-copy-button').innerText;navigator.clipboard.writeText(e).then(()=>console.info('copied authorization code '+e+' to clipboard')).catch(t=>console.error('failed to copy code '+e+' to clipboard: '+t))};const n=setTimeout(()=>e('manual'),2e3),t=document.forms[0].elements;fetch(t.redirect_uri.value,{method:'POST',mode:'no-cors',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:t.encoded_params.value}).then(()=>clearTimeout(n)).then(()=>e('success')).catch(()=>e('manual'))}</script>
<script>window.onload=()=>{const e=t=>{Array.from(document.querySelectorAll('.state')).forEach(e=>e.hidden=!0);const e=document.getElementById(t);e.hidden=!1,document.title=e.dataset.title,document.getElementById('favicon').setAttribute('href','data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>'+e.dataset.favicon+'</text></svg>')};e('loading'),window.history.replaceState(null,'','./'),document.getElementById('manual-copy-button').onclick=()=>{const e=document.getElementById('manual-copy-button').innerText;navigator.clipboard.writeText(e).then(()=>console.info('copied authorization code '+e+' to clipboard')).catch(t=>console.error('failed to copy code '+e+' to clipboard: '+t))};const n=setTimeout(()=>e('manual'),2e3),t=document.forms[0].elements;fetch(t.redirect_uri.value,{method:'POST',mode:'no-cors',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:t.encoded_params.value}).then(t=>{clearTimeout(n),e('success')}).catch(()=>e('manual'))}</script>
<link id="favicon" rel="icon"/>
</head>
<body>
@@ -61,7 +61,7 @@ var (
// It's okay if this changes in the future, but this gives us a chance to eyeball the formatting.
// Our browser-based integration tests should find any incompatibilities.
testExpectedCSP = `default-src 'none'; ` +
`script-src 'sha256-cjTdJmRvuz5EHNb/cw6pFk9iWyjegU9Ihx7Fb9tlqRg='; ` +
`script-src 'sha256-P1dCaXS9frmkvGZ/cH/UljR70IOH963lmfptEgcn9j8='; ` +
`style-src 'sha256-CtfkX7m8x2UdGYvGgDq+6b6yIAQsASW9pbQK+sG8fNA='; ` +
`img-src data:; ` +
`connect-src *; ` +

View File

@@ -834,21 +834,68 @@ func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Req
}()
var params url.Values
if h.useFormPost {
// Return HTTP 405 for anything that's not a POST.
if r.Method != http.MethodPost {
return httperr.Newf(http.StatusMethodNotAllowed, "wanted POST")
if h.useFormPost { // nolint:nestif
// Return HTTP 405 for anything that's not a POST or an OPTIONS request.
if r.Method != http.MethodPost && r.Method != http.MethodOptions {
h.logger.V(debugLogLevel).Info("Pinniped: Got unexpected request on callback listener", "method", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
return nil // keep listening for more requests
}
// Parse and pull the response parameters from a application/x-www-form-urlencoded request body.
// For POST and OPTIONS requests, calculate the allowed origin for CORS.
issuerURL, parseErr := url.Parse(h.issuer)
if parseErr != nil {
return httperr.Wrap(http.StatusInternalServerError, "invalid issuer url", parseErr)
}
allowOrigin := issuerURL.Scheme + "://" + issuerURL.Host
if r.Method == http.MethodOptions {
// Google Chrome decided that it should do CORS preflight checks for this Javascript form submission POST request.
// See https://developer.chrome.com/blog/private-network-access-preflight/
origin := r.Header.Get("Origin")
if origin == "" {
// The CORS preflight request should have an origin.
h.logger.V(debugLogLevel).Info("Pinniped: Got OPTIONS request without origin header")
w.WriteHeader(http.StatusBadRequest)
return nil // keep listening for more requests
}
h.logger.V(debugLogLevel).Info("Pinniped: Got CORS preflight request from browser", "origin", origin)
// To tell the browser that it is okay to make the real POST request, return the following response.
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Vary", "*") // supposed to use Vary when Access-Control-Allow-Origin is a specific host
w.Header().Set("Access-Control-Allow-Credentials", "false")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Private-Network", "true")
// If the browser would like to send some headers on the real request, allow them. Chrome doesn't
// currently send this header at the moment. This is in case some browser in the future decides to
// request to be allowed to send specific headers by using Access-Control-Request-Headers.
requestedHeaders := r.Header.Get("Access-Control-Request-Headers")
if requestedHeaders != "" {
w.Header().Set("Access-Control-Allow-Headers", requestedHeaders)
}
w.WriteHeader(http.StatusNoContent)
return nil // keep listening for more requests
} // Otherwise, this is a POST request...
// Parse and pull the response parameters from an application/x-www-form-urlencoded request body.
if err := r.ParseForm(); err != nil {
return httperr.Wrap(http.StatusBadRequest, "invalid form", err)
}
params = r.Form
// Allow CORS requests for POST so in the future our Javascript code can be updated to use the fetch API's
// mode "cors", and still be compatible with older CLI versions starting with those that have this code
// for CORS headers. Updating to use CORS would allow our Javascript code (form_post.js) to see the true
// http response status from this endpoint. Note that the POST response does not need to set as many CORS
// headers as the OPTIONS preflight response.
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Vary", "*") // supposed to use Vary when Access-Control-Allow-Origin is a specific host
} else {
// Return HTTP 405 for anything that's not a GET.
if r.Method != http.MethodGet {
return httperr.Newf(http.StatusMethodNotAllowed, "wanted GET")
h.logger.V(debugLogLevel).Info("Pinniped: Got unexpected request on callback listener", "method", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
return nil // keep listening for more requests
}
// Pull response parameters from the URL query string.

View File

@@ -1825,6 +1825,8 @@ func TestHandlePasteCallback(t *testing.T) {
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := &handlerState{
callbacks: make(chan callbackResult, 1),
state: state.State("test-state"),
@@ -1866,62 +1868,161 @@ func TestHandleAuthCodeCallback(t *testing.T) {
}
}
tests := []struct {
name string
method string
query string
body []byte
contentType string
opt func(t *testing.T) Option
wantErr string
wantHTTPStatus int
name string
method string
query string
body []byte
headers http.Header
opt func(t *testing.T) Option
wantErr string
wantHTTPStatus int
wantNoCallbacks bool
wantHeaders http.Header
}{
{
name: "wrong method",
method: "POST",
query: "",
wantErr: "wanted GET",
wantHTTPStatus: http.StatusMethodNotAllowed,
name: "wrong method returns an error but keeps listening",
method: http.MethodPost,
query: "",
wantNoCallbacks: true,
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusMethodNotAllowed,
},
{
name: "wrong method for form_post",
method: "GET",
query: "",
opt: withFormPostMode,
wantErr: "wanted POST",
wantHTTPStatus: http.StatusMethodNotAllowed,
name: "wrong method for form_post returns an error but keeps listening",
method: http.MethodGet,
query: "",
opt: withFormPostMode,
wantNoCallbacks: true,
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusMethodNotAllowed,
},
{
name: "invalid form for form_post",
method: "POST",
method: http.MethodPost,
query: "",
contentType: "application/x-www-form-urlencoded",
headers: map[string][]string{"Content-Type": {"application/x-www-form-urlencoded"}},
body: []byte(`%`),
opt: withFormPostMode,
wantErr: `invalid form: invalid URL escape "%"`,
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusBadRequest,
},
{
name: "invalid state",
query: "state=invalid",
wantErr: "missing or invalid state parameter",
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusForbidden,
},
{
name: "error code from provider",
query: "state=test-state&error=some_error",
wantErr: `login failed with code "some_error"`,
wantHeaders: map[string][]string{},
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`,
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusBadRequest,
},
{
name: "in form post mode, invalid issuer url config during CORS preflight request returns an error",
method: http.MethodOptions,
query: "",
headers: map[string][]string{"Origin": {"https://some-origin.com"}},
wantErr: `invalid issuer url: parse "://bad-url": missing protocol scheme`,
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusInternalServerError,
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
h.useFormPost = true
h.issuer = "://bad-url"
return nil
}
},
},
{
name: "in form post mode, invalid issuer url config during POST request returns an error",
method: http.MethodPost,
query: "",
headers: map[string][]string{"Origin": {"https://some-origin.com"}},
wantErr: `invalid issuer url: parse "://bad-url": missing protocol scheme`,
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusInternalServerError,
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
h.useFormPost = true
h.issuer = "://bad-url"
return nil
}
},
},
{
name: "in form post mode, options request is missing origin header results in 400 and keeps listener running",
method: http.MethodOptions,
query: "",
opt: withFormPostMode,
wantNoCallbacks: true,
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusBadRequest,
},
{
name: "in form post mode, valid CORS request responds with 402 and CORS headers and keeps listener running",
method: http.MethodOptions,
query: "",
headers: map[string][]string{"Origin": {"https://some-origin.com"}},
wantNoCallbacks: true,
wantHTTPStatus: http.StatusNoContent,
wantHeaders: map[string][]string{
"Access-Control-Allow-Credentials": {"false"},
"Access-Control-Allow-Methods": {"POST, OPTIONS"},
"Access-Control-Allow-Origin": {"https://valid-issuer.com"},
"Vary": {"*"},
"Access-Control-Allow-Private-Network": {"true"},
},
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
h.useFormPost = true
h.issuer = "https://valid-issuer.com/with/some/path"
return nil
}
},
},
{
name: "in form post mode, valid CORS request with Access-Control-Request-Headers responds with 402 and CORS headers including Access-Control-Allow-Headers and keeps listener running",
method: http.MethodOptions,
query: "",
headers: map[string][]string{
"Origin": {"https://some-origin.com"},
"Access-Control-Request-Headers": {"header1, header2, header3"},
},
wantNoCallbacks: true,
wantHTTPStatus: http.StatusNoContent,
wantHeaders: map[string][]string{
"Access-Control-Allow-Credentials": {"false"},
"Access-Control-Allow-Methods": {"POST, OPTIONS"},
"Access-Control-Allow-Origin": {"https://valid-issuer.com"},
"Vary": {"*"},
"Access-Control-Allow-Private-Network": {"true"},
"Access-Control-Allow-Headers": {"header1, header2, header3"},
},
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
h.useFormPost = true
h.issuer = "https://valid-issuer.com/with/some/path"
return nil
}
},
},
{
name: "invalid code",
query: "state=test-state&code=invalid",
wantErr: "could not complete code exchange: some exchange error",
wantHeaders: map[string][]string{},
wantHTTPStatus: http.StatusBadRequest,
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
@@ -1938,8 +2039,10 @@ func TestHandleAuthCodeCallback(t *testing.T) {
},
},
{
name: "valid",
query: "state=test-state&code=valid",
name: "valid",
query: "state=test-state&code=valid",
wantHTTPStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"text/plain; charset=utf-8"}},
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI}
@@ -1955,10 +2058,45 @@ func TestHandleAuthCodeCallback(t *testing.T) {
},
},
{
name: "valid form_post",
method: http.MethodPost,
contentType: "application/x-www-form-urlencoded",
body: []byte(`state=test-state&code=valid`),
name: "valid form_post",
method: http.MethodPost,
headers: map[string][]string{"Content-Type": {"application/x-www-form-urlencoded"}},
body: []byte(`state=test-state&code=valid`),
wantHeaders: map[string][]string{
"Access-Control-Allow-Origin": {"https://valid-issuer.com"},
"Vary": {"*"},
"Content-Type": {"text/plain; charset=utf-8"},
},
wantHTTPStatus: http.StatusOK,
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
}
},
},
{
name: "valid form_post made with the same origin headers that would be used by a Javascript fetch client using mode=cors",
method: http.MethodPost,
headers: map[string][]string{
"Content-Type": {"application/x-www-form-urlencoded"},
"Origin": {"https://some-origin.com"},
},
body: []byte(`state=test-state&code=valid`),
wantHeaders: map[string][]string{
"Access-Control-Allow-Origin": {"https://valid-issuer.com"},
"Vary": {"*"},
"Content-Type": {"text/plain; charset=utf-8"},
},
wantHTTPStatus: http.StatusOK,
opt: func(t *testing.T) Option {
return func(h *handlerState) error {
h.useFormPost = true
@@ -1978,11 +2116,15 @@ func TestHandleAuthCodeCallback(t *testing.T) {
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := &handlerState{
callbacks: make(chan callbackResult, 1),
state: state.State("test-state"),
pkce: pkce.Code("test-pkce"),
nonce: nonce.Nonce("test-nonce"),
logger: testlogger.New(t).Logger,
issuer: "https://valid-issuer.com/with/some/path",
}
if tt.opt != nil {
require.NoError(t, tt.opt(t)(h))
@@ -1998,8 +2140,8 @@ func TestHandleAuthCodeCallback(t *testing.T) {
if tt.method != "" {
req.Method = tt.method
}
if tt.contentType != "" {
req.Header.Set("Content-Type", tt.contentType)
if tt.headers != nil {
req.Header = tt.headers
}
err = h.handleAuthCodeCallback(resp, req)
@@ -2012,11 +2154,19 @@ func TestHandleAuthCodeCallback(t *testing.T) {
}
} else {
require.NoError(t, err)
require.Equal(t, tt.wantHTTPStatus, resp.Code)
}
if tt.wantHeaders != nil {
require.Equal(t, tt.wantHeaders, resp.Header())
}
gotCallback := false
select {
case <-time.After(1 * time.Second):
require.Fail(t, "timed out waiting to receive from callbacks channel")
if !tt.wantNoCallbacks {
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)
@@ -2025,7 +2175,9 @@ func TestHandleAuthCodeCallback(t *testing.T) {
require.NoError(t, result.err)
require.NotNil(t, result.token)
require.Equal(t, result.token.IDToken.Token, "test-id-token")
gotCallback = true
}
require.Equal(t, tt.wantNoCallbacks, !gotCallback)
})
}
}

View File

@@ -7,7 +7,7 @@ params:
github_url: "https://github.com/vmware-tanzu/pinniped"
slack_url: "https://kubernetes.slack.com/messages/pinniped"
community_url: "https://go.pinniped.dev/community"
latest_version: v0.12.0
latest_version: v0.13.0
pygmentsCodefences: true
pygmentsStyle: "pygments"
markup:

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View File

@@ -12,27 +12,31 @@ menu:
## pinniped completion bash
generate the autocompletion script for bash
Generate the autocompletion script for bash
### Synopsis
Generate the autocompletion script for the bash shell.
This script depends on the 'bash-completion' package.
If it is not installed already, you can install it via your OS's package manager.
To load completions in your current shell session:
$ source <(pinniped completion bash)
source <(pinniped completion bash)
To load completions for every new session, execute once:
Linux:
$ pinniped completion bash > /etc/bash_completion.d/pinniped
MacOS:
$ pinniped completion bash > /usr/local/etc/bash_completion.d/pinniped
#### Linux:
pinniped completion bash > /etc/bash_completion.d/pinniped
#### macOS:
pinniped completion bash > /usr/local/etc/bash_completion.d/pinniped
You will need to start a new shell for this setup to take effect.
```
pinniped completion bash
@@ -47,22 +51,23 @@ pinniped completion bash
### SEE ALSO
* [pinniped completion]() - generate the autocompletion script for the specified shell
* [pinniped completion]() - Generate the autocompletion script for the specified shell
## pinniped completion fish
generate the autocompletion script for fish
Generate the autocompletion script for fish
### Synopsis
Generate the autocompletion script for the fish shell.
To load completions in your current shell session:
$ pinniped completion fish | source
pinniped completion fish | source
To load completions for every new session, execute once:
$ pinniped completion fish > ~/.config/fish/completions/pinniped.fish
pinniped completion fish > ~/.config/fish/completions/pinniped.fish
You will need to start a new shell for this setup to take effect.
@@ -80,19 +85,19 @@ pinniped completion fish [flags]
### SEE ALSO
* [pinniped completion]() - generate the autocompletion script for the specified shell
* [pinniped completion]() - Generate the autocompletion script for the specified shell
## pinniped completion powershell
generate the autocompletion script for powershell
Generate the autocompletion script for powershell
### Synopsis
Generate the autocompletion script for powershell.
To load completions in your current shell session:
PS C:\> pinniped completion powershell | Out-String | Invoke-Expression
pinniped completion powershell | Out-String | Invoke-Expression
To load completions for every new session, add the output of the above command
to your powershell profile.
@@ -111,27 +116,30 @@ pinniped completion powershell [flags]
### SEE ALSO
* [pinniped completion]() - generate the autocompletion script for the specified shell
* [pinniped completion]() - Generate the autocompletion script for the specified shell
## pinniped completion zsh
generate the autocompletion script for zsh
Generate the autocompletion script for zsh
### Synopsis
Generate the autocompletion script for the zsh shell.
If shell completion is not already enabled in your environment you will need
to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
echo "autoload -U compinit; compinit" >> ~/.zshrc
To load completions for every new session, execute once:
# Linux:
$ pinniped completion zsh > "${fpath[1]}/_pinniped"
# macOS:
$ pinniped completion zsh > /usr/local/share/zsh/site-functions/_pinniped
#### Linux:
pinniped completion zsh > "${fpath[1]}/_pinniped"
#### macOS:
pinniped completion zsh > /usr/local/share/zsh/site-functions/_pinniped
You will need to start a new shell for this setup to take effect.
@@ -149,7 +157,7 @@ pinniped completion zsh [flags]
### SEE ALSO
* [pinniped completion]() - generate the autocompletion script for the specified shell
* [pinniped completion]() - Generate the autocompletion script for the specified shell
## pinniped get kubeconfig
@@ -173,6 +181,7 @@ pinniped get kubeconfig [flags]
--credential-cache string Path to cluster-specific credentials cache
--generated-name-suffix string Suffix to append to generated cluster, context, user kubeconfig entries (default "-pinniped")
-h, --help help for kubeconfig
--install-hint string This text is shown to the user when the pinniped CLI is not installed. (default "The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli for more details")
--kubeconfig string Path to kubeconfig file
--kubeconfig-context string Kubeconfig context name (default: current active context)
--no-concierge Generate a configuration which does not use the Concierge, but sends the credential to the cluster directly

View File

@@ -0,0 +1,131 @@
---
title: "Pinniped v0.13.0: Security Hardened Pinniped"
slug: secure-tls-idp-refresh
date: 2022-01-21
author: Anjali Telang
image: https://images.unsplash.com/photo-1572880393162-0518ac760495?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1548&q=80
excerpt: "With the release of v0.13.0, Pinniped only supports the use of secure TLS ciphers, configurable Pinniped Supervisor listener ports, and reflecting changes made by the identity provider on the users Kubernetes cluster access"
tags: ['Margo Crawford','Ryan Richard', 'Mo Khan', 'Anjali Telang', 'release']
---
![seals on rocks](https://images.unsplash.com/photo-1572880393162-0518ac760495?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1548&q=80)
*Photo by [Neil Cooper](https://unsplash.com/@neilcooperl) on [Unsplash](https://unsplash.com/s/photos/seal)*
# Pinniped with tighter security posture
Kubernetes users deploying Pinniped in production environments have certain compliance control requirements. With the current release of Pinniped, our efforts are to provide features in Pinniped that meet some of these compliance and regulatory requirements. We have added defaults that give secure deployment options to the administrator while maintaining the best user experience for cluster access.
With v0.13.0 we include the use of secure TLS ciphers for all components and configurable listener for the Pinniped Supervisor server. However, one of our big new feature updates for this release is the support for reflecting any Identity Provider (IDP) changes to a user's information onto their Pinniped session and Kubernetes cluster access. *This feature will require attention from the cluster administrators responsible for setting up user access to Kubernetes clusters*, so please review details below as well as refer to documentation changes for IDP CRDs.
## IDP changes reflected onto Pinniped Session
A critical compliance use case that many organizations have to meet is to ensure that cluster access is revoked for any employee that has left the organization. As you may know, the Pinniped Supervisor allows users to authenticate with external Identity Providers(IDP) and then issues cluster-scoped tokens for accessing the clusters based on information from the IDP. Prior to the v0.13.0 release, the Supervisor would refresh user's session at regular intervals without making any calls back to the identity provider during the refresh to determine if anything has changed since the initial login. This enabled the desired user experience of “login once per day to access all your clusters”. However, this also meant that any IDP changes to user's information were not reflected on their cluster access until the end of day. With the v0.13.0 release, the Pinniped Supervisor will query the identity provider whenever it refreshes the user's session and will update the session based on any changes made in the IDP.
**Note for all existing Pinniped deployments:** This change updates the internal session storage format, so when an existing installation of Pinniped is upgraded to a version of Pinniped which includes this change, all existing user sessions will fail to refresh, causing users to have to re-login.
### OIDC Identity Provider triggered refreshes
Supporting OIDC IDP refreshes will require certain changes to the OIDCIdentityProvider resource on the cluster. These changes depend mostly on how your OIDC IDP handles refresh tokens. In general, your IDP will either honor sending refresh tokens or not. Lets look at what changes are needed in the IDP configuration for when refresh tokens are supported and when they are not supported.
#### When your OIDC IDP can return refresh tokens (Preferred approach)
If your OIDC IDP can return refresh tokens, it is likely following the recommendations of the OIDC spec as it relates to using the "offline_access" scope for requesting refresh tokens. In this case, you must add the "offline_access" scope name to the list in the **additionalScopes** setting in the **OIDCIdentityProvider resource**, unless the new default value of that setting takes care of it for you.
Note that before this release, the default value of additionalScopes was only "openid" whereas the new default value is to request all of the following scopes: "openid", "offline_access", "email", and "profile". Explicitly setting the *additionalScopes* field will override the default value.
**If you are an Existing Pinniped OIDC user upgrading to this version,** you may need to update the additionalScopes and additionalAuthorizeParameters in your pre-existing installation of the Pinniped Supervisor **before upgrading to this version** so that there is seamless upgrade experience for your end users accessing the cluster. You may also need to update the settings on your OIDC client in the UI or API of your IDP to allow the client to perform *refresh grants*. Please see below for an example using Okta.
Example Okta OIDCProvider CR with updated additionalScopes setting:
```yaml
apiVersion: idp.supervisor.pinniped.dev/v1alpha1
kind: OIDCIdentityProvider
metadata:
namespace: pinniped-supervisor
name: okta
spec:
authorizationConfig:
# Request any scopes other than "openid" for claims besides
# the default claims in your token. The "openid" scope is always
# included.
#
# To learn more about how to customize the claims returned, see here:
# https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/overview/
additionalScopes: [offline_access, groups, email]
# If you would also like to allow your end users to authenticate using
# a password grant, then change this to true. Password grants only work
# with applications created in Okta as "Native Applications".
allowPasswordGrant: false
```
Refer to a more complete example for configuring Okta at [how to configure Okta as IDP with Supervisor]({{< ref "docs/howto/configure-supervisor-with-okta.md" >}}).
Inside Okta, when you create the Application, make sure to select refresh tokens as the Grant type along with Authorization code. See below:
![Okta screenshot with Grant types](/docs/img/refresh-token-grant-okta.png)
#### When your OIDC IDP cannot return refresh tokens
In the case where your IDP is not capable of returning refresh tokens, for example if you are using Dex with the SAML connector, Pinniped will refresh the session using **Access tokens**. Pinniped will validate the Access tokens against the **userinfo endpoint** of your IDP. You are required to provide userinfo endpoint or refresh tokens for session validation. Your login will fail if neither of the two options is provided.
If your access tokens have a lifetime shorter than 3 hours, Pinniped will issue a **warning** that gets displayed to the end users CLI notifying them that the access token TTL is less than 3 hours and the end user will need to re-login again after it expires. Here, the administrator has the option to increase the access token's expiration time in their upstream IDP. A more detailed, admin focused warning is also emitted in the Pinniped supervisor pod logs.
### What about LDAP / Active Directory IDP changes?
LDAP does not have a concept of sessions or refresh tokens. Hence we run LDAP queries against the LDAP or AD IDP to approximate a refresh. For LDAP, we validate if the LDAP entry still exists with no changes to Pinniped UID and username fields. For AD, we validate the same LDAP checks and we also validate that the user's password has not changed since the original login (note that we only store the time that the password was last changed, not the password itself) and their account is not locked or disabled.
## Secure TLS ciphers
As part of our effort to harden Pinniped deployments, we have changed the TLS configuration for all Pinniped components. This will help meet the compliance standards for TLS ciphers in regulatory environments. *Note that this change does not offer any configuration options to the user.* We have tested our TLS configurations with Qualys' [ssltest tool]( https://www.ssllabs.com/ssltest) as well as with [sslyze](https://github.com/nabla-c0d3/sslyze). Please do provide us with any feedback in case your scanning tools show Pinniped is using TLS ciphers of concern to you.
What this means for each of the Pinniped components:
1. Pinniped CLI
- Uses TLS 1.3 for Kubernetes API calls
- Uses TLS 1.2+ and secure ciphers for all other connections
2. Pinniped Concierge
- Uses TLS 1.3 when acting as a server internal to the cluster
- Uses TLS 1.2+ and secure ciphers for the impersonation proxy server
- Uses TLS 1.3 for Kubernetes API calls
- Uses TLS 1.2+ and secure ciphers for JWT authenticator calls (OIDC distributed claim fetching will use this TLS config in a future version)
- The webhook authenticator is unchanged and should be fixed in a future release
3. Pinniped Supervisor
- Uses TLS 1.2+ and secure ciphers for its OIDC server
- Uses TLS 1.3 for Kubernetes API calls
- Uses TLS 1.2+ and secure ciphers against OIDC IDPs
- Uses TLS 1.2+ and secure ciphers and some legacy ciphers against LDAP IDPs
For TLS 1.2, secure ciphers refers to ciphers that provide perfect forward secrecy, confidentiality and authenticity of data. Legacy ciphers refers to ciphers that provide perfect forward secrecy and confidentiality of data but fail to provide authenticity of data. These legacy ciphers are required to support older LDAP IDPs that are still used today such as Active Directory on Windows Server 2012 R2. All TLS 1.3 ciphers support perfect forward secrecy, confidentiality and authenticity of data. Pinniped has never supported TLS versions less than 1.2 and there are no plans to support these deprecated TLS configurations.
## Configurable listen ports for Pinniped servers
One of the features we brought to the release is the ability to configure TLS listen ports for the Pinniped server components.
The listen ports on the Supervisors containers default to 8080 for HTTP and 8443 for HTTPS for both IPv4 and IPv6 addresses. **Note that we do not recommend exposing HTTP port 8080 outside the pod as it is an insecure configuration and has been deprecated in this release. It will be removed in a future release**. Since the Supervisor is an external-facing endpoint with end user access, exposing port 8080 as the listen port is a security risk and should be avoided. With this release, we give you the option to change the HTTP and HTTPS ports. We also allow these listeners to be disabled (for example, security conscious users may want to disable the HTTP listener altogether).
It is unlikely that you would need to override the default port numbers for the Concierge and Supervisor containers. An example of when it might be useful to change the port numbers is deploying the Concierge or Supervisor to a cluster whose nodes are using host networking, and where the default port numbers would conflict with other deployed applications.
More information can be found in the [Supervisor installation documentation]({{< ref "docs/howto/install-supervisor.md" >}}).
The Concierge listen port now **defaults to port 10250** instead of the previous value of 8443. This change helps in deploying the Concierge in firewalled / private cluster environments where traffic to port 10250 is allowed by default (such as in private GKE clusters).
## What else is in this release?
Refer to the [release notes for v0.13.0](https://github.com/vmware-tanzu/pinniped/releases/tag/v0.13.0) for a complete list of fixes and features included in the release.
## Community contributors
The Pinniped community continues to grow, and is a vital part of the project's success. This release includes contributions from users [@mayankbh](https://github.com/mayankbh) and [@rajat404](https://github.com/rajat404). Thank you for helping improve Pinniped!
We thrive on community feedback.
[Are you using Pinniped?](https://github.com/vmware-tanzu/pinniped/discussions/152)
Did you try our new security hardening features?
What other configurations do you need for secure authentication of users to your Kubernetes clusters?
Find us in [#pinniped](https://kubernetes.slack.com/archives/C01BW364RJA) on Kubernetes Slack,
[create an issue](https://github.com/vmware-tanzu/pinniped/issues/new/choose) on our Github repository,
or start a [Discussion](https://github.com/vmware-tanzu/pinniped/discussions).
{{< community >}}

View File

@@ -23,7 +23,8 @@
{{ partial "footer" . }}
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"></script>
<script type="text/javascript"> docsearch({
apiKey: '8f37841a145124eb42a0e3249d49a3c5',
appId: 'FYQE1VJB02',
apiKey: '826ac6c64f20d5e00a72a0599cf76177',
indexName: 'pinniped',
inputSelector: '.docsearch-input',
debug: false // Set debug to true if you want to inspect the dropdown

View File

@@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
@@ -293,32 +292,21 @@ func runPinnipedLoginOIDC(
t.Logf("starting CLI subprocess")
require.NoError(t, cmd.Start())
t.Cleanup(func() {
err := cmd.Wait()
err := cmd.Wait() // handles closing of file descriptors
t.Logf("CLI subprocess exited with code %d", cmd.ProcessState.ExitCode())
require.NoErrorf(t, err, "CLI process did not exit cleanly")
})
// Start a background goroutine to read stderr from the CLI and parse out the login URL.
loginURLChan := make(chan string)
spawnTestGoroutine(t, func() (err error) {
t.Helper()
defer func() {
closeErr := stderr.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stderr stream closed with error: %w", closeErr)
}
}()
loginURLChan := make(chan string, 1)
spawnTestGoroutine(ctx, t, func() error {
reader := bufio.NewReader(testlib.NewLoggerReader(t, "stderr", stderr))
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
loginURL, err := url.Parse(strings.TrimSpace(scanner.Text()))
if err == nil && loginURL.Scheme == "https" {
loginURLChan <- loginURL.String()
loginURLChan <- loginURL.String() // this channel is buffered so this will not block
return nil
}
}
@@ -327,23 +315,14 @@ func runPinnipedLoginOIDC(
})
// Start a background goroutine to read stdout from the CLI and parse out an ExecCredential.
credOutputChan := make(chan clientauthenticationv1beta1.ExecCredential)
spawnTestGoroutine(t, func() (err error) {
defer func() {
closeErr := stdout.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stdout stream closed with error: %w", closeErr)
}
}()
credOutputChan := make(chan clientauthenticationv1beta1.ExecCredential, 1)
spawnTestGoroutine(ctx, t, func() error {
reader := bufio.NewReader(testlib.NewLoggerReader(t, "stdout", stdout))
var out clientauthenticationv1beta1.ExecCredential
if err := json.NewDecoder(reader).Decode(&out); err != nil {
return fmt.Errorf("could not read ExecCredential from stdout: %w", err)
}
credOutputChan <- out
credOutputChan <- out // this channel is buffered so this will not block
return readAndExpectEmpty(reader)
})
@@ -398,11 +377,33 @@ func readAndExpectEmpty(r io.Reader) (err error) {
return nil
}
func spawnTestGoroutine(t *testing.T, f func() error) {
// Note: Callers should ensure that f eventually returns, otherwise this helper will leak a go routine.
func spawnTestGoroutine(ctx context.Context, t *testing.T, f func() error) {
t.Helper()
var eg errgroup.Group
t.Cleanup(func() {
require.NoError(t, eg.Wait(), "background goroutine failed")
egCh := make(chan error, 1) // do not block the go routine from exiting even after the select has completed
go func() {
egCh <- eg.Wait()
}()
leewayCh := make(chan struct{})
go func() {
<-ctx.Done()
// give f up to 30 seconds after the context is canceled to return
// this prevents "race" conditions where f is orchestrated via the same context
time.Sleep(30 * time.Second)
close(leewayCh)
}()
select {
case <-leewayCh:
t.Errorf("background goroutine hung: %v", ctx.Err())
case err := <-egCh:
require.NoError(t, err, "background goroutine failed")
}
})
eg.Go(f)
}

View File

@@ -19,6 +19,7 @@ import (
"regexp"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
@@ -29,6 +30,7 @@ import (
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
authv1alpha "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
@@ -49,7 +51,7 @@ import (
func TestE2EFullIntegration(t *testing.T) { // nolint:gocyclo
env := testlib.IntegrationEnv(t)
ctx, cancelFunc := context.WithTimeout(context.Background(), 15*time.Minute)
ctx, cancelFunc := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancelFunc()
// Build pinniped CLI.
@@ -106,6 +108,9 @@ func TestE2EFullIntegration(t *testing.T) { // nolint:gocyclo
// Add an OIDC upstream IDP and try using it to authenticate during kubectl commands.
t.Run("with Supervisor OIDC upstream IDP and automatic flow", func(t *testing.T) {
testCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
t.Cleanup(cancel)
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
page := browsertest.Open(t)
@@ -157,48 +162,58 @@ func TestE2EFullIntegration(t *testing.T) { // nolint:gocyclo
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
start := time.Now()
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6")
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
stderrPipe, err := kubectlCmd.StderrPipe()
// Wrap the stdout and stderr pipes with TeeReaders which will copy each incremental read to an
// in-memory buffer, so we can have the full output available to us at the end.
originalStderrPipe, err := kubectlCmd.StderrPipe()
require.NoError(t, err)
stdoutPipe, err := kubectlCmd.StdoutPipe()
originalStdoutPipe, err := kubectlCmd.StdoutPipe()
require.NoError(t, err)
var stderrPipeBuf, stdoutPipeBuf bytes.Buffer
stderrPipe := io.TeeReader(originalStderrPipe, &stderrPipeBuf)
stdoutPipe := io.TeeReader(originalStdoutPipe, &stdoutPipeBuf)
t.Logf("starting kubectl subprocess")
require.NoError(t, kubectlCmd.Start())
t.Cleanup(func() {
err := kubectlCmd.Wait()
// Consume readers so that the tee buffers will contain all the output so far.
_, stdoutReadAllErr := readAllCtx(testCtx, stdoutPipe)
_, stderrReadAllErr := readAllCtx(testCtx, stderrPipe)
// Note that Wait closes the stdout/stderr pipes, so we don't need to close them ourselves.
waitErr := kubectlCmd.Wait()
t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode())
stdout, stdoutErr := ioutil.ReadAll(stdoutPipe)
if stdoutErr != nil {
stdout = []byte("<error reading stdout: " + stdoutErr.Error() + ">")
// Upon failure, print the full output so far of the kubectl command.
var testAlreadyFailedErr error
if t.Failed() {
testAlreadyFailedErr = errors.New("test failed prior to clean up function")
}
stderr, stderrErr := ioutil.ReadAll(stderrPipe)
if stderrErr != nil {
stderr = []byte("<error reading stderr: " + stderrErr.Error() + ">")
cleanupErrs := utilerrors.NewAggregate([]error{waitErr, stdoutReadAllErr, stderrReadAllErr, testAlreadyFailedErr})
if cleanupErrs != nil {
t.Logf("kubectl stdout was:\n----start of stdout\n%s\n----end of stdout", stdoutPipeBuf.String())
t.Logf("kubectl stderr was:\n----start of stderr\n%s\n----end of stderr", stderrPipeBuf.String())
}
require.NoErrorf(t, err, "kubectl process did not exit cleanly, stdout/stderr: %q/%q", string(stdout), string(stderr))
require.NoErrorf(t, cleanupErrs, "kubectl process did not exit cleanly and/or the test failed. "+
"Note: if kubectl's first call to the Pinniped CLI results in the Pinniped CLI returning an error, "+
"then kubectl may call the Pinniped CLI again, which may hang because it will wait for the user "+
"to finish the login. This test will kill the kubectl process after a timeout. In this case, the "+
" kubectl output printed above will include multiple prompts for the user to enter their authcode.",
)
})
// Start a background goroutine to read stderr from the CLI and parse out the login URL.
loginURLChan := make(chan string)
spawnTestGoroutine(t, func() (err error) {
defer func() {
closeErr := stderrPipe.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stderr stream closed with error: %w", closeErr)
}
}()
loginURLChan := make(chan string, 1)
spawnTestGoroutine(testCtx, t, func() error {
reader := bufio.NewReader(testlib.NewLoggerReader(t, "stderr", stderrPipe))
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
loginURL, err := url.Parse(strings.TrimSpace(scanner.Text()))
if err == nil && loginURL.Scheme == "https" {
loginURLChan <- loginURL.String()
loginURLChan <- loginURL.String() // this channel is buffered so this will not block
return nil
}
}
@@ -206,23 +221,14 @@ func TestE2EFullIntegration(t *testing.T) { // nolint:gocyclo
})
// Start a background goroutine to read stdout from kubectl and return the result as a string.
kubectlOutputChan := make(chan string)
spawnTestGoroutine(t, func() (err error) {
defer func() {
closeErr := stdoutPipe.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stdout stream closed with error: %w", closeErr)
}
}()
output, err := ioutil.ReadAll(stdoutPipe)
kubectlOutputChan := make(chan string, 1)
spawnTestGoroutine(testCtx, t, func() error {
output, err := readAllCtx(testCtx, stdoutPipe)
if err != nil {
return err
}
t.Logf("kubectl output:\n%s\n", output)
kubectlOutputChan <- string(output)
kubectlOutputChan <- string(output) // this channel is buffered so this will not block
return nil
})
@@ -234,7 +240,7 @@ func TestE2EFullIntegration(t *testing.T) { // nolint:gocyclo
require.Fail(t, "timed out waiting for login URL")
case loginURL = <-loginURLChan:
}
t.Logf("navigating to login page")
t.Logf("navigating to login page: %q", loginURL)
require.NoError(t, page.Navigate(loginURL))
// Expect to be redirected to the upstream provider and log in.
@@ -260,7 +266,7 @@ func TestE2EFullIntegration(t *testing.T) { // nolint:gocyclo
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env,
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env,
downstream,
kubeconfigPath,
sessionCachePath,
@@ -1177,3 +1183,42 @@ func getSecretNameFromSignature(t *testing.T, signature string, typeLabel string
signatureAsValidName := strings.ToLower(b32.EncodeToString(signatureBytes))
return fmt.Sprintf("pinniped-storage-%s-%s", typeLabel, signatureAsValidName)
}
func readAllCtx(ctx context.Context, r io.Reader) ([]byte, error) {
errCh := make(chan error, 1)
data := &atomic.Value{}
go func() { // copied from io.ReadAll and modified to use the atomic.Value above
b := make([]byte, 0, 512)
data.Store(string(b)) // cast to string to make a copy of the byte slice
for {
if len(b) == cap(b) {
// Add more capacity (let append pick how much).
b = append(b, 0)[:len(b)]
data.Store(string(b)) // cast to string to make a copy of the byte slice
}
n, err := r.Read(b[len(b):cap(b)])
b = b[:len(b)+n]
data.Store(string(b)) // cast to string to make a copy of the byte slice
if err != nil {
if err == io.EOF {
err = nil
}
errCh <- err
return
}
}
}()
select {
case <-ctx.Done():
b, _ := data.Load().(string)
return nil, fmt.Errorf("failed to complete read all: %w, data read so far:\n%q", ctx.Err(), b)
case err := <-errCh:
b, _ := data.Load().(string)
if len(b) == 0 {
return nil, err
}
return []byte(b), err
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
@@ -60,6 +60,10 @@ func TestFormPostHTML_Parallel(t *testing.T) {
//
// This case is fairly unlikely in practice, and if the CLI encounters
// an error it can also expose it via stderr anyway.
//
// In the future, we could change the Javascript code to use mode 'cors'
// because we have upgraded our CLI callback endpoint to handle CORS,
// and then we could change this to formpostExpectManualState().
formpostExpectSuccessState(t, page)
})
@@ -109,6 +113,19 @@ func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values))
results := make(chan url.Values)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 404 for any other requests aside from POSTs. We do not need to support CORS preflight OPTIONS
// requests for this test because both the web page and the callback are on 127.0.0.1 (same origin).
if r.Method != http.MethodPost {
t.Logf("test callback server got unexpeted request method")
w.WriteHeader(http.StatusNotFound)
return
}
// Allow CORS requests. This will be needed for this test in the future if we change
// the Javascript code from using mode 'no-cors' to instead use mode 'cors'. At the
// moment it should be ignored by the browser.
w.Header().Set("Access-Control-Allow-Origin", "*")
assert.NoError(t, r.ParseForm())
// Extract only the POST parameters (r.Form also contains URL query parameters).

View File

@@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package browsertest provides integration test helpers for our browser-based tests.
@@ -119,7 +119,7 @@ func LoginToUpstream(t *testing.T, page *agouti.Page, upstream testlib.TestOIDCU
{
Name: "Okta",
IssuerPattern: regexp.MustCompile(`\Ahttps://.+\.okta\.com/.+\z`),
LoginPagePattern: regexp.MustCompile(`\Ahttps://.+\.okta\.com/.+\z`),
LoginPagePattern: regexp.MustCompile(`\Ahttps://.+\.okta\.com/.*\z`),
UsernameSelector: "input#okta-signin-username",
PasswordSelector: "input#okta-signin-password",
LoginButtonSelector: "input#okta-signin-submit",