diff --git a/client/web/auth.go b/client/web/auth.go index f4ed2aced..ec33c2743 100644 --- a/client/web/auth.go +++ b/client/web/auth.go @@ -9,6 +9,8 @@ import ( "encoding/base64" "errors" "net/http" + "net/url" + "strings" "time" "tailscale.com/client/tailscale/apitype" @@ -148,10 +150,6 @@ func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsRes // and stores it back to the session cache. Creating of a new session includes // generating a new auth URL from the control server. func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) { - a, err := s.newAuthURL(ctx, src.Node.ID) - if err != nil { - return nil, err - } sid, err := s.newSessionID() if err != nil { return nil, err @@ -160,14 +158,44 @@ func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*b ID: sid, SrcNode: src.Node.ID, SrcUser: src.UserProfile.ID, - AuthID: a.ID, - AuthURL: a.URL, Created: s.timeNow(), } + + if s.controlSupportsCheckMode(ctx) { + // control supports check mode, so get a new auth URL and return. + a, err := s.newAuthURL(ctx, src.Node.ID) + if err != nil { + return nil, err + } + session.AuthID = a.ID + session.AuthURL = a.URL + } else { + // control does not support check mode, so there is no additional auth we can do. + session.Authenticated = true + } + s.browserSessions.Store(sid, session) return session, nil } +// controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity. +// We assume that only "tailscale.com" control servers support check mode. +// This allows the web client to be used with non-standard control servers. +// If an error occurs getting the control URL, this method returns true to fail closed. +// +// TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode. +func (s *Server) controlSupportsCheckMode(ctx context.Context) bool { + prefs, err := s.lc.GetPrefs(ctx) + if err != nil { + return true + } + controlURL, err := url.Parse(prefs.ControlURL) + if err != nil { + return true + } + return strings.HasSuffix(controlURL.Host, ".tailscale.com") +} + // awaitUserAuth blocks until the given session auth has been completed // by the user on the control server, then updates the session cache upon // completion. An error is returned if control auth failed for any reason. diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts index e99bb6edc..f8537681a 100644 --- a/client/web/src/hooks/auth.ts +++ b/client/web/src/hooks/auth.ts @@ -58,10 +58,10 @@ export default function useAuth() { .then((d) => { if (d.authUrl) { window.open(d.authUrl, "_blank") - // refresh data when auth complete - apiFetch("/auth/session/wait", "GET").then(() => loadAuth()) + return apiFetch("/auth/session/wait", "GET") } }) + .then(() => loadAuth()) .catch((error) => { console.error(error) }) diff --git a/client/web/web_test.go b/client/web/web_test.go index bd256bdac..ef6a34bfc 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -20,6 +20,7 @@ import ( "github.com/google/go-cmp/cmp" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/net/memnet" "tailscale.com/tailcfg" @@ -167,7 +168,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) { lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() - localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }) + localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil) defer localapi.Close() go localapi.Serve(lal) @@ -334,6 +335,7 @@ func TestAuthorizeRequest(t *testing.T) { localapi := mockLocalAPI(t, map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, func() *ipnstate.PeerStatus { return self }, + nil, ) defer localapi.Close() go localapi.Serve(lal) @@ -433,11 +435,16 @@ func TestServeAuth(t *testing.T) { ProfilePicURL: user.ProfilePicURL, } + testControlURL := &defaultControlURL + lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() localapi := mockLocalAPI(t, map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, func() *ipnstate.PeerStatus { return self }, + func() *ipn.Prefs { + return &ipn.Prefs{ControlURL: *testControlURL} + }, ) defer localapi.Close() go localapi.Serve(lal) @@ -461,7 +468,7 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, + AuthURL: *testControlURL + testAuthPathSuccess, }) failureCookie := "ts-cookie-failure" s.browserSessions.Store(failureCookie, &browserSession{ @@ -470,7 +477,7 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathError, - AuthURL: testControlURL + testAuthPathError, + AuthURL: *testControlURL + testAuthPathError, }) expiredCookie := "ts-cookie-expired" s.browserSessions.Store(expiredCookie, &browserSession{ @@ -479,12 +486,13 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: sixtyDaysAgo, AuthID: "/a/old-auth-url", - AuthURL: testControlURL + "/a/old-auth-url", + AuthURL: *testControlURL + "/a/old-auth-url", }) tests := []struct { name string + controlURL string // if empty, defaultControlURL is used cookie string // cookie attached to request wantNewCookie bool // want new cookie generated during request wantSession *browserSession // session associated w/ cookie after request @@ -505,7 +513,7 @@ func TestServeAuth(t *testing.T) { name: "new-session", path: "/api/auth/session/new", wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath}, + wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, wantNewCookie: true, wantSession: &browserSession{ ID: "GENERATED_ID", // gets swapped for newly created ID by test @@ -513,7 +521,7 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: timeNow, AuthID: testAuthPath, - AuthURL: testControlURL + testAuthPath, + AuthURL: *testControlURL + testAuthPath, Authenticated: false, }, }, @@ -529,7 +537,7 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, + AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: false, }, }, @@ -538,14 +546,14 @@ func TestServeAuth(t *testing.T) { path: "/api/auth/session/new", // should not create new session cookie: successCookie, wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPathSuccess}, + wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess}, wantSession: &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, + AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: false, }, }, @@ -561,7 +569,7 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, + AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: true, }, }, @@ -577,7 +585,7 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, + AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: true, }, }, @@ -594,7 +602,7 @@ func TestServeAuth(t *testing.T) { path: "/api/auth/session/new", cookie: failureCookie, wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath}, + wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, wantNewCookie: true, wantSession: &browserSession{ ID: "GENERATED_ID", @@ -602,7 +610,7 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: timeNow, AuthID: testAuthPath, - AuthURL: testControlURL + testAuthPath, + AuthURL: *testControlURL + testAuthPath, Authenticated: false, }, }, @@ -611,7 +619,7 @@ func TestServeAuth(t *testing.T) { path: "/api/auth/session/new", cookie: expiredCookie, wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath}, + wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, wantNewCookie: true, wantSession: &browserSession{ ID: "GENERATED_ID", @@ -619,13 +627,34 @@ func TestServeAuth(t *testing.T) { SrcUser: user.ID, Created: timeNow, AuthID: testAuthPath, - AuthURL: testControlURL + testAuthPath, + AuthURL: *testControlURL + testAuthPath, Authenticated: false, }, }, + { + name: "control-server-no-check-mode", + controlURL: "http://alternate-server.com/", + path: "/api/auth/session/new", + wantStatus: http.StatusOK, + wantResp: &newSessionAuthResponse{}, + wantNewCookie: true, + wantSession: &browserSession{ + ID: "GENERATED_ID", // gets swapped for newly created ID by test + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: timeNow, + Authenticated: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.controlURL != "" { + testControlURL = &tt.controlURL + } else { + testControlURL = &defaultControlURL + } + r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil) r.RemoteAddr = remoteIP r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) @@ -694,7 +723,7 @@ func TestRequireTailscaleIP(t *testing.T) { lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() - localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }) + localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil) defer localapi.Close() go localapi.Serve(lal) @@ -771,7 +800,7 @@ func TestRequireTailscaleIP(t *testing.T) { } var ( - testControlURL = "http://localhost:8080" + defaultControlURL = "https://controlplane.tailscale.com" testAuthPath = "/a/12345" testAuthPathSuccess = "/a/will-succeed" testAuthPathError = "/a/will-error" @@ -783,7 +812,7 @@ var ( // 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 { +func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs) *http.Server { return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/localapi/v0/whois": @@ -800,6 +829,9 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu case "/localapi/v0/status": writeJSON(w, ipnstate.Status{Self: self()}) return + case "/localapi/v0/prefs": + writeJSON(w, prefs()) + return default: t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) } @@ -808,7 +840,7 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu func mockNewAuthURL(_ context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { // Create new dummy auth URL. - return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: testControlURL + testAuthPath}, nil + return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: defaultControlURL + testAuthPath}, nil } func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {