From f4833d25342a9f9ccb56a04e2601b00a5fd26f8d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 27 Jul 2021 10:53:14 +0200 Subject: [PATCH] feat: Create PathBasedAuthorizer --- src/authorization/PathBasedAuthorizer.ts | 52 +++++++++++++++++++ src/index.ts | 1 + .../authorization/PathBasedAuthorizer.test.ts | 51 ++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 src/authorization/PathBasedAuthorizer.ts create mode 100644 test/unit/authorization/PathBasedAuthorizer.test.ts diff --git a/src/authorization/PathBasedAuthorizer.ts b/src/authorization/PathBasedAuthorizer.ts new file mode 100644 index 0000000000..39fc697a52 --- /dev/null +++ b/src/authorization/PathBasedAuthorizer.ts @@ -0,0 +1,52 @@ +import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import { ensureTrailingSlash, trimTrailingSlashes } from '../util/PathUtil'; +import type { Authorization } from './Authorization'; +import type { AuthorizerArgs } from './Authorizer'; +import { Authorizer } from './Authorizer'; + +/** + * Redirects requests to specific authorizers based on their identifier. + * The keys in the input map will be converted to regular expressions. + * The regular expressions should all start with a slash + * and will be evaluated relative to the base URL. + * + * Will error if no match is found. + */ +export class PathBasedAuthorizer extends Authorizer { + private readonly baseUrl: string; + private readonly paths: Map; + + public constructor(baseUrl: string, paths: Record) { + super(); + this.baseUrl = ensureTrailingSlash(baseUrl); + const entries = Object.entries(paths).map(([ key, val ]): [RegExp, Authorizer] => [ new RegExp(key, 'u'), val ]); + this.paths = new Map(entries); + } + + public async canHandle(input: AuthorizerArgs): Promise { + const authorizer = this.findAuthorizer(input.identifier.path); + await authorizer.canHandle(input); + } + + public async handle(input: AuthorizerArgs): Promise { + const authorizer = this.findAuthorizer(input.identifier.path); + return authorizer.handle(input); + } + + /** + * Find the authorizer corresponding to the given path. + * Errors if there is no match. + */ + private findAuthorizer(path: string): Authorizer { + if (path.startsWith(this.baseUrl)) { + // We want to keep the leading slash + const relative = path.slice(trimTrailingSlashes(this.baseUrl).length); + for (const [ regex, authorizer ] of this.paths) { + if (regex.test(relative)) { + return authorizer; + } + } + } + throw new NotImplementedHttpError('No regex matches the given path.'); + } +} diff --git a/src/index.ts b/src/index.ts index c0cd695ad4..7c075528ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export * from './authorization/Authorization'; export * from './authorization/Authorizer'; export * from './authorization/AuxiliaryAuthorizer'; export * from './authorization/DenyAllAuthorizer'; +export * from './authorization/PathBasedAuthorizer'; export * from './authorization/WebAclAuthorization'; export * from './authorization/WebAclAuthorizer'; diff --git a/test/unit/authorization/PathBasedAuthorizer.test.ts b/test/unit/authorization/PathBasedAuthorizer.test.ts new file mode 100644 index 0000000000..ecc773f290 --- /dev/null +++ b/test/unit/authorization/PathBasedAuthorizer.test.ts @@ -0,0 +1,51 @@ +import type { Authorizer, AuthorizerArgs } from '../../../src/authorization/Authorizer'; +import { PathBasedAuthorizer } from '../../../src/authorization/PathBasedAuthorizer'; +import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; + +describe('A PathBasedAuthorizer', (): void => { + const baseUrl = 'http://test.com/foo/'; + let input: AuthorizerArgs; + let authorizers: jest.Mocked[]; + let authorizer: PathBasedAuthorizer; + + beforeEach(async(): Promise => { + input = { + identifier: { path: `${baseUrl}first` }, + permissions: { read: true, append: false, write: false, control: false }, + credentials: { webId: 'http://alice.test.com/card#me' }, + }; + + authorizers = [ + { canHandle: jest.fn(), handle: jest.fn() }, + { canHandle: jest.fn(), handle: jest.fn() }, + ] as any; + const paths = { + '/first': authorizers[0], + '/second': authorizers[1], + }; + authorizer = new PathBasedAuthorizer(baseUrl, paths); + }); + + it('can only handle requests with a matching path.', async(): Promise => { + input.identifier.path = 'http://wrongsite/'; + await expect(authorizer.canHandle(input)).rejects.toThrow(NotImplementedHttpError); + input.identifier.path = `${baseUrl}third`; + await expect(authorizer.canHandle(input)).rejects.toThrow(NotImplementedHttpError); + input.identifier.path = `${baseUrl}first`; + await expect(authorizer.canHandle(input)).resolves.toBeUndefined(); + input.identifier.path = `${baseUrl}second`; + await expect(authorizer.canHandle(input)).resolves.toBeUndefined(); + }); + + it('can only handle requests supported by the stored authorizers.', async(): Promise => { + await expect(authorizer.canHandle(input)).resolves.toBeUndefined(); + authorizers[0].canHandle.mockRejectedValueOnce(new Error('not supported')); + await expect(authorizer.canHandle(input)).rejects.toThrow('not supported'); + }); + + it('passes the handle requests to the matching authorizer.', async(): Promise => { + await expect(authorizer.handle(input)).resolves.toBeUndefined(); + expect(authorizers[0].handle).toHaveBeenCalledTimes(1); + expect(authorizers[0].handle).toHaveBeenLastCalledWith(input); + }); +});