Show errors from the form_post POST request on the page

This commit is contained in:
Ryan Richard 2023-09-28 14:17:02 -07:00
parent 78cb86215b
commit 62c597eb3b
7 changed files with 74 additions and 36 deletions

View File

@ -442,8 +442,11 @@ else
fi fi
# Test that the federation domain is working before we proceed. # Test that the federation domain is working before we proceed.
echo "Fetching FederationDomain discovery info via command: ${proxy_env_vars}curl -fLsS --cacert \"$root_ca_crt_path\" \"$issuer/.well-known/openid-configuration\"" echo "Fetching FederationDomain discovery info via command:" \
https_proxy="$proxy_server" no_proxy="$proxy_except" curl -fLsS --cacert "$root_ca_crt_path" "$issuer/.well-known/openid-configuration" | jq . "${proxy_env_vars}curl -fLsS --retry-all-errors --retry 5 --cacert \"$root_ca_crt_path\" \"$issuer/.well-known/openid-configuration\""
https_proxy="$proxy_server" no_proxy="$proxy_except" curl -fLsS \
--retry-all-errors --retry 5 --cacert "$root_ca_crt_path" \
"$issuer/.well-known/openid-configuration" | jq .
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then
certificateAuthorityData=$(cat "$root_ca_crt_path" | base64) certificateAuthorityData=$(cat "$root_ca_crt_path" | base64)

View File

