Files
flynn/src/gateway/auth.ts
T
2026-02-16 12:14:25 -08:00

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;
}