diff --git a/config/http/middleware/no-websockets.json b/config/http/middleware/no-websockets.json index 7984172b95..1d6469a264 100644 --- a/config/http/middleware/no-websockets.json +++ b/config/http/middleware/no-websockets.json @@ -10,8 +10,18 @@ "@id": "urn:solid-server:default:Middleware", "@type": "SequenceHandler", "handlers": [ - { "@id": "urn:solid-server:default:Middleware_Header" }, - { "@id": "urn:solid-server:default:Middleware_Cors" } + { + "comment": "These handlers can be executed in any order.", + "@id": "urn:solid-server:default:ParallelMiddleware", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:Middleware_Header" } + ] + }, + { + "comment": "CORS has to be last since it can close the connection.", + "@id": "urn:solid-server:default:Middleware_Cors" + } ] } ] diff --git a/config/ldp/authorization/acp.json b/config/ldp/authorization/acp.json index 90dc1fc23f..844eaade08 100644 --- a/config/ldp/authorization/acp.json +++ b/config/ldp/authorization/acp.json @@ -37,6 +37,28 @@ "@type": "SubfolderResourcesGenerator", "subfolders": [ "acp" ] }, + { + "comment": "Middleware exposes the required ACP headers.", + "@id": "urn:solid-server:default:ParallelMiddleware", + "@type": "ParallelHandler", + "handlers": [{ + "@type": "AcpHeaderHandler", + "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "strategy": { "@id": "urn:solid-server:default:AcrIdentifierStrategy" }, + "modes": [ + "http://www.w3.org/ns/auth/acl#Read", + "http://www.w3.org/ns/auth/acl#Append", + "http://www.w3.org/ns/auth/acl#Write", + "http://www.w3.org/ns/auth/acl#Control" + ], + "attributes": [ + "http://www.w3.org/ns/solid/acp#target", + "http://www.w3.org/ns/solid/acp#agent", + "http://www.w3.org/ns/solid/acp#client", + "http://www.w3.org/ns/solid/acp#issuer" + ] + }] + }, { "comment": "In case of ACP authorization the ACR resources determine authorization.", "@id": "urn:solid-server:default:AuthResourceHttpHandler", diff --git a/src/index.ts b/src/index.ts index 323a990d09..80ef2d6074 100644 --- a/src/index.ts +++ b/src/index.ts @@ -292,6 +292,7 @@ export * from './server/WebSocketHandler'; export * from './server/WebSocketServerFactory'; // Server/Middleware +export * from './server/middleware/AcpHeaderHandler'; export * from './server/middleware/CorsHandler'; export * from './server/middleware/HeaderHandler'; export * from './server/middleware/StaticAssetHandler'; diff --git a/src/server/middleware/AcpHeaderHandler.ts b/src/server/middleware/AcpHeaderHandler.ts new file mode 100644 index 0000000000..e36d79b14e --- /dev/null +++ b/src/server/middleware/AcpHeaderHandler.ts @@ -0,0 +1,39 @@ +import type { AuxiliaryIdentifierStrategy } from '../../http/auxiliary/AuxiliaryIdentifierStrategy'; +import type { TargetExtractor } from '../../http/input/identifier/TargetExtractor'; +import { addHeader } from '../../util/HeaderUtil'; +import { ACP } from '../../util/Vocabularies'; +import type { HttpHandlerInput } from '../HttpHandler'; +import { HttpHandler } from '../HttpHandler'; + +/** + * Handles all the required ACP headers as defined at + * https://solid.github.io/authorization-panel/acp-specification/#conforming-acp-server + */ +export class AcpHeaderHandler extends HttpHandler { + private readonly targetExtractor: TargetExtractor; + private readonly strategy: AuxiliaryIdentifierStrategy; + private readonly modes: string[]; + private readonly attributes: string[]; + + public constructor(targetExtractor: TargetExtractor, strategy: AuxiliaryIdentifierStrategy, + modes: string[], attributes: string[]) { + super(); + this.targetExtractor = targetExtractor; + this.strategy = strategy; + this.modes = modes; + this.attributes = attributes; + } + + public async handle({ request, response }: HttpHandlerInput): Promise { + const identifier = await this.targetExtractor.handleSafe({ request }); + if (!this.strategy.isAuxiliaryIdentifier(identifier)) { + return; + } + const linkValues = [ + `<${ACP.AccessControlResource}>; rel="type"`, + ...this.modes.map((mode): string => `<${mode}>; rel="${ACP.grant}"`), + ...this.attributes.map((attribute): string => `<${attribute}>; rel="${ACP.attribute}"`), + ]; + addHeader(response, 'Link', linkValues); + } +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index ffeeb0efbe..2220a62ea1 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -118,6 +118,10 @@ export const ACL = createVocabulary('http://www.w3.org/ns/auth/acl#', ); export const ACP = createVocabulary('http://www.w3.org/ns/solid/acp#', + // Used for ACP middleware headers + 'AccessControlResource', + 'grant', + 'attribute', // Access Control Resource 'resource', 'accessControl', diff --git a/test/integration/AcpServer.test.ts b/test/integration/AcpServer.test.ts index e7e26ef53a..b39175a9de 100644 --- a/test/integration/AcpServer.test.ts +++ b/test/integration/AcpServer.test.ts @@ -118,4 +118,21 @@ describe.each(stores)('An LDP handler with ACP using %s', (name, { storeConfig, response = await fetch(baseUrl); expect(response.status).toBe(200); }); + + it('returns the required Link headers.', async(): Promise => { + const baseAcr = joinUrl(baseUrl, '.acr'); + const response = await fetch(baseAcr, { method: 'OPTIONS' }); + const linkHeaders = response.headers.get('link'); + expect(linkHeaders).toContain('; rel="type"'); + + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#grant"'); + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#grant"'); + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#grant"'); + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#grant"'); + + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#attribute"'); + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#attribute"'); + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#attribute"'); + expect(linkHeaders).toContain('; rel="http://www.w3.org/ns/solid/acp#attribute"'); + }); }); diff --git a/test/integration/config/ldp-with-acp.json b/test/integration/config/ldp-with-acp.json index 880e83925b..13b083b30d 100644 --- a/test/integration/config/ldp-with-acp.json +++ b/test/integration/config/ldp-with-acp.json @@ -4,20 +4,24 @@ "css:config/app/main/default.json", "css:config/app/init/default.json", "css:config/app/setup/disabled.json", + "css:config/http/handler/simple.json", "css:config/http/middleware/no-websockets.json", "css:config/http/server-factory/no-websockets.json", "css:config/http/static/default.json", "css:config/identity/access/public.json", + "css:config/identity/handler/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", + "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/acp.json", "css:config/ldp/handler/default.json", "css:config/ldp/metadata-parser/default.json", "css:config/ldp/metadata-writer/default.json", "css:config/ldp/modes/default.json", + "css:config/storage/key-value/memory.json", "css:config/storage/middleware/default.json", "css:config/util/auxiliary/acr.json", @@ -30,7 +34,7 @@ ], "@graph": [ { - "comment": "An HTTP server with only the LDP handler as HttpHandler and an unsecure authenticator.", + "comment": "An HTTP server with only the LDP handler as HttpHandler and an unsecure authenticator using ACP.", "@id": "urn:solid-server:test:Instances", "@type": "RecordObject", "record": [ diff --git a/test/unit/server/middleware/AcpHeaderHandler.test.ts b/test/unit/server/middleware/AcpHeaderHandler.test.ts new file mode 100644 index 0000000000..8b4262fadd --- /dev/null +++ b/test/unit/server/middleware/AcpHeaderHandler.test.ts @@ -0,0 +1,47 @@ +import { ACP } from '@solid/access-control-policy/dist/constant/acp'; +import { createResponse } from 'node-mocks-http'; +import type { AuxiliaryIdentifierStrategy } from '../../../../src/http/auxiliary/AuxiliaryIdentifierStrategy'; +import type { TargetExtractor } from '../../../../src/http/input/identifier/TargetExtractor'; +import type { HttpRequest } from '../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../src/server/HttpResponse'; +import { AcpHeaderHandler } from '../../../../src/server/middleware/AcpHeaderHandler'; +import { ACL } from '../../../../src/util/Vocabularies'; +import { SimpleSuffixStrategy } from '../../../util/SimpleSuffixStrategy'; + +describe('an AcpHeaderHandler', (): void => { + const request: HttpRequest = {} as any; + let response: HttpResponse; + const modes = [ ACL.Read, ACL.Write ]; + const attributes = [ ACP.agent, ACP.client ]; + let targetExtractor: jest.Mocked; + let strategy: AuxiliaryIdentifierStrategy; + let handler: AcpHeaderHandler; + + beforeEach(async(): Promise => { + response = createResponse() as HttpResponse; + targetExtractor = { + handleSafe: jest.fn().mockResolvedValue({ path: 'http://example.org/foo/bar' }), + } as any; + + strategy = new SimpleSuffixStrategy('.acr'); + + handler = new AcpHeaderHandler(targetExtractor, strategy, modes, attributes); + }); + + it('adds no headers if the target is not an ACR.', async(): Promise => { + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({}); + }); + + it('adds all the required headers.', async(): Promise => { + targetExtractor.handleSafe.mockResolvedValueOnce({ path: 'http://example.org/foo/bar.acr' }); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({ link: [ + '; rel="type"', + '; rel="http://www.w3.org/ns/solid/acp#grant"', + '; rel="http://www.w3.org/ns/solid/acp#grant"', + '; rel="http://www.w3.org/ns/solid/acp#attribute"', + '; rel="http://www.w3.org/ns/solid/acp#attribute"', + ]}); + }); +});