diff --git a/pkg/auth_service.go b/pkg/auth_service.go index 1726494a..1bccc3e3 100644 --- a/pkg/auth_service.go +++ b/pkg/auth_service.go @@ -9,11 +9,14 @@ import ( "fmt" "io" "math/rand" + "net" "net/http" "net/url" "os" "strconv" "strings" + "sync" + "sync/atomic" "time" "github.com/pkg/browser" @@ -22,11 +25,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" @@ -34,6 +41,16 @@ var ( oAuthUrlParamValueScopes = "offline_access openid profile email" oAuthUrlParamValueRedirect = "http://localhost:" + strconv.Itoa(httpAuthPort) + "/authorization" oAuthTokenEndpoint = "https://auth.qovery.com/oauth/token" + + authSuccessMessage = "Authentication successful, you'll be redirected to Qovery console. If it's not the case, click on this link: " + authFailureMessage = "Authentication failed. Run 'qovery auth' again, or contact #support on https://discord.qovery.com." + + // Upper bound for the graceful shutdown. It frees the port; it does not + // decide when the failure response is complete, see awaitFailureDelivered. + authShutdownTimeout = 5 * time.Second + + // exitProcess is replaceable so tests can observe an authentication failure. + exitProcess = os.Exit ) type TokensResponse struct { @@ -51,8 +68,6 @@ type DeviceFlowParameters struct { } func DoRequestUserToAuthenticate(headless bool, skipVersionCheck bool) { - qoveryConsoleUrl := "https://console.qovery.com" - if !skipVersionCheck { available, message, _ := CheckAvailableNewVersion() if available { @@ -70,66 +85,255 @@ 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) } + // 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... ") - srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", httpAuthPort)} + serveAuthorization(srv, listener) +} + +// authorizationServer is the callback server of one authentication attempt. +type authorizationServer struct { + *http.Server + failed atomic.Bool + + // shutdownTimeout is read once at construction, so the package variable is + // not touched from server goroutines. + shutdownTimeout time.Duration + + // failedConn is the connection that carries the failure response. + // delivered closes once that connection is idle again after the failing + // handler: the http server only gets there after the response is completely + // written. Closed and hijacked connections prove nothing about delivery. + failMu sync.Mutex + failedConn net.Conn + delivered chan struct{} + deliveredOnce sync.Once +} + +type connContextKey struct{} + +func (s *authorizationServer) markDelivered() { + s.deliveredOnce.Do(func() { close(s.delivered) }) +} + +// serveAuthorization serves until the attempt ends. Serve returns as soon as +// Shutdown starts, and Shutdown closes the listener first, so the fixed port is +// free again when this function returns. On failure the process exits here, not +// in the handler, and only once the failure response is confirmed completely +// written: the browser must not get it truncated. If that never gets confirmed +// (connection lost mid-write), the CLI keeps waiting instead of exiting. +func serveAuthorization(srv *authorizationServer, listener net.Listener) { + _ = srv.Serve(listener) + srv.shutdown() + if srv.failed.Load() { + srv.awaitFailureDelivered() + exitProcess(1) + } +} + +// shutdown stops the server and waits, within authShutdownTimeout, for in-flight +// requests to finish. It is safe to call more than once. A timeout here does not +// mean the failure response is complete: see awaitFailureDelivered. +func (s *authorizationServer) shutdown() { + ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + utils.PrintlnError(err) + } +} + +// awaitFailureDelivered blocks until the failure response is confirmed +// completely written. There is no timeout on purpose: exiting earlier could +// truncate the response the user is about to read. +func (s *authorizationServer) awaitFailureDelivered() { + <-s.delivered +} + +// fail answers the browser with the failure message and ends the attempt. The +// handler only returns: exiting here would kill the process before the response +// is complete (a flushed response is still chunked and unterminated). +func (s *authorizationServer) fail(writer http.ResponseWriter, request *http.Request, status int) { + // Without a tracked connection nothing can ever confirm delivery, so the + // attempt stays failed but neither exits nor shuts down. + conn, _ := request.Context().Value(connContextKey{}).(net.Conn) + s.failMu.Lock() + s.failedConn = conn + s.failMu.Unlock() + + http.Error(writer, authFailureMessage, status) + utils.PrintlnError(errors.New("authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. ")) + if s.failed.CompareAndSwap(false, true) && conn != nil { + // Shutdown disables keep-alive, so a response finished during it closes + // the connection without ever going idle and delivery could not be + // confirmed. Shut down only once it is. + go func() { + s.awaitFailureDelivered() + s.shutdown() + }() + } +} + +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)) +} + +// authorizationPage is the callback page. The element is named statusElement +// because a global status resolves to window.status, which is string-valued: +// assigning the DOM element to it would store a string and lose the element. +func authorizationPage(port int) string { + return fmt.Sprintf(`

Authenticating...

+`, port, qoveryConsoleUrl, qoveryConsoleUrl, qoveryConsoleUrl) +} - http.HandleFunc("/authorization", func(writer http.ResponseWriter, request *http.Request) { - js := fmt.Sprintf(``, httpAuthPort) +// 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)) *authorizationServer { + srv := &authorizationServer{ + Server: &http.Server{Addr: fmt.Sprintf("localhost:%d", httpAuthPort)}, + delivered: make(chan struct{}), + + shutdownTimeout: authShutdownTimeout, + } + srv.ConnContext = func(ctx context.Context, conn net.Conn) context.Context { + return context.WithValue(ctx, connContextKey{}, conn) + } + srv.ConnState = func(conn net.Conn, state http.ConnState) { + switch state { + case http.StateIdle: + srv.failMu.Lock() + isFailed := conn == srv.failedConn + srv.failMu.Unlock() + if isFailed { + srv.markDelivered() + } + case http.StateNew, http.StateActive, http.StateHijacked, http.StateClosed: + } + } + mux := http.NewServeMux() - _, _ = 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 + "")) + // 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(authorizationPage(httpAuthPort))) }) - http.HandleFunc("/authorization/valid", func(writer http.ResponseWriter, request *http.Request) { - code := request.URL.Query()["code"][0] - res, err := http.PostForm(oAuthTokenEndpoint, url.Values{ - "grant_type": {"authorization_code"}, - "client_id": {oAuthUrlParamValueClient}, - "code": {code}, - "redirect_uri": {oAuthUrlParamValueRedirect}, - "code_verifier": {verifier}, - }) + // The callback is one-shot. A repeat (page reload, double request) must not + // replay the consumed code: it gets the first success again, whatever it + // carries. The lock also makes a concurrent repeat wait for the first exchange. + var ( + mu sync.Mutex + done bool + ) + mux.HandleFunc("/authorization/valid", func(writer http.ResponseWriter, request *http.Request) { + mu.Lock() + defer mu.Unlock() + if done { + _, _ = writer.Write([]byte(authSuccessMessage)) + return + } + + // The attempt already failed and is shutting down: do not exchange again. + if srv.failed.Load() { + http.Error(writer, authFailureMessage, http.StatusUnauthorized) + return + } + codes := request.URL.Query()["code"] + if len(codes) == 0 || codes[0] == "" { + srv.fail(writer, request, http.StatusBadRequest) + return + } + tokens, err := exchangeAuthorizationCode(verifier, codes[0]) if err != nil { - utils.PrintlnError(errors.New("authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. ")) - os.Exit(0) - } else { - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(res.Body) - - tokens := TokensResponse{} - err := json.NewDecoder(res.Body).Decode(&tokens) - if err != nil { - utils.PrintlnError(errors.New("authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. ")) - os.Exit(0) - } - expiredAt := time.Now().Local().Add(time.Duration(tokens.ExpiresIn-60) * time.Second) - _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt, utils.RefreshToken(tokens.RefreshToken)) - utils.PrintlnInfo("Success!") + // A rejected or stale code must not be stored nor reported as a success. + srv.fail(writer, request, http.StatusUnauthorized) + return } + done = true + onTokens(tokens) + utils.PrintlnInfo("Success!") + _, _ = writer.Write([]byte(authSuccessMessage)) + delay := authShutdownDelay go func() { - time.Sleep(time.Second) - if err := srv.Shutdown(context.TODO()); err != nil { - utils.PrintlnError(err) - } + time.Sleep(delay) + srv.shutdown() }() }) - _ = srv.ListenAndServe() + srv.Handler = mux + return srv +} + +// exchangeAuthorizationCode trades the authorization code for tokens. It fails +// on a non-200 answer or an empty access token, which is how the token endpoint +// reports a rejected or already used code. +func exchangeAuthorizationCode(verifier string, code string) (TokensResponse, error) { + res, err := http.PostForm(oAuthTokenEndpoint, url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {oAuthUrlParamValueClient}, + "code": {code}, + "redirect_uri": {oAuthUrlParamValueRedirect}, + "code_verifier": {verifier}, + }) + if err != nil { + return TokensResponse{}, err + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return TokensResponse{}, fmt.Errorf("token endpoint answered %d", res.StatusCode) + } + // Read the whole body: Unmarshal rejects trailing data after the first JSON + // value, which a streaming Decode would accept. + payload, err := io.ReadAll(res.Body) + if err != nil { + return TokensResponse{}, err + } + tokens := TokensResponse{} + if err := json.Unmarshal(payload, &tokens); err != nil { + return TokensResponse{}, err + } + if tokens.AccessToken == "" { + return TokensResponse{}, errors.New("token endpoint returned an empty access token") + } + return tokens, nil } func createCodeVerifier() string { @@ -171,8 +375,7 @@ func runHeadlessFlow() { tokens, err := getTokensWith(parameters) if err == nil { - expiredAt := time.Now().Local().Add(time.Duration(tokens.ExpiresIn-60) * time.Second) - _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt, utils.RefreshToken(tokens.RefreshToken)) + storeTokens(tokens) utils.PrintlnInfo("Success!") return } diff --git a/pkg/auth_service_test.go b/pkg/auth_service_test.go new file mode 100644 index 00000000..0e713495 --- /dev/null +++ b/pkg/auth_service_test.go @@ -0,0 +1,798 @@ +package pkg + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "regexp" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// exchangeRecorder records the code_verifier of every token exchange. The +// handler runs on server goroutines, so reads go through snapshot. +type exchangeRecorder struct { + mu sync.Mutex + exchanges []string +} + +func (r *exchangeRecorder) record(entry string) { + r.mu.Lock() + defer r.mu.Unlock() + r.exchanges = append(r.exchanges, entry) +} + +func (r *exchangeRecorder) snapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.exchanges...) +} + +// stubTokenEndpoint answers every token exchange with a success. +func stubTokenEndpoint(t *testing.T) *exchangeRecorder { + t.Helper() + return stubTokenEndpointWith(t, http.StatusOK, `{"access_token":"at","refresh_token":"rt","expires_in":3600}`) +} + +func stubTokenEndpointWith(t *testing.T, status int, body string) *exchangeRecorder { + t.Helper() + recorder := &exchangeRecorder{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + recorder.record(r.PostForm.Get("code_verifier") + "|" + r.PostForm.Get("code")) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(ts.Close) + + prevEndpoint, prevDelay := oAuthTokenEndpoint, authShutdownDelay + oAuthTokenEndpoint = ts.URL + authShutdownDelay = 0 + t.Cleanup(func() { oAuthTokenEndpoint, authShutdownDelay = prevEndpoint, prevDelay }) + return recorder +} + +// tokenStore collects stored tokens. onTokens runs on a server goroutine, so +// the test reads them through snapshot. +type tokenStore struct { + mu sync.Mutex + tokens []TokensResponse +} + +func (s *tokenStore) store(tokens TokensResponse) { + s.mu.Lock() + defer s.mu.Unlock() + s.tokens = append(s.tokens, tokens) +} + +func (s *tokenStore) snapshot() []TokensResponse { + s.mu.Lock() + defer s.mu.Unlock() + return append([]TokensResponse(nil), s.tokens...) +} + +// trackingListener remembers accepted connections so a test can sever them the +// way a dying process would. +type trackingListener struct { + net.Listener + // gate, when set, holds back every write on accepted connections until it + // is closed. It simulates a slow client. + gate chan struct{} + // failWrites makes the gated writes fail once the gate opens, like a client + // that went away mid-response. + failWrites bool + mu sync.Mutex + conns []net.Conn +} + +// gatedConn blocks writes until the gate is closed. +type gatedConn struct { + net.Conn + gate <-chan struct{} + fail bool +} + +func (c *gatedConn) Write(b []byte) (int, error) { + <-c.gate + if c.fail { + return 0, errors.New("simulated write failure") + } + return c.Conn.Write(b) +} + +func (l *trackingListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err == nil && l.gate != nil { + conn = &gatedConn{Conn: conn, gate: l.gate, fail: l.failWrites} + } + if err == nil { + l.mu.Lock() + l.conns = append(l.conns, conn) + l.mu.Unlock() + } + return conn, err +} + +func (l *trackingListener) killConnections() { + l.mu.Lock() + defer l.mu.Unlock() + for _, conn := range l.conns { + _ = conn.Close() + } +} + +// stubExit replaces exitProcess with a channel for tests that only mount the +// handler. The handler must never exit on these paths. +func stubExit(t *testing.T) chan int { + t.Helper() + exits := make(chan int, 4) + prevExit := exitProcess + exitProcess = func(code int) { exits <- code } + t.Cleanup(func() { exitProcess = prevExit }) + return exits +} + +// runningAuthorization is a callback server run the way DoRequestUserToAuthenticate +// runs it, through serveAuthorization. The exit stub severs every connection +// before it reports the code, like the real process exit does: a response that +// was not completed before the exit reaches the client truncated. +type runningAuthorization struct { + url string + exits chan int + done chan struct{} +} + +type authorizationOptions struct { + gate chan struct{} + failWrites bool + // untracked drops the connection tracking, so the failing handler cannot + // tell which connection carries its response. + untracked bool +} + +func startAuthorization(t *testing.T, onTokens func(TokensResponse)) *runningAuthorization { + t.Helper() + return startAuthorizationOn(t, onTokens, authorizationOptions{}) +} + +func startAuthorizationOn(t *testing.T, onTokens func(TokensResponse), opts authorizationOptions) *runningAuthorization { + t.Helper() + inner, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + listener := &trackingListener{Listener: inner, gate: opts.gate, failWrites: opts.failWrites} + running := &runningAuthorization{ + url: "http://" + inner.Addr().String(), + exits: make(chan int, 4), + done: make(chan struct{}), + } + prevExit := exitProcess + exitProcess = func(code int) { + listener.killConnections() + running.exits <- code + } + srv := newAuthorizationServer("v", onTokens) + if opts.untracked { + srv.ConnContext = nil + } + go func() { + defer close(running.done) + serveAuthorization(srv, listener) + }() + t.Cleanup(func() { + _ = srv.Close() + // An unconfirmed failure keeps serveAuthorization waiting by design. + srv.markDelivered() + <-running.done + exitProcess = prevExit + }) + return running +} + +func waitForExit(t *testing.T, exits <-chan int, want int) { + t.Helper() + select { + case code := <-exits: + if code != want { + t.Errorf("exit status = %d, want %d", code, want) + } + case <-time.After(5 * time.Second): + t.Fatal("exitProcess was not called") + } +} + +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) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer func() { _ = res.Body.Close() }() + body, _ := io.ReadAll(res.Body) + return string(body) +} + +func TestAuthorizationServerRepeatedAttemptsUseTheirOwnVerifier(t *testing.T) { + verifiers := stubTokenEndpoint(t) + + // Reuse the same address for every attempt, like the fixed callback port. + first, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := first.Addr().String() + listener := first + + store := &tokenStore{} + for i, verifier := range []string{"verifier-1", "verifier-2", "verifier-3"} { + srv := newAuthorizationServer(verifier, store.store) + + done := make(chan error, 1) + // A second attempt used to panic here on the duplicate "/authorization" pattern. + go func() { done <- srv.Serve(listener) }() + + code := "code-" + verifier + if body := get(t, "http://"+addr+"/authorization?code="+code); body == "" { + t.Fatalf("attempt %d: empty /authorization body", i) + } + get(t, "http://"+addr+"/authorization/valid?code="+code) + + select { + case err := <-done: + if err != http.ErrServerClosed { + t.Fatalf("attempt %d: Serve returned %v, want ErrServerClosed", i, err) + } + case <-time.After(5 * time.Second): + t.Fatalf("attempt %d: server did not shut down after the callback", i) + } + + // The port must be free for the next attempt. + listener, err = net.Listen("tcp", addr) + if err != nil { + t.Fatalf("attempt %d: port not released after shutdown: %v", i, err) + } + } + _ = listener.Close() + + want := []string{"verifier-1|code-verifier-1", "verifier-2|code-verifier-2", "verifier-3|code-verifier-3"} + got := verifiers.snapshot() + if len(got) != len(want) { + t.Fatalf("token exchanges = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("exchange %d = %q, want %q", i, got[i], want[i]) + } + } + if stored := store.snapshot(); len(stored) != 3 || stored[0].AccessToken != "at" || stored[0].RefreshToken != "rt" { + t.Errorf("stored tokens = %+v", stored) + } +} + +func TestAuthorizationServersDoNotShareHandlers(t *testing.T) { + verifiers := stubTokenEndpoint(t) + + a := httptest.NewServer(newAuthorizationServer("verifier-a", func(TokensResponse) {}).Handler) + defer a.Close() + b := httptest.NewServer(newAuthorizationServer("verifier-b", func(TokensResponse) {}).Handler) + defer b.Close() + + get(t, b.URL+"/authorization/valid?code=c") + get(t, a.URL+"/authorization/valid?code=c") + + want := []string{"verifier-b|c", "verifier-a|c"} + got := verifiers.snapshot() + if len(got) != len(want) { + t.Fatalf("token exchanges = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("exchange %d = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestAuthorizationServerDoesNotUseDefaultServeMux(t *testing.T) { + stubTokenEndpoint(t) + _ = newAuthorizationServer("v", func(TokensResponse) {}) + _ = newAuthorizationServer("v", func(TokensResponse) {}) + + if _, pattern := http.DefaultServeMux.Handler(httptest.NewRequest(http.MethodGet, "/authorization", nil)); pattern != "" { + t.Errorf("/authorization registered on http.DefaultServeMux (pattern %q)", pattern) + } +} + +func TestAuthorizationServerRejectedExchangeStoresNothing(t *testing.T) { + cases := map[string]struct { + status int + body string + }{ + "non-200 answer": {http.StatusForbidden, `{"error":"invalid_grant"}`}, + "200, empty tokens": {http.StatusOK, `{}`}, + "200, invalid JSON": {http.StatusOK, `not json`}, + "200, error payload": {http.StatusOK, `{"error":"invalid_grant"}`}, + "200, valid tokens then trailing garbage": {http.StatusOK, `{"access_token":"at","refresh_token":"rt","expires_in":3600} not json`}, + "200, valid tokens then a second value": {http.StatusOK, `{"access_token":"at","refresh_token":"rt","expires_in":3600}{}`}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + stubTokenEndpointWith(t, tc.status, tc.body) + var stored atomic.Int32 + a := startAuthorization(t, func(TokensResponse) { stored.Add(1) }) + + res, body := getComplete(t, a.url+"/authorization/valid?code=stale") + assertCompleteFailure(t, res, body, http.StatusUnauthorized) + waitForExit(t, a.exits, 1) + <-a.done + if extra := len(a.exits); extra != 0 { + t.Errorf("%d extra exit call(s), want exactly one", extra) + } + if n := stored.Load(); n != 0 { + t.Errorf("tokens stored %d time(s) after a rejected exchange", n) + } + }) + } +} + +// getComplete reads the whole response and fails if the connection was cut +// before the body was complete. +// The graceful shutdown gives up after authShutdownTimeout, but a failure +// response still being written must not be cut by the exit that follows. +func TestAuthorizationServerFailureExitWaitsForSlowDelivery(t *testing.T) { + stubTokenEndpointWith(t, http.StatusForbidden, `{"error":"invalid_grant"}`) + prevTimeout := authShutdownTimeout + authShutdownTimeout = 20 * time.Millisecond + t.Cleanup(func() { authShutdownTimeout = prevTimeout }) + + gate := make(chan struct{}) + var release sync.Once + releaseGate := func() { release.Do(func() { close(gate) }) } + t.Cleanup(releaseGate) + a := startAuthorizationOn(t, func(TokensResponse) {}, authorizationOptions{gate: gate}) + + type result struct { + res *http.Response + body string + err error + } + delivered := make(chan result, 1) + go func() { + res, err := http.Get(a.url + "/authorization/valid?code=stale") + if err != nil { + delivered <- result{err: err} + return + } + defer func() { _ = res.Body.Close() }() + body, err := io.ReadAll(res.Body) + delivered <- result{res, string(body), err} + }() + + // Well past the graceful timeout, the response is still held back. + select { + case code := <-a.exits: + t.Fatalf("exitProcess(%d) ran while the failure response was not delivered", code) + case got := <-delivered: + t.Fatalf("response arrived while the connection was gated: %+v", got) + case <-time.After(300 * time.Millisecond): + } + + releaseGate() + got := <-delivered + if got.err != nil { + t.Fatalf("response truncated: %v (got %q)", got.err, got.body) + } + assertCompleteFailure(t, got.res, got.body, http.StatusUnauthorized) + waitForExit(t, a.exits, 1) + <-a.done +} + +// A failure response that cannot be written is never confirmed delivered. The +// connection closes without going idle, and that must not release the exit. +func TestAuthorizationServerFailureExitNeverRunsWhenDeliveryIsUnconfirmed(t *testing.T) { + stubTokenEndpointWith(t, http.StatusForbidden, `{"error":"invalid_grant"}`) + // The old fixed delivery timeout, shortened: waiting longer than any of + // the graceful timeouts must still not exit. + prevTimeout := authShutdownTimeout + authShutdownTimeout = 20 * time.Millisecond + t.Cleanup(func() { authShutdownTimeout = prevTimeout }) + + gate := make(chan struct{}) + a := startAuthorizationOn(t, func(TokensResponse) {}, authorizationOptions{gate: gate, failWrites: true}) + + clientErr := make(chan error, 1) + go func() { + res, err := http.Get(a.url + "/authorization/valid?code=stale") + if err == nil { + _ = res.Body.Close() + } + clientErr <- err + }() + close(gate) + + // The client sees the connection die before any complete response. + select { + case err := <-clientErr: + if err == nil { + t.Fatal("client got a response although every write failed") + } + case <-time.After(5 * time.Second): + t.Fatal("client never saw the connection close") + } + select { + case code := <-a.exits: + t.Fatalf("exitProcess(%d) ran although the failure response was never delivered", code) + case <-a.done: + t.Fatal("serveAuthorization returned although the failure response was never delivered") + case <-time.After(500 * time.Millisecond): + } +} + +// Without a tracked connection nothing proves the response was delivered, so a +// failure must neither exit nor shut the server down, though the browser still +// gets its complete answer. +func TestAuthorizationServerFailureWithoutTrackedConnectionNeverExits(t *testing.T) { + stubTokenEndpointWith(t, http.StatusForbidden, `{"error":"invalid_grant"}`) + a := startAuthorizationOn(t, func(TokensResponse) {}, authorizationOptions{untracked: true}) + + res, body := getComplete(t, a.url+"/authorization/valid?code=stale") + assertCompleteFailure(t, res, body, http.StatusUnauthorized) + + select { + case code := <-a.exits: + t.Fatalf("exitProcess(%d) ran without a confirmed delivery", code) + case <-a.done: + t.Fatal("serveAuthorization returned without a confirmed delivery") + case <-time.After(500 * time.Millisecond): + } + // Still serving: a later callback is refused without a new exchange. + if status, _ := getStatus(t, a.url+"/authorization/valid?code=again"); status != http.StatusUnauthorized { + t.Errorf("later callback status = %d, want %d", status, http.StatusUnauthorized) + } +} + +// The handler alone, mounted without serveAuthorization, must not exit either. +func TestAuthorizationServerFailureHandlerAloneNeverExits(t *testing.T) { + stubTokenEndpointWith(t, http.StatusForbidden, `{"error":"invalid_grant"}`) + exits := stubExit(t) + srv := newAuthorizationServer("v", func(TokensResponse) {}) + ts := httptest.NewServer(srv.Handler) + defer ts.Close() + + res, body := getComplete(t, ts.URL+"/authorization/valid?code=stale") + assertCompleteFailure(t, res, body, http.StatusUnauthorized) + ts.Close() + if len(exits) != 0 { + t.Errorf("the handler exited the process without a confirmed delivery") + } + select { + case <-srv.delivered: + t.Error("delivery marked confirmed without a tracked connection") + default: + } +} + +func getComplete(t *testing.T, url string) (*http.Response, string) { + t.Helper() + res, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer func() { _ = res.Body.Close() }() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("GET %s: response truncated: %v (got %q)", url, err, body) + } + return res, string(body) +} + +func assertCompleteFailure(t *testing.T, res *http.Response, body string, wantStatus int) { + t.Helper() + if res.StatusCode != wantStatus { + t.Errorf("status = %d, want %d", res.StatusCode, wantStatus) + } + if want := authFailureMessage + "\n"; body != want { + t.Errorf("body = %q, want %q", body, want) + } + if res.ContentLength != int64(len(body)) { + t.Errorf("Content-Length = %d, want %d: the response is not framed as complete", res.ContentLength, len(body)) + } +} + +func TestAuthorizationServerMissingCodeFailsAndExits(t *testing.T) { + for _, path := range []string{"/authorization/valid", "/authorization/valid?code="} { + t.Run(path, func(t *testing.T) { + exchanges := stubTokenEndpoint(t) + var stored atomic.Int32 + a := startAuthorization(t, func(TokensResponse) { stored.Add(1) }) + + res, body := getComplete(t, a.url+path) + assertCompleteFailure(t, res, body, http.StatusBadRequest) + waitForExit(t, a.exits, 1) + <-a.done + if extra := len(a.exits); extra != 0 { + t.Errorf("%d extra exit call(s), want exactly one", extra) + } + if got := exchanges.snapshot(); len(got) != 0 { + t.Errorf("token exchanges = %v, want none", got) + } + if n := stored.Load(); n != 0 { + t.Errorf("tokens stored %d time(s) without a code", n) + } + }) + } +} + +// 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) + store := &tokenStore{} + ts := httptest.NewServer(newAuthorizationServer("v", store.store).Handler) + defer ts.Close() + + status, body := getStatus(t, ts.URL+"/authorization/valid?code=ok") + if status != http.StatusOK || body != authSuccessMessage { + t.Errorf("status = %d, body = %q, want 200 with the success message", status, body) + } + if stored := store.snapshot(); len(stored) != 1 { + t.Errorf("stored tokens = %+v, want one entry", stored) + } +} + +// A repeated callback must not replay the consumed code: with a real token +// endpoint it would fail and take the CLI down after the tokens were stored. +func TestAuthorizationServerRepeatedCallbackIsOneShot(t *testing.T) { + exchanges := stubTokenEndpoint(t) + exits := stubExit(t) + store := &tokenStore{} + ts := httptest.NewServer(newAuthorizationServer("v", store.store).Handler) + defer ts.Close() + + for _, path := range []string{"/authorization/valid?code=ok", "/authorization/valid?code=ok", "/authorization/valid?code=other", "/authorization/valid"} { + if status, body := getStatus(t, ts.URL+path); status != http.StatusOK || body != authSuccessMessage { + t.Errorf("%s: status = %d, body = %q, want the first success again", path, status, body) + } + } + if got := exchanges.snapshot(); len(got) != 1 { + t.Errorf("token exchanges = %v, want exactly one", got) + } + if stored := store.snapshot(); len(stored) != 1 { + t.Errorf("stored tokens = %+v, want one entry", stored) + } + ts.Close() + if len(exits) != 0 { + t.Errorf("a repeated callback exited the process") + } +} + +func TestAuthorizationServerConcurrentCallbacksExchangeOnce(t *testing.T) { + exchanges := stubTokenEndpoint(t) + exits := stubExit(t) + store := &tokenStore{} + ts := httptest.NewServer(newAuthorizationServer("v", store.store).Handler) + defer ts.Close() + + const callers = 8 + type answer struct { + status int + body string + err error + } + answers := make(chan answer, callers) + for i := 0; i < callers; i++ { + go func() { + res, err := http.Get(ts.URL + "/authorization/valid?code=ok") + if err != nil { + answers <- answer{err: err} + return + } + defer func() { _ = res.Body.Close() }() + body, err := io.ReadAll(res.Body) + answers <- answer{status: res.StatusCode, body: string(body), err: err} + }() + } + for i := 0; i < callers; i++ { + got := <-answers + if got.err != nil || got.status != http.StatusOK || got.body != authSuccessMessage { + t.Errorf("concurrent callback: status = %d, body = %q, err = %v, want the success answer", got.status, got.body, got.err) + } + } + if got := exchanges.snapshot(); len(got) != 1 { + t.Errorf("token exchanges = %v, want exactly one", got) + } + if stored := store.snapshot(); len(stored) != 1 { + t.Errorf("stored tokens = %+v, want one entry", stored) + } + ts.Close() + if len(exits) != 0 { + t.Errorf("a concurrent callback exited the process") + } +} + +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") + } + // The page runs against what the real callback server answers, not a copy + // of its strings. + var answerStatus int + var answerBody string + switch scenario { + case "success": + stubTokenEndpoint(t) + answerStatus, answerBody = callbackAnswer(t) + case "http-error": + stubTokenEndpointWith(t, http.StatusForbidden, `{"error":"invalid_grant"}`) + answerStatus, answerBody = callbackAnswer(t) + default: + 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]) + cmd.Env = append(os.Environ(), fmt.Sprintf("AUTH_RESPONSE_STATUS=%d", answerStatus), "AUTH_RESPONSE_BODY="+answerBody) + 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 +} + +// callbackAnswer asks a real callback server for its /authorization/valid answer. +func callbackAnswer(t *testing.T) (int, string) { + t.Helper() + a := startAuthorization(t, func(TokensResponse) {}) + return getStatus(t, a.url+"/authorization/valid?code=abc") +} + +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 got.Text != authSuccessMessage { + t.Errorf("text = %q, want the server's success message %q", got.Text, authSuccessMessage) + } + 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 got.Text != authFailureMessage+"\n" { + t.Errorf("text = %q, want the server's failure message %q", got.Text, authFailureMessage+"\n") + } + 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..738df749 --- /dev/null +++ b/pkg/testdata/auth_page_harness.js @@ -0,0 +1,126 @@ +// 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. +// For success and http-error, AUTH_RESPONSE_STATUS and AUTH_RESPONSE_BODY carry +// the answer the real callback server gave, so the page is run against it. +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); + }, +}); +// Every way a page can navigate is recorded: assigning window.location, +// assigning location.href, and location.assign/replace. +const navigate = (target) => navigations.push(String(target)); +const location = { search: "?code=abc%20123", assign: navigate, replace: navigate }; +Object.defineProperty(location, "href", { + get: () => "http://localhost:10999/authorization" + location.search, + set: navigate, +}); +Object.defineProperty(sandbox, "location", { + get: () => location, + set: navigate, +}); +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" || scenario === "http-error") { + xhr.status = Number(process.env.AUTH_RESPONSE_STATUS); + xhr.responseText = process.env.AUTH_RESPONSE_BODY; + 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));