fix(tailshare): keep Tailscale auth keys out of persisted preferences
CI / format (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 1m53s
CI / install (pull_request) Successful in 6m33s
CI / typetest (pull_request) Successful in 1m39s
CI / typecheck (pull_request) Successful in 2m15s
CI / node-tests (pull_request) Successful in 2m27s
CI / browser-tests (pull_request) Successful in 3m29s

An auth key enrolls a device on the tailnet, and serializing it into
tailshare:config alongside the hostname and exit node left it readable by
every same-origin script long after the registration flow needed it.

Move it to a credential store that lives in the tab's memory and is
discarded once the node reaches Running. Persisting it is now an explicit
opt-in with its own storage key and a warning, and the presence of that
stored copy is the flag, so there is no separate setting to drift out of
sync. The raw key no longer reaches the prepare context: consumers see
whether one is set, and the input is write-only.

parseConfig no longer reads authKey, so a key left in an older
tailshare:config is ignored and drops out on the next settings write.
There is no migration.

Splitting parseConfig and the failure redaction out of IpnContext gives
tailshare its first unit tests, covering the storage rules, the opt-in,
and that no failure path serializes the key. Reload, worker success,
initialization failure, and fallback still need a browser harness.

Closes #190

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 10:16:38 +00:00
co-authored by Claude
parent 9b72c5c406
commit aaec216f2a
13 changed files with 466 additions and 84 deletions
+2
View File
@@ -10580,6 +10580,7 @@
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@types/node": "^25.6.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4",
"babel-loader": "^9.2.1",
@@ -10593,6 +10594,7 @@
"sass": "^1.89.0",
"sass-loader": "^16.0.5",
"style-loader": "^4.0.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"webpack": "^5.99.9",
"webpack-cli": "^5.1.4",
+6
View File
@@ -15,6 +15,12 @@ Tailshare is under active development and does not yet expose the whole stack. T
- a Mantine application shell with routing, color scheme switching, and a tailnet drawer;
- a placeholder files route.
## Auth keys
Tailshare settings live in `localStorage` under `tailshare:config`. A Tailscale auth key does not: it enrolls a device on your tailnet, so it is held in memory for the tab that typed it and discarded once the node registers. A reload before registration means entering it again, which is the intended cost.
The config modal offers to keep the key in this browser instead. That copy sits in plain text under `tailshare:authKey`, readable by any script on the origin, and survives registration until you forget it. Take the option only if you need unattended re-registration, and prefer the manual login flow when you do not.
## Intended scope
The application is intended to expose the features implemented across the repository, and those tracked in the open issues. The planned surface includes:
+3
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "webpack serve --mode development",
"build": "NODE_ENV=production webpack --mode production",
"test": "tsx --test --test-force-exit 'src/**/*.test.ts'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -34,6 +35,7 @@
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@types/node": "^25.6.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4",
"babel-loader": "^9.2.1",
@@ -47,6 +49,7 @@
"sass": "^1.89.0",
"sass-loader": "^16.0.5",
"style-loader": "^4.0.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"webpack": "^5.99.9",
"webpack-cli": "^5.1.4",
+107
View File
@@ -0,0 +1,107 @@
import { beforeEach, suite, test } from "node:test"
import assert from "node:assert/strict"
import {
clearAuthKey,
getAuthKey,
isAuthKeyPersisted,
isAuthKeySet,
setAuthKey,
setAuthKeyPersisted,
} from "./authKey"
class FakeStorage implements Storage {
private items = new Map<string, string>()
get length(): number {
return this.items.size
}
key(index: number): string | null {
return [...this.items.keys()][index] ?? null
}
getItem(key: string): string | null {
return this.items.get(key) ?? null
}
setItem(key: string, value: string): void {
this.items.set(key, value)
}
removeItem(key: string): void {
this.items.delete(key)
}
clear(): void {
this.items.clear()
}
}
let storage: FakeStorage
beforeEach(() => {
storage = new FakeStorage()
globalThis.localStorage = storage
clearAuthKey()
})
suite("auth key storage", () => {
test("keeps the key in memory and out of storage by default", () => {
setAuthKey("tskey-auth-secret")
assert.equal(getAuthKey(), "tskey-auth-secret")
assert.equal(isAuthKeySet(), true)
assert.equal(isAuthKeyPersisted(), false)
assert.equal(storage.length, 0)
})
test("persists on explicit opt-in and survives a reload", () => {
setAuthKey("tskey-auth-secret")
setAuthKeyPersisted(true)
assert.equal(isAuthKeyPersisted(), true)
assert.equal(storage.getItem("tailshare:authKey"), "tskey-auth-secret")
const reloaded = new FakeStorage()
reloaded.setItem("tailshare:authKey", "tskey-auth-secret")
globalThis.localStorage = reloaded
assert.equal(getAuthKey(), "tskey-auth-secret")
})
test("opting out removes the stored copy and keeps the key for this tab", () => {
setAuthKey("tskey-auth-secret")
setAuthKeyPersisted(true)
setAuthKeyPersisted(false)
assert.equal(isAuthKeyPersisted(), false)
assert.equal(storage.length, 0)
assert.equal(getAuthKey(), "tskey-auth-secret")
})
test("clearing removes both copies", () => {
setAuthKey("tskey-auth-secret")
setAuthKeyPersisted(true)
clearAuthKey()
assert.equal(getAuthKey(), "")
assert.equal(isAuthKeySet(), false)
assert.equal(isAuthKeyPersisted(), false)
assert.equal(storage.length, 0)
})
test("replacing a persisted key updates the stored copy", () => {
setAuthKey("tskey-auth-first")
setAuthKeyPersisted(true)
setAuthKey("tskey-auth-second")
assert.equal(storage.getItem("tailshare:authKey"), "tskey-auth-second")
})
test("survives storage that throws", () => {
globalThis.localStorage = new Proxy(storage, {
get() {
throw new Error("storage disabled")
},
})
setAuthKey("tskey-auth-secret")
assert.equal(getAuthKey(), "tskey-auth-secret")
assert.equal(isAuthKeyPersisted(), false)
})
})
+95
View File
@@ -0,0 +1,95 @@
import { useSyncExternalStore } from "react"
const STORAGE_KEY = "tailshare:authKey"
// Auth keys are enrollment credentials, so they live in this tab's memory and
// are gone on reload. Persisting one is an explicit opt-in, and the presence of
// the stored copy is the flag: there is no separate setting to fall out of sync.
let memoryAuthKey = ""
const listeners = new Set<() => void>()
function storage(): Storage | null {
try {
return globalThis.localStorage ?? null
} catch {
return null
}
}
function stored(): string {
try {
return storage()?.getItem(STORAGE_KEY) ?? ""
} catch {
return ""
}
}
function write(value: string | null): void {
try {
const target = storage()
if (!target) return
if (value === null) target.removeItem(STORAGE_KEY)
else target.setItem(STORAGE_KEY, value)
} catch {
// storage unavailable
}
}
function notify(): void {
for (const listener of [...listeners]) listener()
}
export function getAuthKey(): string {
return memoryAuthKey || stored()
}
export function isAuthKeySet(): boolean {
return !!getAuthKey()
}
export function isAuthKeyPersisted(): boolean {
return !!stored()
}
export function setAuthKey(value: string): void {
memoryAuthKey = value
if (isAuthKeyPersisted()) write(value || null)
notify()
}
export function setAuthKeyPersisted(persist: boolean): void {
write(persist ? getAuthKey() : null)
notify()
}
export function clearAuthKey(): void {
memoryAuthKey = ""
write(null)
notify()
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
const onStorage = (e: StorageEvent) => {
if (e.storageArea === storage() && (e.key === STORAGE_KEY || e.key === null)) listener()
}
window.addEventListener("storage", onStorage)
return () => {
listeners.delete(listener)
window.removeEventListener("storage", onStorage)
}
}
const serverSnapshot = () => false
function useAuthKeyStore(get: () => boolean): boolean {
return useSyncExternalStore(subscribe, get, serverSnapshot)
}
export function useAuthKeySet(): boolean {
return useAuthKeyStore(isAuthKeySet)
}
export function useAuthKeyPersisted(): boolean {
return useAuthKeyStore(isAuthKeyPersisted)
}
+27
View File
@@ -0,0 +1,27 @@
import { suite, test } from "node:test"
import assert from "node:assert/strict"
import { parseConfig } from "./config"
suite("tailshare configuration", () => {
test("drops an auth key left over from an older persisted config", () => {
const config = parseConfig(
JSON.stringify({ hostname: "alpha", authKey: "tskey-auth-secret", autostart: true }),
)
assert.deepEqual(config, {
hostname: "alpha",
autostart: true,
useWorker: true,
fileOps: "memory",
})
})
test("a round trip through the config never writes an auth key back", () => {
const raw = JSON.stringify({ hostname: "alpha", authKey: "tskey-auth-secret" })
const rewritten = JSON.stringify({ ...parseConfig(raw), exitNode: "nodeid" })
assert.equal(rewritten.includes("tskey-auth-secret"), false)
assert.equal(rewritten.includes("authKey"), false)
assert.equal(parseConfig(rewritten).hostname, "alpha")
})
})
+27
View File
@@ -0,0 +1,27 @@
export type TailshareConfig = {
hostname?: string
controlURL?: string
exitNode?: string
autostart?: boolean
useWorker?: boolean
fileOps?: "memory" | "opfs"
}
// Preferences only. The Tailscale auth key is a credential and lives in
// ../authKey, never in the persisted config.
export function parseConfig(raw: string): TailshareConfig {
const cfg: TailshareConfig = { useWorker: true, fileOps: "memory" }
try {
const parsed = JSON.parse(raw)
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return cfg
if (typeof parsed.hostname === "string") cfg.hostname = parsed.hostname
if (typeof parsed.controlURL === "string") cfg.controlURL = parsed.controlURL
if (typeof parsed.exitNode === "string") cfg.exitNode = parsed.exitNode
if (typeof parsed.autostart === "boolean") cfg.autostart = parsed.autostart
if (typeof parsed.useWorker === "boolean") cfg.useWorker = parsed.useWorker
if (parsed.fileOps === "memory" || parsed.fileOps === "opfs") cfg.fileOps = parsed.fileOps
} catch {
// return defaults
}
return cfg
}
+74 -76
View File
@@ -28,14 +28,19 @@ import {
} from "react"
import { initIPN, InMemoryFileOps, InMemoryState } from "@webnet/tsconnect"
import type { IpnClient } from "@webnet/tsconnect"
import {
serializeError,
type SerializedError,
useLocalStorage,
useSecureContext,
useSharedWorkerAvailable,
} from "@webnet/react"
import { useLocalStorage, useSecureContext, useSharedWorkerAvailable } from "@webnet/react"
import wasmUrl from "@webnet/tsconnect/main.wasm"
import {
clearAuthKey,
getAuthKey,
isAuthKeyPersisted,
setAuthKey,
setAuthKeyPersisted,
useAuthKeyPersisted,
useAuthKeySet,
} from "../authKey"
import { initializationFailure, type IpnInitializationFailure } from "../ipnFailure"
import { parseConfig, type TailshareConfig } from "../config"
import type { WorkerConfig } from "@webnet/tsconnect-worker"
export const IpnStoreContext = createIpnStoreContext()
@@ -46,72 +51,18 @@ export const useIpnStore = createUseIpnStore(IpnStoreContext)
export const useIpnSelector = createUseIpnSelector(IpnStoreContext)
export const useIpnDispatch = createUseIpnDispatch(IpnStoreContext)
export type TailshareConfig = {
hostname?: string
controlURL?: string
authKey?: string
exitNode?: string
autostart?: boolean
useWorker?: boolean
fileOps?: "memory" | "opfs"
}
export type IpnInitializationFailure = {
error: SerializedError
mode: "worker-with-fallback" | "main-thread"
workerError?: SerializedError
}
function redactError(value: string, authKey?: string): string {
return value
.replaceAll(authKey ?? "\0", "[redacted]")
.replace(/(authkey-[\w-]+|authKey[=:]\s*)\S+/gi, "$1[redacted]")
}
function initializationFailure(
error: unknown,
mode: IpnInitializationFailure["mode"],
authKey?: string,
): IpnInitializationFailure {
const errors =
error &&
typeof error === "object" &&
"errors" in error &&
Array.isArray((error as { errors?: unknown }).errors)
? (error as { errors: unknown[] }).errors
: undefined
if (errors?.length && mode === "worker-with-fallback") {
return {
error: serializeError(errors.at(-1), (value) => redactError(value, authKey)),
mode,
workerError: serializeError(errors[0], (value) => redactError(value, authKey)),
}
}
return { error: serializeError(error, (value) => redactError(value, authKey)), mode }
}
function parseConfig(raw: string): TailshareConfig {
const cfg: TailshareConfig = { useWorker: true, fileOps: "memory" }
try {
const parsed = JSON.parse(raw)
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return cfg
if (typeof parsed.hostname === "string") cfg.hostname = parsed.hostname
if (typeof parsed.controlURL === "string") cfg.controlURL = parsed.controlURL
if (typeof parsed.authKey === "string") cfg.authKey = parsed.authKey
if (typeof parsed.exitNode === "string") cfg.exitNode = parsed.exitNode
if (typeof parsed.autostart === "boolean") cfg.autostart = parsed.autostart
if (typeof parsed.useWorker === "boolean") cfg.useWorker = parsed.useWorker
if (parsed.fileOps === "memory" || parsed.fileOps === "opfs") cfg.fileOps = parsed.fileOps
} catch {
// return defaults
}
return cfg
}
export type { IpnInitializationFailure, TailshareConfig }
export const IpnPrepareContext = createContext<{
config: TailshareConfig
configAtBuild: TailshareConfig | null
setConfig: (patch: Partial<TailshareConfig>) => void
authKeySet: boolean
authKeyPersisted: boolean
authKeyChanged: boolean
setAuthKey: (value: string) => void
setAuthKeyPersisted: (persist: boolean) => void
forgetAuthKey: () => void
willBuild: boolean
build: () => void
initializationFailure: IpnInitializationFailure | null
@@ -139,6 +90,28 @@ export function IpnProvider({ children }: { children: ReactNode }) {
[setConfigRaw],
)
const authKeySet = useAuthKeySet()
const authKeyPersisted = useAuthKeyPersisted()
// Held in a ref rather than state: discarding the key must not change the
// identity of workerConfig, or useBuildIpnWorker would tear the live
// connection down and reconnect the moment the node starts running.
const authKeyAtBuildRef = useRef("")
const [authKeyChanged, setAuthKeyChanged] = useState(false)
const changeAuthKey = useCallback((value: string) => {
setAuthKey(value)
setAuthKeyChanged(value !== authKeyAtBuildRef.current)
}, [])
const forgetAuthKey = useCallback(() => {
clearAuthKey()
setAuthKeyChanged(!!authKeyAtBuildRef.current)
}, [])
const discardAuthKey = useCallback(() => {
if (isAuthKeyPersisted()) return
clearAuthKey()
authKeyAtBuildRef.current = ""
}, [])
const [willBuild, setWillBuild] = useState(false)
const [failure, setFailure] = useState<IpnInitializationFailure | null>(null)
@@ -147,6 +120,8 @@ export function IpnProvider({ children }: { children: ReactNode }) {
const build = useCallback(() => {
if (!secureContext) return
configAtBuildRef.current = config
authKeyAtBuildRef.current = getAuthKey()
setAuthKeyChanged(false)
setFailure(null)
setWillBuild(true)
}, [config, secureContext])
@@ -172,12 +147,12 @@ export function IpnProvider({ children }: { children: ReactNode }) {
wasmUrl: wasmUrl as string,
hostname: buildConfig.hostname || undefined,
controlURL: buildConfig.controlURL || undefined,
authKey: buildConfig.authKey || undefined,
authKey: authKeyAtBuildRef.current || undefined,
stateStorage: "indexeddb",
fileOps: buildConfig.fileOps === "opfs",
fileOpsDir: "taildrop",
}),
[buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey, buildConfig.fileOps],
[buildConfig.hostname, buildConfig.controlURL, buildConfig.fileOps, willBuild],
)
const workerClient = useBuildIpnWorker(
@@ -185,7 +160,7 @@ export function IpnProvider({ children }: { children: ReactNode }) {
workerConfig,
null,
(error) => {
setFailure(initializationFailure(error, "worker-with-fallback", buildConfig.authKey))
setFailure(initializationFailure(error, "worker-with-fallback", authKeyAtBuildRef.current))
setWillBuild(false)
},
)
@@ -197,11 +172,11 @@ export function IpnProvider({ children }: { children: ReactNode }) {
initIPN(wasmUrl).then(
(builder) => setIpnBuilder(() => builder),
(error) => {
setFailure(initializationFailure(error, "main-thread", buildConfig.authKey))
setFailure(initializationFailure(error, "main-thread", authKeyAtBuildRef.current))
setWillBuild(false)
},
)
}, [willBuild, useWorker, buildConfig.authKey])
}, [willBuild, useWorker])
const builderParams = useMemo(() => {
const params: Parameters<Exclude<typeof ipnBuilder, null>>[0] = {}
@@ -209,9 +184,9 @@ export function IpnProvider({ children }: { children: ReactNode }) {
params.stateStorage = new InMemoryState()
if (buildConfig.hostname) params.hostname = buildConfig.hostname
if (buildConfig.controlURL) params.controlURL = buildConfig.controlURL
if (buildConfig.authKey) params.authKey = buildConfig.authKey
if (authKeyAtBuildRef.current) params.authKey = authKeyAtBuildRef.current
return params
}, [buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey])
}, [buildConfig.hostname, buildConfig.controlURL, willBuild])
const mainIpn = useBuildIpn(
localStore,
@@ -220,7 +195,7 @@ export function IpnProvider({ children }: { children: ReactNode }) {
null,
(error) => {
setIpnBuilder(null)
setFailure(initializationFailure(error, "main-thread", buildConfig.authKey))
setFailure(initializationFailure(error, "main-thread", authKeyAtBuildRef.current))
setWillBuild(false)
},
)
@@ -239,6 +214,12 @@ export function IpnProvider({ children }: { children: ReactNode }) {
config,
configAtBuild,
setConfig,
authKeySet,
authKeyPersisted,
authKeyChanged,
setAuthKey: changeAuthKey,
setAuthKeyPersisted,
forgetAuthKey,
willBuild,
build,
initializationFailure: failure,
@@ -250,6 +231,11 @@ export function IpnProvider({ children }: { children: ReactNode }) {
config,
configAtBuild,
setConfig,
authKeySet,
authKeyPersisted,
authKeyChanged,
changeAuthKey,
forgetAuthKey,
willBuild,
build,
failure,
@@ -265,6 +251,7 @@ export function IpnProvider({ children }: { children: ReactNode }) {
<>
<AutoLogin ipn={ipn} />
<AutoExitNode ipn={ipn} config={config} setConfig={setConfig} />
<DiscardAuthKey discard={discardAuthKey} />
</>
)}
<AutoInit build={build} config={config} setConfig={setConfig} />
@@ -283,6 +270,17 @@ function AutoLogin({ ipn }: { ipn: IpnClient }) {
return null
}
function DiscardAuthKey({ discard }: { discard: () => void }) {
const state = useIpnSelector(getIpnState)
const discardOnce = useEffectEvent(discard)
useEffect(() => {
if (state === "Running") discardOnce()
}, [state])
return null
}
function AutoExitNode({
ipn,
config,
+44
View File
@@ -0,0 +1,44 @@
import { suite, test } from "node:test"
import assert from "node:assert/strict"
import { initializationFailure } from "./ipnFailure"
const KEY = "tskey-auth-kabcdef1234-secret"
suite("IPN initialization failures", () => {
test("redacts the active key from the message", () => {
const failure = initializationFailure(
new Error(`register failed for ${KEY}`),
"main-thread",
KEY,
)
assert.equal(failure.error.message.includes(KEY), false)
assert.match(failure.error.message, /\[redacted\]/)
})
test("redacts key-shaped text even without the active key", () => {
const failure = initializationFailure(
new Error(`control rejected authKey: ${KEY}`),
"main-thread",
)
assert.equal(failure.error.message.includes(KEY), false)
})
test("redacts both errors of a worker-then-fallback failure", () => {
const aggregate = new Error("both failed") as Error & { errors: unknown[] }
aggregate.errors = [new Error(`worker: ${KEY}`), new Error(`main thread: ${KEY}`)]
const failure = initializationFailure(aggregate, "worker-with-fallback", KEY)
assert.equal(failure.error.message.includes(KEY), false)
assert.equal(failure.workerError?.message.includes(KEY), false)
assert.equal(JSON.stringify(failure).includes(KEY), false)
})
test("an empty key does not redact every position in the message", () => {
const failure = initializationFailure(new Error("wasm fetch failed"), "main-thread", "")
assert.equal(failure.error.message, "wasm fetch failed")
})
})
+35
View File
@@ -0,0 +1,35 @@
import { serializeError, type SerializedError } from "@webnet/react"
export type IpnInitializationFailure = {
error: SerializedError
mode: "worker-with-fallback" | "main-thread"
workerError?: SerializedError
}
function redactError(value: string, authKey?: string): string {
return value
.replaceAll(authKey || "\0", "[redacted]")
.replace(/(authkey-[\w-]+|authKey[=:]\s*)\S+/gi, "$1[redacted]")
}
export function initializationFailure(
error: unknown,
mode: IpnInitializationFailure["mode"],
authKey?: string,
): IpnInitializationFailure {
const errors =
error &&
typeof error === "object" &&
"errors" in error &&
Array.isArray((error as { errors?: unknown }).errors)
? (error as { errors: unknown[] }).errors
: undefined
if (errors?.length && mode === "worker-with-fallback") {
return {
error: serializeError(errors.at(-1), (value) => redactError(value, authKey)),
mode,
workerError: serializeError(errors[0], (value) => redactError(value, authKey)),
}
}
return { error: serializeError(error, (value) => redactError(value, authKey)), mode }
}
+42 -8
View File
@@ -30,7 +30,7 @@ import {
getWaitingFiles,
} from "@webnet/tsconnect-redux"
import { IpnContext, IpnPrepareContext, useIpnSelector } from "../contexts/IpnContext"
import { use } from "react"
import { use, useEffect, useState } from "react"
import styles from "./tailscale.scss"
import { CopyIcon, DownloadIcon, TrashIcon } from "@phosphor-icons/react"
import { useDisclosure } from "@mantine/hooks"
@@ -126,6 +126,7 @@ function TailscaleState() {
function TailscaleConfig() {
const [opened, { open, close }] = useDisclosure()
const [authKeyDraft, setAuthKeyDraft] = useState("")
const exitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers)
const dispatch = useTailshareDispatch()
@@ -136,12 +137,17 @@ function TailscaleConfig() {
built &&
(config.hostname !== built.hostname ||
config.controlURL !== built.controlURL ||
config.authKey !== built.authKey ||
prepare.authKeyChanged ||
!!config.useWorker !== !!built.useWorker ||
config.fileOps !== built.fileOps)
const opfsWithoutWorker = config.fileOps === "opfs" && (!workerAvailable || !config.useWorker)
// Drop the typed copy once the key is discarded on registration or forgotten.
useEffect(() => {
if (!prepare.authKeySet) setAuthKeyDraft("")
}, [prepare.authKeySet])
return (
<>
<Group>
@@ -178,18 +184,46 @@ function TailscaleConfig() {
<TextInput
mt="xs"
withAsterisk
type="password"
label={<>Pregenerated authkey</>}
description={
// TODO: make this more clear that this has security implications
<>
This is a soft credential that will be stored in the browser - only set this if you
must, logging in manually is more secure
An authkey enrolls a new device on your tailnet. Tailshare keeps it in memory for this
tab only and discards it once the node registers. Logging in manually is safer.
</>
}
placeholder="(Manual login flow)"
value={config.authKey ?? ""}
onChange={(e) => setConfig({ authKey: e.target.value || undefined })}
placeholder={prepare.authKeySet ? "(Set, hidden)" : "(Manual login flow)"}
value={authKeyDraft}
onChange={(e) => {
setAuthKeyDraft(e.target.value)
prepare.setAuthKey(e.target.value)
}}
/>
<Group mt="xs" justify="space-between" align="center">
<Checkbox
disabled={!prepare.authKeySet}
label="Keep this authkey in this browser"
checked={prepare.authKeyPersisted}
onChange={(e) => prepare.setAuthKeyPersisted(e.target.checked)}
/>
<Button
variant="subtle"
color="red"
disabled={!prepare.authKeySet}
onClick={() => {
setAuthKeyDraft("")
prepare.forgetAuthKey()
}}
>
Forget authkey
</Button>
</Group>
{prepare.authKeyPersisted && (
<Text mt="xs" c="orange" size="sm">
The authkey is stored in plain text on this origin and survives registration. Any script
running here can read it. Forget it once the node is enrolled.
</Text>
)}
<TextInput
mt="xs"
withAsterisk
+1
View File
@@ -5,6 +5,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["node"],
"strict": true,
"noEmit": true,
"isolatedModules": true,
+3
View File
@@ -24,6 +24,9 @@
"check-submodule": {
"cache": false
},
"@webnet/tailshare#test": {
"dependsOn": ["^build"]
},
"@webnet/tsconnect#typecheck": {
"dependsOn": ["build-go", "^build"]
},