/**
* 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