Advertisement

← vibesdk

worker/utils/authUtils.ts

9.5 KB Download file
/**
 * Centralized Authentication Utilities
 */

import type {  AuthUser, SessionResponse } from '../types/auth-types';
import type { User } from '../database/schema';
import { createLogger } from '../logger';
import { SecurityError, SecurityErrorType } from 'shared/types/errors';

const logger = createLogger('AuthUtils');

/**
 * Enforce the deployment-level email allowlist (ALLOWED_EMAIL).
 *
 * Centralized admission gate: every authentication path that admits a user
 * (register, login, OAuth callback, email verification, and any future path)
 * must call this before creating a user or issuing a session. Comparison is
 * case-insensitive on both sides.
 */
export function enforceAllowedEmail(
	env: Env,
	email: string,
	action: 'register' | 'login' | 'oauth',
): void {
	const allowedEmail = env.ALLOWED_EMAIL ?? '';
	if (!allowedEmail) return;
	if (email.toLowerCase() !== allowedEmail.toLowerCase()) {
		throw new SecurityError(
			SecurityErrorType.UNAUTHORIZED,
			`Email Whitelisting is enabled. Please use the allowed email to ${action}.`,
			403,
		);
	}
}

/**
 * Extract sessionId from cookie
*/
export function extractSessionId(request: Request): string | null {
    const cookieHeader = request.headers.get('Cookie');
       if (!cookieHeader) {
               return null;
       }

       const cookies = parseCookies(cookieHeader);
       return cookies['sessionId'];
}


/**
 * Token extraction priorities and methods
 */
export enum TokenExtractionMethod {
	AUTHORIZATION_HEADER = 'authorization_header',
	COOKIE = 'cookie',
	QUERY_PARAMETER = 'query_parameter',
}

/**
 * Result of token extraction with metadata
 */
export interface TokenExtractionResult {
	token: string | null;
	method?: TokenExtractionMethod;
	cookieName?: string;
}

/**
 * Extract JWT token from request with multiple fallback methods
 * Prioritizes Authorization header, then cookies, then query parameters
 */
export function extractToken(request: Request): string | null {
	const result = extractTokenWithMetadata(request);
	return result.token;
}

/**
 * Extract JWT token from request with extraction method metadata
 * Useful for security logging and analysis
 */
export function extractTokenWithMetadata(
	request: Request,
): TokenExtractionResult {
	// Priority 1: Authorization header (most secure)
	const authHeader = request.headers.get('Authorization');
	if (authHeader?.startsWith('Bearer ')) {
		const token = authHeader.substring(7);
		if (token && token.length > 0) {
			return {
				token,
				method: TokenExtractionMethod.AUTHORIZATION_HEADER,
			};
		}
	}

	// Priority 2: Cookies (secure for browser requests)
	const cookieHeader = request.headers.get('Cookie');
	if (cookieHeader) {
		const cookies = parseCookies(cookieHeader);

		// Check common cookie names in order of preference
		const cookieNames = ['accessToken', 'auth_token', 'jwt'];
		for (const cookieName of cookieNames) {
			if (cookies[cookieName]) {
				return {
					token: cookies[cookieName],
					method: TokenExtractionMethod.COOKIE,
					cookieName,
				};
			}
		}
	}

	// NOTE: JWTs are intentionally NOT accepted from the URL query string.
	// URL components leak into access logs, browser history, Referer headers and
	// error-reporting breadcrumbs, where a captured token would remain valid until
	// its natural expiry. WebSocket handshakes use the one-time ticket flow instead
	// (see worker/middleware/auth/ticketAuth.ts).
	return { token: null };
}

/**
 * Parse cookie header into key-value pairs
 */
export function parseCookies(cookieHeader: string): Record<string, string> {
	const cookies: Record<string, string> = {};
	const pairs = cookieHeader.split(';');

	for (const pair of pairs) {
		const [key, value] = pair.trim().split('=');
		if (key && value) {
			cookies[key] = decodeURIComponent(value);
		}
	}

	return cookies;
}

/**
 * Clear authentication cookie using secure cookie options
 */
export function clearAuthCookie(name: string): string {
	return createSecureCookie({
		name,
		value: '',
		maxAge: 0,
	});
}

/**
 * Clear all auth cookies from response using consolidated approach
 */
export function clearAuthCookies(response: Response): void {
	response.headers.append('Set-Cookie', clearAuthCookie('accessToken'));
	response.headers.append('Set-Cookie', clearAuthCookie('auth_token'));
}

/**
 * Enhanced cookie creation with security options
 */
export interface CookieOptions {
	name: string;
	value: string;
	maxAge?: number; // seconds
	httpOnly?: boolean;
	secure?: boolean;
	sameSite?: 'Strict' | 'Lax' | 'None';
	path?: string;
	domain?: string;
}

/**
 * Create secure cookie string with all options
 */
