diff --git a/config/http/handler/default.json b/config/http/handler/default.json index 565bbe5fcf..d8b500b6dd 100644 --- a/config/http/handler/default.json +++ b/config/http/handler/default.json @@ -14,6 +14,7 @@ "@id": "urn:solid-server:default:BaseHttpHandler", "@type": "WaterfallHandler", "handlers": [ + { "@id": "urn:solid-server:default:JwksHandler" }, { "@id": "urn:solid-server:default:StaticAssetHandler" }, { "@id": "urn:solid-server:default:OidcHandler" }, { "@id": "urn:solid-server:default:NotificationHttpHandler" }, diff --git a/config/identity/handler/base/jwks.json b/config/identity/handler/base/jwks.json index ed6be4b6a5..b7ce5506cc 100644 --- a/config/identity/handler/base/jwks.json +++ b/config/identity/handler/base/jwks.json @@ -14,6 +14,14 @@ "@type": "ContainerPathStorage", "relativePath": "/idp/keys/", "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + }, + { + "@id": "urn:solid-server:default:JwksHandler", + "@type": "JwksHandler", + "path": "/.well-known/jwks.json", + "generator": { + "@id": "urn:solid-server:default:JwkGenerator" + } } ] } diff --git a/src/index.ts b/src/index.ts index a57c18c4cb..41c0bd6447 100644 --- a/src/index.ts +++ b/src/index.ts @@ -374,6 +374,7 @@ export * from './server/description/StorageLocationStrategy'; export * from './server/middleware/AcpHeaderHandler'; export * from './server/middleware/CorsHandler'; export * from './server/middleware/HeaderHandler'; +export * from './server/middleware/JwksHandler'; export * from './server/middleware/StaticAssetHandler'; export * from './server/middleware/WebSocketAdvertiser'; diff --git a/src/server/middleware/JwksHandler.ts b/src/server/middleware/JwksHandler.ts new file mode 100644 index 0000000000..e9c9051283 --- /dev/null +++ b/src/server/middleware/JwksHandler.ts @@ -0,0 +1,46 @@ +import { REQUEST_METHOD } from '@solid/access-token-verifier/dist/constant/REQUEST_METHOD'; +import { HttpHandler, type HttpHandlerInput } from '../HttpHandler'; +import type { JwkGenerator } from '../../identity/configuration/JwkGenerator'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; + +const allowedMethods = new Set([ 'GET', 'HEAD' ]); +const methodsNotAllowed: string[] = [ ...REQUEST_METHOD ].filter((method): boolean => !allowedMethods.has(method)); + +export class JwksHandler extends HttpHandler { + public constructor( + private readonly path: string, + private readonly generator: JwkGenerator, + ) { + super(); + } + + public async canHandle({ request }: HttpHandlerInput): Promise { + const { method, url } = request; + + if (!allowedMethods.has(method)) { + throw new MethodNotAllowedHttpError( + methodsNotAllowed, + `Only GET or HEAD requests can target the storage description.`, + ); + } + + if (url !== this.path) { + throw new NotImplementedHttpError(`This handler is not configured for ${url}`); + } + } + + public async handle({ request, response }: HttpHandlerInput): Promise { + const key = await this.generator.getPublicKey(); + + // eslint-disable-next-line ts/naming-convention -- HTTP header + response.writeHead(200, { 'content-type': 'application/json' }); + + if (request.method === 'HEAD') { + response.end(); + return; + } + + response.end(JSON.stringify({ keys: [ Object.assign(key, { kid: 'TODO' }) ]})); + } +} diff --git a/test/unit/server/middleware/JwksHandler.test.ts b/test/unit/server/middleware/JwksHandler.test.ts new file mode 100644 index 0000000000..2a9d25e0a9 --- /dev/null +++ b/test/unit/server/middleware/JwksHandler.test.ts @@ -0,0 +1,66 @@ +import { createRequest, createResponse, type MockResponse } from 'node-mocks-http'; +import { REQUEST_METHOD } from '@solid/access-token-verifier/dist/constant/REQUEST_METHOD'; +import type { Response } from 'express'; +import type { HttpResponse } from '../../../../src/server/HttpResponse'; +import { JwksHandler } from '../../../../src/server/middleware/JwksHandler'; +import { guardStream } from '../../../../src/util/GuardedStream'; +import type { AlgJwk, JwkGenerator } from '../../../../src/identity/configuration/JwkGenerator'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; + +describe('a JwksHandler', (): void => { + const key: AlgJwk = { alg: 'ES256' }; + const path = 'http://example.org/.well-known/jwks.json'; + const generator: JwkGenerator = jest.mocked({ + alg: key.alg, + getPrivateKey: jest.fn(), + getPublicKey: jest.fn, any[]>().mockResolvedValue(key), + }); + + let handler: JwksHandler; + let response: MockResponse; + + beforeEach((): void => { + handler = new JwksHandler(path, generator); + response = createResponse(); + jest.clearAllMocks(); + }); + + it('does not handle requests with methods other than GET or HEAD.', async(): Promise => { + for (const method of REQUEST_METHOD) { + if (method === 'GET' || method === 'HEAD') { + continue; + } + + const request = guardStream(createRequest({ method, url: path })); + + await expect(handler.canHandle({ request, response })).rejects.toThrow(MethodNotAllowedHttpError); + } + }); + + it('does not handle requests with other paths than the configured on.', async(): Promise => { + const request = guardStream(createRequest({ url: 'https://example.org/other/path' })); + + await expect(handler.canHandle({ request, response })).rejects.toThrow(NotImplementedHttpError); + }); + + it('handles a HEAD request to the configured path.', async(): Promise => { + const request = guardStream(createRequest({ method: 'HEAD', url: path })); + + await handler.handleSafe({ request, response }); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toEqual(expect.objectContaining({ 'content-type': 'application/json' })); + }); + + it('handles a GET request to the configured path.', async(): Promise => { + const request = guardStream(createRequest({ method: 'GET', url: path })); + + await handler.handleSafe({ request, response }); + + expect(generator.getPublicKey).toHaveBeenCalledTimes(1); + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toEqual(expect.objectContaining({ 'content-type': 'application/json' })); + expect(JSON.parse(response._getData())).toEqual(expect.objectContaining({ keys: expect.arrayContaining([ key ]) })); + }); +});