diff options
Diffstat (limited to 'ipn/webauth/webauth_test.go')
| -rw-r--r-- | ipn/webauth/webauth_test.go | 496 |
1 files changed, 496 insertions, 0 deletions
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) + } + })} +} |
