From 71d4e05fb618be43862005347fd2b7a54d7f2628 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Wed, 30 Jun 2021 11:50:01 -0500 Subject: [PATCH] Add custom response_mode=form_post HTML template. This is a new pacakge internal/oidc/provider/formposthtml containing a number of static files embedded using the relatively recent Go "//go:embed" functionality introduced in Go 1.16 (https://blog.golang.org/go1.16). The Javascript and CSS files are minifiied and injected to make a single self-contained HTML response. There is a special Content-Security-Policy helper to calculate hash-based script-src and style-src rules. This new code is covered by a new integration test that exercises the JS/HTML functionality in a real browser outside of the rest of the Supervisor. Signed-off-by: Matt Moyer --- go.mod | 3 +- go.sum | 8 + internal/oidc/callback/callback_handler.go | 6 +- .../oidc/callback/callback_handler_test.go | 2 +- internal/oidc/oidc.go | 5 +- .../oidc/provider/formposthtml/form_post.css | 87 +++++++ .../provider/formposthtml/form_post.gohtml | 34 +++ .../oidc/provider/formposthtml/form_post.js | 54 ++++ .../provider/formposthtml/formposthtml.go | 65 +++++ .../formposthtml/formposthtml_test.go | 101 ++++++++ internal/testutil/assertions.go | 6 +- test/integration/formposthtml_test.go | 245 ++++++++++++++++++ 12 files changed, 609 insertions(+), 7 deletions(-) create mode 100644 internal/oidc/provider/formposthtml/form_post.css create mode 100644 internal/oidc/provider/formposthtml/form_post.gohtml create mode 100644 internal/oidc/provider/formposthtml/form_post.js create mode 100644 internal/oidc/provider/formposthtml/formposthtml.go create mode 100644 internal/oidc/provider/formposthtml/formposthtml_test.go create mode 100644 test/integration/formposthtml_test.go diff --git a/go.mod b/go.mod index 2b1522e8..3a224243 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.pinniped.dev -go 1.14 +go 1.16 require ( github.com/MakeNowJust/heredoc/v2 v2.0.1 @@ -26,6 +26,7 @@ require ( github.com/spf13/cobra v1.2.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 + github.com/tdewolff/minify/v2 v2.9.18 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a golang.org/x/net v0.0.0-20210520170846-37e1c6afe023 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 diff --git a/go.sum b/go.sum index a57507b9..eb6dfbd2 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,7 @@ github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -840,6 +841,7 @@ github.com/markbates/safe v1.0.0/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kN github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/markbates/sigtx v1.0.0/go.mod h1:QF1Hv6Ic6Ca6W+T+DL0Y/ypborFKyvUY9HmuCD4VeTc= github.com/markbates/willie v1.0.9/go.mod h1:fsrFVWl91+gXpx/6dv715j7i11fYPfZ9ZGfH0DQzY7w= +github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -1150,6 +1152,12 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/subosito/gotenv v1.1.1/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tdewolff/minify/v2 v2.9.18 h1:j5Is0sOGp4cxm0o3HgvHCWCvTtmKnfB0qv0FCRbmgZY= +github.com/tdewolff/minify/v2 v2.9.18/go.mod h1:0y0mXZnisZm8HcgQvAV0btxa1IgecGam90zMuHqEZuc= +github.com/tdewolff/parse/v2 v2.5.18 h1:d67Ql/Pe36JcJZ7J2MY8upx6iTxbxGS9lzwyFGtMmd0= +github.com/tdewolff/parse/v2 v2.5.18/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= +github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4= +github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= github.com/tidwall/gjson v1.7.1/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk= diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index d585c962..8b9ab93e 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -18,6 +18,7 @@ import ( "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/downstreamsession" "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/plog" ) @@ -35,7 +36,7 @@ func NewHandler( stateDecoder, cookieDecoder oidc.Decoder, redirectURI string, ) http.Handler { - return securityheader.Wrap(httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + handler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { state, err := validateRequest(r, stateDecoder, cookieDecoder) if err != nil { return err @@ -97,7 +98,8 @@ func NewHandler( oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) return nil - })) + }) + return securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy()) } func authcode(r *http.Request) string { diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 4d749355..23912944 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -149,7 +149,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", - wantBodyFormResponseRegexp: ``, + wantBodyFormResponseRegexp: `(.+)`, wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, wantDownstreamIDTokenUsername: upstreamUsername, wantDownstreamIDTokenGroups: upstreamGroupMembership, diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index d92a0f1b..d29979c8 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -14,6 +14,7 @@ import ( "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" ) @@ -217,7 +218,7 @@ func FositeOauth2Helper( MinParameterEntropy: fosite.MinParameterEntropy, } - return compose.Compose( + provider := compose.Compose( oauthConfig, oauthStore, &compose.CommonStrategy{ @@ -233,6 +234,8 @@ func FositeOauth2Helper( compose.OAuth2PKCEFactory, TokenExchangeFactory, ) + provider.(*fosite.Fosite).FormPostHTMLTemplate = formposthtml.Template() + return provider } // FositeErrorForLog generates a list of information about the provided Fosite error that can be diff --git a/internal/oidc/provider/formposthtml/form_post.css b/internal/oidc/provider/formposthtml/form_post.css new file mode 100644 index 00000000..c65c2fc7 --- /dev/null +++ b/internal/oidc/provider/formposthtml/form_post.css @@ -0,0 +1,87 @@ +/* Copyright 2021 the Pinniped contributors. All Rights Reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +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: none; + cursor: pointer; + transition: all .1s; +} + +button:hover { + background-color: #eee; + transform: scale(1.01); +} + +button:active { + background-color: #ddd; + transform: scale(.99); +} + +code { + word-wrap: break-word; + hyphens: auto; + hyphenate-character: ''; + font-size: 12px; + font-family: monospace; + color: #333; +} + +.copy-icon { + float: left; + width: 36px; + height: 36px; + padding-top: 2px; + padding-right: 10px; + background-size: contain; + background-repeat: no-repeat; + /* + This is the "copy-to-clipboard-line.svg" icon from Clarity (https://clarity.design/): + https://github.com/vmware/clarity-assets/blob/master/icons/essential/copy-to-clipboard-line.svg + */ + background-image: url("data:image/svg+xml,%3Csvg version='1.1' width='36' height='36' viewBox='0 0 36 36' preserveAspectRatio='xMidYMid meet' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3Ecopy-to-clipboard-line%3C/title%3E%3Cpath d='M22.6,4H21.55a3.89,3.89,0,0,0-7.31,0H13.4A2.41,2.41,0,0,0,11,6.4V10H25V6.4A2.41,2.41,0,0,0,22.6,4ZM23,8H13V6.25A.25.25,0,0,1,13.25,6h2.69l.12-1.11A1.24,1.24,0,0,1,16.61,4a2,2,0,0,1,3.15,1.18l.09.84h2.9a.25.25,0,0,1,.25.25Z' class='clr-i-outline clr-i-outline-path-1'%3E%3C/path%3E%3Cpath d='M33.25,18.06H21.33l2.84-2.83a1,1,0,1,0-1.42-1.42L17.5,19.06l5.25,5.25a1,1,0,0,0,.71.29,1,1,0,0,0,.71-1.7l-2.84-2.84H33.25a1,1,0,0,0,0-2Z' class='clr-i-outline clr-i-outline-path-2'%3E%3C/path%3E%3Cpath d='M29,16h2V6.68A1.66,1.66,0,0,0,29.35,5H27.08V7H29Z' class='clr-i-outline clr-i-outline-path-3'%3E%3C/path%3E%3Cpath d='M29,31H7V7H9V5H6.64A1.66,1.66,0,0,0,5,6.67V31.32A1.66,1.66,0,0,0,6.65,33H29.36A1.66,1.66,0,0,0,31,31.33V22.06H29Z' class='clr-i-outline clr-i-outline-path-4'%3E%3C/path%3E%3Crect x='0' y='0' width='36' height='36' fill-opacity='0'/%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; +} diff --git a/internal/oidc/provider/formposthtml/form_post.gohtml b/internal/oidc/provider/formposthtml/form_post.gohtml new file mode 100644 index 00000000..92be18d2 --- /dev/null +++ b/internal/oidc/provider/formposthtml/form_post.gohtml @@ -0,0 +1,34 @@ + + + + + + + + + + +
+ + +
+ + + + + diff --git a/internal/oidc/provider/formposthtml/form_post.js b/internal/oidc/provider/formposthtml/form_post.js new file mode 100644 index 00000000..4c0eb7df --- /dev/null +++ b/internal/oidc/provider/formposthtml/form_post.js @@ -0,0 +1,54 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +window.onload = () => { + const transitionToState = (id) => { + // Hide all the other ".state"
s. + Array.from(document.querySelectorAll('.state')).forEach(e => e.hidden = true); + + // Unhide the current state
. + const currentDiv = document.getElementById(id) + currentDiv.hidden = false; + + // Set the window title. + document.title = currentDiv.dataset.title; + + // Set the favicon using inline SVG (does not work on Safari). + document.getElementById('favicon').setAttribute( + 'href', + 'data:image/svg+xml,' + + currentDiv.dataset.favicon + + '' + ); + } + + // At load, show the spinner, hide the other divs, set the favicon, and + // replace the URL path with './' so the upstream auth code disappears. + transitionToState('loading'); + window.history.replaceState(null, '', './'); + + // When the copy button is clicked, copy to the clipboard. + document.getElementById('manual-copy-button').onclick = () => { + const code = document.getElementById('manual-copy-button').innerText; + navigator.clipboard.writeText(code) + .then(() => console.info('copied authorization code ' + code + ' to clipboard')) + .catch(e => console.error('failed to copy code ' + code + ' to clipboard: ' + e)); + }; + + // Set a timeout to transition to the "manual" state if nothing succeeds within 2s. + const timeout = setTimeout(() => transitionToState('manual'), 2000); + + // Try to submit the POST callback, handling the success and error cases. + const responseParams = document.forms[0].elements; + fetch( + responseParams['redirect_uri'].value, + { + method: 'POST', + mode: 'no-cors', + headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'}, + body: responseParams['encoded_params'].value, + }) + .then(() => clearTimeout(timeout)) + .then(() => transitionToState('success')) + .catch(() => transitionToState('manual')); +}; diff --git a/internal/oidc/provider/formposthtml/formposthtml.go b/internal/oidc/provider/formposthtml/formposthtml.go new file mode 100644 index 00000000..4eeebf74 --- /dev/null +++ b/internal/oidc/provider/formposthtml/formposthtml.go @@ -0,0 +1,65 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package formposthtml defines HTML templates used by the Supervisor. +//nolint: gochecknoglobals // This package uses globals to ensure that all parsing and minifying happens at init. +package formposthtml + +import ( + "crypto/sha256" + _ "embed" // Needed to trigger //go:embed directives below. + "encoding/base64" + "html/template" + "strings" + + "github.com/tdewolff/minify/v2/minify" +) + +var ( + //go:embed form_post.css + rawCSS string + minifiedCSS = mustMinify(minify.CSS(rawCSS)) + + //go:embed form_post.js + rawJS string + minifiedJS = mustMinify(minify.JS(rawJS)) + + //go:embed form_post.gohtml + rawHTMLTemplate string +) + +// Parse the Go templated HTML and inject functions providing the minified inline CSS and JS. +var parsedHTMLTemplate = template.Must(template.New("form_post.gohtml").Funcs(template.FuncMap{ + "minifiedCSS": func() template.CSS { return template.CSS(minifiedCSS) }, + "minifiedJS": func() template.JS { return template.JS(minifiedJS) }, //nolint:gosec // This is 100% static input, not attacker-controlled. +}).Parse(rawHTMLTemplate)) + +// Generate the CSP header value once since it's effectively constant: +var cspValue = strings.Join([]string{ + `default-src 'none'`, + `script-src '` + cspHash(minifiedJS) + `'`, + `style-src '` + cspHash(minifiedCSS) + `'`, + `img-src data:`, + `connect-src *`, + `frame-ancestors 'none'`, +}, "; ") + +func mustMinify(s string, err error) string { + if err != nil { + panic(err) + } + return s +} + +func cspHash(s string) string { + hashBytes := sha256.Sum256([]byte(s)) + return "sha256-" + base64.StdEncoding.EncodeToString(hashBytes[:]) +} + +// ContentSecurityPolicy returns the Content-Security-Policy header value to make the Template() operate correctly. +// +// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src#:~:text=%27%3Chash-algorithm%3E-%3Cbase64-value%3E%27. +func ContentSecurityPolicy() string { return cspValue } + +// Template returns the html/template.Template for rendering the response_type=form_post response page. +func Template() *template.Template { return parsedHTMLTemplate } diff --git a/internal/oidc/provider/formposthtml/formposthtml_test.go b/internal/oidc/provider/formposthtml/formposthtml_test.go new file mode 100644 index 00000000..a8a1a929 --- /dev/null +++ b/internal/oidc/provider/formposthtml/formposthtml_test.go @@ -0,0 +1,101 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package formposthtml + +import ( + "bytes" + "fmt" + "net/url" + "testing" + + "github.com/ory/fosite" + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/here" +) + +var ( + testRedirectURL = "http://127.0.0.1:12345/callback" + + testResponseParams = url.Values{ + "code": []string{"test-S629KHsCCBYV0PQ6FDSrn6iEXtVImQRBh7NCAk.JezyUSdCiSslYjtUmv7V5VAgiCz3ZkES9mYldg9GhqU"}, + "scope": []string{"openid offline_access pinniped:request-audience"}, + "state": []string{"01234567890123456789012345678901"}, + } + + testExpectedFormPostOutput = here.Doc(` + + + + + + + + + + +
+ + +
+ + + + + + `) + + // 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='; ` + + `style-src 'sha256-TLAQE3UR2KpwP7AzMCE4iPDizh7zLPx9UXeK5ntuoRg='; ` + + `img-src data:; ` + + `connect-src *; ` + + `frame-ancestors 'none'` +) + +func TestTemplate(t *testing.T) { + // Use the Fosite helper to render the form, ensuring that the parameters all have the same names + types. + var buf bytes.Buffer + fosite.WriteAuthorizeFormPostResponse(testRedirectURL, testResponseParams, Template(), &buf) + + // Render again so we can confirm that there is no error returned (Fosite ignores any error). + var buf2 bytes.Buffer + require.NoError(t, Template().Execute(&buf2, struct { + RedirURL string + Parameters url.Values + }{ + RedirURL: testRedirectURL, + Parameters: testResponseParams, + })) + + require.Equal(t, buf.String(), buf2.String()) + require.Equal(t, testExpectedFormPostOutput, buf.String()) +} + +func TestContentSecurityPolicyHashes(t *testing.T) { + require.Equal(t, testExpectedCSP, ContentSecurityPolicy()) +} + +func TestHelpers(t *testing.T) { + // These are silly tests but it's easy to we might as well have them. + require.Equal(t, "test", mustMinify("test", nil)) + require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) }) + + // Example test vector from https://content-security-policy.com/hash/. + require.Equal(t, "sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=", cspHash("doSomething();")) +} diff --git a/internal/testutil/assertions.go b/internal/testutil/assertions.go index 54fc8563..9286bff1 100644 --- a/internal/testutil/assertions.go +++ b/internal/testutil/assertions.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testutil @@ -55,7 +55,9 @@ func RequireNumberOfSecretsMatchingLabelSelector(t *testing.T, secrets v1.Secret } func RequireSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) { - require.Equal(t, "default-src 'none'; frame-ancestors 'none'", response.Header().Get("Content-Security-Policy")) + // This is a more relaxed assertion rather than an exact match, so it can cover all the CSP headers we use. + require.Contains(t, response.Header().Get("Content-Security-Policy"), "default-src 'none'") + require.Equal(t, "DENY", response.Header().Get("X-Frame-Options")) require.Equal(t, "1; mode=block", response.Header().Get("X-XSS-Protection")) require.Equal(t, "nosniff", response.Header().Get("X-Content-Type-Options")) diff --git a/test/integration/formposthtml_test.go b/test/integration/formposthtml_test.go new file mode 100644 index 00000000..21522ed3 --- /dev/null +++ b/test/integration/formposthtml_test.go @@ -0,0 +1,245 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/token/hmac" + "github.com/sclevine/agouti" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/httputil/securityheader" + "go.pinniped.dev/internal/oidc/provider/formposthtml" + "go.pinniped.dev/test/testlib" + "go.pinniped.dev/test/testlib/browsertest" +) + +func TestFormPostHTML(t *testing.T) { + // Run a mock callback handler, simulating the one running in the CLI. + callbackURL, expectCallback := formpostCallbackServer(t) + + // Open a single browser for all subtests to use (in sequence). + page := browsertest.Open(t) + + t.Run("success", func(t *testing.T) { + // Serve the form_post template with successful parameters. + responseParams := formpostRandomParams(t) + formpostInitiate(t, page, formpostTemplateServer(t, callbackURL, responseParams)) + + // Now we handle the callback and assert that we got what we expected. This should transition + // the UI into the success state. + expectCallback(t, responseParams) + formpostExpectSuccessState(t, page) + }) + + t.Run("callback server error", func(t *testing.T) { + // Serve the form_post template with a redirect URI that will return an HTTP 500 response. + responseParams := formpostRandomParams(t) + formpostInitiate(t, page, formpostTemplateServer(t, callbackURL+"?fail=500", responseParams)) + + // Now we handle the callback and assert that we got what we expected. + expectCallback(t, responseParams) + + // This is not 100% the behavior we'd like, but because our JS is making + // a cross-origin fetch() without CORS, we don't get to know anything + // about the response (even whether it is 200 vs. 500), so this case + // is the same as the success case. + // + // This case is fairly unlikely in practice, and if the CLI encounters + // an error it can also expose it via stderr anyway. + formpostExpectSuccessState(t, page) + }) + + t.Run("network failure", func(t *testing.T) { + // Serve the form_post template with a redirect URI that will return a network error. + responseParams := formpostRandomParams(t) + formpostInitiate(t, page, formpostTemplateServer(t, callbackURL+"?fail=close", responseParams)) + + // Now we handle the callback and assert that we got what we expected. + // This will trigger the callback server to close the client connection abruptly because + // of the `?fail=close` parameter above. + expectCallback(t, responseParams) + + // This failure should cause the UI to enter the "manual" state. + formpostExpectManualState(t, page, responseParams.Get("code")) + }) + + t.Run("timeout", func(t *testing.T) { + // Serve the form_post template with successful parameters. + responseParams := formpostRandomParams(t) + formpostInitiate(t, page, formpostTemplateServer(t, callbackURL, responseParams)) + + // Sleep for longer than the two second timeout. + // During this sleep we are blocking the callback from returning. + time.Sleep(3 * time.Second) + + // Assert that the timeout fires and we see the manual instructions. + formpostExpectManualState(t, page, responseParams.Get("code")) + + // Now simulate the callback finally succeeding, in which case + // the manual instructions should disappear and we should see the success + // div instead. + expectCallback(t, responseParams) + formpostExpectSuccessState(t, page) + }) +} + +// formpostCallbackServer runs a test server that simulates the CLI's callback handler. +// It returns the URL of the running test server and a function for fetching the next +// received form POST parameters. +// +// The test server supports special `?fail=close` and `?fail=500` to force error cases. +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) { + assert.NoError(t, r.ParseForm()) + + // Extract only the POST parameters (r.Form also contains URL query parameters). + postParams := url.Values{} + for k := range r.Form { + if v := r.PostFormValue(k); v != "" { + postParams.Set(k, v) + } + } + + // Send the form parameters back on the results channel, giving up if the + // request context is cancelled (such as if the client disconnects). + select { + case results <- postParams: + case <-r.Context().Done(): + return + } + + switch r.URL.Query().Get("fail") { + case "close": // If "fail=close" is passed, close the connection immediately. + if conn, _, err := w.(http.Hijacker).Hijack(); err == nil { + _ = conn.Close() + } + return + case "500": // If "fail=500" is passed, return a 500 error. + w.WriteHeader(http.StatusInternalServerError) + return + } + })) + t.Cleanup(func() { + close(results) + server.Close() + }) + return server.URL, func(t *testing.T, expected url.Values) { + t.Logf("expecting to get a POST callback...") + select { + case actual := <-results: + require.Equal(t, expected, actual, "did not receive expected callback") + case <-time.After(3 * time.Second): + t.Errorf("failed to receive expected callback %v", expected) + t.FailNow() + } + } +} + +// formpostTemplateServer runs a test server that serves formposthtml.Template() rendered with test parameters. +func formpostTemplateServer(t *testing.T, redirectURI string, responseParams url.Values) string { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fosite.WriteAuthorizeFormPostResponse(redirectURI, responseParams, formposthtml.Template(), w) + }) + server := httptest.NewServer(securityheader.WrapWithCustomCSP( + handler, + formposthtml.ContentSecurityPolicy(), + )) + t.Cleanup(server.Close) + return server.URL +} + +// formpostRandomParams is a helper to generate random OAuth2 response parameters for testing. +func formpostRandomParams(t *testing.T) url.Values { + generator := &hmac.HMACStrategy{GlobalSecret: testlib.RandBytes(t, 32), TokenEntropy: 32} + authCode, _, err := generator.Generate() + require.NoError(t, err) + return url.Values{ + "code": []string{authCode}, + "scope": []string{"openid offline_access pinniped:request-audience"}, + "state": []string{testlib.RandHex(t, 16)}, + } +} + +// formpostExpectTitle asserts that the page has the expected title. +func formpostExpectTitle(t *testing.T, page *agouti.Page, expected string) { + actual, err := page.Title() + require.NoError(t, err) + require.Equal(t, expected, actual) +} + +// formpostExpectTitle asserts that the page has the expected SVG/emoji favicon. +func formpostExpectFavicon(t *testing.T, page *agouti.Page, expected string) { + iconURL, err := page.First("#favicon").Attribute("href") + require.NoError(t, err) + require.True(t, strings.HasPrefix(iconURL, "data:image/svg+xml,