From 22596a5df2e70595dfe6bf03291008dfcbec864b Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Thu, 8 Oct 2026 11:45:13 +0200 Subject: [PATCH 01/15] fix(auth): use a fresh mux and server per interactive auth attempt --- pkg/auth_service.go | 54 +++++++++++++--- pkg/auth_service_test.go | 131 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 pkg/auth_service_test.go diff --git a/pkg/auth_service.go b/pkg/auth_service.go index 1726494a..1fccfd2c 100644 --- a/pkg/auth_service.go +++ b/pkg/auth_service.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "math/rand" + "net" "net/http" "net/url" "os" @@ -22,11 +23,15 @@ import ( ) const ( - httpAuthPort = 10999 - oAuthQoveryUrl = "https://auth.qovery.com/login?code_challenge_method=S256&scope=%s&client=%s&protocol=oauth2&response_type=%s&audience=%s&redirect_uri=%s&code_challenge=%s" + httpAuthPort = 10999 + qoveryConsoleUrl = "https://console.qovery.com" + oAuthQoveryUrl = "https://auth.qovery.com/login?code_challenge_method=S256&scope=%s&client=%s&protocol=oauth2&response_type=%s&audience=%s&redirect_uri=%s&code_challenge=%s" ) var ( + // Delay before the callback server shuts down, so the browser gets its response. + authShutdownDelay = time.Second + oAuthUrlParamValueClient = "MJ2SJpu12PxIzgmc5z5Y7N8m5MnaF7Y0" oAuthUrlParamValueHeadlessClient = "f9drkTNpxsEw2VU2PVDrxhyT3vVuFT0Y" oAuthUrlParamValueAudience = "https://core.qovery.com" @@ -51,8 +56,6 @@ type DeviceFlowParameters struct { } func DoRequestUserToAuthenticate(headless bool, skipVersionCheck bool) { - qoveryConsoleUrl := "https://console.qovery.com" - if !skipVersionCheck { available, message, _ := CheckAvailableNewVersion() if available { @@ -70,15 +73,45 @@ func DoRequestUserToAuthenticate(headless bool, skipVersionCheck bool) { utils.PrintlnError(errors.New("can not create authorization code challenge. Please contact the #support at 'https://discord.qovery.com'. ")) os.Exit(0) } + // TODO link to web auth + _ = browser.OpenURL(fmt.Sprintf(oAuthQoveryUrl, url.QueryEscape(oAuthUrlParamValueScopes), oAuthUrlParamValueClient, url.QueryEscape(oAuthUrlParamValueResponseType), + url.QueryEscape(oAuthUrlParamValueAudience), url.QueryEscape(oAuthUrlParamValueRedirect), challenge)) + + // Listen before opening the browser so a busy port is reported instead of + // leaving the user waiting on a callback that can never arrive. + srv := newAuthorizationServer(verifier, storeTokens) + listener, err := net.Listen("tcp", srv.Addr) + if err != nil { + utils.PrintlnError(fmt.Errorf("can not listen on %s for the authentication callback, is another 'qovery auth' running? %w", srv.Addr, err)) + os.Exit(1) + } + // TODO link to web auth _ = browser.OpenURL(fmt.Sprintf(oAuthQoveryUrl, url.QueryEscape(oAuthUrlParamValueScopes), oAuthUrlParamValueClient, url.QueryEscape(oAuthUrlParamValueResponseType), url.QueryEscape(oAuthUrlParamValueAudience), url.QueryEscape(oAuthUrlParamValueRedirect), challenge)) fmt.Println("\nOpening your browser, waiting for your authentication... ") + // Serve returns as soon as Shutdown starts, and Shutdown closes the listener + // first, so the fixed port is free again when this function returns. + _ = srv.Serve(listener) +} + +func storeTokens(tokens TokensResponse) { + expiredAt := time.Now().Local().Add(time.Duration(tokens.ExpiresIn-60) * time.Second) + _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt, utils.RefreshToken(tokens.RefreshToken)) +} + +// newAuthorizationServer builds a server with its own mux for one interactive +// authentication attempt. The handlers must not be registered on +// http.DefaultServeMux: a second attempt in the same process (re-auth after a +// 401) would panic on the duplicate "/authorization" pattern. The PKCE verifier +// is captured per attempt. +func newAuthorizationServer(verifier string, onTokens func(TokensResponse)) *http.Server { srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", httpAuthPort)} + mux := http.NewServeMux() - http.HandleFunc("/authorization", func(writer http.ResponseWriter, request *http.Request) { + mux.HandleFunc("/authorization", func(writer http.ResponseWriter, request *http.Request) { js := fmt.Sprintf(``, httpAuthPort) - - _, _ = writer.Write([]byte(js)) - _, _ = writer.Write([]byte("Authentication successful, you'll be redirected to Qovery console. If it's not the case, click on this link: " + qoveryConsoleUrl + "")) + page := fmt.Sprintf(`

Authenticating...

+`, httpAuthPort, qoveryConsoleUrl, qoveryConsoleUrl, qoveryConsoleUrl) + + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = writer.Write([]byte(page)) }) mux.HandleFunc("/authorization/valid", func(writer http.ResponseWriter, request *http.Request) { codes := request.URL.Query()["code"] - if len(codes) == 0 { - http.Error(writer, "missing authorization code", http.StatusBadRequest) + if len(codes) == 0 || codes[0] == "" { + http.Error(writer, authFailureMessage, http.StatusBadRequest) return } tokens, err := exchangeAuthorizationCode(verifier, codes[0]) if err != nil { // A rejected or stale code must not be stored nor reported as a success. - http.Error(writer, "authentication unsuccessful", http.StatusUnauthorized) + http.Error(writer, authFailureMessage, http.StatusUnauthorized) + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() // the browser must get its answer before the process exits + } utils.PrintlnError(errors.New("authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. ")) - exitProcess(0) + exitProcess(1) return } onTokens(tokens) utils.PrintlnInfo("Success!") + _, _ = writer.Write([]byte("Authentication successful, you'll be redirected to Qovery console. If it's not the case, click on this link: ")) delay := authShutdownDelay go func() { diff --git a/pkg/auth_service_test.go b/pkg/auth_service_test.go index 1b534c1c..a1576a22 100644 --- a/pkg/auth_service_test.go +++ b/pkg/auth_service_test.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" @@ -53,6 +54,17 @@ func stubTokenEndpointWith(t *testing.T, status int, body string) *exchangeRecor return recorder } +func getStatus(t *testing.T, url string) (int, string) { + t.Helper() + res, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer func() { _ = res.Body.Close() }() + body, _ := io.ReadAll(res.Body) + return res.StatusCode, string(body) +} + func get(t *testing.T, url string) string { t.Helper() res, err := http.Get(url) @@ -181,32 +193,77 @@ func TestAuthorizationServerRejectedExchangeStoresNothing(t *testing.T) { if err != nil { t.Fatal(err) } + body, _ := io.ReadAll(res.Body) _ = res.Body.Close() if stored != 0 { t.Errorf("tokens stored %d time(s) after a rejected exchange", stored) } - if len(exits) != 1 { - t.Errorf("exit calls = %v, want exactly one", exits) + if len(exits) != 1 || exits[0] == 0 { + t.Errorf("exit calls = %v, want exactly one nonzero exit", exits) } if res.StatusCode != http.StatusUnauthorized { t.Errorf("status = %d, want %d", res.StatusCode, http.StatusUnauthorized) } + if body := string(body); strings.Contains(body, "successful") || !strings.Contains(body, "failed") { + t.Errorf("browser body = %q, want a failure message", body) + } }) } } func TestAuthorizationServerMissingCodeIsABadRequest(t *testing.T) { - stubTokenEndpoint(t) + exchanges := stubTokenEndpoint(t) ts := httptest.NewServer(newAuthorizationServer("v", func(TokensResponse) { t.Error("tokens stored") }).Handler) defer ts.Close() - res, err := http.Get(ts.URL + "/authorization/valid") - if err != nil { - t.Fatal(err) + for _, path := range []string{"/authorization/valid", "/authorization/valid?code="} { + status, body := getStatus(t, ts.URL+path) + if status != http.StatusBadRequest { + t.Errorf("%s: status = %d, want %d", path, status, http.StatusBadRequest) + } + if strings.Contains(body, "successful") { + t.Errorf("%s: body = %q, want a failure message", path, body) + } + } + if got := exchanges.snapshot(); len(got) != 0 { + t.Errorf("token exchanges = %v, want none", got) + } +} + +// The callback page is served before the token exchange runs, so it must not +// claim success itself. +func TestAuthorizationPageDoesNotClaimSuccess(t *testing.T) { + exchanges := stubTokenEndpoint(t) + ts := httptest.NewServer(newAuthorizationServer("v", func(TokensResponse) {}).Handler) + defer ts.Close() + + status, body := getStatus(t, ts.URL+"/authorization?code=abc") + if status != http.StatusOK { + t.Fatalf("status = %d, want %d", status, http.StatusOK) + } + if strings.Contains(strings.ToLower(body), "successful") { + t.Errorf("page claims success before the exchange: %q", body) + } + if !strings.Contains(body, "/authorization/valid") { + t.Errorf("page does not call /authorization/valid: %q", body) + } + if got := exchanges.snapshot(); len(got) != 0 { + t.Errorf("serving the page triggered token exchanges: %v", got) + } +} + +func TestAuthorizationServerSuccessfulExchangeAnswersSuccess(t *testing.T) { + stubTokenEndpoint(t) + var stored []TokensResponse + ts := httptest.NewServer(newAuthorizationServer("v", func(tokens TokensResponse) { stored = append(stored, tokens) }).Handler) + defer ts.Close() + + status, body := getStatus(t, ts.URL+"/authorization/valid?code=ok") + if status != http.StatusOK || !strings.Contains(body, "Authentication successful") { + t.Errorf("status = %d, body = %q, want 200 with the success message", status, body) } - _ = res.Body.Close() - if res.StatusCode != http.StatusBadRequest { - t.Errorf("status = %d, want %d", res.StatusCode, http.StatusBadRequest) + if len(stored) != 1 { + t.Errorf("stored tokens = %+v, want one entry", stored) } } From 318db2035fb8cc7740e3c60e164c6d76eba63958 Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Thu, 8 Oct 2026 12:12:40 +0200 Subject: [PATCH 05/15] test(auth): assert a failed exchange exits with status 1 --- pkg/auth_service_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/auth_service_test.go b/pkg/auth_service_test.go index a1576a22..8c654646 100644 --- a/pkg/auth_service_test.go +++ b/pkg/auth_service_test.go @@ -199,8 +199,8 @@ func TestAuthorizationServerRejectedExchangeStoresNothing(t *testing.T) { if stored != 0 { t.Errorf("tokens stored %d time(s) after a rejected exchange", stored) } - if len(exits) != 1 || exits[0] == 0 { - t.Errorf("exit calls = %v, want exactly one nonzero exit", exits) + if len(exits) != 1 || exits[0] != 1 { + t.Errorf("exit calls = %v, want exactly one exit with status 1", exits) } if res.StatusCode != http.StatusUnauthorized { t.Errorf("status = %d, want %d", res.StatusCode, http.StatusUnauthorized) From b9565bd32fb59e68894b2b410ccc7ff93b0f92d8 Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Thu, 8 Oct 2026 12:17:31 +0200 Subject: [PATCH 06/15] fix(auth): avoid window.status clash in callback page and test its script in node --- pkg/auth_service.go | 43 +++++----- pkg/auth_service_test.go | 130 ++++++++++++++++++++++++++++++ pkg/testdata/auth_page_harness.js | 123 ++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 pkg/testdata/auth_page_harness.js diff --git a/pkg/auth_service.go b/pkg/auth_service.go index d90537c6..e831de9f 100644 --- a/pkg/auth_service.go +++ b/pkg/auth_service.go @@ -102,42 +102,47 @@ func storeTokens(tokens TokensResponse) { _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt, utils.RefreshToken(tokens.RefreshToken)) } -// newAuthorizationServer builds a server with its own mux for one interactive -// authentication attempt. The handlers must not be registered on -// http.DefaultServeMux: a second attempt in the same process (re-auth after a -// 401) would panic on the duplicate "/authorization" pattern. The PKCE verifier -// is captured per attempt. -func newAuthorizationServer(verifier string, onTokens func(TokensResponse)) *http.Server { - srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", httpAuthPort)} - mux := http.NewServeMux() - - // The page only shows what /authorization/valid answers: the success message - // is not part of it, so a failed token exchange can never look like a success. - mux.HandleFunc("/authorization", func(writer http.ResponseWriter, request *http.Request) { - page := fmt.Sprintf(`

Authenticating...

+// authorizationPage is the callback page. The script avoids top-level `var` +// declarations: they become window properties, and names like `status` are +// read-only strings there, which breaks DOM updates. +func authorizationPage(port int) string { + return fmt.Sprintf(`

Authenticating...

`, httpAuthPort, qoveryConsoleUrl, qoveryConsoleUrl, qoveryConsoleUrl) +`, port, qoveryConsoleUrl, qoveryConsoleUrl, qoveryConsoleUrl) +} +// newAuthorizationServer builds a server with its own mux for one interactive +// authentication attempt. The handlers must not be registered on +// http.DefaultServeMux: a second attempt in the same process (re-auth after a +// 401) would panic on the duplicate "/authorization" pattern. The PKCE verifier +// is captured per attempt. +func newAuthorizationServer(verifier string, onTokens func(TokensResponse)) *http.Server { + srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", httpAuthPort)} + mux := http.NewServeMux() + + // The page only shows what /authorization/valid answers: the success message + // is not part of it, so a failed token exchange can never look like a success. + mux.HandleFunc("/authorization", func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = writer.Write([]byte(page)) + _, _ = writer.Write([]byte(authorizationPage(httpAuthPort))) }) mux.HandleFunc("/authorization/valid", func(writer http.ResponseWriter, request *http.Request) { diff --git a/pkg/auth_service_test.go b/pkg/auth_service_test.go index 8c654646..3ba04463 100644 --- a/pkg/auth_service_test.go +++ b/pkg/auth_service_test.go @@ -1,10 +1,14 @@ package pkg import ( + "encoding/json" + "fmt" "io" "net" "net/http" "net/http/httptest" + "os/exec" + "regexp" "strings" "sync" "testing" @@ -267,3 +271,129 @@ func TestAuthorizationServerSuccessfulExchangeAnswersSuccess(t *testing.T) { t.Errorf("stored tokens = %+v, want one entry", stored) } } + +type pageSnapshot struct { + Text string `json:"text"` + Children []struct { + Tag string `json:"tag"` + Href string `json:"href"` + Text string `json:"text"` + } `json:"children"` + Requests []struct { + Method string `json:"method"` + URL string `json:"url"` + Async bool `json:"async"` + Sent bool `json:"sent"` + } `json:"requests"` + Timers []int `json:"timers"` + Navigations []string `json:"navigations"` +} + +type pageRun struct { + Pending pageSnapshot `json:"pending"` + AfterResponse pageSnapshot `json:"afterResponse"` + AfterTimers pageSnapshot `json:"afterTimers"` + Error string `json:"error"` +} + +// runAuthorizationPage serves the real /authorization page, extracts its inline +// script and runs it in node against a fake browser (testdata/auth_page_harness.js). +func runAuthorizationPage(t *testing.T, scenario string) pageRun { + t.Helper() + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to run the callback page script") + } + stubTokenEndpoint(t) + ts := httptest.NewServer(newAuthorizationServer("v", func(TokensResponse) {}).Handler) + defer ts.Close() + + _, page := getStatus(t, ts.URL+"/authorization?code=abc%20123") + match := regexp.MustCompile(`(?s)]*>(.*)`).FindStringSubmatch(page) + if match == nil { + t.Fatalf("no inline script in page: %q", page) + } + + cmd := exec.Command(node, "testdata/auth_page_harness.js", scenario) + cmd.Stdin = strings.NewReader(match[1]) + stdout, err := cmd.Output() + if err != nil { + t.Fatalf("harness failed: %v\n%s", err, stdout) + } + var run pageRun + if err := json.Unmarshal(stdout, &run); err != nil { + t.Fatalf("harness output %q: %v", stdout, err) + } + if run.Error != "" { + t.Fatalf("page script threw: %s", run.Error) + } + return run +} + +func assertPendingPage(t *testing.T, run pageRun) { + t.Helper() + p := run.Pending + if p.Text != "Authenticating..." || len(p.Children) != 0 { + t.Errorf("pending text = %q with %d children, want the Authenticating... placeholder", p.Text, len(p.Children)) + } + wantURL := fmt.Sprintf("http://localhost:%d/authorization/valid?code=abc%%20123", httpAuthPort) + if len(p.Requests) != 1 || p.Requests[0].Method != "GET" || p.Requests[0].URL != wantURL || !p.Requests[0].Async || !p.Requests[0].Sent { + t.Errorf("requests = %+v, want one sent async GET %s", p.Requests, wantURL) + } + if len(p.Timers) != 0 || len(p.Navigations) != 0 { + t.Errorf("pending page scheduled timers %v / navigations %v", p.Timers, p.Navigations) + } +} + +func TestAuthorizationPagePending(t *testing.T) { + run := runAuthorizationPage(t, "pending") + assertPendingPage(t, run) + if got := run.AfterResponse; got.Text != "Authenticating..." || len(got.Timers) != 0 { + t.Errorf("page changed without an answer: %+v", got) + } +} + +func TestAuthorizationPageSuccessShowsLinkAndSchedulesRedirect(t *testing.T) { + run := runAuthorizationPage(t, "success") + assertPendingPage(t, run) + + got := run.AfterResponse + if !strings.HasPrefix(got.Text, "Authentication successful") { + t.Errorf("text = %q, want the server's success message", got.Text) + } + if len(got.Children) != 1 || got.Children[0].Tag != "a" || got.Children[0].Href != qoveryConsoleUrl || got.Children[0].Text != qoveryConsoleUrl { + t.Errorf("children = %+v, want one link to %s", got.Children, qoveryConsoleUrl) + } + if len(got.Timers) != 1 || got.Timers[0] != 2000 || len(got.Navigations) != 0 { + t.Errorf("timers = %v, navigations = %v, want one 2000ms timer and no navigation yet", got.Timers, got.Navigations) + } + if after := run.AfterTimers; len(after.Navigations) != 1 || after.Navigations[0] != qoveryConsoleUrl { + t.Errorf("navigations after the timer = %v, want [%s]", after.Navigations, qoveryConsoleUrl) + } +} + +func TestAuthorizationPageHTTPErrorShowsServerMessage(t *testing.T) { + run := runAuthorizationPage(t, "http-error") + assertPendingPage(t, run) + + got := run.AfterResponse + if !strings.Contains(got.Text, "Authentication failed") || strings.Contains(got.Text, "successful") { + t.Errorf("text = %q, want the failure message", got.Text) + } + if len(got.Children) != 0 || len(got.Timers) != 0 || len(run.AfterTimers.Navigations) != 0 { + t.Errorf("failure must not add a link or redirect: %+v", run.AfterTimers) + } +} + +func TestAuthorizationPageNetworkErrorShowsUnreachableMessage(t *testing.T) { + run := runAuthorizationPage(t, "network-error") + assertPendingPage(t, run) + + got := run.AfterResponse + if !strings.Contains(got.Text, "could not be reached") || strings.Contains(got.Text, "successful") { + t.Errorf("text = %q, want the unreachable message", got.Text) + } + if len(got.Children) != 0 || len(got.Timers) != 0 || len(run.AfterTimers.Navigations) != 0 { + t.Errorf("failure must not add a link or redirect: %+v", run.AfterTimers) + } +} diff --git a/pkg/testdata/auth_page_harness.js b/pkg/testdata/auth_page_harness.js new file mode 100644 index 00000000..6618087f --- /dev/null +++ b/pkg/testdata/auth_page_harness.js @@ -0,0 +1,123 @@ +// Runs the callback page script against a minimal fake browser and prints the +// observable state as JSON. Usage: node auth_page_harness.js < script.js +// Scenarios: pending, success, http-error, network-error. +const vm = require("vm"); + +const scenario = process.argv[2]; +const script = require("fs").readFileSync(0, "utf8"); + +function makeElement(tag) { + let text = ""; + const el = { + tagName: tag, + children: [], + href: "", + appendChild(child) { + if (typeof child !== "object" || child === null) { + throw new TypeError("appendChild: argument is not a Node"); + } + this.children.push(child); + return child; + }, + }; + // Like the DOM, setting textContent drops the existing children. + Object.defineProperty(el, "textContent", { + get: () => text, + set: (v) => { + text = String(v); + el.children = []; + }, + }); + return el; +} + +const statusElement = makeElement("p"); +statusElement.textContent = "Authenticating..."; + +const requests = []; +const timers = []; +const navigations = []; +let windowStatus = ""; + +class FakeXHR { + constructor() { + this.status = 0; + this.responseText = ""; + requests.push(this); + } + open(method, url, async) { + this.method = method; + this.url = url; + this.async = async; + } + send(body) { + this.sent = true; + this.body = body; + } +} + +// The page runs as a top-level script, so its `var` declarations land on the +// global object, which is the window. In browsers window.status is a string +// accessor: assigning an element to it stores the element's string form. +const sandbox = { + URLSearchParams, + encodeURIComponent, + XMLHttpRequest: FakeXHR, + document: { + getElementById: (id) => (id === "status" ? statusElement : null), + createElement: makeElement, + }, + setTimeout: (fn, delay) => timers.push({ fn, delay }), +}; +sandbox.window = sandbox; +Object.defineProperty(sandbox, "status", { + get: () => windowStatus, + set: (v) => { + windowStatus = String(v); + }, +}); +let location = { search: "?code=abc%20123" }; +Object.defineProperty(sandbox, "location", { + get: () => location, + set: (v) => { + navigations.push(String(v)); + }, +}); +vm.createContext(sandbox); + +function snapshot() { + return { + text: statusElement.textContent, + children: statusElement.children.map((c) => ({ tag: c.tagName, href: c.href, text: c.textContent })), + requests: requests.map((r) => ({ method: r.method, url: r.url, async: r.async, sent: !!r.sent })), + timers: timers.map((t) => t.delay), + navigations: navigations.slice(), + }; +} + +const out = {}; +try { + vm.runInContext(script, sandbox); + out.pending = snapshot(); + + const xhr = requests[0]; + if (scenario === "success") { + xhr.status = 200; + xhr.responseText = "Authentication successful, you'll be redirected. Click: "; + xhr.onload(); + } else if (scenario === "http-error") { + xhr.status = 401; + xhr.responseText = "Authentication failed, run 'qovery auth' again."; + xhr.onload(); + } else if (scenario === "network-error") { + xhr.onerror(); + } + out.afterResponse = snapshot(); + + // Firing the scheduled redirect must navigate the window. + timers.forEach((t) => t.fn()); + out.afterTimers = snapshot(); +} catch (e) { + out.error = String(e); +} +console.log(JSON.stringify(out)); From e4ab34d297c590c5b6b68695873e3f6158d19374 Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Thu, 8 Oct 2026 12:20:17 +0200 Subject: [PATCH 07/15] docs(auth): correct the authorizationPage comment on the window.status clash --- pkg/auth_service.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/auth_service.go b/pkg/auth_service.go index e831de9f..5dbfbb63 100644 --- a/pkg/auth_service.go +++ b/pkg/auth_service.go @@ -102,9 +102,10 @@ func storeTokens(tokens TokensResponse) { _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt, utils.RefreshToken(tokens.RefreshToken)) } -// authorizationPage is the callback page. The script avoids top-level `var` -// declarations: they become window properties, and names like `status` are -// read-only strings there, which breaks DOM updates. +// authorizationPage is the callback page. Top-level `var` declarations in the +// script become window properties, and `window.status` is a string property +// that would coerce an element to text. The element is named statusElement to +// avoid that clash. func authorizationPage(port int) string { return fmt.Sprintf(`

Authenticating...