diff options
Diffstat (limited to 'ipn')
| -rw-r--r-- | ipn/ipnlocal/web_client.go | 3 | ||||
| -rw-r--r-- | ipn/webauth/webauth.go | 324 | ||||
| -rw-r--r-- | ipn/webauth/webauth_test.go | 496 |
3 files changed, 823 insertions, 0 deletions
diff --git a/ipn/ipnlocal/web_client.go b/ipn/ipnlocal/web_client.go index 9708ed166..fdda95b07 100644 --- a/ipn/ipnlocal/web_client.go +++ b/ipn/ipnlocal/web_client.go @@ -14,6 +14,7 @@ import ( "tailscale.com/client/tailscale" "tailscale.com/client/web" "tailscale.com/envknob" + "tailscale.com/ipn/webauth" "tailscale.com/net/netutil" ) @@ -56,6 +57,8 @@ func (b *LocalBackend) WebClientInit() (err error) { // TODO(sonia): allow passing back dev mode flag LocalClient: b.webClient.lc, Logf: b.logf, + TimeNow: b.clock.Now, + AuthServer: webauth.NewServer(b.webClient.lc, b.clock.Now), }); err != nil { return fmt.Errorf("web.NewServer: %w", err) } diff --git a/ipn/webauth/webauth.go b/ipn/webauth/webauth.go new file mode 100644 index 000000000..a27f92575 --- /dev/null +++ b/ipn/webauth/webauth.go @@ -0,0 +1,324 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// TODO: package docs +package webauth + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "tailscale.com/client/tailscale" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/tailcfg" + "tailscale.com/util/httpm" +) + +type Server struct { + // sessions is an in-memory cache of user browser sessions. + // + // Users obtain a valid browser session by connecting to the + // app's UI over Tailscale and verifying their identity by + // authenticating on the control server. + // + // sessions get reset on every webAuthServer initialization. + // + // The map provides a lookup of the session by cookie value + // (browserSession.ID => browserSession). + sessions sync.Map + + lc *tailscale.LocalClient + timeNow func() time.Time +} + +func NewServer(lc *tailscale.LocalClient, timeNow func() time.Time) *Server { + return &Server{ + lc: lc, + timeNow: timeNow, + } +} + +func (s *Server) IsLoggedIn(r *http.Request) bool { + session, _, err := s.getTailscaleBrowserSession(r) + if err != nil { + return false + } + return session.isAuthorized(s.timeNow()) +} + +type LoginResponse struct { + OK bool `json:"ok"` // true when user is already logged in + AuthURL string `json:"authUrl,omitempty"` // filled when user has control login action to take +} + +func (s *Server) ServeLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != httpm.GET { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var resp LoginResponse + + session, whois, err := s.getTailscaleBrowserSession(r) + switch { + case err != nil && !errors.Is(err, errNoSession): + http.Error(w, err.Error(), http.StatusUnauthorized) + return + case session.isAuthorized(s.timeNow()): + resp = LoginResponse{OK: true} // already logged in + case session == nil: + // Create a new session for the user to log in. + d, err := s.getOrAwaitAuth(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, + AuthID: d.ID, + AuthURL: d.URL, + Created: s.timeNow(), + } + s.sessions.Store(sid, session) + // Set the cookie on browser. + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sid, + Raw: sid, + Path: "/", + Expires: session.expires(), + }) + resp = LoginResponse{OK: false, AuthURL: session.AuthURL} + default: + // Otherwise there's already an active ongoing login. + // If the user has requested that this request "wait" for login, + // we block until the user control auth has been completed. + // Otherwise we directly return the login URL. + if r.URL.Query().Get("wait") != "true" { + resp = LoginResponse{OK: false, AuthURL: session.AuthURL} + break // quick return + } + // Block until user completes auth. + d, err := s.getOrAwaitAuth(r.Context(), session.AuthID, whois.Node.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + // Clean up the session. Doing this on any error from control + // server to avoid the user getting stuck with a bad session + // cookie. + s.sessions.Delete(session.ID) + return + } + if d.Complete { + session.Authenticated = d.Complete + s.sessions.Store(session.ID, session) + } + if session.isAuthorized(s.timeNow()) { + resp = LoginResponse{OK: true} + } else { + resp = LoginResponse{OK: false, AuthURL: session.AuthURL} + } + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") +} + +// browserSession holds data about a user's browser session. +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.NodeID + SrcUser tailcfg.UserID + AuthID string // from tailcfg.WebClientAuthResponse + AuthURL string // from tailcfg.WebClientAuthResponse + Created time.Time + Authenticated bool +} + +const ( + sessionCookieName = "TS-Web-Session" // default session cookie name; TODO(sonia): make configurable, pass through NewServer + sessionCookieExpiry = time.Hour * 24 * 30 // default session expiry, 30 days +) + +// 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 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(now time.Time) bool { + switch { + case s == nil: + return false + case !s.Authenticated: + return false // awaiting auth + case s.isExpired(now): + return false // expired + } + return true +} + +// isExpired reports true if s is expired. +// 2023-10-05: Sessions expire by default 30 days after creation. +func (s *browserSession) isExpired(now time.Time) bool { + return !s.Created.IsZero() && now.After(s.expires()) +} + +// expires reports when the given session expires. +func (s *browserSession) expires() time.Time { + return s.Created.Add(sessionCookieExpiry) +} + +var ( + errNoSession = errors.New("no-browser-session") + errNotUsingTailscale = errors.New("not-using-tailscale") + errTaggedRemoteSource = errors.New("tagged-remote-source") + errTaggedLocalSource = errors.New("tagged-local-source") + errNotOwner = errors.New("not-owner") +) + +// getTailscaleBrowserSession retrieves the browser session associated with +// the request, if one exists. +// +// An error is returned in any of the following cases: +// +// - (errNotUsingTailscale) The request was not made over tailscale. +// +// - (errNoSession) The request does not have a session. +// +// - (errTaggedRemoteSource) The source is remote (another node) and tagged. +// Users must use their own user-owned devices to manage other nodes' +// web clients. +// +// - (errTaggedLocalSource) The source is local (the same node) and tagged. +// Tagged nodes can only be remotely managed, allowing ACLs to dictate +// access to web clients. +// +// - (errNotOwner) The source is not the owner of this client (if the +// client is user-owned). Only the owner is allowed to manage the +// node via the web client. +// +// 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. +// +// 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, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr) + status, statusErr := s.lc.StatusWithoutPeers(r.Context()) + switch { + case whoIsErr != nil: + return nil, nil, errNotUsingTailscale + case statusErr != nil: + return nil, whoIs, statusErr + case status.Self == nil: + return nil, whoIs, errors.New("missing self node in tailscale status") + // TODO: these whois rules would not be general... + case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID: + return nil, whoIs, errTaggedLocalSource + case whoIs.Node.IsTagged(): + return nil, whoIs, errTaggedRemoteSource + case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID: + return nil, whoIs, errNotOwner + } + srcNode := whoIs.Node.ID + srcUser := whoIs.UserProfile.ID + + cookie, err := r.Cookie(sessionCookieName) + if errors.Is(err, http.ErrNoCookie) { + return nil, whoIs, errNoSession + } else if err != nil { + return nil, whoIs, err + } + v, ok := s.sessions.Load(cookie.Value) + if !ok { + 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, whoIs, errNoSession + } else if session.isExpired(s.timeNow()) { + // Session expired, remove from session map and return errNoSession. + s.sessions.Delete(session.ID) + return nil, whoIs, errNoSession + } + return session, whoIs, nil +} + +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.sessions.Load(cookie); !ok { + return cookie, nil + } + } + return "", errors.New("webAuthServer.newSessionID: too many collisions generating new session; please refresh page") +} + +// getOrAwaitAuth connects to the control server for user auth, +// with the following behavior: +// +// 1. If authID 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 authID is provided non-empty, the connection to control blocks until +// the user has completed authenticating the associated auth URL, +// or until ctx is canceled. +func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { + type data struct { + ID string + Src tailcfg.NodeID + } + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(data{ID: authID, 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.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 +} diff --git a/ipn/webauth/webauth_test.go b/ipn/webauth/webauth_test.go new file mode 100644 index 000000000..bbdeb884d --- /dev/null +++ b/ipn/webauth/webauth_test.go @@ -0,0 +1,496 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package webauth + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "tailscale.com/client/tailscale" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn/ipnstate" + "tailscale.com/net/memnet" + "tailscale.com/tailcfg" + "tailscale.com/types/views" +) + +func TestGetTailscaleBrowserSession(t *testing.T) { + userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} + userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)} + + userANodeIP := "100.100.100.101" + userBNodeIP := "100.100.100.102" + taggedNodeIP := "100.100.100.103" + + var selfNode *ipnstate.PeerStatus + tags := views.SliceOf([]string{"tag:server"}) + tailnetNodes := map[string]*apitype.WhoIsResponse{ + userANodeIP: { + Node: &tailcfg.Node{ID: 1, StableID: "1"}, + UserProfile: userA, + }, + userBNodeIP: { + Node: &tailcfg.Node{ID: 2, StableID: "2"}, + UserProfile: userB, + }, + taggedNodeIP: { + Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()}, + }, + } + + lal := memnet.Listen("local-tailscaled.sock:80") + defer lal.Close() + localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }) + defer localapi.Close() + go localapi.Serve(lal) + + s := &Server{ + timeNow: time.Now, + lc: &tailscale.LocalClient{Dial: lal.Dial}, + } + + // Add some browser sessions to cache state. + userASession := &browserSession{ + ID: "cookie1", + SrcNode: 1, + SrcUser: userA.ID, + Created: time.Now(), + Authenticated: false, // not yet authenticated + } + userBSession := &browserSession{ + ID: "cookie2", + SrcNode: 2, + SrcUser: userB.ID, + Created: time.Now().Add(-2 * sessionCookieExpiry), + Authenticated: true, // expired + } + userASessionAuthorized := &browserSession{ + ID: "cookie3", + SrcNode: 1, + SrcUser: userA.ID, + Created: time.Now(), + Authenticated: true, // authenticated and not expired + } + s.sessions.Store(userASession.ID, userASession) + s.sessions.Store(userBSession.ID, userBSession) + s.sessions.Store(userASessionAuthorized.ID, userASessionAuthorized) + + tests := []struct { + name string + selfNode *ipnstate.PeerStatus + remoteAddr string + cookie string + + wantSession *browserSession + wantError error + wantIsAuthorized bool // response from session.isAuthorized + }{ + { + name: "not-connected-over-tailscale", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: "77.77.77.77", + wantSession: nil, + wantError: errNotUsingTailscale, + }, + { + name: "no-session-user-self-node", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: "not-a-cookie", + wantSession: nil, + wantError: errNoSession, + }, + { + name: "no-session-tagged-self-node", + selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags}, + remoteAddr: userANodeIP, + wantSession: nil, + wantError: errNoSession, + }, + { + name: "not-owner", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userBNodeIP, + wantSession: nil, + wantError: errNotOwner, + }, + { + name: "tagged-remote-source", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: taggedNodeIP, + wantSession: nil, + wantError: errTaggedRemoteSource, + }, + { + name: "tagged-local-source", + selfNode: &ipnstate.PeerStatus{ID: "3"}, + remoteAddr: taggedNodeIP, // same node as selfNode + wantSession: nil, + wantError: errTaggedLocalSource, + }, + { + name: "not-tagged-local-source", + selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID}, + remoteAddr: userANodeIP, // same node as selfNode + cookie: userASession.ID, + wantSession: userASession, + wantError: nil, // should not error + }, + { + name: "has-session", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: userASession.ID, + wantSession: userASession, + wantError: nil, + }, + { + name: "has-authorized-session", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: userASessionAuthorized.ID, + wantSession: userASessionAuthorized, + wantError: nil, + wantIsAuthorized: true, + }, + { + name: "session-associated-with-different-source", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, + remoteAddr: userBNodeIP, + cookie: userASession.ID, + wantSession: nil, + wantError: errNoSession, + }, + { + name: "session-expired", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, + remoteAddr: userBNodeIP, + cookie: userBSession.ID, + wantSession: nil, + wantError: errNoSession, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selfNode = tt.selfNode + r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}} + if tt.cookie != "" { + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) + } + session, _, err := s.getTailscaleBrowserSession(r) + if !errors.Is(err, tt.wantError) { + t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) + } + if diff := cmp.Diff(session, tt.wantSession); diff != "" { + t.Errorf("wrong session; (-got+want):%v", diff) + } + if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized { + t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized) + } + }) + } +} + +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) + + timeNow := time.Now() + oneHourAgo := timeNow.Add(-time.Hour) + sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2) + + s := &Server{ + lc: &tailscale.LocalClient{Dial: lal.Dial}, + timeNow: func() time.Time { return timeNow }, + } + + successCookie := "ts-cookie-success" + s.sessions.Store(successCookie, &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + }) + failureCookie := "ts-cookie-failure" + s.sessions.Store(failureCookie, &browserSession{ + ID: failureCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathError, + AuthURL: testControlURL + testAuthPathError, + }) + expiredCookie := "ts-cookie-expired" + s.sessions.Store(expiredCookie, &browserSession{ + ID: expiredCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: sixtyDaysAgo, + AuthID: "/a/old-auth-url", + AuthURL: testControlURL + "/a/old-auth-url", + }) + + tests := []struct { + name string + cookie string + query string + wantStatus int + wantResp *LoginResponse + wantNewCookie bool // new cookie generated + wantSession *browserSession // session associated w/ cookie at end of request + }{ + { + name: "new-session-created", + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + wantSession: &browserSession{ + ID: "GENERATED_ID", // gets swapped for newly created ID by test + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: timeNow, + AuthID: testAuthPath, + AuthURL: testControlURL + testAuthPath, + Authenticated: false, + }, + }, + { + name: "query-existing-incomplete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess}, + wantSession: &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + Authenticated: false, + }, + }, + { + 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: &LoginResponse{OK: true}, + wantSession: &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + Authenticated: true, + }, + }, + { + name: "query-existing-complete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: true}, + wantSession: &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + Authenticated: true, + }, + }, + { + name: "transition-to-failed-session", + cookie: failureCookie, + query: "wait=true", + wantStatus: http.StatusUnauthorized, + wantResp: nil, + wantSession: nil, // session deleted + }, + { + name: "failed-session-cleaned-up", + cookie: failureCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + wantSession: &browserSession{ + ID: "GENERATED_ID", + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: timeNow, + AuthID: testAuthPath, + AuthURL: testControlURL + testAuthPath, + Authenticated: false, + }, + }, + { + name: "expired-cookie-gets-new-session", + cookie: expiredCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + wantSession: &browserSession{ + ID: "GENERATED_ID", + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: timeNow, + AuthID: testAuthPath, + AuthURL: testControlURL + testAuthPath, + Authenticated: false, + }, + }, + } + 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.ServeLogin(w, r) + res := w.Result() + defer res.Body.Close() + + // Validate response status/data. + if gotStatus := res.StatusCode; tt.wantStatus != gotStatus { + t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus) + } + var gotResp *LoginResponse + 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 diff := cmp.Diff(gotResp, tt.wantResp); diff != "" { + t.Errorf("wrong response; (-got+want):%v", diff) + } + // Validate cookie creation. + sessionID := tt.cookie + var gotCookie bool + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName { + gotCookie = true + sessionID = c.Value + break + } + } + if gotCookie != tt.wantNewCookie { + t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie) + } + // Validate browser session contents. + var gotSesson *browserSession + if s, ok := s.sessions.Load(sessionID); ok { + gotSesson = s.(*browserSession) + } + if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" { + // If requested, swap in the generated session ID before + // comparing got/want. + tt.wantSession.ID = sessionID + } + if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" { + t.Errorf("wrong session; (-got+want):%v", diff) + } + }) + } +} + +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. +// +// self accepts a function that resolves to a self node status, +// so that tests may swap out the /localapi/v0/status response +// as desired. +func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus) *http.Server { + return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/localapi/v0/whois": + addr := r.URL.Query().Get("addr") + if addr == "" { + t.Fatalf("/whois call missing \"addr\" query") + } + if node := whoIs[addr]; node != nil { + if err := json.NewEncoder(w).Encode(&node); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + } + http.Error(w, "not a node", http.StatusUnauthorized) + return + case "/localapi/v0/status": + status := ipnstate.Status{Self: self()} + if err := json.NewEncoder(w).Encode(status); 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{ID: testAuthPath, 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) + } + })} +} |
