import type { IncomingMessage } from 'http'; export interface AuthConfig { /** Static bearer token. If set, all connections must provide it. */ token?: string; /** Trust Tailscale-User-Login header for identity. */ tailscaleIdentity?: boolean; } export interface AuthResult { authenticated: boolean; identity?: string; error?: string; } export interface NodeAuthScopeConfig { enabled: boolean; method: string; nodeRole?: string; allowedRoles?: string[]; roleScopes?: Record; } /** * Authenticates a WebSocket upgrade request or HTTP request. * * Auth is checked in this order: * 1. If token is configured, validate via: * a. Authorization: Bearer header * b. ?token= query parameter (useful for browser WebSocket clients) * 2. If tailscaleIdentity is enabled, extract identity from Tailscale-User-Login header * 3. If no auth is configured, allow all connections */ export function authenticateRequest(req: IncomingMessage, config: AuthConfig): AuthResult { // If token auth is configured, it's required if (config.token) { let tokenValid = false; // Check Authorization header first const authHeader = req.headers['authorization']; if (authHeader) { const parts = authHeader.split(' '); if (parts.length !== 2 || parts[0] !== 'Bearer') { return { authenticated: false, error: 'Invalid Authorization format (expected: Bearer )' }; } tokenValid = parts[1] === config.token; } else { // Check query parameter as fallback (useful for WebSocket from browsers) const queryToken = extractQueryToken(req); if (queryToken !== undefined) { tokenValid = queryToken === config.token; } else { return { authenticated: false, error: 'Authorization required (Bearer token header or ?token= query parameter)' }; } } if (!tokenValid) { return { authenticated: false, error: 'Invalid token' }; } // Token is valid — check for Tailscale identity too const identity = extractTailscaleIdentity(req, config); return { authenticated: true, identity: identity ?? 'token-user' }; } // If Tailscale identity is configured (no token), use it if (config.tailscaleIdentity) { const identity = extractTailscaleIdentity(req, config); if (identity) { return { authenticated: true, identity }; } // Tailscale identity configured but header not present — still allow (might be local) return { authenticated: true, identity: 'anonymous' }; } // No auth configured — allow all return { authenticated: true, identity: 'anonymous' }; } export function authorizeNodeMethod(config: NodeAuthScopeConfig): AuthResult { if (!config.method.startsWith('node.')) { return { authenticated: true }; } if (!config.enabled) { return { authenticated: false, error: 'Node RPC is disabled' }; } if (config.method === 'node.register') { return { authenticated: true }; } if (!config.nodeRole) { return { authenticated: false, error: 'Node not registered for this connection' }; } const allowedRoles = config.allowedRoles ?? []; if (allowedRoles.length > 0 && !allowedRoles.includes(config.nodeRole)) { return { authenticated: false, error: `Node role '${config.nodeRole}' is not allowed` }; } const defaultScopes = ['node.capabilities.get']; const roleScopes = config.roleScopes ?? {}; const permitted = roleScopes[config.nodeRole] ?? defaultScopes; if (!permitted.includes(config.method)) { return { authenticated: false, error: `Method '${config.method}' is not permitted for node role '${config.nodeRole}'` }; } return { authenticated: true }; } function extractQueryToken(req: IncomingMessage): string | undefined { try { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); const token = url.searchParams.get('token'); return token ?? undefined; } catch { return undefined; } } function extractTailscaleIdentity(req: IncomingMessage, config: AuthConfig): string | undefined { if (!config.tailscaleIdentity) {return undefined;} const header = req.headers['tailscale-user-login']; if (typeof header === 'string' && header.length > 0) { return header; } return undefined; }