feat: add query-param token auth and optional HTTP auth to gateway

Support ?token= query parameter as a fallback for WebSocket clients that
cannot set Authorization headers (e.g. browsers). Add authHttp option to
GatewayServer so token auth can be applied to HTTP requests too, returning
401 with WWW-Authenticate header on failure.
This commit is contained in:
William Valentin
2026-02-06 16:51:41 -08:00
parent 0eb1f7a073
commit 20930a4816
4 changed files with 132 additions and 12 deletions
+32 -10
View File
@@ -14,27 +14,39 @@ export interface AuthResult {
}
/**
* Authenticates a WebSocket upgrade request.
* Authenticates a WebSocket upgrade request or HTTP request.
*
* Auth is checked in this order:
* 1. If token is configured, validate Authorization header (Bearer token)
* 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) {
return { authenticated: false, error: 'Authorization header required' };
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)' };
}
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return { authenticated: false, error: 'Invalid Authorization format (expected: Bearer <token>)' };
}
if (parts[1] !== config.token) {
if (!tokenValid) {
return { authenticated: false, error: 'Invalid token' };
}
@@ -57,6 +69,16 @@ export function authenticateRequest(req: IncomingMessage, config: AuthConfig): A
return { authenticated: true, identity: 'anonymous' };
}
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'];