All files / src/auth middleware.ts

13.58% Statements 11/81
0% Branches 0/62
12.5% Functions 1/8
10.25% Lines 8/78

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221    4x                                                     4x                                                                         4x                                                                       4x                                           4x                                                           4x 12x   12x                                                                                                                              
import { MiddlewareHandler, Context } from "hono";
import { DataDriver } from "@rebasepro/types";
import { verifyAccessToken, AccessTokenPayload } from "./jwt";
import { HonoEnv } from "../api/types";
 
/**
 * Result from a custom auth validator.
 * - `false`/`null`/`undefined` = not authenticated
 * - `true` = authenticated as default user
 * - object with `userId` or `uid` = authenticated with user info
 */
export type AuthResult = boolean | null | undefined | { userId?: string; uid?: string; roles?: string[]; [key: string]: unknown };
 
/**
 * Options for creating an auth middleware via createAuthMiddleware()
 */
export interface AuthMiddlewareOptions {
    /** DataDriver to scope via withAuth() for RLS */
    driver: DataDriver;
    /** If true, return 401 when no valid token is present (default: false) */
    requireAuth?: boolean;
    /** Optional custom validator (for non-JWT auth, e.g. Firebase Auth) */
    validator?: (c: Context<HonoEnv>) => Promise<AuthResult>;
}
 
/**
 * Express middleware that requires a valid JWT token
 * Returns 401 if token is missing or invalid
 */
export const requireAuth: MiddlewareHandler<HonoEnv> = async (
    c,
    next
) => {
    const authHeader = c.req.header("authorization");
    const queryToken = c.req.query("token");
    const hasBearer = authHeader && authHeader.startsWith("Bearer ");
 
    Iif (!hasBearer && !queryToken) {
        return c.json({
            error: {
                message: "Authorization header or token query parameter missing or invalid",
                code: "UNAUTHORIZED"
            }
        }, 401);
    }
 
    const token = hasBearer ? authHeader!.substring(7) : queryToken!;
    const payload = verifyAccessToken(token);
 
    Iif (!payload) {
        return c.json({
            error: {
                message: "Invalid or expired token",
                code: "UNAUTHORIZED"
            }
        }, 401);
    }
 
    c.set("user", payload);
    return next();
};
 
/**
 * Middleware that requires the user to have an admin or schema-admin role.
 * Must be used AFTER requireAuth or on a route where user is guaranteed.
 */
export const requireAdmin: MiddlewareHandler<HonoEnv> = async (
    c,
    next
) => {
    const user = c.get("user");
    Iif (!user) {
        return c.json({
            error: {
                message: "User not authenticated. requireAuth middleware is missing?",
                code: "UNAUTHORIZED"
            }
        }, 401);
    }
 
    const roles = (typeof user === "object" && user !== null && "roles" in user) ? (user.roles || []) : [];
    const isAdmin = roles.some((role: string) => {
        return role === "admin" || role === "schema-admin";
    });
 
    Iif (!isAdmin) {
        return c.json({
            error: {
                message: "Admin privileges required for this operation",
                code: "FORBIDDEN"
            }
        }, 403);
    }
 
    return next();
};
 
 
/**
 * Middleware that optionally extracts user from JWT
 * Does not return 401 if token is missing - allows anonymous access
 */
export const optionalAuth: MiddlewareHandler<HonoEnv> = async (
    c,
    next
) => {
    const authHeader = c.req.header("authorization");
    const queryToken = c.req.query("token");
    const hasBearer = authHeader && authHeader.startsWith("Bearer ");
 
    Iif (hasBearer || queryToken) {
        const token = hasBearer ? authHeader!.substring(7) : queryToken!;
        const payload = verifyAccessToken(token);
        Iif (payload) {
            c.set("user", payload);
        }
    }
 
    return next();
};
 
/**
 * Extract user from token - for WebSocket authentication
 */
export function extractUserFromToken(token: string): AccessTokenPayload | null {
    return verifyAccessToken(token);
}
 
/**
 * Helper to scope a DataDriver via withAuth() for RLS.
 * SECURITY: If withAuth() is available but fails, the error is re-thrown
 * so the request is denied rather than proceeding with unscoped access.
 */
async function scopeDataDriver(
    driver: DataDriver,
    user: { uid: string; roles?: string[] }
): Promise<DataDriver> {
    Iif ("withAuth" in driver && typeof (driver as Record<string, unknown>).withAuth === "function") {
        // Fail closed — do NOT catch and swallow errors here.
        // If RLS scoping fails the request must be rejected.
        return await (driver as unknown as { withAuth: (user: Record<string, unknown>) => Promise<DataDriver> }).withAuth(user);
    }
    return driver;
}
 
/**
 * Create a configurable auth middleware that handles:
 * 1. Token extraction (via custom validator or JWT Bearer token)
 * 2. RLS-scoped DataDriver via withAuth()
 * 3. Optional enforcement (401 when requireAuth is true and no user)
 *
 * This is the single source of truth for HTTP auth in Rebase.
 * Use this instead of manually parsing tokens in route handlers.
 */
export function createAuthMiddleware(options: AuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
    const { driver, requireAuth: enforceAuth = false, validator } = options;
 
    return async (c, next) => {
        if (validator) {
            // Custom validator path (e.g., Firebase Auth, API keys)
            try {
                const authResult = await validator(c);
                if (authResult && typeof authResult === "object") {
                    const id = ("userId" in authResult ? authResult.userId : undefined)
                        || ("uid" in authResult ? authResult.uid : undefined);
                    if (id) {
                        const roles = authResult.roles || [];
                        c.set("user", { userId: id, roles });
                        const user = { uid: id, roles, ...authResult };
                        c.set("driver", await scopeDataDriver(driver, user));
                    } else {
                        c.set("driver", driver);
                    }
                } else if (authResult === true) {
                    c.set("user", { userId: "default", roles: [] });
                    c.set("driver", driver);
                } else {
                    // Not authenticated - driver stays unscoped
                    c.set("driver", driver);
                }
            } catch (error) {
                return c.json({ error: { message: "Unauthorized", code: "UNAUTHORIZED" } }, 401);
            }
        } else {
            // Default JWT path
            try {
                const authHeader = c.req.header("authorization");
                const queryToken = c.req.query("token");
                const hasBearer = authHeader && authHeader.startsWith("Bearer ");
                
                if (hasBearer || queryToken) {
                    const token = hasBearer ? authHeader!.substring(7) : queryToken!;
                    const payload = extractUserFromToken(token);
 
                    if (payload) {
                        c.set("user", payload);
                        const user = { uid: payload.userId, roles: payload.roles };
                        c.set("driver", await scopeDataDriver(driver, user));
                    } else {
                        console.error("[AUTH] Token payload empty or invalid for token: " + token.substring(0, 10));
                        c.set("driver", driver);
                    }
                } else {
                    console.error("[AUTH] No token found! Auth header:", authHeader, "Query:", queryToken, "Path:", c.req.path);
                    c.set("driver", driver);
                }
            } catch (error) {
                console.error("Default auth validation error", error);
                c.set("driver", driver);
            }
        }
 
        Iif (enforceAuth && !c.get("user")) {
            console.error("[AUTH] Rejecting with 401. Path:", c.req.path);
            return c.json({ error: { message: "Unauthorized: Invalid or missing token", code: "UNAUTHORIZED" } }, 401);
        }
 
        return next();
    };
}