client/web: hook up auth flow

Connects serveTailscaleAuth to the localapi webclient endpoint
and pipes auth URLs and session cookies back to the browser to
redirect users from the frontend.

All behind debug flags for now.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/9886/head
Sonia Appasamy 12 months ago committed by Sonia Appasamy
parent 09b5bb3e55
commit 73bbf941f8

@ -1,5 +1,6 @@
import React from "react" import React from "react"
import { Footer, Header, IP, State } from "src/components/legacy" import { Footer, Header, IP, State } from "src/components/legacy"
import useAuth, { AuthResponse } from "src/hooks/auth"
import useNodeData, { NodeData } from "src/hooks/node-data" import useNodeData, { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg" import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
@ -19,14 +20,7 @@ export default function App() {
return !needsLogin && return !needsLogin &&
(data.DebugMode === "login" || data.DebugMode === "full") ? ( (data.DebugMode === "login" || data.DebugMode === "full") ? (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10"> <WebClient {...data} />
{data.DebugMode === "login" ? (
<LoginView {...data} />
) : (
<ManageView {...data} />
)}
<Footer className="mt-20" licensesURL={data.LicensesURL} />
</div>
) : ( ) : (
// Legacy client UI // Legacy client UI
<div className="py-14"> <div className="py-14">
@ -40,7 +34,34 @@ export default function App() {
) )
} }
function LoginView(props: NodeData) { function WebClient(props: NodeData) {
const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth()
if (loadingAuth) {
return <div className="text-center py-14">Loading...</div>
}
return (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
{props.DebugMode === "full" && auth?.ok ? (
<ManagementView {...props} />
) : (
<ReadonlyView data={props} auth={auth} waitOnAuth={waitOnAuth} />
)}
<Footer className="mt-20" licensesURL={props.LicensesURL} />
</div>
)
}
function ReadonlyView({
data,
auth,
waitOnAuth,
}: {
data: NodeData
auth?: AuthResponse
waitOnAuth: () => Promise<void>
}) {
return ( return (
<> <>
<div className="pb-52 mx-auto"> <div className="pb-52 mx-auto">
@ -48,14 +69,14 @@ function LoginView(props: NodeData) {
</div> </div>
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4"> <div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<ProfilePic url={props.Profile.ProfilePicURL} /> <ProfilePic url={data.Profile.ProfilePicURL} />
<div className="font-medium"> <div className="font-medium">
<div className="text-neutral-500 text-xs uppercase tracking-wide"> <div className="text-neutral-500 text-xs uppercase tracking-wide">
Owned by Owned by
</div> </div>
<div className="text-neutral-800 text-sm leading-tight"> <div className="text-neutral-800 text-sm leading-tight">
{/* TODO(sonia): support tagged node profile view more eloquently */} {/* TODO(sonia): support tagged node profile view more eloquently */}
{props.Profile.LoginName} {data.Profile.LoginName}
</div> </div>
</div> </div>
</div> </div>
@ -64,19 +85,29 @@ function LoginView(props: NodeData) {
<ConnectedDeviceIcon /> <ConnectedDeviceIcon />
<div className="text-neutral-800"> <div className="text-neutral-800">
<div className="text-lg font-medium leading-[25.20px]"> <div className="text-lg font-medium leading-[25.20px]">
{props.DeviceName} {data.DeviceName}
</div> </div>
<div className="text-sm leading-tight">{props.IP}</div> <div className="text-sm leading-tight">{data.IP}</div>
</div> </div>
</div> </div>
<button className="button button-blue ml-6">Access</button> {data.DebugMode === "full" && (
<button
className="button button-blue ml-6"
onClick={() => {
window.open(auth?.authUrl, "_blank")
waitOnAuth()
}}
>
Access
</button>
)}
</div> </div>
</div> </div>
</> </>
) )
} }
function ManageView(props: NodeData) { function ManagementView(props: NodeData) {
return ( return (
<div className="px-5"> <div className="px-5">
<div className="flex justify-between mb-12"> <div className="flex justify-between mb-12">
@ -101,6 +132,7 @@ function ManageView(props: NodeData) {
Tailscale is up and running. You can connect to this device from devices Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address. in your tailnet by using its name or IP address.
</p> </p>
<button className="button button-blue mt-6">Advertise exit node</button>
</div> </div>
) )
} }

@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch } from "src/api"
export type AuthResponse = {
ok: boolean
authUrl?: string
}
// useAuth reports and refreshes Tailscale auth status
// for the web client.
export default function useAuth() {
const [data, setData] = useState<AuthResponse>()
const [loading, setLoading] = useState<boolean>(false)
const loadAuth = useCallback((wait?: boolean) => {
const url = wait ? "/auth?wait=true" : "/auth"
setLoading(true)
return apiFetch(url, "GET")
.then((r) => r.json())
.then((d) => {
setLoading(false)
setData(d)
})
.catch((error) => {
setLoading(false)
console.error(error)
})
}, [])
useEffect(() => {
loadAuth()
}, [])
const waitOnAuth = useCallback(() => loadAuth(true), [])
return { data, loading, waitOnAuth }
}

@ -5,8 +5,10 @@
package web package web
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -19,6 +21,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
@ -58,7 +61,8 @@ type Server struct {
// //
// The map provides a lookup of the session by cookie value // The map provides a lookup of the session by cookie value
// (browserSession.ID => browserSession). // (browserSession.ID => browserSession).
browserSessions sync.Map browserSessions sync.Map
controlServerURL atomic.Value // access through getControlServerURL
} }
const ( const (
@ -77,25 +81,26 @@ type browserSession struct {
// ID is the unique identifier for the session. // ID is the unique identifier for the session.
// It is passed in the user's "TS-Web-Session" browser cookie. // It is passed in the user's "TS-Web-Session" browser cookie.
ID string ID string
SrcNode tailcfg.StableNodeID SrcNode tailcfg.NodeID
SrcUser tailcfg.UserID SrcUser tailcfg.UserID
AuthURL string // control server URL for user to authenticate the session AuthURL string // control server URL for user to authenticate the session
Authenticated time.Time // when zero, authentication not complete Created time.Time
Authenticated bool
} }
// isAuthorized reports true if the given session is authorized // isAuthorized reports true if the given session is authorized
// to be used by its associated user to access the full management // to be used by its associated user to access the full management
// web client. // web client.
// //
// isAuthorized is true only when s.Authenticated is non-zero // isAuthorized is true only when s.Authenticated is true (i.e.
// (i.e. the user has authenticated the session) and the session // the user has authenticated the session) and the session is not
// is not expired. // expired.
// 2023-10-05: Sessions expire by default after 30 days. // 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isAuthorized() bool { func (s *browserSession) isAuthorized() bool {
switch { switch {
case s == nil: case s == nil:
return false return false
case s.Authenticated.IsZero(): case !s.Authenticated:
return false // awaiting auth return false // awaiting auth
case s.isExpired(): // TODO: add time field to server? case s.isExpired(): // TODO: add time field to server?
return false // expired return false // expired
@ -104,20 +109,20 @@ func (s *browserSession) isAuthorized() bool {
} }
// isExpired reports true if s is expired. // isExpired reports true if s is expired.
// 2023-10-05: Sessions expire by default after 30 days. // 2023-10-05: Sessions expire by default 30 days after creation.
// If s.Authenticated is zero, isExpired reports false.
func (s *browserSession) isExpired() bool { func (s *browserSession) isExpired() bool {
return !s.Authenticated.IsZero() && s.Authenticated.Before(time.Now().Add(-sessionCookieExpiry)) // TODO: add time field to server? return !s.Created.IsZero() && time.Now().After(s.expires()) // TODO: add time field to server?
}
// 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. // ServerOpts contains options for constructing a new Server.
type ServerOpts struct { type ServerOpts struct {
DevMode bool DevMode bool
// LoginOnly indicates that the server should only serve the minimal
// login client and not the full web client.
LoginOnly bool
// CGIMode indicates if the server is running as a CGI script. // CGIMode indicates if the server is running as a CGI script.
CGIMode bool CGIMode bool
@ -223,7 +228,11 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
return true return true
case strings.HasPrefix(r.URL.Path, "/api/"): case strings.HasPrefix(r.URL.Path, "/api/"):
// All other /api/ endpoints require a valid browser session. // All other /api/ endpoints require a valid browser session.
session, err := s.getTailscaleBrowserSession(r) //
// TODO(sonia): s.getTailscaleBrowserSession 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() { if err != nil || !session.isAuthorized() {
http.Error(w, "no valid session", http.StatusUnauthorized) http.Error(w, "no valid session", http.StatusUnauthorized)
return false return false
@ -275,6 +284,7 @@ var (
errNotUsingTailscale = errors.New("not-using-tailscale") errNotUsingTailscale = errors.New("not-using-tailscale")
errTaggedSource = errors.New("tagged-source") errTaggedSource = errors.New("tagged-source")
errNotOwner = errors.New("not-owner") errNotOwner = errors.New("not-owner")
errFailedAuth = errors.New("failed-auth")
) )
// getTailscaleBrowserSession retrieves the browser session associated with // getTailscaleBrowserSession retrieves the browser session associated with
@ -296,70 +306,122 @@ var (
// If no error is returned, the browserSession is always non-nil. // If no error is returned, the browserSession is always non-nil.
// getTailscaleBrowserSession does not check whether the session has been // getTailscaleBrowserSession does not check whether the session has been
// authorized by the user. Callers can use browserSession.isAuthorized. // authorized by the user. Callers can use browserSession.isAuthorized.
func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, error) { //
// 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, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
switch { switch {
case err != nil: case err != nil:
return nil, errNotUsingTailscale return nil, nil, errNotUsingTailscale
case whoIs.Node.IsTagged(): case whoIs.Node.IsTagged():
return nil, errTaggedSource return nil, whoIs, errTaggedSource
} }
srcNode := whoIs.Node.StableID srcNode := whoIs.Node.ID
srcUser := whoIs.UserProfile.ID srcUser := whoIs.UserProfile.ID
status, err := s.lc.StatusWithoutPeers(r.Context()) status, err := s.lc.StatusWithoutPeers(r.Context())
switch { switch {
case err != nil: case err != nil:
return nil, err return nil, whoIs, err
case status.Self == nil: case status.Self == nil:
return nil, errors.New("missing self node in tailscale status") return nil, whoIs, errors.New("missing self node in tailscale status")
case !status.Self.IsTagged() && status.Self.UserID != srcUser: case !status.Self.IsTagged() && status.Self.UserID != srcUser:
return nil, errNotOwner return nil, whoIs, errNotOwner
} }
cookie, err := r.Cookie(sessionCookieName) cookie, err := r.Cookie(sessionCookieName)
if errors.Is(err, http.ErrNoCookie) { if errors.Is(err, http.ErrNoCookie) {
return nil, errNoSession return nil, whoIs, errNoSession
} else if err != nil { } else if err != nil {
return nil, err return nil, whoIs, err
} }
v, ok := s.browserSessions.Load(cookie.Value) v, ok := s.browserSessions.Load(cookie.Value)
if !ok { if !ok {
return nil, errNoSession return nil, whoIs, errNoSession
} }
session := v.(*browserSession) session := v.(*browserSession)
if session.SrcNode != srcNode || session.SrcUser != srcUser { if session.SrcNode != srcNode || session.SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node. // 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. // 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 errNoSession because there is no session for this user.
return nil, errNoSession return nil, whoIs, errNoSession
} else if session.isExpired() { } else if session.isExpired() {
// Session expired, remove from session map and return errNoSession. // Session expired, remove from session map and return errNoSession.
s.browserSessions.Delete(session.ID) s.browserSessions.Delete(session.ID)
return nil, errNoSession return nil, whoIs, errNoSession
} }
return session, nil return session, whoIs, nil
} }
type authResponse struct { type authResponse struct {
OK bool `json:"ok"` // true when user has valid auth session 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 AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take
Error string `json:"error,omitempty"` // filled when Ok is false
} }
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { 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 var resp authResponse
session, err := s.getTailscaleBrowserSession(r) session, whois, err := s.getTailscaleBrowserSession(r)
switch { switch {
case err != nil && !errors.Is(err, errNoSession): case err != nil && !errors.Is(err, errNoSession):
resp = authResponse{OK: false, Error: err.Error()} http.Error(w, err.Error(), http.StatusUnauthorized)
return
case session == nil: case session == nil:
// TODO(tailscale/corp#14335): Create a new auth path from control, // Create a new session.
// and store back to s.browserSessions and request cookie. d, err := s.getOrAwaitAuthURL(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,
AuthURL: d.URL,
Created: time.Now(),
}
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(): case !session.isAuthorized():
// TODO(tailscale/corp#14335): Check on the session auth path status from control, if r.URL.Query().Get("wait") == "true" {
// and store back to s.browserSessions. // Client requested we block until user completes auth.
d, err := s.getOrAwaitAuthURL(r.Context(), session.AuthURL, whois.Node.ID)
if errors.Is(err, errFailedAuth) {
http.Error(w, "user is unauthorized", http.StatusUnauthorized)
s.browserSessions.Delete(session.ID) // clean up the failed session
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if d.Complete {
session.Authenticated = d.Complete
s.browserSessions.Store(session.ID, session)
}
}
if session.isAuthorized() {
resp = authResponse{OK: true}
} else {
resp = authResponse{OK: false, AuthURL: session.AuthURL}
}
default: default:
resp = authResponse{OK: true} resp = authResponse{OK: true}
} }
@ -371,6 +433,84 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") 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")
}
func (s *Server) getControlServerURL(ctx context.Context) (string, error) {
if v := s.controlServerURL.Load(); v != nil {
v, _ := v.(string)
return v, nil
}
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
return "", err
}
url := prefs.ControlURLOrDefault()
s.controlServerURL.Store(url)
return url, nil
}
// getOrAwaitAuthURL connects to the control server for user auth,
// with the following behavior:
//
// 1. If authURL 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 authURL is provided non-empty, the connection to control
// blocks until the user has completed the URL. getOrAwaitAuthURL
// terminates when either the URL is completed, or ctx is canceled.
func (s *Server) getOrAwaitAuthURL(ctx context.Context, authURL string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
serverURL, err := s.getControlServerURL(ctx)
if err != nil {
return nil, err
}
type data struct {
ID string
Src tailcfg.NodeID
}
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(data{
ID: strings.TrimPrefix(authURL, serverURL),
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.StatusUnauthorized {
// User completed auth, but control server reported
// them unauthorized to manage this node.
return nil, errFailedAuth
} else 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. // serveAPI serves requests for the web client api.
// It should only be called by Server.ServeHTTP, via Server.apiHandler, // It should only be called by Server.ServeHTTP, via Server.apiHandler,
// which protects the handler using gorilla csrf. // which protects the handler using gorilla csrf.

@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -18,6 +19,7 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype" "tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/net/memnet" "tailscale.com/net/memnet"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@ -151,15 +153,15 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
tags := views.SliceOf([]string{"tag:server"}) tags := views.SliceOf([]string{"tag:server"})
tailnetNodes := map[string]*apitype.WhoIsResponse{ tailnetNodes := map[string]*apitype.WhoIsResponse{
userANodeIP: { userANodeIP: {
Node: &tailcfg.Node{StableID: "Node1"}, Node: &tailcfg.Node{ID: 1},
UserProfile: userA, UserProfile: userA,
}, },
userBNodeIP: { userBNodeIP: {
Node: &tailcfg.Node{StableID: "Node2"}, Node: &tailcfg.Node{ID: 2},
UserProfile: userB, UserProfile: userB,
}, },
taggedNodeIP: { taggedNodeIP: {
Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()}, Node: &tailcfg.Node{ID: 3, Tags: tags.AsSlice()},
}, },
} }
@ -174,21 +176,24 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
// Add some browser sessions to cache state. // Add some browser sessions to cache state.
userASession := &browserSession{ userASession := &browserSession{
ID: "cookie1", ID: "cookie1",
SrcNode: "Node1", SrcNode: 1,
SrcUser: userA.ID, SrcUser: userA.ID,
Authenticated: time.Time{}, // not yet authenticated Created: time.Now(),
Authenticated: false, // not yet authenticated
} }
userBSession := &browserSession{ userBSession := &browserSession{
ID: "cookie2", ID: "cookie2",
SrcNode: "Node2", SrcNode: 2,
SrcUser: userB.ID, SrcUser: userB.ID,
Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired Created: time.Now().Add(-2 * sessionCookieExpiry),
Authenticated: true, // expired
} }
userASessionAuthorized := &browserSession{ userASessionAuthorized := &browserSession{
ID: "cookie3", ID: "cookie3",
SrcNode: "Node1", SrcNode: 1,
SrcUser: userA.ID, SrcUser: userA.ID,
Authenticated: time.Now(), // authenticated and not expired Created: time.Now(),
Authenticated: true, // authenticated and not expired
} }
s.browserSessions.Store(userASession.ID, userASession) s.browserSessions.Store(userASession.ID, userASession)
s.browserSessions.Store(userBSession.ID, userBSession) s.browserSessions.Store(userBSession.ID, userBSession)
@ -281,7 +286,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
if tt.cookie != "" { if tt.cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
} }
session, err := s.getTailscaleBrowserSession(r) session, _, err := s.getTailscaleBrowserSession(r)
if !errors.Is(err, tt.wantError) { if !errors.Is(err, tt.wantError) {
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
} }
@ -322,9 +327,10 @@ func TestAuthorizeRequest(t *testing.T) {
validCookie := "ts-cookie" validCookie := "ts-cookie"
s.browserSessions.Store(validCookie, &browserSession{ s.browserSessions.Store(validCookie, &browserSession{
ID: validCookie, ID: validCookie,
SrcNode: remoteNode.Node.StableID, SrcNode: remoteNode.Node.ID,
SrcUser: user.ID, SrcUser: user.ID,
Authenticated: time.Now(), Created: time.Now(),
Authenticated: true,
}) })
tests := []struct { tests := []struct {
@ -391,6 +397,149 @@ func TestAuthorizeRequest(t *testing.T) {
} }
} }
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)
s := &Server{
lc: &tailscale.LocalClient{Dial: lal.Dial},
tsDebugMode: "full",
}
successCookie := "ts-cookie-success"
s.browserSessions.Store(successCookie, &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: time.Now(),
AuthURL: testControlURL + testAuthPathSuccess,
})
failureCookie := "ts-cookie-failure"
s.browserSessions.Store(failureCookie, &browserSession{
ID: failureCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: time.Now(),
AuthURL: testControlURL + testAuthPathError,
})
expiredCookie := "ts-cookie-expired"
s.browserSessions.Store(expiredCookie, &browserSession{
ID: expiredCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: time.Now().Add(-sessionCookieExpiry * 2),
AuthURL: testControlURL + "/a/old-auth-url",
})
tests := []struct {
name string
cookie string
query string
wantStatus int
wantResp *authResponse
wantNewCookie bool // new cookie generated
}{
{
name: "new-session-created",
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
wantNewCookie: true,
}, {
name: "query-existing-incomplete-session",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess},
}, {
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},
}, {
name: "query-existing-complete-session",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: true},
}, {
name: "transition-to-failed-session",
cookie: failureCookie,
query: "wait=true",
wantStatus: http.StatusUnauthorized,
wantResp: nil,
}, {
name: "failed-session-cleaned-up",
cookie: failureCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
wantNewCookie: true,
}, {
name: "expired-cookie-gets-new-session",
cookie: expiredCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
wantNewCookie: true,
},
}
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()
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 !reflect.DeepEqual(gotResp, tt.wantResp) {
t.Errorf("wrong response; want=%v, got=%v", tt.wantResp, gotResp)
}
var gotCookie bool
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName {
gotCookie = true
break
}
}
if gotCookie != tt.wantNewCookie {
t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
}
})
}
}
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 // mockLocalAPI constructs a test localapi handler that can be used
// to simulate localapi responses without a functioning tailnet. // to simulate localapi responses without a functioning tailnet.
// //
@ -423,6 +572,43 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
return return
case "/localapi/v0/prefs":
prefs := ipn.Prefs{ControlURL: testControlURL}
if err := json.NewEncoder(w).Encode(prefs); 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{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: default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
} }

@ -2179,7 +2179,6 @@ func (h *Handler) serveDebugWebClient(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
} }
func defBool(a string, def bool) bool { func defBool(a string, def bool) bool {

Loading…
Cancel
Save