Advertisement

← vibesdk

worker/middleware/auth/routeAuth.ts

9.6 KB Download file
/**
 * Route Authentication Middleware
 */

import { createMiddleware } from 'hono/factory';
import { AuthUser } from '../../types/auth-types';
import { createLogger } from '../../logger';
import { AppService } from '../../database';
import { authMiddleware } from './auth';
import { RateLimitService } from '../../services/rate-limit/rateLimits';
import { errorResponse } from '../../api/responses';
import { Context } from 'hono';
import { AppEnv } from '../../types/appenv';
import { RateLimitExceededError } from 'shared/types/errors';
import * as Sentry from '@sentry/cloudflare';
import { getUserConfigurableSettings } from 'worker/config';
import { authenticateViaTicket, hasTicketParam } from './ticketAuth';

const logger = createLogger('RouteAuth');

/**
 * Authentication levels for route protection
 */
export type AuthLevel = 'public' | 'authenticated' | 'owner-only';

/**
 * Authentication requirement configuration
 */
export interface AuthRequirement {
    required: boolean;
    level: 'public' | 'authenticated' | 'owner-only';
    resourceOwnershipCheck?: (user: AuthUser, params: Record<string, string>, env: Env) => Promise<boolean>;
}

/**
 * Ticket authentication configuration
 */
export type TicketResourceType = 'agent' | 'vault';

export interface TicketAuthConfig {
    /** Type of resource this ticket authenticates */
    resourceType: TicketResourceType;
    /** Path parameter name containing the resource ID (for agent) */
    paramName?: string;
}

/**
 * Additional options for setAuthLevel middleware
 */
export interface AuthLevelOptions {
    /** Ticket-based authentication configuration */
    ticketAuth?: TicketAuthConfig;
}

/**
 * Common auth requirement configurations
 */
export const AuthConfig = {
    // Public route - no authentication required
    public: { 
        required: false,
        level: 'public' as const
    },
    
    // Require full authentication (no anonymous users)
    authenticated: { 
        required: true, 
        level: 'authenticated' as const 
    },
    
    // Require resource ownership (for app editing)
    ownerOnly: { 
        required: true, 
        level: 'owner-only' as const,
        resourceOwnershipCheck: checkAppOwnership
    },
    
    // Public read access, but owner required for modifications
    publicReadOwnerWrite: { 
        required: false 
    }
} as const;

/**
 * Route authentication logic that enforces authentication requirements
 */
export async function routeAuthChecks(
    user: AuthUser | null,
    env: Env,
    requirement: AuthRequirement,
    params?: Record<string, string>
): Promise<{ success: boolean; response?: Response }> {
    try {
        // Public routes always pass
        console.log('requirement', requirement, 'for user', user);
        if (requirement.level === 'public') {
            return { success: true };
        }

        // For authenticated routes
        if (requirement.level === 'authenticated') {
            if (!user) {
                return {
                    success: false,
                    response: createAuthRequiredResponse()
                };
            }

            return { success: true };
        }

        // For owner-only routes
        if (requirement.level === 'owner-only') {
            if (!user) {
                return {
                    success: false,
                    response: createAuthRequiredResponse('Account required')
                };
            }

            // Check resource ownership if function provided
            if (requirement.resourceOwnershipCheck) {
                if (params) {
                    const isOwner = await requirement.resourceOwnershipCheck(user, params, env);
                    return {
                        success: isOwner,
                        response: isOwner ? undefined : createForbiddenResponse('You can only access your own resources')
                    }
                }
                return {
                    success: false,
                    response: createForbiddenResponse('Invalid resource ownership')
                };
            }

            return { success: true };
        }

        // Default fallback
        return { success: true };
    } catch (error) {
        logger.error('Error in route auth middleware', error);
        return {
            success: false,
            response: new Response(JSON.stringify({
                success: false,
                error: 'Authentication check failed'
            }), {
                status: 500,
                headers: { 'Content-Type': 'application/json' }
            })
        };
    }
}

/*
 * Enforce authentication requirement
 */
