From 73bbf941f8ba3e33e7b780b724165b11d0984783 Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Wed, 18 Oct 2023 16:45:25 -0400 Subject: [PATCH] client/web: hook up auth flow Connects serveTailscaleAuth to the localapi webclient endpoint and pipes auth URLs and session cookies back to the browser to redirect users from the frontend. All behind debug flags for now. Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy --- client/web/src/components/app.tsx | 62 ++++++--- client/web/src/hooks/auth.ts | 37 ++++++ client/web/web.go | 214 ++++++++++++++++++++++++------ client/web/web_test.go | 210 +++++++++++++++++++++++++++-- ipn/localapi/localapi.go | 1 - 5 files changed, 459 insertions(+), 65 deletions(-) create mode 100644 client/web/src/hooks/auth.ts diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index eb403a5e7..004bc39c5 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -1,5 +1,6 @@ import React from "react" import { Footer, Header, IP, State } from "src/components/legacy" +import useAuth, { AuthResponse } from "src/hooks/auth" import useNodeData, { NodeData } from "src/hooks/node-data" import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg" import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" @@ -19,14 +20,7 @@ export default function App() { return !needsLogin && (data.DebugMode === "login" || data.DebugMode === "full") ? ( -
- {data.DebugMode === "login" ? ( - - ) : ( - - )} -
-
+ ) : ( // Legacy client UI
@@ -40,7 +34,34 @@ export default function App() { ) } -function LoginView(props: NodeData) { +function WebClient(props: NodeData) { + const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth() + + if (loadingAuth) { + return
Loading...
+ } + + return ( +
+ {props.DebugMode === "full" && auth?.ok ? ( + + ) : ( + + )} +
+
+ ) +} + +function ReadonlyView({ + data, + auth, + waitOnAuth, +}: { + data: NodeData + auth?: AuthResponse + waitOnAuth: () => Promise +}) { return ( <>
@@ -48,14 +69,14 @@ function LoginView(props: NodeData) {
- +
Owned by
{/* TODO(sonia): support tagged node profile view more eloquently */} - {props.Profile.LoginName} + {data.Profile.LoginName}
@@ -64,19 +85,29 @@ function LoginView(props: NodeData) {
- {props.DeviceName} + {data.DeviceName}
-
{props.IP}
+
{data.IP}
- + {data.DebugMode === "full" && ( + + )}
) } -function ManageView(props: NodeData) { +function ManagementView(props: NodeData) { return (
@@ -101,6 +132,7 @@ function ManageView(props: NodeData) { Tailscale is up and running. You can connect to this device from devices in your tailnet by using its name or IP address.

+
) } diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts new file mode 100644 index 000000000..1fa26aec5 --- /dev/null +++ b/client/web/src/hooks/auth.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from "react" +import { apiFetch } from "src/api" + +export type AuthResponse = { + ok: boolean + authUrl?: string +} + +// useAuth reports and refreshes Tailscale auth status +// for the web client. +export default function useAuth() { + const [data, setData] = useState() + const [loading, setLoading] = useState(false) + + const loadAuth = useCallback((wait?: boolean) => { + const url = wait ? "/auth?wait=true" : "/auth" + setLoading(true) + return apiFetch(url, "GET") + .then((r) => r.json()) + .then((d) => { + setLoading(false) + setData(d) + }) + .catch((error) => { + setLoading(false) + console.error(error) + }) + }, []) + + useEffect(() => { + loadAuth() + }, []) + + const waitOnAuth = useCallback(() => loadAuth(true), []) + + return { data, loading, waitOnAuth } +} diff --git a/client/web/web.go b/client/web/web.go index 93b0a2600..3827f71c7 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -5,8 +5,10 @@ package web import ( + "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -19,6 +21,7 @@ "slices" "strings" "sync" + "sync/atomic" "time" "github.com/gorilla/csrf" @@ -58,7 +61,8 @@ type Server struct { // // The map provides a lookup of the session by cookie value // (browserSession.ID => browserSession). - browserSessions sync.Map + browserSessions sync.Map + controlServerURL atomic.Value // access through getControlServerURL } const ( @@ -77,25 +81,26 @@ type browserSession struct { // ID is the unique identifier for the session. // It is passed in the user's "TS-Web-Session" browser cookie. ID string - SrcNode tailcfg.StableNodeID + SrcNode tailcfg.NodeID SrcUser tailcfg.UserID - AuthURL string // control server URL for user to authenticate the session - Authenticated time.Time // when zero, authentication not complete + AuthURL string // control server URL for user to authenticate the session + Created time.Time + Authenticated bool } // isAuthorized reports true if the given session is authorized // to be used by its associated user to access the full management // web client. // -// isAuthorized is true only when s.Authenticated is non-zero -// (i.e. the user has authenticated the session) and the session -// is not expired. -// 2023-10-05: Sessions expire by default after 30 days. +// isAuthorized is true only when s.Authenticated is true (i.e. +// the user has authenticated the session) and the session is not +// expired. +// 2023-10-05: Sessions expire by default 30 days after creation. func (s *browserSession) isAuthorized() bool { switch { case s == nil: return false - case s.Authenticated.IsZero(): + case !s.Authenticated: return false // awaiting auth case s.isExpired(): // TODO: add time field to server? return false // expired @@ -104,20 +109,20 @@ func (s *browserSession) isAuthorized() bool { } // isExpired reports true if s is expired. -// 2023-10-05: Sessions expire by default after 30 days. -// If s.Authenticated is zero, isExpired reports false. +// 2023-10-05: Sessions expire by default 30 days after creation. func (s *browserSession) isExpired() bool { - return !s.Authenticated.IsZero() && s.Authenticated.Before(time.Now().Add(-sessionCookieExpiry)) // TODO: add time field to server? + return !s.Created.IsZero() && time.Now().After(s.expires()) // TODO: add time field to server? +} + +// expires reports when the given session expires. +func (s *browserSession) expires() time.Time { + return s.Created.Add(sessionCookieExpiry) } // ServerOpts contains options for constructing a new Server. type ServerOpts struct { DevMode bool - // LoginOnly indicates that the server should only serve the minimal - // login client and not the full web client. - LoginOnly bool - // CGIMode indicates if the server is running as a CGI script. CGIMode bool @@ -223,7 +228,11 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo return true case strings.HasPrefix(r.URL.Path, "/api/"): // All other /api/ endpoints require a valid browser session. - session, err := s.getTailscaleBrowserSession(r) + // + // TODO(sonia): s.getTailscaleBrowserSession calls whois again, + // should try and use the above call instead of running another + // localapi request. + session, _, err := s.getTailscaleBrowserSession(r) if err != nil || !session.isAuthorized() { http.Error(w, "no valid session", http.StatusUnauthorized) return false @@ -275,6 +284,7 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) { errNotUsingTailscale = errors.New("not-using-tailscale") errTaggedSource = errors.New("tagged-source") errNotOwner = errors.New("not-owner") + errFailedAuth = errors.New("failed-auth") ) // getTailscaleBrowserSession retrieves the browser session associated with @@ -296,70 +306,122 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) { // If no error is returned, the browserSession is always non-nil. // getTailscaleBrowserSession does not check whether the session has been // authorized by the user. Callers can use browserSession.isAuthorized. -func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, error) { +// +// The WhoIsResponse is always populated, with a non-nil Node and UserProfile, +// unless getTailscaleBrowserSession reports errNotUsingTailscale. +func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) { whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) switch { case err != nil: - return nil, errNotUsingTailscale + return nil, nil, errNotUsingTailscale case whoIs.Node.IsTagged(): - return nil, errTaggedSource + return nil, whoIs, errTaggedSource } - srcNode := whoIs.Node.StableID + srcNode := whoIs.Node.ID srcUser := whoIs.UserProfile.ID status, err := s.lc.StatusWithoutPeers(r.Context()) switch { case err != nil: - return nil, err + return nil, whoIs, err case status.Self == nil: - return nil, errors.New("missing self node in tailscale status") + return nil, whoIs, errors.New("missing self node in tailscale status") case !status.Self.IsTagged() && status.Self.UserID != srcUser: - return nil, errNotOwner + return nil, whoIs, errNotOwner } cookie, err := r.Cookie(sessionCookieName) if errors.Is(err, http.ErrNoCookie) { - return nil, errNoSession + return nil, whoIs, errNoSession } else if err != nil { - return nil, err + return nil, whoIs, err } v, ok := s.browserSessions.Load(cookie.Value) if !ok { - return nil, errNoSession + return nil, whoIs, errNoSession } session := v.(*browserSession) if session.SrcNode != srcNode || session.SrcUser != srcUser { // In this case the browser cookie is associated with another tailscale node. // Maybe the source browser's machine was logged out and then back in as a different node. // Return errNoSession because there is no session for this user. - return nil, errNoSession + return nil, whoIs, errNoSession } else if session.isExpired() { // Session expired, remove from session map and return errNoSession. s.browserSessions.Delete(session.ID) - return nil, errNoSession + return nil, whoIs, errNoSession } - return session, nil + return session, whoIs, nil } type authResponse struct { OK bool `json:"ok"` // true when user has valid auth session AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take - Error string `json:"error,omitempty"` // filled when Ok is false } func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != httpm.GET { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } var resp authResponse - session, err := s.getTailscaleBrowserSession(r) + session, whois, err := s.getTailscaleBrowserSession(r) switch { case err != nil && !errors.Is(err, errNoSession): - resp = authResponse{OK: false, Error: err.Error()} + http.Error(w, err.Error(), http.StatusUnauthorized) + return case session == nil: - // TODO(tailscale/corp#14335): Create a new auth path from control, - // and store back to s.browserSessions and request cookie. + // Create a new session. + d, err := s.getOrAwaitAuthURL(r.Context(), "", whois.Node.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sid, err := s.newSessionID() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + session := &browserSession{ + ID: sid, + SrcNode: whois.Node.ID, + SrcUser: whois.UserProfile.ID, + AuthURL: d.URL, + Created: time.Now(), + } + s.browserSessions.Store(sid, session) + // Set the cookie on browser. + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sid, + Raw: sid, + Path: "/", + Expires: session.expires(), + }) + resp = authResponse{OK: false, AuthURL: d.URL} case !session.isAuthorized(): - // TODO(tailscale/corp#14335): Check on the session auth path status from control, - // and store back to s.browserSessions. + if r.URL.Query().Get("wait") == "true" { + // Client requested we block until user completes auth. + d, err := s.getOrAwaitAuthURL(r.Context(), session.AuthURL, whois.Node.ID) + if errors.Is(err, errFailedAuth) { + http.Error(w, "user is unauthorized", http.StatusUnauthorized) + s.browserSessions.Delete(session.ID) // clean up the failed session + return + } else if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if d.Complete { + session.Authenticated = d.Complete + s.browserSessions.Store(session.ID, session) + } + } + if session.isAuthorized() { + resp = authResponse{OK: true} + } else { + resp = authResponse{OK: false, AuthURL: session.AuthURL} + } default: resp = authResponse{OK: true} } @@ -371,6 +433,84 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") } +func (s *Server) newSessionID() (string, error) { + raw := make([]byte, 16) + for i := 0; i < 5; i++ { + if _, err := rand.Read(raw); err != nil { + return "", err + } + cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw) + if _, ok := s.browserSessions.Load(cookie); !ok { + return cookie, nil + } + } + return "", errors.New("too many collisions generating new session; please refresh page") +} + +func (s *Server) getControlServerURL(ctx context.Context) (string, error) { + if v := s.controlServerURL.Load(); v != nil { + v, _ := v.(string) + return v, nil + } + prefs, err := s.lc.GetPrefs(ctx) + if err != nil { + return "", err + } + url := prefs.ControlURLOrDefault() + s.controlServerURL.Store(url) + return url, nil +} + +// getOrAwaitAuthURL connects to the control server for user auth, +// with the following behavior: +// +// 1. If authURL is provided empty, a new auth URL is created on the +// control server and reported back here, which can then be used +// to redirect the user on the frontend. +// 2. If authURL is provided non-empty, the connection to control +// blocks until the user has completed the URL. getOrAwaitAuthURL +// terminates when either the URL is completed, or ctx is canceled. +func (s *Server) getOrAwaitAuthURL(ctx context.Context, authURL string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { + serverURL, err := s.getControlServerURL(ctx) + if err != nil { + return nil, err + } + type data struct { + ID string + Src tailcfg.NodeID + } + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(data{ + ID: strings.TrimPrefix(authURL, serverURL), + Src: src, + }); err != nil { + return nil, err + } + url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client" + req, err := http.NewRequestWithContext(ctx, "POST", url, &b) + if err != nil { + return nil, err + } + resp, err := s.lc.DoLocalRequest(req) + if err != nil { + return nil, err + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + // User completed auth, but control server reported + // them unauthorized to manage this node. + return nil, errFailedAuth + } else if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed request: %s", body) + } + var authResp *tailcfg.WebClientAuthResponse + if err := json.Unmarshal(body, &authResp); err != nil { + return nil, err + } + return authResp, nil +} + // serveAPI serves requests for the web client api. // It should only be called by Server.ServeHTTP, via Server.apiHandler, // which protects the handler using gorilla csrf. diff --git a/client/web/web_test.go b/client/web/web_test.go index b44aeb4ef..60fd29287 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -11,6 +11,7 @@ "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" "time" @@ -18,6 +19,7 @@ "github.com/google/go-cmp/cmp" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/net/memnet" "tailscale.com/tailcfg" @@ -151,15 +153,15 @@ func TestGetTailscaleBrowserSession(t *testing.T) { tags := views.SliceOf([]string{"tag:server"}) tailnetNodes := map[string]*apitype.WhoIsResponse{ userANodeIP: { - Node: &tailcfg.Node{StableID: "Node1"}, + Node: &tailcfg.Node{ID: 1}, UserProfile: userA, }, userBNodeIP: { - Node: &tailcfg.Node{StableID: "Node2"}, + Node: &tailcfg.Node{ID: 2}, UserProfile: userB, }, taggedNodeIP: { - Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()}, + Node: &tailcfg.Node{ID: 3, Tags: tags.AsSlice()}, }, } @@ -174,21 +176,24 @@ func TestGetTailscaleBrowserSession(t *testing.T) { // Add some browser sessions to cache state. userASession := &browserSession{ ID: "cookie1", - SrcNode: "Node1", + SrcNode: 1, SrcUser: userA.ID, - Authenticated: time.Time{}, // not yet authenticated + Created: time.Now(), + Authenticated: false, // not yet authenticated } userBSession := &browserSession{ ID: "cookie2", - SrcNode: "Node2", + SrcNode: 2, SrcUser: userB.ID, - Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired + Created: time.Now().Add(-2 * sessionCookieExpiry), + Authenticated: true, // expired } userASessionAuthorized := &browserSession{ ID: "cookie3", - SrcNode: "Node1", + SrcNode: 1, SrcUser: userA.ID, - Authenticated: time.Now(), // authenticated and not expired + Created: time.Now(), + Authenticated: true, // authenticated and not expired } s.browserSessions.Store(userASession.ID, userASession) s.browserSessions.Store(userBSession.ID, userBSession) @@ -281,7 +286,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) { if tt.cookie != "" { r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) } - session, err := s.getTailscaleBrowserSession(r) + session, _, err := s.getTailscaleBrowserSession(r) if !errors.Is(err, tt.wantError) { t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) } @@ -322,9 +327,10 @@ func() *ipnstate.PeerStatus { return self }, validCookie := "ts-cookie" s.browserSessions.Store(validCookie, &browserSession{ ID: validCookie, - SrcNode: remoteNode.Node.StableID, + SrcNode: remoteNode.Node.ID, SrcUser: user.ID, - Authenticated: time.Now(), + Created: time.Now(), + Authenticated: true, }) tests := []struct { @@ -391,6 +397,149 @@ func() *ipnstate.PeerStatus { return self }, } } +func TestServeTailscaleAuth(t *testing.T) { + user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} + self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID} + remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user} + remoteIP := "100.100.100.101" + + lal := memnet.Listen("local-tailscaled.sock:80") + defer lal.Close() + localapi := mockLocalAPI(t, + map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, + func() *ipnstate.PeerStatus { return self }, + ) + defer localapi.Close() + go localapi.Serve(lal) + + s := &Server{ + lc: &tailscale.LocalClient{Dial: lal.Dial}, + tsDebugMode: "full", + } + + successCookie := "ts-cookie-success" + s.browserSessions.Store(successCookie, &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: time.Now(), + AuthURL: testControlURL + testAuthPathSuccess, + }) + failureCookie := "ts-cookie-failure" + s.browserSessions.Store(failureCookie, &browserSession{ + ID: failureCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: time.Now(), + AuthURL: testControlURL + testAuthPathError, + }) + expiredCookie := "ts-cookie-expired" + s.browserSessions.Store(expiredCookie, &browserSession{ + ID: expiredCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: time.Now().Add(-sessionCookieExpiry * 2), + AuthURL: testControlURL + "/a/old-auth-url", + }) + + tests := []struct { + name string + cookie string + query string + wantStatus int + wantResp *authResponse + wantNewCookie bool // new cookie generated + }{ + { + name: "new-session-created", + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + }, { + name: "query-existing-incomplete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess}, + }, { + name: "transition-to-successful-session", + cookie: successCookie, + // query "wait" indicates the FE wants to make + // local api call to wait until session completed. + query: "wait=true", + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: true}, + }, { + name: "query-existing-complete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: true}, + }, { + name: "transition-to-failed-session", + cookie: failureCookie, + query: "wait=true", + wantStatus: http.StatusUnauthorized, + wantResp: nil, + }, { + name: "failed-session-cleaned-up", + cookie: failureCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + }, { + name: "expired-cookie-gets-new-session", + cookie: expiredCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/auth", nil) + r.URL.RawQuery = tt.query + r.RemoteAddr = remoteIP + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) + w := httptest.NewRecorder() + s.serveTailscaleAuth(w, r) + res := w.Result() + defer res.Body.Close() + if gotStatus := res.StatusCode; tt.wantStatus != gotStatus { + t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus) + } + var gotResp *authResponse + if res.StatusCode == http.StatusOK { + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &gotResp); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(gotResp, tt.wantResp) { + t.Errorf("wrong response; want=%v, got=%v", tt.wantResp, gotResp) + } + var gotCookie bool + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName { + gotCookie = true + break + } + } + if gotCookie != tt.wantNewCookie { + t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie) + } + }) + } +} + +var ( + testControlURL = "http://localhost:8080" + testAuthPath = "/a/12345" + testAuthPathSuccess = "/a/will-succeed" + testAuthPathError = "/a/will-error" +) + // mockLocalAPI constructs a test localapi handler that can be used // to simulate localapi responses without a functioning tailnet. // @@ -423,6 +572,43 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu } w.Header().Set("Content-Type", "application/json") return + case "/localapi/v0/prefs": + prefs := ipn.Prefs{ControlURL: testControlURL} + if err := json.NewEncoder(w).Encode(prefs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth + type reqData struct { + ID string + Src tailcfg.NodeID + } + var data reqData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if data.Src == 0 { + http.Error(w, "missing Src node", http.StatusBadRequest) + return + } + var resp *tailcfg.WebClientAuthResponse + if data.ID == "" { + resp = &tailcfg.WebClientAuthResponse{URL: testControlURL + testAuthPath} + } else if data.ID == testAuthPathSuccess { + resp = &tailcfg.WebClientAuthResponse{Complete: true} + } else if data.ID == testAuthPathError { + http.Error(w, "authenticated as wrong user", http.StatusUnauthorized) + return + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return default: t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) } diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index f9b040e8f..4151d94e7 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -2179,7 +2179,6 @@ type reqData struct { return } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(resp.StatusCode) } func defBool(a string, def bool) bool {