130 lines
4.2 KiB
TypeScript
130 lines
4.2 KiB
TypeScript
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<string, string[]>;
|
|
}
|
|
|
|
/**
|
|
* Authenticates a WebSocket upgrade request or HTTP request.
|
|
*
|
|
* Auth is checked in this order:
|
|
* 1. If token is configured, validate via:
|
|
* a. Authorization: Bearer <token> header
|
|
* b. ?token=<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 <token>)' };
|
|
}
|
|
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;
|
|
}
|