fix(gateway): prefer bootstrap auth over tailscale (#59232)

* fix(gateway): prefer bootstrap auth over tailscale

* fix(gateway): prefer bootstrap auth over tailscale (#59232) (thanks @ngutman)
This commit is contained in:
Nimrod Gutman
2026-04-01 23:20:10 +03:00
committed by GitHub
parent 5cf254a5f7
commit 017bc5261c
3 changed files with 48 additions and 13 deletions

View File

@@ -169,23 +169,58 @@ describe("resolveConnectAuthDecision", () => {
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("returns the original decision when device fallback does not apply", async () => {
it("prefers a valid bootstrap token over an already successful shared auth path", async () => {
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "token" },
authResult: { ok: true, method: "tailscale" },
authOk: true,
authMethod: "tailscale",
bootstrapTokenCandidate: "bootstrap-token",
deviceTokenCandidate: undefined,
deviceTokenCandidateSource: undefined,
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "operator",
role: "node",
scopes: [],
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
verifyBootstrapToken,
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("token");
expect(decision.authMethod).toBe("bootstrap-token");
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("keeps the original successful auth path when bootstrap validation fails", async () => {
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
ok: false,
reason: "bootstrap_token_invalid",
}));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "tailscale" },
authOk: true,
authMethod: "tailscale",
bootstrapTokenCandidate: "bootstrap-token",
deviceTokenCandidate: undefined,
deviceTokenCandidateSource: undefined,
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "node",
scopes: [],
verifyBootstrapToken,
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("tailscale");
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
});

View File

@@ -170,13 +170,7 @@ export async function resolveConnectAuthDecision(params: {
let authMethod = params.state.authMethod;
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
if (
params.hasDeviceIdentity &&
params.deviceId &&
params.publicKey &&
!authOk &&
bootstrapTokenCandidate
) {
if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) {
const tokenCheck = await params.verifyBootstrapToken({
deviceId: params.deviceId,
publicKey: params.publicKey,
@@ -185,9 +179,14 @@ export async function resolveConnectAuthDecision(params: {
scopes: params.scopes,
});
if (tokenCheck.ok) {
// Prefer an explicit valid bootstrap token even when another auth path
// (for example tailscale serve header auth) already succeeded. QR pairing
// relies on the server classifying the handshake as bootstrap-token so the
// initial node pairing can be silently auto-approved and the bootstrap
// token can be revoked after approval.
authOk = true;
authMethod = "bootstrap-token";
} else {
} else if (!authOk) {
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
}
}