mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-01-28 00:22:27 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d6a2af894 | ||
|
|
dab653f8df | ||
|
|
8698d71809 | ||
|
|
96d4d3ec7c | ||
|
|
7c87d7447c | ||
|
|
a2e578bdbb | ||
|
|
366782ab75 | ||
|
|
e4e764860a | ||
|
|
bb71545dee | ||
|
|
19ec85c84e | ||
|
|
427eef2038 | ||
|
|
28169637c8 | ||
|
|
a5b83c90a6 |
@@ -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'));
|
||||
};
|
||||
|
||||
@@ -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 formposthtml
|
||||
@@ -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 a=b=>{Array.from(document.querySelectorAll('.state')).forEach(a=>a.hidden=!0);const a=document.getElementById(b);a.hidden=!1,document.title=a.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>'+a.dataset.favicon+'</text></svg>')};a('loading'),window.history.replaceState(null,'','./'),document.getElementById('manual-copy-button').onclick=()=>{const a=document.getElementById('manual-copy-button').innerText;navigator.clipboard.writeText(a).then(()=>console.info('copied authorization code '+a+' to clipboard')).catch(b=>console.error('failed to copy code '+a+' to clipboard: '+b))};const c=setTimeout(()=>a('manual'),2e3),b=document.forms[0].elements;fetch(b.redirect_uri.value,{method:'POST',mode:'no-cors',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:b.encoded_params.value}).then(()=>clearTimeout(c)).then(()=>a('success')).catch(()=>a('manual'))}</script>
|
||||
<script>window.onload=()=>{const a=b=>{Array.from(document.querySelectorAll('.state')).forEach(a=>a.hidden=!0);const a=document.getElementById(b);a.hidden=!1,document.title=a.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>'+a.dataset.favicon+'</text></svg>')};a('loading'),window.history.replaceState(null,'','./'),document.getElementById('manual-copy-button').onclick=()=>{const a=document.getElementById('manual-copy-button').innerText;navigator.clipboard.writeText(a).then(()=>console.info('copied authorization code '+a+' to clipboard')).catch(b=>console.error('failed to copy code '+a+' to clipboard: '+b))};const c=setTimeout(()=>a('manual'),2e3),b=document.forms[0].elements;fetch(b.redirect_uri.value,{method:'POST',mode:'no-cors',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:b.encoded_params.value}).then(b=>{clearTimeout(c),a('success')}).catch(()=>a('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-U+tKnJ2oMSYKSxmSX3V2mPBN8xdr9JpampKAhbSo108='; ` +
|
||||
`script-src 'sha256-+M/LwI0kltqjqTbsYcEYpN4nMkcCMkOmJcr1pbUSP2Q='; ` +
|
||||
`style-src 'sha256-CtfkX7m8x2UdGYvGgDq+6b6yIAQsASW9pbQK+sG8fNA='; ` +
|
||||
`img-src data:; ` +
|
||||
`connect-src *; ` +
|
||||
@@ -83,6 +83,7 @@ func TestTemplate(t *testing.T) {
|
||||
Parameters: testResponseParams,
|
||||
}))
|
||||
|
||||
// t.Logf("actual value:\n%s", buf2.String()) // useful when updating minify library causes new output
|
||||
require.Equal(t, buf.String(), buf2.String())
|
||||
require.Equal(t, testExpectedFormPostOutput, buf.String())
|
||||
}
|
||||
|
||||
30
internal/testutil/kube_server_compatibility.go
Normal file
30
internal/testutil/kube_server_compatibility.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
certificatesv1 "k8s.io/api/certificates/v1"
|
||||
"k8s.io/client-go/discovery"
|
||||
)
|
||||
|
||||
func KubeServerSupportsCertificatesV1API(t *testing.T, discoveryClient discovery.DiscoveryInterface) bool {
|
||||
t.Helper()
|
||||
groupList, err := discoveryClient.ServerGroups()
|
||||
require.NoError(t, err)
|
||||
for _, group := range groupList.Groups {
|
||||
if group.Name == certificatesv1.GroupName {
|
||||
for _, version := range group.Versions {
|
||||
if version.Version == "v1" {
|
||||
// Note: v1 should exist in Kubernetes 1.19 and above
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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 oidcclient implements a CLI OIDC login flow.
|
||||
@@ -830,21 +830,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.
|
||||
|
||||
@@ -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 oidcclient
|
||||
@@ -1697,6 +1697,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"),
|
||||
@@ -1738,62 +1740,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 {
|
||||
@@ -1810,8 +1911,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}
|
||||
@@ -1827,10 +1930,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
|
||||
@@ -1850,11 +1988,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))
|
||||
@@ -1870,8 +2012,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)
|
||||
@@ -1884,11 +2026,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)
|
||||
@@ -1897,7 +2047,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -286,32 +285,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
|
||||
}
|
||||
}
|
||||
@@ -320,23 +308,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)
|
||||
})
|
||||
|
||||
@@ -391,11 +370,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -938,6 +938,9 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl
|
||||
namespaceName := testlib.CreateNamespace(ctx, t, "impersonation").Name
|
||||
kubeClient := adminClient.CoreV1()
|
||||
saName, _, saUID := createServiceAccountToken(ctx, t, adminClient, namespaceName)
|
||||
expectedUsername := serviceaccount.MakeUsername(namespaceName, saName)
|
||||
expectedUID := string(saUID)
|
||||
expectedGroups := []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}
|
||||
|
||||
_, tokenRequestProbeErr := kubeClient.ServiceAccounts(namespaceName).CreateToken(ctx, saName, &authenticationv1.TokenRequest{}, metav1.CreateOptions{})
|
||||
if k8serrors.IsNotFound(tokenRequestProbeErr) && tokenRequestProbeErr.Error() == "the server could not find the requested resource" {
|
||||
@@ -1002,8 +1005,8 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl
|
||||
// new service account tokens include the pod info in the extra fields
|
||||
require.Equal(t,
|
||||
expectedWhoAmIRequestResponse(
|
||||
serviceaccount.MakeUsername(namespaceName, saName),
|
||||
[]string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"},
|
||||
expectedUsername,
|
||||
expectedGroups,
|
||||
map[string]identityv1alpha1.ExtraValue{
|
||||
"authentication.kubernetes.io/pod-name": {pod.Name},
|
||||
"authentication.kubernetes.io/pod-uid": {string(pod.UID)},
|
||||
@@ -1017,7 +1020,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl
|
||||
rbacv1.Subject{Kind: rbacv1.ServiceAccountKind, Name: saName, Namespace: namespaceName},
|
||||
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "system:node-bootstrapper"},
|
||||
)
|
||||
testlib.WaitForUserToHaveAccess(t, serviceaccount.MakeUsername(namespaceName, saName), []string{}, &authorizationv1.ResourceAttributes{
|
||||
testlib.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
|
||||
Verb: "create", Group: certificatesv1.GroupName, Version: "*", Resource: "certificatesigningrequests",
|
||||
})
|
||||
|
||||
@@ -1041,20 +1044,34 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
saCSR, err := impersonationProxySAClient.Kubernetes.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = adminClient.CertificatesV1beta1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// make sure the user info that the CSR captured matches the SA, including the UID
|
||||
require.Equal(t, serviceaccount.MakeUsername(namespaceName, saName), saCSR.Spec.Username)
|
||||
require.Equal(t, string(saUID), saCSR.Spec.UID)
|
||||
require.Equal(t, []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}, saCSR.Spec.Groups)
|
||||
require.Equal(t, map[string]certificatesv1beta1.ExtraValue{
|
||||
"authentication.kubernetes.io/pod-name": {pod.Name},
|
||||
"authentication.kubernetes.io/pod-uid": {string(pod.UID)},
|
||||
}, saCSR.Spec.Extra)
|
||||
if testutil.KubeServerSupportsCertificatesV1API(t, adminClient.Discovery()) {
|
||||
saCSR, err := impersonationProxySAClient.Kubernetes.CertificatesV1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
err = adminClient.CertificatesV1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
// make sure the user info that the CSR captured matches the SA, including the UID
|
||||
require.Equal(t, expectedUsername, saCSR.Spec.Username)
|
||||
require.Equal(t, expectedUID, saCSR.Spec.UID)
|
||||
require.Equal(t, expectedGroups, saCSR.Spec.Groups)
|
||||
require.Equal(t, map[string]certificatesv1.ExtraValue{
|
||||
"authentication.kubernetes.io/pod-name": {pod.Name},
|
||||
"authentication.kubernetes.io/pod-uid": {string(pod.UID)},
|
||||
}, saCSR.Spec.Extra)
|
||||
} else {
|
||||
// On old Kubernetes clusters use CertificatesV1beta1
|
||||
saCSR, err := impersonationProxySAClient.Kubernetes.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
err = adminClient.CertificatesV1beta1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
// make sure the user info that the CSR captured matches the SA, including the UID
|
||||
require.Equal(t, expectedUsername, saCSR.Spec.Username)
|
||||
require.Equal(t, expectedUID, saCSR.Spec.UID)
|
||||
require.Equal(t, expectedGroups, saCSR.Spec.Groups)
|
||||
require.Equal(t, map[string]certificatesv1beta1.ExtraValue{
|
||||
"authentication.kubernetes.io/pod-name": {pod.Name},
|
||||
"authentication.kubernetes.io/pod-uid": {string(pod.UID)},
|
||||
}, saCSR.Spec.Extra)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("kubectl as a client", func(t *testing.T) {
|
||||
@@ -2416,7 +2433,7 @@ func getCredForConfig(t *testing.T, config *rest.Config) *loginv1alpha1.ClusterC
|
||||
return out
|
||||
}
|
||||
|
||||
func getUIDAndExtraViaCSR(ctx context.Context, t *testing.T, uid string, client kubernetes.Interface) (string, map[string]certificatesv1beta1.ExtraValue) {
|
||||
func getUIDAndExtraViaCSR(ctx context.Context, t *testing.T, uid string, client kubernetes.Interface) (string, map[string][]string) {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
@@ -2439,18 +2456,43 @@ func getUIDAndExtraViaCSR(ctx context.Context, t *testing.T, uid string, client
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
csReq, err := client.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.CertificatesV1beta1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
outUID := uid // in the future this may not be empty on some clusters
|
||||
if len(outUID) == 0 {
|
||||
outUID = csReq.Spec.UID
|
||||
extrasAsStrings := map[string][]string{}
|
||||
|
||||
if testutil.KubeServerSupportsCertificatesV1API(t, client.Discovery()) {
|
||||
csReq, err := client.CertificatesV1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.CertificatesV1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(outUID) == 0 {
|
||||
outUID = csReq.Spec.UID
|
||||
}
|
||||
|
||||
// Convert each `ExtraValue` to `[]string` to return, so we don't have to deal with v1beta1 types versus v1 types
|
||||
for k, v := range csReq.Spec.Extra {
|
||||
extrasAsStrings[k] = v
|
||||
}
|
||||
} else {
|
||||
// On old Kubernetes clusters use CertificatesV1beta1
|
||||
csReq, err := client.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.CertificatesV1beta1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(outUID) == 0 {
|
||||
outUID = csReq.Spec.UID
|
||||
}
|
||||
|
||||
// Convert each `ExtraValue` to `[]string` to return, so we don't have to deal with v1beta1 types versus v1 types
|
||||
for k, v := range csReq.Spec.Extra {
|
||||
extrasAsStrings[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return outUID, csReq.Spec.Extra
|
||||
return outUID, extrasAsStrings
|
||||
}
|
||||
|
||||
func parallelIfNotEKS(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,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"
|
||||
@@ -47,10 +49,10 @@ import (
|
||||
)
|
||||
|
||||
// TestE2EFullIntegration tests a full integration scenario that combines the supervisor, concierge, and CLI.
|
||||
func TestE2EFullIntegration(t *testing.T) { // nolint:gocyclo
|
||||
func TestE2EFullIntegration(t *testing.T) {
|
||||
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.
|
||||
@@ -107,6 +109,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)
|
||||
|
||||
@@ -158,48 +163,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
|
||||
}
|
||||
}
|
||||
@@ -207,23 +222,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
|
||||
})
|
||||
|
||||
@@ -235,7 +241,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.
|
||||
@@ -261,7 +267,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,
|
||||
@@ -1056,3 +1062,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -108,6 +112,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"k8s.io/client-go/util/keyutil"
|
||||
|
||||
identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1"
|
||||
"go.pinniped.dev/internal/testutil"
|
||||
"go.pinniped.dev/test/testlib"
|
||||
)
|
||||
|
||||
@@ -281,28 +282,53 @@ func TestWhoAmI_CSR_Parallel(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
useCertificatesV1API := testutil.KubeServerSupportsCertificatesV1API(t, kubeClient.Discovery())
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, kubeClient.CertificatesV1beta1().CertificateSigningRequests().
|
||||
Delete(context.Background(), csrName, metav1.DeleteOptions{}))
|
||||
if useCertificatesV1API {
|
||||
require.NoError(t, kubeClient.CertificatesV1().CertificateSigningRequests().
|
||||
Delete(context.Background(), csrName, metav1.DeleteOptions{}))
|
||||
} else {
|
||||
// On old clusters use v1beta1
|
||||
require.NoError(t, kubeClient.CertificatesV1beta1().CertificateSigningRequests().
|
||||
Delete(context.Background(), csrName, metav1.DeleteOptions{}))
|
||||
}
|
||||
})
|
||||
|
||||
// this is a blind update with no resource version checks, which is only safe during tests
|
||||
// use the beta CSR API to support older clusters
|
||||
_, err = kubeClient.CertificatesV1beta1().CertificateSigningRequests().UpdateApproval(ctx, &certificatesv1beta1.CertificateSigningRequest{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: csrName,
|
||||
},
|
||||
Status: certificatesv1beta1.CertificateSigningRequestStatus{
|
||||
Conditions: []certificatesv1beta1.CertificateSigningRequestCondition{
|
||||
{
|
||||
Type: certificatesv1beta1.CertificateApproved,
|
||||
Status: corev1.ConditionTrue,
|
||||
Reason: "WhoAmICSRTest",
|
||||
if useCertificatesV1API {
|
||||
_, err = kubeClient.CertificatesV1().CertificateSigningRequests().UpdateApproval(ctx, csrName, &certificatesv1.CertificateSigningRequest{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: csrName,
|
||||
},
|
||||
Status: certificatesv1.CertificateSigningRequestStatus{
|
||||
Conditions: []certificatesv1.CertificateSigningRequestCondition{
|
||||
{
|
||||
Type: certificatesv1.CertificateApproved,
|
||||
Status: corev1.ConditionTrue,
|
||||
Reason: "WhoAmICSRTest",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
}, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
// On old Kubernetes clusters use CertificatesV1beta1
|
||||
_, err = kubeClient.CertificatesV1beta1().CertificateSigningRequests().UpdateApproval(ctx, &certificatesv1beta1.CertificateSigningRequest{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: csrName,
|
||||
},
|
||||
Status: certificatesv1beta1.CertificateSigningRequestStatus{
|
||||
Conditions: []certificatesv1beta1.CertificateSigningRequestCondition{
|
||||
{
|
||||
Type: certificatesv1beta1.CertificateApproved,
|
||||
Status: corev1.ConditionTrue,
|
||||
Reason: "WhoAmICSRTest",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
crtPEM, err := csr.WaitForCertificate(ctx, kubeClient, csrName, csrUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 testlib
|
||||
@@ -247,7 +247,7 @@ func loadEnvVars(t *testing.T, result *TestEnv) {
|
||||
result.SupervisorUpstreamOIDC = TestOIDCUpstream{
|
||||
Issuer: needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER"),
|
||||
CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER_CA_BUNDLE")),
|
||||
AdditionalScopes: strings.Fields(os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES")),
|
||||
AdditionalScopes: filterEmpty(strings.Split(strings.ReplaceAll(os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES"), " ", ""), ",")),
|
||||
UsernameClaim: os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME_CLAIM"),
|
||||
GroupsClaim: os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_GROUPS_CLAIM"),
|
||||
ClientID: needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_CLIENT_ID"),
|
||||
|
||||
Reference in New Issue
Block a user