summaryrefslogtreecommitdiffhomepage
path: root/ipn
diff options
context:
space:
mode:
Diffstat (limited to 'ipn')
-rw-r--r--ipn/ipnlocal/web_client.go3
-rw-r--r--ipn/webauth/webauth.go324
-rw-r--r--ipn/webauth/webauth_test.go496
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)
+ }
+ })}
+}