export function createSecureCookie(options: CookieOptions): string {
	const {
		name,
		value,
		maxAge = 7 * 24 * 60 * 60, // 7 days default
		secure = true,
		sameSite = 'Lax',
		path = '/',
		domain,
	} = options;

	const parts = [`${name}=${encodeURIComponent(value)}`];

	if (maxAge > 0) parts.push(`Max-Age=${maxAge}`);
	if (path) parts.push(`Path=${path}`);
	if (domain) parts.push(`Domain=${domain}`);
	if (secure) parts.push('Secure');
	if (sameSite) parts.push(`SameSite=${sameSite}`);

    parts.push('HttpOnly');

	return parts.join('; ');
}

/**
 * Set auth cookies with proper security settings
 */
export function setSecureAuthCookies(
	response: Response,
	tokens: {
		accessToken: string;
		accessTokenExpiry?: number; // seconds
	},
): void {
	const {
		accessToken,
		accessTokenExpiry = 3 * 24 * 60 * 60, // 3 days
	} = tokens;

	// Set access token cookie
	response.headers.append(
		'Set-Cookie',
		createSecureCookie({
			name: 'accessToken',
			value: accessToken,
			maxAge: accessTokenExpiry,
			sameSite: 'Lax',
		}),
	);
}

/**
 * Extract request metadata for security analysis
 */
export interface RequestMetadata {
	ipAddress: string;
	userAgent: string;
	referer?: string;
	origin?: string;
	acceptLanguage?: string;

	// Cloudflare-specific headers
	cfConnectingIp?: string;
	cfRay?: string;
	cfCountry?: string;
	cfTimezone?: string;
}

/**
 * Extract comprehensive request metadata
 */
export function extractRequestMetadata(request: Request): RequestMetadata {
	const headers = request.headers;

	return {
		ipAddress:
			headers.get('CF-Connecting-IP') ||
			headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
			headers.get('X-Real-IP') ||
			'unknown',
		userAgent: headers.get('User-Agent') || 'unknown',
		referer: headers.get('Referer') || undefined,
		origin: headers.get('Origin') || undefined,
		acceptLanguage: headers.get('Accept-Language') || undefined,

		// Cloudflare-specific
		cfConnectingIp: headers.get('CF-Connecting-IP') || undefined,
		cfRay: headers.get('CF-Ray') || undefined,
		cfCountry: headers.get('CF-IPCountry') || undefined,
		cfTimezone: headers.get('CF-Timezone') || undefined,
	};
}

export type { SessionResponse } from '../types/auth-types';

export function mapUserResponse(
	user: (Partial<User> & { id: string; email: string }) | AuthUser,
): AuthUser {
	// Handle AuthUser type - already in correct format
	if ('isAnonymous' in user) {
		return user as AuthUser;
	}

	// Map from User schema type
	return {
		id: user.id,
		email: user.email,
		displayName: user.displayName || undefined,
		username: user.username || undefined,
		avatarUrl: user.avatarUrl || undefined,
		bio: user.bio || undefined,
		timezone: user.timezone || undefined,
		provider: user.provider || undefined,
		emailVerified: user.emailVerified || undefined,
		createdAt: user.createdAt || undefined,
	};
}

export function formatAuthResponse(
	user: AuthUser,
	sessionId: string,
	expiresAt: Date | null,
): SessionResponse {
	const response: SessionResponse = { user, sessionId, expiresAt };
    
	return response;
}

/**
 * Validate and sanitize redirect URL to prevent open redirect attacks
 * Returns null if the URL is invalid or potentially malicious
 */
export function validateRedirectUrl(redirectUrl: string, request: Request): string | null {
	try {
		const requestUrl = new URL(request.url);

		const redirectUrlObj = redirectUrl.startsWith('/')
			? new URL(redirectUrl, requestUrl.origin)
			: new URL(redirectUrl);

		if (redirectUrlObj.origin !== requestUrl.origin) {
			logger.warn('Redirect URL rejected: different origin', {
				redirectUrl,
				requestOrigin: requestUrl.origin,
				redirectOrigin: redirectUrlObj.origin
			});
			return null;
		}

		// Block paths that themselves initiate privileged/auth-mutating flows. Notably
		// `/oauth/` and `/auth/` (Cloudflare account linking) — without these, a post-login
		// open-forward can chain straight into account linking.
		const forbiddenPaths = ['/api/auth/', '/logout', '/api/github-exporter/', '/oauth/', '/auth/'];
		if (forbiddenPaths.some(path => redirectUrlObj.pathname.startsWith(path))) {
			logger.warn('Redirect URL rejected: forbidden path', {
				redirectUrl,
				pathname: redirectUrlObj.pathname
			});
			return null;
		}

		// Reject nested redirect parameters that could re-trigger another flow after this
		// redirect resolves (e.g. /oauth/login?return_url=/settings smuggled via a path).
		const nestedRedirectParams = ['return_url', 'redirect_url', 'continue'];
		if (nestedRedirectParams.some(param => redirectUrlObj.searchParams.has(param))) {
			logger.warn('Redirect URL rejected: nested redirect parameter', {
				redirectUrl,
				pathname: redirectUrlObj.pathname
			});
			return null;
		}

		return redirectUrl;
	} catch (error) {
		logger.warn('Invalid redirect URL format', { redirectUrl, error });
		return null;
	}
}