client/web: signal need to wait for auth across tabs

This amends the session creation and auth status querying logic of the device UI
backend. On creation of new browser sessions we now store a PendingAuth flag
as part of the session that indicates a pending auth process that needs to be
awaited. On auth status queries, the server initiates a polling for the auth result
if it finds this flag to be true. Once the polling is completes, the flag is set to false.

Why this change was necessary: with regular browser settings, the device UI
frontend opens the control auth URL in a new tab and starts polling for the
results of the auth flow in the current tab. With certain browser settings (that
we still want to support), however, the auth URL opens in the same tab, thus
aborting the subsequent call to auth/session/wait that initiates the polling,
and preventing successful registration of the auth results in the session
status. The new logic ensures the polling happens on the next call to /api/auth
in these kinds of scenarios.

In addition to ensuring the auth wait happens, we now also revalidate the auth
state whenever an open tab regains focus, so that auth changes effected in one
tab propagate to other tabs without the need to refresh. This improves the
experience for all users of the web client when they've got multiple tabs open,
regardless of their browser settings.

Fixes #11905

Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
main
Gesa Stupperich 1 month ago committed by GitHub
parent 16fa81e804
commit 7a43e41a27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 17
      client/web/auth.go
  2. 11
      client/web/src/api.ts
  3. 78
      client/web/src/hooks/auth.ts
  4. 13
      client/web/web.go
  5. 51
      client/web/web_test.go

@ -37,6 +37,7 @@ type browserSession struct {
AuthURL string // from tailcfg.WebClientAuthResponse AuthURL string // from tailcfg.WebClientAuthResponse
Created time.Time Created time.Time
Authenticated bool Authenticated bool
PendingAuth bool
} }
// isAuthorized reports true if the given session is authorized // isAuthorized reports true if the given session is authorized
@ -172,12 +173,14 @@ func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*b
} }
session.AuthID = a.ID session.AuthID = a.ID
session.AuthURL = a.URL session.AuthURL = a.URL
session.PendingAuth = true
} else { } else {
// control does not support check mode, so there is no additional auth we can do. // control does not support check mode, so there is no additional auth we can do.
session.Authenticated = true session.Authenticated = true
} }
s.browserSessions.Store(sid, session) s.browserSessions.Store(sid, session)
return session, nil return session, nil
} }
@ -206,16 +209,24 @@ func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) err
if session.isAuthorized(s.timeNow()) { if session.isAuthorized(s.timeNow()) {
return nil // already authorized return nil // already authorized
} }
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode) a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
if err != nil { if err != nil {
// Clean up the session. Doing this on any error from control // Don't delete the session on context cancellation, as this is expected
// server to avoid the user getting stuck with a bad session // when users navigate away or refresh the page.
// cookie. if errors.Is(err, context.Canceled) {
return err
}
// Clean up the session for non-cancellation errors from control server
// to avoid the user getting stuck with a bad session cookie.
s.browserSessions.Delete(session.ID) s.browserSessions.Delete(session.ID)
return err return err
} }
if a.Complete { if a.Complete {
session.Authenticated = a.Complete session.Authenticated = a.Complete
session.PendingAuth = false
s.browserSessions.Store(session.ID, session) s.browserSessions.Store(session.ID, session)
} }
return nil return nil