export async function enforceAuthRequirement(c: Context<AppEnv>) : Promise<Response | undefined> {
    let user: AuthUser | null = c.get('user') || null;

    const requirement = c.get('authLevel') as AuthRequirement | undefined;
    const authOptions = c.get('authLevelOptions') as AuthLevelOptions | undefined;
    
    if (!requirement) {
        logger.error('No authentication level found');
        return errorResponse('No authentication level found', 500);
    }
    
    // Only perform auth if we need it or don't have user yet
    if (!user && (requirement.level === 'authenticated' || requirement.level === 'owner-only')) {
        const request = c.req.raw;
        const env = c.env;
        const params = c.req.param();
        
        // Strategy 1: Ticket-based auth (if configured and ticket present)
        if (authOptions?.ticketAuth && hasTicketParam(request)) {
            const ticketAuth = await authenticateViaTicket(request, env, authOptions.ticketAuth, params);
            if (ticketAuth) {
                user = ticketAuth.user;
                c.set('user', user);
                c.set('sessionId', ticketAuth.sessionId);
                Sentry.setUser({ id: user.id, email: user.email });
                
                const config = await getUserConfigurableSettings(c.env, user.id);
                c.set('config', config);
                
                // Skip rate limiting for ticket auth (already rate-limited at ticket creation)
                logger.info('Authenticated via ticket', { userId: user.id, resourceType: authOptions.ticketAuth.resourceType });
            } else {
                // Ticket was provided but invalid - reject immediately
                return errorResponse('Invalid or expired ticket', 403);
            }
        }
        
        // Strategy 2: Standard JWT auth (header/cookie)
        if (!user) {
            const userSession = await authMiddleware(c.req.raw, c.env);
            if (!userSession) {
                return errorResponse('Authentication required', 401);
            }
            user = userSession.user;
            c.set('user', user);
            c.set('sessionId', userSession.sessionId);
            Sentry.setUser({ id: user.id, email: user.email });

            const config = await getUserConfigurableSettings(c.env, user.id);
            c.set('config', config);

            try {
                await RateLimitService.enforceAuthRateLimit(c.env, config.security.rateLimit, user, c.req.raw);
            } catch (error) {
                if (error instanceof RateLimitExceededError) {
                    return errorResponse(error, 429);
                }
                logger.error('Error enforcing auth rate limit', error);
                return errorResponse('Internal server error', 500);
            }
        }
    }
    
    const params = c.req.param();
    const env = c.env;
    const result = await routeAuthChecks(user, env, requirement, params);
    if (!result.success) {
        logger.warn('Authentication check failed', result.response, requirement, user);
        return result.response;
    }
}

export function setAuthLevel(requirement: AuthRequirement, options?: AuthLevelOptions) {
    return createMiddleware(async (c, next) => {
        c.set('authLevel', requirement);
        if (options) {
            c.set('authLevelOptions', options);
        }
        return await next();
    })
}

/**
 * Create standardized authentication required response
 */
function createAuthRequiredResponse(message?: string): Response {
    return new Response(JSON.stringify({
        success: false,
        error: {
            type: 'AUTHENTICATION_REQUIRED',
            message: message || 'Authentication required',
            action: 'login'
        }
    }), {
        status: 401,
        headers: {
            'Content-Type': 'application/json',
            'WWW-Authenticate': 'Bearer realm="API"'
        }
    });
}

/**
 * Create standardized forbidden response
 */
function createForbiddenResponse(message: string): Response {
    return new Response(JSON.stringify({
        success: false,
        error: {
            type: 'FORBIDDEN',
            message,
            action: 'insufficient_permissions'
        }
    }), {
        status: 403,
        headers: { 'Content-Type': 'application/json' }
    });
}

/**
 * Check if user owns an app by agent/app ID
 */
export async function checkAppOwnership(user: AuthUser, params: Record<string, string>, env: Env): Promise<boolean> {
    try {
        const agentId = params.agentId || params.id;
        if (!agentId) {
            return false;
        }

        const appService = new AppService(env);
        const ownershipResult = await appService.checkAppOwnership(agentId, user.id);
        return ownershipResult.isOwner;
    } catch (error) {
        logger.error('Error checking app ownership', error);
        return false;
    }
}