Merge pull request #987 from vmware-tanzu/chrome_cors
Add CORS request handling to CLI's localhost listener
This commit is contained in:
commit
f6f188565b
@ -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 = () => {
|
||||
@ -48,7 +48,14 @@ window.onload = () => {
|
||||
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);
|
||||
if (response.ok) {
|
||||
transitionToState('success');
|
||||
} else {
|
||||
// Got non-2XX http response status.
|
||||
transitionToState('manual');
|
||||
}
|
||||
})
|
||||
.catch(() => transitionToState('manual'));
|
||||
};
|
||||
|
@ -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),t.ok?e('success'):e('manual')}).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-Lon+X41NoXuVGPqi3LsAPmBqlDmwbu3lGhQii7/Zjrc='; ` +
|
||||
`style-src 'sha256-CtfkX7m8x2UdGYvGgDq+6b6yIAQsASW9pbQK+sG8fNA='; ` +
|
||||
`img-src data:; ` +
|
||||
`connect-src *; ` +
|
||||
|
@ -834,13 +834,46 @@ 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
|
||||
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)
|
||||
issuerURL, parseErr := url.Parse(h.issuer)
|
||||
if parseErr != nil {
|
||||
return httperr.Wrap(http.StatusInternalServerError, "invalid issuer url", parseErr)
|
||||
}
|
||||
// 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", issuerURL.Scheme+"://"+issuerURL.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
|
||||
}
|
||||
|
||||
// Parse and pull the response parameters from a application/x-www-form-urlencoded request body.
|
||||
// Return HTTP 405 for anything that's not a POST.
|
||||
if r.Method != http.MethodPost {
|
||||
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 an application/x-www-form-urlencoded request body.
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return httperr.Wrap(http.StatusBadRequest, "invalid form", err)
|
||||
}
|
||||
@ -848,7 +881,9 @@ func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Req
|
||||
} 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.
|
||||
|
@ -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,35 +1868,38 @@ 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,
|
||||
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,
|
||||
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 "%"`,
|
||||
@ -1918,6 +1923,75 @@ func TestHandleAuthCodeCallback(t *testing.T) {
|
||||
wantErr: `login failed with code "some_error": optional error description`,
|
||||
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`,
|
||||
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,
|
||||
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"},
|
||||
"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"},
|
||||
"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",
|
||||
@ -1938,8 +2012,9 @@ func TestHandleAuthCodeCallback(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
query: "state=test-state&code=valid",
|
||||
name: "valid",
|
||||
query: "state=test-state&code=valid",
|
||||
wantHTTPStatus: http.StatusOK,
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI}
|
||||
@ -1955,10 +2030,11 @@ 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`),
|
||||
wantHTTPStatus: http.StatusOK,
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.useFormPost = true
|
||||
@ -1978,11 +2054,14 @@ 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,
|
||||
}
|
||||
if tt.opt != nil {
|
||||
require.NoError(t, tt.opt(t)(h))
|
||||
@ -1998,8 +2077,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 +2091,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 +2112,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user