/** * 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<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>} */ 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 <= 0) return '/'; return normalized.substring(0, lastSlash + 1); } /** * Handle unauthorized request * @param {object} request - Fastify request * @param {object} reply - Fastify reply * @param {boolean} isAuthenticated - Whether user is authenticated * @param {string} wacAllow - WAC-Allow header value * @param {string|null} authError - Authentication error message (for DPoP failures) * @param {string|null} issuer - IdP issuer URL for WWW-Authenticate header */ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, authError = null, issuer = null) { reply.header('WAC-Allow', wacAllow); const statusCode = isAuthenticated ? 403 : 401; const realm = issuer || 'Solid'; if (!isAuthenticated) { reply.header('WWW-Authenticate', `DPoP realm="${realm}", Bearer realm="${realm}"`); } // Check if browser wants HTML const accept = request.headers.accept || ''; if (accept.includes('text/html')) { // If mashlib is enabled, serve mashlib instead of static error page // Mashlib has built-in login functionality via panes.runDataBrowser() if (request.mashlibEnabled) { const html = request.mashlibModule ? generateModuleDatabrowserHtml(request.mashlibModule) : generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null); return reply.code(statusCode).type('text/html').send(html); } return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request)); } // Return JSON for API clients if (!isAuthenticated) { return reply.code(401).send({ error: 'Unauthorized', message: authError || 'Authentication required' }); } else { return reply.code(403).send({ error: 'Forbidden', message: 'Access denied' }); } } /** * Generate a beautiful error page for browsers */ function getErrorPage(statusCode, isAuthenticated, request) { const is401 = statusCode === 401; const title = is401 ? 'Authentication Required' : 'Access Denied'; const subtitle = is401 ? "This resource is protected. You'll need to sign in to continue." : "You're signed in, but you don't have permission to view this resource."; const baseUrl = `${request.protocol}://${request.headers.host || request.hostname}`; return `
${subtitle}
This is a Solid Pod — a personal data store where you control your own data. Resources can be private, shared with specific people, or public. ${is401 ? "The Data Browser lets you sign in with your WebID to access protected content." : 'Ask the owner to grant you access.'}
HTTP ${statusCode} • ${request.url}