client/web: skip check mode for non-tailscale.com control servers (#10413)

client/web: skip check mode for non-tailscale.com control servers

Only enforce check mode if the control server URL ends in
".tailscale.com".  This allows the web client to be used with headscale
(or other) control servers while we work with the project to add check
mode support (tracked in juanfont/headscale#1623).

Updates #10261

Co-authored-by: Sonia Appasamy <sonia@tailscale.com>
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
Signed-off-by: Will Norris <will@tailscale.com>
pull/10420/head
Will Norris 7 months ago committed by GitHub
parent ab0e25beaa
commit 26db9775f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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.

@ -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)
})

@ -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) {

Loading…
Cancel
Save