Files
tailscale-custom/client/web/src/hooks/auth.ts
T
Gesa Stupperich 7a43e41a27 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>
2026-03-11 08:15:21 +00:00

110 lines
2.9 KiB
TypeScript

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api"
import useSWR from "swr"
export type AuthResponse = {
serverMode: AuthServerMode
authorized: boolean
viewerIdentity?: {
loginName: string
nodeName: string
nodeIP: string
profilePicUrl?: string
capabilities: { [key in PeerCapability]: boolean }
}
needsSynoAuth?: boolean
}
export type AuthServerMode = "login" | "readonly" | "manage"
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
/**
* canEdit reports whether the given auth response specifies that the viewer
* has the ability to edit the given capability.
*/
export function canEdit(cap: PeerCapability, auth: AuthResponse): boolean {
if (!auth.authorized || !auth.viewerIdentity) {
return false
}
if (auth.viewerIdentity.capabilities["*"] === true) {
return true // can edit all features
}
return auth.viewerIdentity.capabilities[cap] === true
}
/**
* hasAnyEditCapabilities reports whether the given auth response specifies
* that the viewer has at least one edit capability. If this is true, the
* user is able to go through the auth flow to authenticate a management
* session.
*/
export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
return Object.values(auth.viewerIdentity?.capabilities || {}).includes(true)
}
/**
* useAuth reports and refreshes Tailscale auth status for the web client.
*/
export default function useAuth() {
const { data, error, mutate } = useSWR<AuthResponse>("/auth")
const [ranSynoAuth, setRanSynoAuth] = useState<boolean>(false)
const loading = !data && !error
// Start Synology auth flow if needed.
useEffect(() => {
if (data?.needsSynoAuth && !ranSynoAuth) {
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setRanSynoAuth(true)
mutate()
})
.catch((error) => {
console.error("Synology auth error:", error)
})
}
}, [data?.needsSynoAuth, ranSynoAuth, mutate])
const newSession = useCallback(() => {
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
.then((d) => {
if (d.authUrl) {
window.open(d.authUrl, "_blank")
return apiFetch("/auth/session/wait", "GET")
}
})
.then(() => {
mutate()
})
.catch((error) => {
console.error(error)
})
}, [mutate])
// Start regular auth flow.
useEffect(() => {
const needsAuth =
data &&
!loading &&
!data.authorized &&
hasAnyEditCapabilities(data) &&
new URLSearchParams(window.location.search).get("check") === "now"
if (needsAuth) {
newSession()
}
}, [data, loading, newSession])
return {
data,
loading,
newSession,
}
}