263 lines
8.1 KiB
TypeScript
263 lines
8.1 KiB
TypeScript
import type { IncomingMessage } from "node:http";
|
|
import {
|
|
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
|
|
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
|
type AuthRateLimiter,
|
|
type RateLimitCheckResult,
|
|
} from "../../auth-rate-limit.js";
|
|
import {
|
|
authorizeHttpGatewayConnect,
|
|
authorizeWsControlUiGatewayConnect,
|
|
type GatewayAuthResult,
|
|
type ResolvedGatewayAuth,
|
|
} from "../../auth.js";
|
|
|
|
type HandshakeConnectAuth = {
|
|
token?: string;
|
|
bootstrapToken?: string;
|
|
deviceToken?: string;
|
|
password?: string;
|
|
};
|
|
|
|
export type DeviceTokenCandidateSource = "explicit-device-token" | "shared-token-fallback";
|
|
|
|
export type ConnectAuthState = {
|
|
authResult: GatewayAuthResult;
|
|
authOk: boolean;
|
|
authMethod: GatewayAuthResult["method"];
|
|
sharedAuthOk: boolean;
|
|
sharedAuthProvided: boolean;
|
|
bootstrapTokenCandidate?: string;
|
|
deviceTokenCandidate?: string;
|
|
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
|
|
};
|
|
|
|
type VerifyDeviceTokenResult = { ok: boolean };
|
|
type VerifyBootstrapTokenResult = { ok: boolean; reason?: string };
|
|
|
|
export type ConnectAuthDecision = {
|
|
authResult: GatewayAuthResult;
|
|
authOk: boolean;
|
|
authMethod: GatewayAuthResult["method"];
|
|
};
|
|
|
|
function trimToUndefined(value: string | undefined): string | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function resolveSharedConnectAuth(
|
|
connectAuth: HandshakeConnectAuth | null | undefined,
|
|
): { token?: string; password?: string } | undefined {
|
|
const token = trimToUndefined(connectAuth?.token);
|
|
const password = trimToUndefined(connectAuth?.password);
|
|
if (!token && !password) {
|
|
return undefined;
|
|
}
|
|
return { token, password };
|
|
}
|
|
|
|
function resolveDeviceTokenCandidate(connectAuth: HandshakeConnectAuth | null | undefined): {
|
|
token?: string;
|
|
source?: DeviceTokenCandidateSource;
|
|
} {
|
|
const explicitDeviceToken = trimToUndefined(connectAuth?.deviceToken);
|
|
if (explicitDeviceToken) {
|
|
return { token: explicitDeviceToken, source: "explicit-device-token" };
|
|
}
|
|
const fallbackToken = trimToUndefined(connectAuth?.token);
|
|
if (!fallbackToken) {
|
|
return {};
|
|
}
|
|
return { token: fallbackToken, source: "shared-token-fallback" };
|
|
}
|
|
|
|
function resolveBootstrapTokenCandidate(
|
|
connectAuth: HandshakeConnectAuth | null | undefined,
|
|
): string | undefined {
|
|
return trimToUndefined(connectAuth?.bootstrapToken);
|
|
}
|
|
|
|
export async function resolveConnectAuthState(params: {
|
|
resolvedAuth: ResolvedGatewayAuth;
|
|
connectAuth: HandshakeConnectAuth | null | undefined;
|
|
hasDeviceIdentity: boolean;
|
|
req: IncomingMessage;
|
|
trustedProxies: string[];
|
|
allowRealIpFallback: boolean;
|
|
rateLimiter?: AuthRateLimiter;
|
|
clientIp?: string;
|
|
}): Promise<ConnectAuthState> {
|
|
const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth);
|
|
const sharedAuthProvided = Boolean(sharedConnectAuth);
|
|
const bootstrapTokenCandidate = params.hasDeviceIdentity
|
|
? resolveBootstrapTokenCandidate(params.connectAuth)
|
|
: undefined;
|
|
const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } =
|
|
params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
|
|
const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate);
|
|
|
|
let authResult: GatewayAuthResult = await authorizeWsControlUiGatewayConnect({
|
|
auth: params.resolvedAuth,
|
|
connectAuth: sharedConnectAuth,
|
|
req: params.req,
|
|
trustedProxies: params.trustedProxies,
|
|
allowRealIpFallback: params.allowRealIpFallback,
|
|
rateLimiter: hasDeviceTokenCandidate ? undefined : params.rateLimiter,
|
|
clientIp: params.clientIp,
|
|
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
|
});
|
|
|
|
if (
|
|
hasDeviceTokenCandidate &&
|
|
authResult.ok &&
|
|
params.rateLimiter &&
|
|
(authResult.method === "token" || authResult.method === "password")
|
|
) {
|
|
const sharedRateCheck: RateLimitCheckResult = params.rateLimiter.check(
|
|
params.clientIp,
|
|
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
|
);
|
|
if (!sharedRateCheck.allowed) {
|
|
authResult = {
|
|
ok: false,
|
|
reason: "rate_limited",
|
|
rateLimited: true,
|
|
retryAfterMs: sharedRateCheck.retryAfterMs,
|
|
};
|
|
} else {
|
|
params.rateLimiter.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
|
|
}
|
|
}
|
|
|
|
const sharedAuthResult =
|
|
sharedConnectAuth &&
|
|
(await authorizeHttpGatewayConnect({
|
|
auth: { ...params.resolvedAuth, allowTailscale: false },
|
|
connectAuth: sharedConnectAuth,
|
|
req: params.req,
|
|
trustedProxies: params.trustedProxies,
|
|
allowRealIpFallback: params.allowRealIpFallback,
|
|
// Shared-auth probe only; rate-limit side effects are handled in the
|
|
// primary auth flow (or deferred for device-token candidates).
|
|
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
|
}));
|
|
// Trusted-proxy auth is semantically shared: the proxy vouches for identity,
|
|
// no per-device credential needed. Include it so operator connections
|
|
// can skip device identity via roleCanSkipDeviceIdentity().
|
|
const sharedAuthOk =
|
|
(sharedAuthResult?.ok === true &&
|
|
(sharedAuthResult.method === "token" || sharedAuthResult.method === "password")) ||
|
|
(authResult.ok && authResult.method === "trusted-proxy");
|
|
|
|
return {
|
|
authResult,
|
|
authOk: authResult.ok,
|
|
authMethod:
|
|
authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"),
|
|
sharedAuthOk,
|
|
sharedAuthProvided,
|
|
bootstrapTokenCandidate,
|
|
deviceTokenCandidate,
|
|
deviceTokenCandidateSource,
|
|
};
|
|
}
|
|
|
|
export async function resolveConnectAuthDecision(params: {
|
|
state: ConnectAuthState;
|
|
hasDeviceIdentity: boolean;
|
|
deviceId?: string;
|
|
publicKey?: string;
|
|
role: string;
|
|
scopes: string[];
|
|
rateLimiter?: AuthRateLimiter;
|
|
clientIp?: string;
|
|
verifyBootstrapToken: (params: {
|
|
deviceId: string;
|
|
publicKey: string;
|
|
token: string;
|
|
role: string;
|
|
scopes: string[];
|
|
}) => Promise<VerifyBootstrapTokenResult>;
|
|
verifyDeviceToken: (params: {
|
|
deviceId: string;
|
|
token: string;
|
|
role: string;
|
|
scopes: string[];
|
|
}) => Promise<VerifyDeviceTokenResult>;
|
|
}): Promise<ConnectAuthDecision> {
|
|
let authResult = params.state.authResult;
|
|
let authOk = params.state.authOk;
|
|
let authMethod = params.state.authMethod;
|
|
|
|
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
|
|
if (
|
|
params.hasDeviceIdentity &&
|
|
params.deviceId &&
|
|
params.publicKey &&
|
|
!authOk &&
|
|
bootstrapTokenCandidate
|
|
) {
|
|
const tokenCheck = await params.verifyBootstrapToken({
|
|
deviceId: params.deviceId,
|
|
publicKey: params.publicKey,
|
|
token: bootstrapTokenCandidate,
|
|
role: params.role,
|
|
scopes: params.scopes,
|
|
});
|
|
if (tokenCheck.ok) {
|
|
authOk = true;
|
|
authMethod = "bootstrap-token";
|
|
} else {
|
|
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
|
|
}
|
|
}
|
|
|
|
const deviceTokenCandidate = params.state.deviceTokenCandidate;
|
|
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
|
|
return { authResult, authOk, authMethod };
|
|
}
|
|
|
|
if (params.rateLimiter) {
|
|
const deviceRateCheck = params.rateLimiter.check(
|
|
params.clientIp,
|
|
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
|
|
);
|
|
if (!deviceRateCheck.allowed) {
|
|
authResult = {
|
|
ok: false,
|
|
reason: "rate_limited",
|
|
rateLimited: true,
|
|
retryAfterMs: deviceRateCheck.retryAfterMs,
|
|
};
|
|
}
|
|
}
|
|
if (!authResult.rateLimited) {
|
|
const tokenCheck = await params.verifyDeviceToken({
|
|
deviceId: params.deviceId,
|
|
token: deviceTokenCandidate,
|
|
role: params.role,
|
|
scopes: params.scopes,
|
|
});
|
|
if (tokenCheck.ok) {
|
|
authOk = true;
|
|
authMethod = "device-token";
|
|
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
|
} else {
|
|
authResult = {
|
|
ok: false,
|
|
reason:
|
|
params.state.deviceTokenCandidateSource === "explicit-device-token"
|
|
? "device_token_mismatch"
|
|
: (authResult.reason ?? "device_token_mismatch"),
|
|
};
|
|
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
|
}
|
|
}
|
|
|
|
return { authResult, authOk, authMethod };
|
|
}
|