[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/bourgeoa/JavaScriptSolidServer/patchAppend/src/auth/middleware.js [Back]  [Original]

/**
 * Authorization middleware
 * Combines authentication (token verification) with WAC checking
 * Supports both simple Bearer tokens and Solid-OIDC DPoP tokens
 */

import { getWebIdFromRequestAsync } from './token.js';
import { checkAccess, getRequiredMode } from '../wac/checker.js';
import { AccessMode } from '../wac/parser.js';
import { parseN3Patch } from '../patch/n3-patch.js';
import { parseSparqlUpdate } from '../patch/sparql-update.js';
import * as storage from '../storage/filesystem.js';
import { getEffectiveUrlPath } from '../utils/url.js';
import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js';

/**
 * Build a resource URL for WAC checking, normalizing path-based pod access
 * to subdomain form so URLs match ACL entries.
 *
 * In subdomain mode, ACLs reference subdomain URLs (e.g. https://alice.example.com/public/).
 * Path-based access on the main domain (e.g. https://example.com/alice/public/) must be
 * normalized to match.
 *
 * @param {object} request - Fastify request
 * @param {string} urlPath - URL path (e.g. /alice/public/file.ttl)
 * @returns {string} Normalized resource URL
 */
export function buildResourceUrl(request, urlPath) {
  // Use request.headers.host (includes port) instead of request.hostname (strips port)
  const host = request.headers.host || request.hostname;
  if (request.subdomainsEnabled && request.baseDomain &&
      request.hostname === request.baseDomain && !request.podName) {
    const pathMatch = urlPath.match(/^\/([^/]+)(\/.*)?$/);
    // Treat a path segment as a pod name only if it looks like one:
    //   - not a dotfile (.well-known, .acl, .meta, ...)
    //   - no dot (pod names are DNS labels; file names have extensions)
    // This avoids rewriting /mashlib.js to https://mashlib.js.basedomain/
    // which would fail WAC against the base domain's ACL. (#307)
    if (pathMatch && !pathMatch[1].startsWith('.') && !pathMatch[1].includes('.')) {
      const podName = pathMatch[1];
      const remainder = pathMatch[2] || '/';
      return `${request.protocol}://${podName}.${request.baseDomain}${remainder}`;
    }
  }
  return `${request.protocol}://${host}${urlPath}`;
}

/**
 * Check if request is authorized
 * @param {object} request - Fastify request
 * @param {object} reply - Fastify reply
 * @param {object} options - Optional settings
 * @param {string} options.requiredMode - Override the required access mode (e.g., 'Write' for git push)
 * @returns {Promise}
 */
export async function authorize(request, reply, options = {}) {
  const urlPath = request.url.split('?')[0];
  const method = request.method;

  // OPTIONS is always allowed (CORS preflight)
  if (method === 'OPTIONS') {
    return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
  }

  // Public mode - skip all WAC checks, allow unauthenticated access
  if (request.config?.public) {
    const modes = request.config?.readOnly ? 'read' : 'read write append';
    return { authorized: true, webId: null, wacAllow: `public="${modes}"`, authError: null };
  }

  // Get WebID from token (supports both simple and Solid-OIDC tokens)
  const { webId, error: authError } = await getWebIdFromRequestAsync(request);

  // ACL files require special handling - check Control permission on protected resource
  if (urlPath.endsWith('.acl')) {
    return authorizeAclAccess(request, urlPath, method, webId, authError);
  }

  // Log auth failures for debugging
  if (authError) {
    request.log.warn({ authError, method, urlPath, hasAuth: !!request.headers.authorization }, 'Auth error');
  }

  // Get effective storage path (includes pod name in subdomain mode)
  const storagePath = getEffectiveUrlPath(request);

  // Get resource info
  const stats = await storage.stat(storagePath);
  const resourceExists = stats !== null;
  const isContainer = stats?.isDirectory || urlPath.endsWith('/');

  // Build resource URL, normalizing path-based pod access to subdomain form for WAC
  const resourceUrl = buildResourceUrl(request, urlPath);

  // Get required access mode - use override if provided, otherwise derive from method
  let requiredMode = options.requiredMode || getRequiredMode(method);

  // PATCH can be authorized as Append when it is insert-only.
  // Any delete operation (or parse ambiguity) stays Write.
  if (!options.requiredMode && method === 'PATCH') {
    requiredMode = getPatchRequiredMode(request, resourceUrl);
  }

  // For write operations on non-existent resources, check parent container
  let checkPath = storagePath;
  let checkUrl = resourceUrl;
  let checkIsContainer = isContainer;

  if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
    // Check write permission on parent container
    const parentPath = getParentPath(storagePath);
    checkPath = parentPath;
    // For URL, also need to get parent (normalized for subdomain WAC matching)
    const parentUrlPath = getParentPath(urlPath);
    checkUrl = buildResourceUrl(request, parentUrlPath);
    checkIsContainer = true;
  }

  // Check WAC permissions
  const { allowed, wacAllow, paymentRequired, paid, balance, currency } = await checkAccess({
    resourceUrl: checkUrl,
    resourcePath: checkPath,
    isContainer: checkIsContainer,
    agentWebId: webId,
    requiredMode
  });

  return { authorized: allowed, webId, wacAllow, authError, paymentRequired, paid, balance, currency };
}

/**
 * Determine PATCH required mode from patch payload semantics.
 * Insert-only patches require Append; delete-capable patches require Write.
 */
function getPatchRequiredMode(request, baseUri) {
  const contentType = (request.headers['content-type'] || '').toLowerCase();
  const rawBody = Buffer.isBuffer(request.body) ? request.body.toString() : request.body;

  if (typeof rawBody !== 'string') {
    return AccessMode.WRITE;
  }

  try {
    if (contentType.includes('application/sparql-update')) {
      const update = parseSparqlUpdate(rawBody, baseUri);
      return update.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE;
    }

    if (contentType.includes('text/n3') || contentType.includes('application/n3')) {
      const patch = parseN3Patch(rawBody, baseUri);
      return patch.deletes.length === 0 ? AccessMode.APPEND : AccessMode.WRITE;
    }
  } catch {
    // Fail closed to Write when patch parsing is invalid/ambiguous.
    return AccessMode.WRITE;
  }

  return AccessMode.WRITE;
}

/**
 * Get parent container path
 */
function getParentPath(path) {
  const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
  const lastSlash = normalized.lastIndexOf('/');
  if (lastSlash 

Web Proxy Viewer  |  New URL  |  Original Page