@ -123,7 +123,10 @@ export function useAPI() {
return apiFetch<{ url?: string }>("/up", "POST", t.data) return apiFetch<{ url?: string }>("/up", "POST", t.data)
.then((d) => d.url && window.open(d.url, "_blank")) // "up" login step .then((d) => d.url && window.open(d.url, "_blank")) // "up" login step
.then(() => incrementMetric("web_client_node_connect")) .then(() => incrementMetric("web_client_node_connect"))
.then(() => mutate("/data")) .then(() => {
mutate("/data")
mutate("/auth")
})
.catch(handlePostError("Failed to login")) .catch(handlePostError("Failed to login"))
/** /**
@ -134,9 +137,9 @@ export function useAPI() {
// For logout, must increment metric before running api call, // For logout, must increment metric before running api call,
// as tailscaled will be unreachable after the call completes. // as tailscaled will be unreachable after the call completes.
incrementMetric("web_client_node_disconnect") incrementMetric("web_client_node_disconnect")
return apiFetch("/local/v0/logout", "POST").catch( return apiFetch("/local/v0/logout", "POST")
handlePostError("Failed to logout") .then(() => mutate("/auth"))
) .catch(handlePostError("Failed to logout"))
/** /**
* "new-auth-session" handles creating a new check mode session to * "new-auth-session" handles creating a new check mode session to

@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api" import { apiFetch, setSynoToken } from "src/api"
import useSWR from "swr"
export type AuthResponse = { export type AuthResponse = {
serverMode: AuthServerMode serverMode: AuthServerMode
@ -49,33 +50,26 @@ export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
* useAuth reports and refreshes Tailscale auth status for the web client. * useAuth reports and refreshes Tailscale auth status for the web client.
*/ */
export default function useAuth() { export default function useAuth() {
const [data, setData] = useState<AuthResponse>() const { data, error, mutate } = useSWR<AuthResponse>("/auth")
const [loading, setLoading] = useState<boolean>(true)
const [ranSynoAuth, setRanSynoAuth] = useState<boolean>(false) const [ranSynoAuth, setRanSynoAuth] = useState<boolean>(false)
const loadAuth = useCallback(() => { const loading = !data && !error
setLoading(true)
return apiFetch<AuthResponse>("/auth", "GET") // Start Synology auth flow if needed.
.then((d) => { useEffect(() => {
setData(d) if (data?.needsSynoAuth && !ranSynoAuth) {
if (d.needsSynoAuth) { fetch("/webman/login.cgi")
fetch("/webman/login.cgi") .then((r) => r.json())
.then((r) => r.json()) .then((a) => {
.then((a) => { setSynoToken(a.SynoToken)
setSynoToken(a.SynoToken) setRanSynoAuth(true)
setRanSynoAuth(true) mutate()
setLoading(false) })
}) .catch((error) => {
} else { console.error("Synology auth error:", error)
setLoading(false) })
} }
return d }, [data?.needsSynoAuth, ranSynoAuth, mutate])
})
.catch((error) => {
setLoading(false)
console.error(error)
})
}, [])
const newSession = useCallback(() => { const newSession = useCallback(() => {
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET") return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
@ -86,34 +80,26 @@ export default function useAuth() {
} }
}) })
.then(() => { .then(() => {
loadAuth() mutate()
}) })
.catch((error) => { .catch((error) => {
console.error(error) console.error(error)
}) })
}, [loadAuth]) }, [mutate])
// Start regular auth flow.
useEffect(() => { useEffect(() => {
loadAuth().then((d) => { const needsAuth =
if (!d) { data &&
return !loading &&
} !data.authorized &&
if ( hasAnyEditCapabilities(data) &&
!d.authorized && new URLSearchParams(window.location.search).get("check") === "now"
hasAnyEditCapabilities(d) &&
// Start auth flow immediately if browser has requested it.
new URLSearchParams(window.location.search).get("check") === "now"
) {
newSession()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => { if (needsAuth) {
loadAuth() // Refresh auth state after syno auth runs newSession()
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, [ranSynoAuth]) }, [data, loading, newSession])
return { return {
data, data,

@ -771,6 +771,19 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
} }
} }
// We might have a session for which we haven't awaited the result yet.
// This can happen when the AuthURL opens in the same browser tab instead
// of a new one due to browser settings.
// (See https://github.com/tailscale/tailscale/issues/11905)
// We therefore set a PendingAuth flag when creating a new session, check
// it here and call awaitUserAuth if we find it to be true. Once the auth
// wait completes, awaitUserAuth will set PendingAuth to false.
if sErr == nil && session.PendingAuth == true {
if err := s.awaitUserAuth(r.Context(), session); err != nil {
sErr = err
}
}
switch { switch {
case sErr != nil && errors.Is(sErr, errNotUsingTailscale): case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1) s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)

@ -582,12 +582,23 @@ func TestServeAuth(t *testing.T) {
successCookie := "ts-cookie-success" successCookie := "ts-cookie-success"
s.browserSessions.Store(successCookie, &browserSession{ s.browserSessions.Store(successCookie, &browserSession{
ID: successCookie, ID: successCookie,
SrcNode: remoteNode.Node.ID, SrcNode: remoteNode.Node.ID,
SrcUser: user.ID, SrcUser: user.ID,
Created: oneHourAgo, Created: oneHourAgo,
AuthID: testAuthPathSuccess, AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess,
PendingAuth: true,
})
successCookie2 := "ts-cookie-success-2"
s.browserSessions.Store(successCookie2, &browserSession{
ID: successCookie2,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
PendingAuth: true,
}) })
failureCookie := "ts-cookie-failure" failureCookie := "ts-cookie-failure"
s.browserSessions.Store(failureCookie, &browserSession{ s.browserSessions.Store(failureCookie, &browserSession{
@ -642,14 +653,15 @@ func TestServeAuth(t *testing.T) {
AuthID: testAuthPath, AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath, AuthURL: *testControlURL + testAuthPath,
Authenticated: false, Authenticated: false,
PendingAuth: true,
}, },
}, },
{ {
name: "query-existing-incomplete-session", name: "existing-session-used",
path: "/api/auth", path: "/api/auth/session/new", // should not create new session
cookie: successCookie, cookie: successCookie,
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode}, wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess},
wantSession: &browserSession{ wantSession: &browserSession{
ID: successCookie, ID: successCookie,
SrcNode: remoteNode.Node.ID, SrcNode: remoteNode.Node.ID,
@ -658,14 +670,15 @@ func TestServeAuth(t *testing.T) {
AuthID: testAuthPathSuccess, AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: false, Authenticated: false,
PendingAuth: true,
}, },
}, },
{ {
name: "existing-session-used", name: "transition-to-successful-session-via-api-auth-session-wait",
path: "/api/auth/session/new", // should not create new session path: "/api/auth/session/wait",
cookie: successCookie, cookie: successCookie,
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess}, wantResp: nil,
wantSession: &browserSession{ wantSession: &browserSession{
ID: successCookie, ID: successCookie,
SrcNode: remoteNode.Node.ID, SrcNode: remoteNode.Node.ID,
@ -673,17 +686,17 @@ func TestServeAuth(t *testing.T) {
Created: oneHourAgo, Created: oneHourAgo,
AuthID: testAuthPathSuccess, AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: false, Authenticated: true,
}, },
}, },
{ {
name: "transition-to-successful-session", name: "transition-to-successful-session-via-api-auth",
path: "/api/auth/session/wait", path: "/api/auth",
cookie: successCookie, cookie: successCookie2,
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: nil, wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantSession: &browserSession{ wantSession: &browserSession{
ID: successCookie, ID: successCookie2,
SrcNode: remoteNode.Node.ID, SrcNode: remoteNode.Node.ID,
SrcUser: user.ID, SrcUser: user.ID,
Created: oneHourAgo, Created: oneHourAgo,
@ -731,6 +744,7 @@ func TestServeAuth(t *testing.T) {
AuthID: testAuthPath, AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath, AuthURL: *testControlURL + testAuthPath,
Authenticated: false, Authenticated: false,
PendingAuth: true,
}, },
}, },
{ {
@ -748,6 +762,7 @@ func TestServeAuth(t *testing.T) {
AuthID: testAuthPath, AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath, AuthURL: *testControlURL + testAuthPath,
Authenticated: false, Authenticated: false,
PendingAuth: true,
}, },
}, },
{ {

Loading…
Cancel
Save