Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/http/handler/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
8 changes: 8 additions & 0 deletions config/identity/handler/base/jwks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
46 changes: 46 additions & 0 deletions src/server/middleware/JwksHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>([ '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<void> {
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}`);
}
Comment on lines +19 to +30
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a RouterHandler class that we generally use to cover these cases, that can wrap around this one. If you looked at the StaticAssetHandler for inspiration, that one doesn't have it because it was made before we had that and we never bothered to update.

}

public async handle({ request, response }: HttpHandlerInput): Promise<void> {
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' }) ]}));
}
}
66 changes: 66 additions & 0 deletions test/unit/server/middleware/JwksHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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<JwkGenerator>({
alg: key.alg,
getPrivateKey: jest.fn(),
getPublicKey: jest.fn<ReturnType<JwkGenerator['getPublicKey']>, any[]>().mockResolvedValue(key),
});

let handler: JwksHandler;
let response: MockResponse<Response & HttpResponse>;

beforeEach((): void => {
handler = new JwksHandler(path, generator);
response = createResponse<Response & HttpResponse>();
jest.clearAllMocks();
});

it('does not handle requests with methods other than GET or HEAD.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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 ]) }));
});
});