@ -67,6 +67,10 @@ code {
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"); 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");
} }
.error {
font-family: monospace;
}
@keyframes loader { @keyframes loader {
to { to {
transform: rotate(360deg); transform: rotate(360deg);

View File

@ -30,5 +30,10 @@ SPDX-License-Identifier: Apache-2.0
<code id="manual-auth-code">{{ .Parameters.Get "code" }}</code> <code id="manual-auth-code">{{ .Parameters.Get "code" }}</code>
</button> </button>
</div> </div>
<div id="error" class="state" data-favicon="⛔" data-title="Error during login" hidden>
<h1>Error during login</h1>
<p id="message" class="error"></p>
<p>Please try again.</p>
</div>
</body> </body>
</html> </html>

View File

@ -2,7 +2,12 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
window.onload = () => { window.onload = () => {
const transitionToState = (id) => { const transitionToState = (id, message) => {
// For the error state, there is also a message to show.
if (id === 'error') {
document.getElementById('message').innerText = message
}
// Hide all the other ".state" <div>s. // Hide all the other ".state" <div>s.
Array.from(document.querySelectorAll('.state')).forEach(e => e.hidden = true); Array.from(document.querySelectorAll('.state')).forEach(e => e.hidden = true);
@ -44,22 +49,31 @@ window.onload = () => {
responseParams['redirect_uri'].value, responseParams['redirect_uri'].value,
{ {
method: 'POST', method: 'POST',
mode: 'no-cors', // in the future, we could change this to "cors" (see comment below) mode: 'cors', // Using 'cors' is required to get actual response status codes.
headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'}, headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'},
body: responseParams['encoded_params'].value, body: responseParams['encoded_params'].value,
}) })
.then(response => { .then(response => {
clearTimeout(timeout); clearTimeout(timeout);
// Requests made using "no-cors" mode will hide the real response.status by making it 0 if (response.ok) {
// and the real response.ok by making it false. // Got 2XX http response status, so the user has logged in successfully.
// 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 do something else (maybe show the error?),
// but we have no way to know the real response 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'); transitionToState('success');
} else {
// Got non-2XX http response status. Show the error after trying to read the response body.
// These are not recoverable errors. The CLI stop listening and is no longer prompting for authcode.
response.text()
.then(function (text) {
transitionToState('error', response.status + ": " + text);
}) })
.catch((reason) => {
console.error("error while reading response.text()", reason);
transitionToState('error', response.status + ": [could not read response body]");
})
}
})
// A network error is encountered or CORS is misconfigured on the server-side.
// This could happen in the case where the CLI is running on a different machine (e.g. ssh jumphost).
// This always happens in Safari because that browser always prevents an https (TLS) web site from making
// fetch calls to an http (non-TLS) localhost site (see https://bugs.webkit.org/show_bug.cgi?id=171934).
.catch(() => transitionToState('manual')); .catch(() => transitionToState('manual'));
}; };

View File

@ -29,8 +29,8 @@ var (
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <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 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}</style> <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 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")}.error{font-family:monospace}@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=e=>{Array.from(document.querySelectorAll(".state")).forEach(e=>e.hidden=!0);const t=document.getElementById(e);t.hidden=!1,document.title=t.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>"+t.dataset.favicon+"</text></svg>")};e("loading"),window.history.replaceState(null,"","./"),document.getElementById("manual-copy-button").onclick=()=>{const e=document.getElementById("manual-copy-button").innerText;navigator.clipboard.writeText(e).then(()=>console.info("copied authorization code "+e+" to clipboard")).catch(t=>console.error("failed to copy code "+e+" to clipboard: "+t))};const n=setTimeout(()=>e("manual"),2e3),t=document.forms[0].elements;fetch(t.redirect_uri.value,{method:"POST",mode:"no-cors",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:t.encoded_params.value}).then(t=>{clearTimeout(n),e("success")}).catch(()=>e("manual"))}</script> <script>window.onload=()=>{const e=(e,t)=>{e==="error"&&(document.getElementById("message").innerText=t),Array.from(document.querySelectorAll(".state")).forEach(e=>e.hidden=!0);const n=document.getElementById(e);n.hidden=!1,document.title=n.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>"+n.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:"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"):t.text().then(function(n){e("error",t.status+": "+n)}).catch(n=>{console.error("error while reading response.text()",n),e("error",t.status+": [could not read response body]")})}).catch(()=>e("manual"))}</script>
<link id="favicon" rel="icon"/> <link id="favicon" rel="icon"/>
</head> </head>
<body> <body>
@ -54,6 +54,11 @@ var (
<code id="manual-auth-code">test-S629KHsCCBYV0PQ6FDSrn6iEXtVImQRBh7NCAk.JezyUSdCiSslYjtUmv7V5VAgiCz3ZkES9mYldg9GhqU</code> <code id="manual-auth-code">test-S629KHsCCBYV0PQ6FDSrn6iEXtVImQRBh7NCAk.JezyUSdCiSslYjtUmv7V5VAgiCz3ZkES9mYldg9GhqU</code>
</button> </button>
</div> </div>
<div id="error" class="state" data-favicon="⛔" data-title="Error during login" hidden>
<h1>Error during login</h1>
<p id="message" class="error"></p>
<p>Please try again.</p>
</div>
</body> </body>
</html> </html>
`) `)
@ -61,8 +66,8 @@ var (
// It's okay if this changes in the future, but this gives us a chance to eyeball the formatting. // 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. // Our browser-based integration tests should find any incompatibilities.
testExpectedCSP = `default-src 'none'; ` + testExpectedCSP = `default-src 'none'; ` +
`script-src 'sha256-uIWC0J7wd7tWtcXmugZCkKsQpqOsQzqBI/mfQMtUde0='; ` + `script-src 'sha256-fiAdxAQHPoodG4cbENki/1TI+cjBOXxw+ADCoCtepQo='; ` +
`style-src 'sha256-kXh6OrB2z7wkx7v1N3ay9deQhV5edwuogARaUtvNYN4='; ` + `style-src 'sha256-p+fPKq5SYyVeT46EkDVZx28MRQ6wlWHdDm3o3qZFGTA='; ` +
`img-src data:; ` + `img-src data:; ` +
`connect-src *; ` + `connect-src *; ` +
`frame-ancestors 'none'` `frame-ancestors 'none'`

View File

@ -922,7 +922,7 @@ func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Req
w.Header().Set("Access-Control-Allow-Origin", allowOrigin) 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("Vary", "*") // supposed to use Vary when Access-Control-Allow-Origin is a specific host
} else { } else {
// Return HTTP 405 for anything that's not a GET. // When we are not using form_post, then return HTTP 405 for anything that's not a GET.
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
h.logger.V(plog.KlogLevelDebug).Info("Pinniped: Got unexpected request on callback listener", "method", r.Method) h.logger.V(plog.KlogLevelDebug).Info("Pinniped: Got unexpected request on callback listener", "method", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
@ -933,6 +933,9 @@ func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Req
params = r.URL.Query() params = r.URL.Query()
} }
// At this point, it doesn't matter if we got the params from a form_post POST request or a regular GET request.
// Next, validate the params, and if we got an authcode then try to use it to complete the login.
// Validate OAuth2 state and fail if it's incorrect (to block CSRF). // Validate OAuth2 state and fail if it's incorrect (to block CSRF).
if err := h.state.Validate(params.Get("state")); err != nil { if err := h.state.Validate(params.Get("state")); err != nil {
return httperr.New(http.StatusForbidden, "missing or invalid state parameter") return httperr.New(http.StatusForbidden, "missing or invalid state parameter")

View File

@ -48,25 +48,15 @@ func TestFormPostHTML_Browser_Parallel(t *testing.T) {
t.Run("callback server error", func(t *testing.T) { t.Run("callback server error", func(t *testing.T) {
browser := browsertest.OpenBrowser(t) browser := browsertest.OpenBrowser(t)
// Serve the form_post template with a redirect URI that will return an HTTP 500 response. // Serve the form_post template with a redirect URI that will return an HTTP 400 response.
responseParams := formpostRandomParams(t) responseParams := formpostRandomParams(t)
formpostInitiate(t, browser, formpostTemplateServer(t, callbackURL+"?fail=500", responseParams)) formpostInitiate(t, browser, formpostTemplateServer(t, callbackURL+"?fail=400", responseParams))
// Now we handle the callback and assert that we got what we expected. // Now we handle the callback and assert that we got what we expected.
expectCallback(t, responseParams) expectCallback(t, responseParams)
// This is not 100% the behavior we'd like, but because our JS is making // This failure should cause the UI to enter the "error" state.
// a cross-origin fetch() without CORS, we don't get to know anything formpostExpectErrorState(t, browser)
// 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.
//
// 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, browser)
}) })
t.Run("network failure", func(t *testing.T) { t.Run("network failure", func(t *testing.T) {
@ -113,7 +103,7 @@ func TestFormPostHTML_Browser_Parallel(t *testing.T) {
// It returns the URL of the running test server and a function for fetching the next // It returns the URL of the running test server and a function for fetching the next
// received form POST parameters. // received form POST parameters.
// //
// The test server supports special `?fail=close` and `?fail=500` to force error cases. // The test server supports special `?fail=close` and `?fail=400` to force error cases.
func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values)) { func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values)) {
t.Helper() t.Helper()
results := make(chan url.Values) results := make(chan url.Values)
@ -156,8 +146,9 @@ func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values))
_ = conn.Close() _ = conn.Close()
} }
return return
case "500": // If "fail=500" is passed, return a 500 error. case "400": // If "fail=400" is passed, return a 400 error.
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("this is the text of the bad request error response"))
return return
} }
})) }))
@ -265,6 +256,19 @@ func formpostExpectSuccessState(t *testing.T, b *browsertest.Browser) {
formpostExpectFavicon(t, b, "✅") formpostExpectFavicon(t, b, "✅")
} }
// formpostExpectErrorState asserts that the page is in the "error" state.
func formpostExpectErrorState(t *testing.T, b *browsertest.Browser) {
t.Helper()
t.Logf("expecting to see error message become visible...")
b.WaitForVisibleElements(t, "div#error")
errorDivText := b.TextOfFirstMatch(t, "div#error")
require.Contains(t, errorDivText, "Error during login")
require.Contains(t, errorDivText, "400: this is the text of the bad request error response")
require.Contains(t, errorDivText, "Please try again.")
require.Equal(t, "Error during login", b.Title(t))
formpostExpectFavicon(t, b, "⛔")
}
// formpostExpectManualState asserts that the page is in the "manual" state and returns the auth code. // formpostExpectManualState asserts that the page is in the "manual" state and returns the auth code.
func formpostExpectManualState(t *testing.T, b *browsertest.Browser) string { func formpostExpectManualState(t *testing.T, b *browsertest.Browser) string {
t.Helper() t.Helper()