summaryrefslogtreecommitdiffhomepage
path: root/client/web
diff options
context:
space:
mode:
authorSonia Appasamy <sonia@tailscale.com>2023-10-31 18:07:50 -0400
committerSonia Appasamy <sonia@tailscale.com>2023-11-01 12:57:00 -0400
commitc612cd0822146adbdf5b43bec1224fbfaf542ad8 (patch)
tree747409af3169ef9ec4bb1121dd2f909a85b99ead /client/web
parent44175653dcd273741a59a0648b55cd062c940cd1 (diff)
downloadtailscale-soniaappasamy/web-auth-restructure.tar.xz
tailscale-soniaappasamy/web-auth-restructure.zip
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
Diffstat (limited to 'client/web')
-rw-r--r--client/web/web.go293
-rw-r--r--client/web/web_test.go533
2 files changed, 54 insertions, 772 deletions
diff --git a/client/web/web.go b/client/web/web.go
index 24c303ee0..4a674ab56 100644
--- a/client/web/web.go
+++ b/client/web/web.go
@@ -5,12 +5,9 @@
package web
import (
- "bytes"
"context"
"crypto/rand"
- "encoding/base64"
"encoding/json"
- "errors"
"fmt"
"io"
"log"
@@ -20,7 +17,6 @@ import (
"path/filepath"
"slices"
"strings"
- "sync"
"time"
"github.com/gorilla/csrf"
@@ -53,75 +49,19 @@ type Server struct {
assetsHandler http.Handler // serves frontend assets
assetsCleanup func() // called from Server.Shutdown
- // browserSessions is an in-memory cache of browser sessions for the
- // full management web client, which is only accessible over Tailscale.
- //
- // Users obtain a valid browser session by connecting to the web client
- // over Tailscale and verifying their identity by authenticating on the
- // control server.
- //
- // browserSessions get reset on every Server restart.
- //
- // The map provides a lookup of the session by cookie value
- // (browserSession.ID => browserSession).
- browserSessions sync.Map
+ auth authServer
}
-const (
- sessionCookieName = "TS-Web-Session"
- sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
-)
+type authServer interface {
+ IsLoggedIn(r *http.Request) bool
+ ServeLogin(w http.ResponseWriter, r *http.Request) // serves /api/auth back to the frontend
+}
var (
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
)
-// browserSession holds data about a user's browser session
-// on the full management web client.
-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
-}
-
-// 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)
-}
-
// ServerOpts contains options for constructing a new Server.
type ServerOpts struct {
DevMode bool
@@ -136,6 +76,9 @@ type ServerOpts struct {
// If nil, a new one will be created.
LocalClient *tailscale.LocalClient
+ // TODO: docs
+ AuthServer authServer
+
// TimeNow optionally provides a time function.
// time.Now is used as default.
TimeNow func() time.Time
@@ -158,6 +101,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
cgiMode: opts.CGIMode,
pathPrefix: opts.PathPrefix,
timeNow: opts.TimeNow,
+ auth: opts.AuthServer,
}
if s.timeNow == nil {
s.timeNow = time.Now
@@ -165,6 +109,9 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
if s.logf == nil {
s.logf = log.Printf
}
+ if s.auth == nil {
+ // todo: default to platform auth?
+ }
s.tsDebugMode = s.debugMode()
s.assetsHandler, s.assetsCleanup = assetsHandler(opts.DevMode)
@@ -266,11 +213,10 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
case strings.HasPrefix(r.URL.Path, "/api/"):
// All other /api/ endpoints require a valid browser session.
//
- // TODO(sonia): s.getTailscaleBrowserSession calls whois again,
+ // TODO(sonia): s.auth.IsLoggedIn 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(s.timeNow()) {
+ if !s.auth.IsLoggedIn(r) {
http.Error(w, "no valid session", http.StatusUnauthorized)
return false
}
@@ -316,218 +262,11 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
return
}
-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")
- 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.browserSessions.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.browserSessions.Delete(session.ID)
- return nil, whoIs, errNoSession
- }
- 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
}
-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, whois, err := s.getTailscaleBrowserSession(r)
- switch {
- case err != nil && !errors.Is(err, errNoSession):
- http.Error(w, err.Error(), http.StatusUnauthorized)
- return
- case session == nil:
- // Create a new session.
- 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.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(s.timeNow()):
- if r.URL.Query().Get("wait") == "true" {
- // Client requested we 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.browserSessions.Delete(session.ID)
- return
- }
- if d.Complete {
- session.Authenticated = d.Complete
- s.browserSessions.Store(session.ID, session)
- }
- }
- if session.isAuthorized(s.timeNow()) {
- resp = authResponse{OK: true}
- } else {
- resp = authResponse{OK: false, AuthURL: session.AuthURL}
- }
- default:
- resp = authResponse{OK: true}
- }
-
- if err := json.NewEncoder(w).Encode(resp); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- 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")
-}
-
-// 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
-}
-
// 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.
@@ -535,9 +274,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
- case path == "/auth":
+ case path == "/auth" && r.Method == httpm.GET:
if s.tsDebugMode == "full" { // behind debug flag
- s.serveTailscaleAuth(w, r)
+ s.auth.ServeLogin(w, r)
return
}
case path == "/data":
diff --git a/client/web/web_test.go b/client/web/web_test.go
index 372ebcb92..f0672e786 100644
--- a/client/web/web_test.go
+++ b/client/web/web_test.go
@@ -5,7 +5,6 @@ package web
import (
"encoding/json"
- "errors"
"fmt"
"io"
"net/http"
@@ -15,13 +14,10 @@ import (
"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"
"tailscale.com/util/httpm"
)
@@ -139,200 +135,34 @@ func TestServeAPI(t *testing.T) {
}
}
-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.browserSessions.Store(userASession.ID, userASession)
- s.browserSessions.Store(userBSession.ID, userBSession)
- s.browserSessions.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)
- }
- })
- }
-}
-
// TestAuthorizeRequest tests the s.authorizeRequest function.
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
func TestAuthorizeRequest(t *testing.T) {
- // Create self and remoteNode owned by same user.
- // See TestGetTailscaleBrowserSession for tests of
- // browser sessions w/ different users.
- user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
- self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
- remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
- remoteIP := "100.100.100.101"
+ remoteTSAddr := "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 },
- )
+ localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/localapi/v0/whois":
+ // Just passes back a whois response when request was made from `remoteTSAddr`.
+ if addr := r.URL.Query().Get("addr"); addr == remoteTSAddr {
+ if err := json.NewEncoder(w).Encode(&apitype.WhoIsResponse{
+ Node: &tailcfg.Node{StableID: "node"},
+ UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)},
+ }); 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
+ default:
+ t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
+ }
+ })}
defer localapi.Close()
go localapi.Serve(lal)
@@ -340,15 +170,8 @@ func TestAuthorizeRequest(t *testing.T) {
lc: &tailscale.LocalClient{Dial: lal.Dial},
tsDebugMode: "full",
timeNow: time.Now,
+ auth: &mockAuthServer{},
}
- validCookie := "ts-cookie"
- s.browserSessions.Store(validCookie, &browserSession{
- ID: validCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: time.Now(),
- Authenticated: true,
- })
tests := []struct {
reqPath string
@@ -393,7 +216,7 @@ func TestAuthorizeRequest(t *testing.T) {
r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
r.RemoteAddr = remoteAddr
if cookie != "" {
- r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
+ r.AddCookie(&http.Cookie{Name: mockCookieName, Value: cookie})
}
w := httptest.NewRecorder()
return s.authorizeRequest(w, r)
@@ -403,311 +226,31 @@ func TestAuthorizeRequest(t *testing.T) {
t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
}
// Do request from Tailscale IP w/o associated session.
- if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
+ if gotOk := doAuthorize(remoteTSAddr, ""); gotOk != tt.wantOkWithoutSession {
t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
}
// Do request from Tailscale IP w/ associated session.
- if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
+ if gotOk := doAuthorize(remoteTSAddr, mockValidCookie); gotOk != tt.wantOkWithSession {
t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
}
})
}
}
-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},
- tsDebugMode: "full",
- timeNow: func() time.Time { return timeNow },
- }
-
- successCookie := "ts-cookie-success"
- s.browserSessions.Store(successCookie, &browserSession{
- ID: successCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: testControlURL + testAuthPathSuccess,
- })
- failureCookie := "ts-cookie-failure"
- s.browserSessions.Store(failureCookie, &browserSession{
- ID: failureCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathError,
- AuthURL: testControlURL + testAuthPathError,
- })
- expiredCookie := "ts-cookie-expired"
- s.browserSessions.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 *authResponse
- wantNewCookie bool // new cookie generated
- wantSession *browserSession // session associated w/ cookie at end of request
- }{
- {
- name: "new-session-created",
- wantStatus: http.StatusOK,
- wantResp: &authResponse{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: &authResponse{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: &authResponse{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: &authResponse{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: &authResponse{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: &authResponse{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.serveTailscaleAuth(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 *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 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.browserSessions.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)
- }
- })
- }
-}
+type mockAuthServer struct{}
var (
- testControlURL = "http://localhost:8080"
- testAuthPath = "/a/12345"
- testAuthPathSuccess = "/a/will-succeed"
- testAuthPathError = "/a/will-error"
+ mockCookieName = "TS-Web-Session"
+ mockValidCookie = "ts-cookie-valid"
)
-// 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)
- }
- })}
+func (s *mockAuthServer) IsLoggedIn(r *http.Request) bool {
+ c, err := r.Cookie(mockCookieName)
+ return err == nil && c.Value == mockValidCookie
+}
+func (s *mockAuthServer) ServeLogin(w http.ResponseWriter, r *http.Request) {
+ // Not used by any tests.
+ // Leaving unimplemented until needed.
+ http.Error(w, "unimplemented", http.StatusInternalServerError)
+ return
}