diff --git a/package.json b/package.json index 72f505a3..f7c8dd8a 100644 --- a/package.json +++ b/package.json @@ -72,10 +72,10 @@ "devDependencies": { "@commitlint/cli": "^16.1.0", "@commitlint/config-conventional": "^16.0.0", - "@types/jest": "^29.5.12", "@types/node": "^20.19.1", "@typescript-eslint/eslint-plugin": "^5.12.1", "@typescript-eslint/parser": "^5.12.1", + "@vitest/coverage-v8": "^3.2.4", "chalk": "^5.4.1", "componentsjs-generator": "^3.1.2", "eslint": "^8.10.0", diff --git a/packages/css/config/uma/overrides/jwks.json b/packages/css/config/uma/overrides/jwks.json index 1221f095..e647fdbe 100644 --- a/packages/css/config/uma/overrides/jwks.json +++ b/packages/css/config/uma/overrides/jwks.json @@ -14,11 +14,18 @@ "overrideParameter": { "@id": "WaterfallHandler:_handlers" }, "overrideTarget": 0, "overrideValue": { - "@id": "urn:solid-server:default:JwksHandler", - "@type": "JwksHandler", - "path": "/.well-known/jwks.json", - "generator": { - "@id": "urn:solid-server:default:JwkGenerator" + "@id": "urn:solid-server:default:JwksRouterHandler", + "@type": "RouterHandler", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "allowedPathNames": [ "^/.well-known/jwks.json" ], + "allowedMethods": [ "HEAD", "GET" ], + "handler": { + "@id": "urn:solid-server:default:JwksHandler", + "@type": "JwksHandler", + "generator": { + "@id": "urn:solid-server:default:JwkGenerator" + } } } }] diff --git a/packages/css/config/uma/overrides/token-extractor.json b/packages/css/config/uma/overrides/token-extractor.json index dd51c3e5..5f30482a 100644 --- a/packages/css/config/uma/overrides/token-extractor.json +++ b/packages/css/config/uma/overrides/token-extractor.json @@ -15,8 +15,7 @@ "@type": "UmaTokenExtractor", "client": { "@id": "urn:solid-server:default:UmaClient" }, "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, - "ownerUtil": { "@id": "urn:solid-server:default:OwnerUtil" }, - "introspect": false + "ownerUtil": { "@id": "urn:solid-server:default:OwnerUtil" } } } ] diff --git a/packages/css/config/uma/parts/owner-util.json b/packages/css/config/uma/parts/owner-util.json index b772de56..56f9fcba 100644 --- a/packages/css/config/uma/parts/owner-util.json +++ b/packages/css/config/uma/parts/owner-util.json @@ -11,16 +11,9 @@ "podStore": { "@id": "urn:solid-server:default:PodStore" }, - "accountStore": { - "@id": "urn:solid-server:default:AccountStore" - }, "storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" }, - "umaPatStore": { - "@id": "urn:solid-server:default:UmaPatStore", - "@type": "MemoryMapStorage" - }, "umaServerURL": { "@id": "urn:solid-server:uma:variable:AuthorizationServer" } diff --git a/packages/css/config/uma/parts/resource-registrar.json b/packages/css/config/uma/parts/resource-registrar.json index b1b3ce30..f7df7de2 100644 --- a/packages/css/config/uma/parts/resource-registrar.json +++ b/packages/css/config/uma/parts/resource-registrar.json @@ -8,7 +8,7 @@ "comment": "Listens to the activities emitted by the MonitoringStore.", "@id": "urn:solid-server:default:ResourceRegistrar", "@type": "ResourceRegistrar", - "store": { + "emitter": { "@id": "urn:solid-server:default:ResourceStore" }, "ownerUtil": { diff --git a/packages/css/package.json b/packages/css/package.json index 20dea497..5a56d977 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -46,9 +46,7 @@ "types": "./dist/index.d.ts", "exports": { "./package.json": "./package.json", - ".": { - "require": "./dist/index.js" - } + ".": "./dist/index.js" }, "files": [ ".componentsignore", diff --git a/packages/css/src/authentication/UmaTokenExtractor.test.ts b/packages/css/src/authentication/UmaTokenExtractor.test.ts deleted file mode 100644 index 70d65431..00000000 --- a/packages/css/src/authentication/UmaTokenExtractor.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { AccessMode, BadRequestHttpError, HttpRequest, NotImplementedHttpError } from '@solid/community-server'; -import { UmaToken } from '../uma/UmaClient'; -import { CredentialGroup } from './Credentials'; -import { UmaTokenExtractor } from './UmaTokenExtractor'; - -const MockUmaClient = { - getAsUrl: jest.fn(), - verifyToken: jest.fn(), - fetchUMAConfig: jest.fn(), - fetchPermissionTicket: jest.fn(), -}; - -const WEB_ID = 'https://example.org/alice'; -const CLIENT = 'https://app.example.org'; -const RESOURCE = 'https://pod.example.org/123'; -const MODES = ['http://www.w3.org/ns/auth/acl#Read']; - -const mockUmaToken: UmaToken = {webid: WEB_ID, azp: CLIENT, - resource: RESOURCE, modes: MODES}; - -describe('A UMATicketExtractor', () => { - const ticketExtractor = new UmaTokenExtractor({umaClient: MockUmaClient}); - afterEach((): void => { - jest.clearAllMocks(); - }); - describe('on a request without Authorization header', () => { - const request = { - method: 'GET', - headers: { }, - } as any as HttpRequest; - it('throws an error', async () =>{ - const result = ticketExtractor.handleSafe(request); - await expect(result).rejects.toThrow(NotImplementedHttpError); - await expect(result).rejects.toThrow('No Bearer Authorization header specified.'); - }); - }); - - describe('on a request without Bearer Authorization header', () => { - const request = { - method: 'GET', - headers: {'authorization': 'Token 123'}, - } as any as HttpRequest; - it('throws an error', async () =>{ - const result = ticketExtractor.handleSafe(request); - await expect(result).rejects.toThrow(NotImplementedHttpError); - await expect(result).rejects.toThrow('No Bearer Authorization header specified.'); - }); - }); - - describe('on a request with Bearer Authorization header', () => { - const request = { - method: 'GET', - headers: {'authorization': 'Bearer 123'}, - } as any as HttpRequest; - - beforeEach(() => { - MockUmaClient.verifyToken.mockResolvedValueOnce(mockUmaToken); - }); - - it('calls the verifier with correct parameters', async () =>{ - await ticketExtractor.handleSafe(request); - expect(MockUmaClient.verifyToken).toHaveBeenCalledTimes(1); - expect(MockUmaClient.verifyToken).toHaveBeenCalledWith('123'); - }); - - it('returns a CredentialSet with the ticket', async () =>{ - const result = ticketExtractor.handleSafe(request); - await expect(result).resolves.toEqual({[CredentialGroup.ticket]: {webId: WEB_ID, - resource: {path: RESOURCE}, - modes: new Set(['read'])}}); - }); - }); - - describe('on a request with Bearer Authorization header and unsupported mode', () => { - const request = { - method: 'GET', - headers: {'authorization': 'Bearer 123'}, - } as any as HttpRequest; - - beforeEach(() => { - MockUmaClient.verifyToken.mockResolvedValueOnce({...mockUmaToken, modes: ['abc']}); - }); - - it('throws an error.', async () =>{ - const result = ticketExtractor.handleSafe(request); - await expect(result).rejects.toThrow(BadRequestHttpError); - await expect(result).rejects.toThrow('Error verifying WebID via Bearer access token: ' + - 'Unknown ACL Mode \'abc\' in token.'); - }); - }); - - describe('on a request with Authorization and a lowercase Bearer token', (): void => { - const request = { - method: 'GET', - headers: { - authorization: 'bearer 123', - }, - } as any as HttpRequest; - beforeEach(() => { - MockUmaClient.verifyToken.mockResolvedValueOnce(mockUmaToken); - }); - - - it('calls the verifier with correct parameters', async () =>{ - await ticketExtractor.handleSafe(request); - expect(MockUmaClient.verifyToken).toHaveBeenCalledTimes(1); - expect(MockUmaClient.verifyToken).toHaveBeenCalledWith('123'); - }); - }); - - describe('when verification throws an error', (): void => { - const request = { - method: 'GET', - headers: { - authorization: 'Bearer token-1234', - }, - } as any as HttpRequest; - - beforeEach((): void => { - MockUmaClient.verifyToken.mockImplementationOnce((): void => { - throw new Error('invalid'); - }); - }); - - it('throws an error.', async (): Promise => { - const result = ticketExtractor.handleSafe(request); - await expect(result).rejects.toThrow(BadRequestHttpError); - await expect(result).rejects.toThrow('Error verifying WebID via Bearer access token: invalid'); - }); - }); -}); diff --git a/packages/css/src/authentication/UmaTokenExtractor.ts b/packages/css/src/authentication/UmaTokenExtractor.ts index d9ba9205..072b3af9 100644 --- a/packages/css/src/authentication/UmaTokenExtractor.ts +++ b/packages/css/src/authentication/UmaTokenExtractor.ts @@ -1,5 +1,7 @@ -import { CredentialsExtractor, getLoggerFor, HttpRequest, - NotImplementedHttpError, BadRequestHttpError, Credentials, TargetExtractor } from '@solid/community-server'; +import { + CredentialsExtractor, getLoggerFor, HttpRequest, + NotImplementedHttpError, BadRequestHttpError, Credentials, TargetExtractor, createErrorMessage +} from '@solid/community-server'; import { UmaClaims, UmaClient } from '../uma/UmaClient'; import { OwnerUtil } from '../util/OwnerUtil'; @@ -13,10 +15,13 @@ export class UmaTokenExtractor extends CredentialsExtractor { /** * Credentials extractor which interprets the contents of the Bearer authorization token as a UMA Access Token. - * @param {UMATokenExtractorArgs} args - properties + * @param client - {@link UmaClient} to verify tokens. + * @param targetExtractor - {@link TargetExtractor} to extract identifier from request. + * @param ownerUtil - {@link OwnerUtil} to find owners and issuers. + * @param introspect - If introspection should be used on incoming tokens. */ public constructor( - private client: UmaClient, + private client: UmaClient, private targetExtractor: TargetExtractor, private ownerUtil: OwnerUtil, private introspect: boolean = false, @@ -34,26 +39,26 @@ export class UmaTokenExtractor extends CredentialsExtractor { } } - /** - * ... - */ public async handle(request: HttpRequest): Promise { this.logger.info('Extracting token from ' + request.headers.authorization); - + const token = request.headers.authorization?.replace(/^Bearer/, '')?.trimStart(); if (!token) throw new BadRequestHttpError('Found empty Bearer token.'); - + try { - const target = await this.targetExtractor.handle({ request }); + const target = await this.targetExtractor.handleSafe({ request }); const owners = await this.ownerUtil.findOwners(target); const issuers = await Promise.all(owners.map(o => this.ownerUtil.findIssuer(o))) const validIssuers = issuers.filter((i): i is string => i !== undefined); - + if (this.introspect) { this.logger.debug('Performing token introspection.'); - const results = await Promise.allSettled(owners.map(owner => this.tryIntrospection(token, owner))); + const results = await Promise.allSettled( + validIssuers.map(issuer => this.client.verifyOpaqueToken(token, issuer))); const succeeded = results.filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled'); - if (succeeded.length === 0) throw new Error (); + if (succeeded.length === 0) + throw new BadRequestHttpError(`Introspection failed: ${ + results.map((r): string => createErrorMessage((r as PromiseRejectedResult).reason)).join(',')}`); return { uma: { rpt: succeeded[0].value }}; } else { this.logger.debug('Verifying JWT.'); @@ -62,16 +67,9 @@ export class UmaTokenExtractor extends CredentialsExtractor { return { uma: { rpt } }; } } catch (error: unknown) { - const msg = `Error verifying WebID via Bearer access token: ${(error as Error).message}`; + const msg = `Error verifying WebID via Bearer access token: ${createErrorMessage(error)}`; this.logger.warn(msg); throw new BadRequestHttpError(msg, {cause: error}); } } - - private async tryIntrospection(token: string, owner: string): Promise { - const issuer = await this.ownerUtil.findIssuer(owner); - if (!issuer) return Promise.reject(); - return this.client.verifyOpaqueToken(token, issuer) - } - } diff --git a/packages/css/src/authorization/UmaAuthorizer.ts b/packages/css/src/authorization/UmaAuthorizer.ts index d931f215..89856be3 100644 --- a/packages/css/src/authorization/UmaAuthorizer.ts +++ b/packages/css/src/authorization/UmaAuthorizer.ts @@ -12,10 +12,10 @@ export const WWW_AUTH = namedNode('urn:css:http:headers:www-authenticate'); /** * Authorizer that bases its decision on that of another Authorizer. - * It discovers the relevant UMA Authorization Server for the requeste resource(s), + * It discovers the relevant UMA Authorization Server for the requested resource(s), * and checks whether that server protects the resource or whether it is public. * If the resource is not public and access is not granted by the other Authorizer, - * the UmaAuthorizer includes a UMA Permission Ticket in the resulting error. + * the UmaAuthorizer includes an UMA Permission Ticket in the resulting error. */ export class UmaAuthorizer extends Authorizer { protected readonly logger = getLoggerFor(this); @@ -23,7 +23,7 @@ export class UmaAuthorizer extends Authorizer { /** * The UmaAuthorizer bases its decisions on those of another {@link Authorizer}. * It uses an {@link OwnerUtil} to retrieve the relevant UMA issuer, - * and an {@link UmaCLient} to communicate with that issuer. + * and an {@link UmaClient} to communicate with that issuer. * @param authorizer - {@link Authorizer} that makes the main decision. * @param ownerUtil - {@link OwnerUtil} that links resources to owners and issuers. * @param umaClient - {@link UmaClient} that communicates with UMA issuers. diff --git a/packages/css/src/authorization/UmaPermissionReader.test.ts b/packages/css/src/authorization/UmaPermissionReader.test.ts deleted file mode 100644 index 629a8c03..00000000 --- a/packages/css/src/authorization/UmaPermissionReader.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AccessMode, IdentifierSetMultiMap } from '@solid/community-server'; -import { UmaPermissionReader } from './UmaPermissionReader'; - -describe('A UmaPermissionReader', () => { - const permissionReader = new UmaPermissionReader(); - - const resource = { path: 'https://example.org/123' }; - const resourceAlt = { path: 'https://example.org/456' }; - const webId = 'https://example.org/alice'; - const modes = new Set(['read', 'write', 'append', 'create', 'delete']); - - - test('should return permissions for UMA token if resources match', async () => { - const permissionSet = await permissionReader.handle({ - requestedModes: new IdentifierSetMultiMap().set(resource, new Set()), - credentials: { ticket: { resource, webId, modes } }, - }); - expect(permissionSet.get(resource)).toBeTruthy(); - expect(permissionSet.get(resource)?.read).toBeTruthy(); - expect(permissionSet.get(resource)?.append).toBeTruthy(); - expect(permissionSet.get(resource)?.create).toBeTruthy(); - expect(permissionSet.get(resource)?.delete).toBeTruthy(); - expect(permissionSet.get(resource)?.write).toBeTruthy(); - }); - - test('should return no permissions for UMA token if resources match but permissions are empty', async () => { - const permissionSet = await permissionReader.handle({ - requestedModes: new IdentifierSetMultiMap().set(resource, new Set()), - credentials: { ticket: { resource, webId, modes: new Set() } }, - }); - expect(permissionSet.get(resource)).toBeTruthy(); - expect(permissionSet.get(resource)?.read).toBeFalsy(); - expect(permissionSet.get(resource)?.append).toBeFalsy(); - expect(permissionSet.get(resource)?.create).toBeFalsy(); - expect(permissionSet.get(resource)?.delete).toBeFalsy(); - expect(permissionSet.get(resource)?.write).toBeFalsy(); - }); - - test('should not return permissions for UMA token if resources mismatch', async () => { - const permissionSet = await permissionReader.handle({ - requestedModes: new IdentifierSetMultiMap().set(resource, new Set()), - credentials: { ticket: { resource, webId, modes } }, - }); - expect(permissionSet.get(resource)).toBeUndefined(); - }); -}); diff --git a/packages/css/src/authorization/UmaPermissionReader.ts b/packages/css/src/authorization/UmaPermissionReader.ts index 93b3ea69..e3c7d117 100644 --- a/packages/css/src/authorization/UmaPermissionReader.ts +++ b/packages/css/src/authorization/UmaPermissionReader.ts @@ -1,4 +1,4 @@ -import { getLoggerFor, PermissionReader, PermissionReaderInput, +import { getLoggerFor, PermissionReader, PermissionReaderInput, PermissionMap, PermissionSet, IdentifierMap } from '@solid/community-server'; import { UmaClaims } from '../uma/UmaClient'; @@ -20,25 +20,25 @@ export class UmaPermissionReader extends PermissionReader { const { permissions, iat: t_iat, exp: t_exp, nbf: t_nbf } = rpt; this.logger.info(`Reading UMA permissions at ${now}`); - + try { if (t_iat && t_iat >= now) throw new Error(`Token seems to be issued in the future at ${t_iat}.`); if (t_exp && t_exp <= now) throw new Error(`Token is expired since ${t_exp}.`); if (t_nbf && t_nbf > now) throw new Error(`Token is not valid before ${t_nbf}.`); - } catch (error) { + } catch (error) { this.logger.warn(`Invalid UMA token: ${error instanceof Error ? error.message : ''}`); return result; } - + for (const { resource_id, resource_scopes, iat: p_iat, exp: p_exp, nbf: p_nbf } of permissions ?? []) { const permissionSet = Object.fromEntries(resource_scopes.map(scope => { try { if (p_iat && p_iat >= now) throw new Error(`UMA permission seems to be issued in the future at ${p_iat}.`); if (p_exp && p_exp <= now) throw new Error(`UMA permission is expired since ${p_exp}.`); if (p_nbf && p_nbf > now) throw new Error(`UMA permission is not valid before ${p_nbf}.`); - } catch (error) { + } catch (error) { this.logger.warn(`Invalid UMA permission: ${error instanceof Error ? error.message : ''}`); - return [scope, false]; + return [scope.replace('urn:example:css:modes:', ''), false]; } return [scope.replace('urn:example:css:modes:', ''), true]; })); diff --git a/packages/css/src/http/output/metadata/UmaTicketMetadataWriter.test.ts b/packages/css/src/http/output/metadata/UmaTicketMetadataWriter.test.ts deleted file mode 100644 index 3fa58d5b..00000000 --- a/packages/css/src/http/output/metadata/UmaTicketMetadataWriter.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { UmaTicketMetadataWriter } from './UmaTicketMetadataWriter'; -import { createResponse } from 'node-mocks-http'; -import { HTTP, HttpResponse, RepresentationMetadata, toObjectTerm } from '@solid/community-server'; -import { AUTH, ACL } from '../../../util/Vocabularies'; - -const WEBID = 'http://example.org/123/profile'; -const POD = 'http://example.org/456'; -const mockExtendedAccountStore = { - getWebIdSettings: jest.fn().mockImplementation(async () => new Map([ - [WEBID, {useIdp: true, clientCredentials: [], podBaseUrl: POD}], - ])), -} as any; - -const mockUmaClient = { - getAsUrl: jest.fn().mockImplementation(() => 'https://as.example.org'), - verifyToken: jest.fn(), - fetchUMAConfig: jest.fn(), - fetchPermissionTicket: jest.fn(), -}; - -describe('A UmaTicketMetadataWriter', () => { - const writer = new UmaTicketMetadataWriter({accountStore: mockExtendedAccountStore, - umaClient: mockUmaClient}); - let response: HttpResponse; - - beforeEach(() => { - response = createResponse(); - mockUmaClient.fetchPermissionTicket.mockResolvedValueOnce('def'); - }); - - afterEach(() => { - mockUmaClient.fetchPermissionTicket.mockReset(); - }); - - it('adds no header if there is no relevant metadata.', async (): Promise => { - const metadata = new RepresentationMetadata(); - await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); - expect(response.getHeaders()).toEqual({ }); - }); - - - it('adds no header if the status code is not 401.', async (): Promise => { - const metadata = new RepresentationMetadata({[HTTP.statusCodeNumber]: '403'}); - await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); - expect(response.getHeaders()).toEqual({ }); - }); - - - it('adds a WWW-Authenticate header if the status code is 401.', async (): Promise => { - const metadata = new RepresentationMetadata({[HTTP.statusCodeNumber]: '401', - [AUTH.ticketNeeds]: [ACL.terms.Read], [AUTH.ticketSubject]: toObjectTerm(`${POD}/def`)}); - await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); - - expect(mockUmaClient.fetchPermissionTicket).toHaveBeenCalledTimes(1); - expect(mockUmaClient.fetchPermissionTicket).toHaveBeenCalledWith({ticketSubject: 'http://example.org/456/def', owner: 'http://example.org/123/profile', ticketNeeds: new Set(['http://www.w3.org/ns/auth/acl#Read'])}); - expect(response.getHeaders()).toEqual({ - 'www-authenticate': 'UMA realm=\"solid\",as_uri=\"https://as.example.org\",ticket=\"def\"', - }); - }); - - it('adds no header if the status code is 401 and the UMA ticket fetching errors.', async (): Promise => { - const metadata = new RepresentationMetadata({[HTTP.statusCodeNumber]: '401', - [AUTH.ticketNeeds]: [ACL.terms.Read], [AUTH.ticketSubject]: toObjectTerm(`${POD}/def`)}); - mockUmaClient.fetchPermissionTicket.mockReset(); - mockUmaClient.fetchPermissionTicket.mockRejectedValueOnce(new Error('invalid')); - await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); - - expect(mockUmaClient.fetchPermissionTicket).toHaveBeenCalledTimes(1); - expect(mockUmaClient.fetchPermissionTicket).toHaveBeenCalledWith({ticketSubject: 'http://example.org/456/def', owner: 'http://example.org/123/profile', ticketNeeds: new Set(['http://www.w3.org/ns/auth/acl#Read'])}); - expect(response.getHeaders()).toEqual({}); - }); - - it('adds no headers if the status code is 401 and the owner is found.', async (): Promise => { - const metadata = new RepresentationMetadata({[HTTP.statusCodeNumber]: '401', - [AUTH.ticketNeeds]: [ACL.terms.Read], [AUTH.ticketSubject]: toObjectTerm(`https://example.org/def`)}); - await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); - - expect(response.getHeaders()).toEqual({}); - }); -}); diff --git a/packages/css/src/server/middleware/JwksHandler.ts b/packages/css/src/server/middleware/JwksHandler.ts index 7646b4d6..9d1f5dce 100644 --- a/packages/css/src/server/middleware/JwksHandler.ts +++ b/packages/css/src/server/middleware/JwksHandler.ts @@ -1,29 +1,14 @@ import type { HttpHandlerInput } from '@solid/community-server'; -import { HttpHandler, JwkGenerator, MethodNotAllowedHttpError, NotImplementedHttpError } from '@solid/community-server'; +import { HttpHandler, JwkGenerator } from '@solid/community-server'; export class JwksHandler extends HttpHandler { constructor( - private path: string, private generator: JwkGenerator, ) { super(); } - public async canHandle({ request }: HttpHandlerInput): Promise { - const { method, url } = request; - - if (!['GET', 'HEAD'].includes(method ?? '')) { - throw new MethodNotAllowedHttpError( - method ? [ method ] : undefined, - `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(); diff --git a/packages/css/src/uma/ResourceRegistrar.ts b/packages/css/src/uma/ResourceRegistrar.ts index 321d46bd..76f84b33 100644 --- a/packages/css/src/uma/ResourceRegistrar.ts +++ b/packages/css/src/uma/ResourceRegistrar.ts @@ -1,7 +1,13 @@ -import type { UmaClient } from './UmaClient'; -import { ResourceIdentifier, MonitoringStore, createErrorMessage } from '@solid/community-server'; -import { AS, getLoggerFor, StaticHandler } from '@solid/community-server'; +import { + ActivityEmitter, + AS, + createErrorMessage, + getLoggerFor, + ResourceIdentifier, + StaticHandler +} from '@solid/community-server'; import { OwnerUtil } from '../util/OwnerUtil'; +import type { UmaClient } from './UmaClient'; /** * Updates the UMA resource registrations when resources are added/removed. @@ -10,13 +16,13 @@ export class ResourceRegistrar extends StaticHandler { protected readonly logger = getLoggerFor(this); public constructor( - protected store: MonitoringStore, + protected emitter: ActivityEmitter, protected ownerUtil: OwnerUtil, protected umaClient: UmaClient, ) { super(); - store.on(AS.Create, async (resource: ResourceIdentifier): Promise => { + emitter.on(AS.Create, async (resource: ResourceIdentifier): Promise => { for (const owner of await this.findOwners(resource)) { this.umaClient.registerResource(resource, await this.findIssuer(owner)).catch((err: Error) => { this.logger.error(`Unable to register resource ${resource.path}: ${createErrorMessage(err)}`); @@ -24,7 +30,7 @@ export class ResourceRegistrar extends StaticHandler { } }); - store.on(AS.Delete, async (resource: ResourceIdentifier): Promise => { + emitter.on(AS.Delete, async (resource: ResourceIdentifier): Promise => { for (const owner of await this.findOwners(resource)) { this.umaClient.deleteResource(resource, await this.findIssuer(owner)).catch((err: Error) => { this.logger.error(`Unable to remove resource registration ${resource.path}: ${createErrorMessage(err)}`); diff --git a/packages/css/src/uma/UmaClient.test.ts b/packages/css/src/uma/UmaClient.test.ts deleted file mode 100644 index 4bc01d58..00000000 --- a/packages/css/src/uma/UmaClient.test.ts +++ /dev/null @@ -1,419 +0,0 @@ -/* eslint-disable require-jsdoc */ -import {UmaClient} from './UmaClient'; -import {fetchUMAConfig} from './util/UmaConfigFetcher'; -import {fetchPermissionTicket} from './util/PermissionTicketFetcher'; -import {verifyUMAToken} from './util/UmaTokenVerifier'; - - -jest.mock('./util/UmaConfigFetcher'); -jest.mock('./util/PermissionTicketFetcher'); -jest.mock('./util/UmaTokenVerifier'); - -const MOCK_AS_URL = 'https://as.example.org'; -const BASE_URL = 'https://pods.example.org'; - -const MOCK_UMA_TOKEN = {webid: 'https://id.example.org/test/123', - azp: 'https://app.example.org/', - resource: 'https://pod.example.org/test/123', - modes: ['http://www.w3.org/ns/auth/acl#Read'], -}; - -const MOCK_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgDr/w3aMO+Vib5zI6 - kRiJR3dD65qfE3X49PpSwR1efl+hRANCAATm5Yfzq2SK1tEFKwCWV6qIfgReMioJ - oJJP7CSASenY6GuRl1ovbE2AgB1kmjFDu6LKT0ATxEZpBdaZW453br4L - -----END PRIVATE KEY----- - `; -const MOCK_CONFIG = { - issuer: MOCK_AS_URL, - jwks_uri: `${MOCK_AS_URL}/jwks`, - permission_registration_endpoint: `${MOCK_AS_URL}/register`, -}; - -describe('A UmaClientImpl', () => { - let umaClient: UmaClient; - - beforeEach(() => { - umaClient = new UmaClientImpl({ - asUrl: MOCK_AS_URL, - baseUrl: BASE_URL, - maxTokenAge: 600, - credentials: { - ecAlgorithm: 'ES256', - ecPrivateKey: MOCK_PRIVATE_KEY, - }, - }); - (fetchUMAConfig as unknown as jest.Mock).mockImplementation(async () => MOCK_CONFIG); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should return the authorization service URL', () => { - expect(umaClient.getAsUrl()).toBe(MOCK_AS_URL); - }); - - it('should return UMA Configuration for AS', async () => { - expect(await umaClient.fetchUMAConfig()).toEqual(MOCK_CONFIG); - }); - - describe('when verifying a UMA Token', () => { - it('should resolve the UMA token if valid', async () => { - (verifyUMAToken as unknown as jest.Mock).mockImplementation(async () => MOCK_UMA_TOKEN); - expect(await umaClient.verifyToken('abc')).toEqual(MOCK_UMA_TOKEN); - expect(verifyUMAToken).toHaveBeenCalled(); - expect(verifyUMAToken).toHaveBeenCalledWith('abc', MOCK_CONFIG, {baseUrl: BASE_URL, maxTokenAge: 600}); - }); - it('should rethrow error if invalid', async () => { - (verifyUMAToken as unknown as jest.Mock).mockImplementation(async () => { - throw new Error('invalid'); - }); - await expect(async () => await umaClient.verifyToken('abc')).rejects - .toThrowError('Error verifying UMA access token: invalid'); - expect(verifyUMAToken).toHaveBeenCalled(); - expect(verifyUMAToken).toHaveBeenCalledWith('abc', MOCK_CONFIG, {baseUrl: BASE_URL, maxTokenAge: 600}); - }); - }); - - describe('when fetching a permission ticket', () => { - it('should yield a valid ticket when the request was successful', async () =>{ - (fetchPermissionTicket as unknown as jest.Mock).mockImplementation(async () => 'abc'); - expect(await umaClient.fetchPermissionTicket({ticketSubject: MOCK_UMA_TOKEN.resource, - owner: MOCK_UMA_TOKEN.webid, - ticketNeeds: new Set(MOCK_UMA_TOKEN.modes)})).toEqual('abc'); - expect(fetchPermissionTicket).toHaveBeenCalled(); - }); - it('should return undefined when an error has occurred', async () =>{ - (fetchPermissionTicket as unknown as jest.Mock).mockImplementation(async () => { - throw new Error('invalid'); - }); - expect(await umaClient.fetchPermissionTicket({ticketSubject: MOCK_UMA_TOKEN.resource, - owner: MOCK_UMA_TOKEN.webid, - ticketNeeds: new Set(MOCK_UMA_TOKEN.modes)})).toBeUndefined(); - expect(fetchPermissionTicket).toHaveBeenCalled(); - }); - }); -}); - - -/* eslint-disable require-jsdoc */ -import fetch from 'node-fetch'; -import {fetchPermissionTicket} from './PermissionTicketFetcher'; - -jest.mock('node-fetch', () => jest.fn()); - -const MOCK_AS_URL = 'https://as.example.org'; -const MOCK_REQUEST = { - owner: 'https://example.org/profiles/123', - ticketNeeds: new Set(['http://www.w3.org/ns/auth/acl#Read']), - ticketSubject: 'https://example.org/pods/123', -}; -const MOCK_OPTIONS = { - permission_registration_endpoint: `${MOCK_AS_URL}/register`, - bearer: 'def', -}; -const MOCK_TICKET_RESPONSE = { - ticket: 'abc', -}; - -describe('A PermissionTicketFetcher', () => { - beforeAll(() => { - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('when a permission registration is performed', () => { - it('and request is valid, should return ticket', async () => { - (fetch as unknown as jest.Mock).mockImplementation(async () => { - return { - ok: true, - status: 200, - json: async () => { - return MOCK_TICKET_RESPONSE; - }, - }; - }, - ); - - expect(await fetchPermissionTicket(MOCK_REQUEST, MOCK_OPTIONS)).toEqual('abc'); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${MOCK_AS_URL}/register`, { - method: 'POST', - headers: { - 'Authorization': `Bearer def`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - owner: MOCK_REQUEST.owner, - resource_set_id: MOCK_REQUEST.ticketSubject, - scopes: [...MOCK_REQUEST.ticketNeeds], - }), - }); - }); - it('and request fails, should throw error', async () => { - (fetch as unknown as jest.Mock).mockImplementation(async () => { - return { - ok: true, - status: 500, - }; - }, - ); - - expect(async () => await fetchPermissionTicket(MOCK_REQUEST, MOCK_OPTIONS)).rejects - .toThrowError('Error while retrieving UMA Ticket: Received status 500 from \'https://as.example.org/register\'.'); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${MOCK_AS_URL}/register`, { - method: 'POST', - headers: { - 'Authorization': `Bearer def`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - owner: MOCK_REQUEST.owner, - resource_set_id: MOCK_REQUEST.ticketSubject, - scopes: [...MOCK_REQUEST.ticketNeeds], - }), - }); - }); - it('and responise is invalid, should throw error', async () => { - (fetch as unknown as jest.Mock).mockImplementation(async () => { - return { - ok: true, - status: 200, - json: async () => { - return {}; - }, - }; - }, - ); - - expect(async () => await fetchPermissionTicket(MOCK_REQUEST, MOCK_OPTIONS)).rejects - .toThrowError('Invalid response from UMA AS: missing or invalid \'ticket\'.'); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${MOCK_AS_URL}/register`, { - method: 'POST', - headers: { - 'Authorization': `Bearer def`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - owner: MOCK_REQUEST.owner, - resource_set_id: MOCK_REQUEST.ticketSubject, - scopes: [...MOCK_REQUEST.ticketNeeds], - }), - }); - }); - }); -}); - - -/* eslint-disable require-jsdoc */ -import fetch from 'node-fetch'; -import {fetchUMAConfig} from './UmaConfigFetcher'; - -jest.mock('jose', () => { - return { - createRemoteJWKSet: jest.fn(), - }; -}); - -jest.mock('node-fetch', () => jest.fn()); - -const MOCK_AS_URL = 'https://as.example.org'; -const MOCK_CONFIG = { - issuer: MOCK_AS_URL, - jwks_uri: `${MOCK_AS_URL}/jwks`, - jwks: undefined, - permission_registration_endpoint: `${MOCK_AS_URL}/register`, -}; - -describe('A UmaConfigFetcher', () => { - beforeAll(() => { - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('when the UMA configuration is requested', () => { - it('and configuration is valid, should return configuration', async () => { - (fetch as unknown as jest.Mock).mockImplementation(async () => { - return { - ok: true, - status: 200, - json: async () => { - return MOCK_CONFIG; - }, - }; - }, - ); - - expect(await fetchUMAConfig(MOCK_AS_URL)).toEqual(MOCK_CONFIG); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${MOCK_AS_URL}/.well-known/uma2-configuration`, undefined); - }); - - it('and configuration is unavailable, should throw error', async () => { - (fetch as unknown as jest.Mock).mockImplementation(async () => { - return { - ok: true, - status: 400, - }; - }, - ); - - expect(async () => await fetchUMAConfig(MOCK_AS_URL)).rejects.toThrowError('Unable to retrieve UMA Configuration for Authorization Server \'https://as.example.org\''); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${MOCK_AS_URL}/.well-known/uma2-configuration`, undefined); - }); - - it('and configuration is empty, should throw error', async () => { - (fetch as unknown as jest.Mock).mockImplementation(async () => { - return { - ok: true, - status: 200, - json: async () => { - return { - }; - }, - }; - }, - ); - - expect(async () => await fetchUMAConfig(MOCK_AS_URL)).rejects.toThrowError('The UMA Configuration for Authorization Server \'https://as.example.org\' is missing required attributes "issuer", "jwks_uri", "permission_registration_endpoint"'); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${MOCK_AS_URL}/.well-known/uma2-configuration`, undefined); - }); - it('and configuration is invalid, should throw error', async () => { - (fetch as unknown as jest.Mock).mockImplementation(async () => { - return { - ok: true, - status: 200, - json: async () => { - return { - issuer: 12345, - jwks_uri: {}, - permission_registration_endpoint: {}, - }; - }, - }; - }, - ); - - expect(async () => await fetchUMAConfig(MOCK_AS_URL)).rejects.toThrowError('The UMA Configuration for Authorization Server \'https://as.example.org\' should have string attributes \"issuer\", \"jwks_uri\", \"permission_registration_endpoint\"'); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${MOCK_AS_URL}/.well-known/uma2-configuration`, undefined); - }); - }); -}); - -/* eslint-disable max-len */ -/* eslint-disable require-jsdoc */ -import * as jose from 'jose'; -import {verifyUMAToken} from './UmaTokenVerifier'; - -jest.mock('jose', () => { - return { - createRemoteJWKSet: jest.fn(), - jwtVerify: jest.fn(), - }; -}); - -const MOCK_RESOURCE = 'https://pod.example.org/test/123'; -const MOCK_AUD = 'solid'; -const MOCK_WEBID = 'https://id.example.org/test/123'; -const MOCK_CLIENT = 'https://app.example.org/'; -const MOCK_MODES = ['http://www.w3.org/ns/auth/acl#Read']; - -const MOCK_AS_URL = 'https://as.example.org'; -const MOCK_CONFIG = { - issuer: MOCK_AS_URL, - jwks_uri: `${MOCK_AS_URL}/jwks`, - jwks: undefined, - permission_registration_endpoint: `${MOCK_AS_URL}/register`, -}; - -describe('A UmaTokenVerifier', () => { - beforeAll(() => { - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('when token validation is requested', () => { - it('and token is valid, should return parsed token', async () => { - (jose.jwtVerify as unknown as jest.Mock).mockImplementation(async () => { - return {payload: { - sub: MOCK_RESOURCE, - webid: MOCK_WEBID, - azp: MOCK_CLIENT, - modes: MOCK_MODES, - }, - }; - }, - ); - - expect(await verifyUMAToken('abc', MOCK_CONFIG, {baseUrl: MOCK_AUD, maxTokenAge: 600})).toEqual({ - webid: MOCK_WEBID, - azp: MOCK_CLIENT, - resource: MOCK_RESOURCE, - modes: MOCK_MODES, - }); - expect(jose.jwtVerify).toHaveBeenCalledTimes(1); - expect(jose.jwtVerify).toHaveBeenCalledWith('abc', undefined, { - 'issuer': MOCK_AS_URL, - 'audience': MOCK_AUD, - 'maxTokenAge': 600, - }); - }); - - test.each` - missing | payload | error - ${'sub'} | ${{webid: MOCK_WEBID, azp: MOCK_CLIENT, modes: MOCK_MODES}} | ${'UMA Access Token is missing \'sub\' claim.'} - ${'webid'} | ${{sub: MOCK_RESOURCE, azp: MOCK_CLIENT, modes: MOCK_MODES}} | ${'UMA Access Token is missing \'webid\' claim.'} - ${'azp'} | ${{sub: MOCK_RESOURCE, webid: MOCK_WEBID, modes: MOCK_MODES}} | ${'UMA Access Token is missing \'azp\' claim.'} - ${'modes'} | ${{sub: MOCK_RESOURCE, webid: MOCK_WEBID, azp: MOCK_CLIENT}} | ${'UMA Access Token is missing \'modes\' claim.'} - `('and token is missing $missing, should throw error', ({missing, payload, expected}) => { - (jose.jwtVerify as unknown as jest.Mock).mockImplementation(async () => { - return {payload: payload, - }; - }, - ); - - expect(async () => await verifyUMAToken('abc', MOCK_CONFIG, {baseUrl: MOCK_AUD, maxTokenAge: 600})).rejects - .toThrowError(expected); - expect(jose.jwtVerify).toHaveBeenCalledTimes(1); - expect(jose.jwtVerify).toHaveBeenCalledWith('abc', undefined, { - 'issuer': MOCK_AS_URL, - 'audience': MOCK_AUD, - 'maxTokenAge': 600, - }); - }); - - test.each` - invalid | payload | error - ${'webid'} | ${{webid: 123, sub: MOCK_RESOURCE, azp: MOCK_CLIENT, modes: MOCK_MODES}} | ${'UMA Access Token is missing \'webid\' claim.'} - ${'azp'} | ${{webid: MOCK_WEBID, sub: MOCK_RESOURCE, azp: 123, modes: MOCK_MODES}} | ${'UMA Access Token is missing \'azp\' claim.'} - ${'modes'} | ${{webid: MOCK_WEBID, sub: MOCK_RESOURCE, azp: MOCK_CLIENT, modes: 'abc'}} | ${'UMA Access Token is missing \'modes\' claim.'} - `('and token has non-string claim $invalid, should throw error', ({invalid, payload, expected}) => { - (jose.jwtVerify as unknown as jest.Mock).mockImplementation(async () => { - return {payload: payload, - }; - }, - ); - - expect(async () => await verifyUMAToken('abc', MOCK_CONFIG, {baseUrl: MOCK_AUD, maxTokenAge: 600})).rejects - .toThrowError(expected); - expect(jose.jwtVerify).toHaveBeenCalledTimes(1); - expect(jose.jwtVerify).toHaveBeenCalledWith('abc', undefined, { - 'issuer': MOCK_AS_URL, - 'audience': MOCK_AUD, - 'maxTokenAge': 600, - }); - }); - }); -}); diff --git a/packages/css/src/uma/UmaClient.ts b/packages/css/src/uma/UmaClient.ts index cd1670d0..4dafaa24 100644 --- a/packages/css/src/uma/UmaClient.ts +++ b/packages/css/src/uma/UmaClient.ts @@ -54,21 +54,6 @@ const REQUIRED_METADATA = [ 'resource_registration_endpoint' ]; -const algMap = { - 'ES256': { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }, - 'ES384': { name: 'ECDSA', namedCurve: 'P-384', hash: 'SHA-384' }, - 'ES512': { name: 'ECDSA', namedCurve: 'P-512', hash: 'SHA-512' }, - 'HS256': { name: 'HMAC', hash: 'SHA-256' }, - 'HS384': { name: 'HMAC', hash: 'SHA-384' }, - 'HS512': { name: 'HMAC', hash: 'SHA-512' }, - 'PS256': { name: 'RSASSA-PSS', hash: 'SHA-256' }, - 'PS384': { name: 'RSASSA-PSS', hash: 'SHA-384' }, - 'PS512': { name: 'RSASSA-PSS', hash: 'SHA-512' }, - 'RS256': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - 'RS384': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' }, - 'RS512': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }, -} - /** * Client interface for the UMA AS. * @@ -125,7 +110,7 @@ export class UmaClient implements SingleThreaded { if (!umaId && this.inProgressResources.has(target.path)) { // Wait for the resource to finish registration if it is still being registered, and there is no UMA ID yet. // Time out after 2s to prevent getting stuck in case something goes wrong during registration. - const timeoutPromise = promises.setTimeout(2000, async () => { + const timeoutPromise = promises.setTimeout(2000, '').then(() => { throw new InternalServerError(`Unable to finish registration for ${target.path}.`) }); await Promise.race([timeoutPromise, once(this.registerEmitter, target.path)]); @@ -204,8 +189,8 @@ export class UmaClient implements SingleThreaded { /** * Validates & parses JWT access token - * @param {string} token - the JWT access token - * @return {UmaToken} + * @param token - the JWT access token + * @param validIssuers - issuers that are allowed to issue a token */ public async verifyJwtToken(token: string, validIssuers: string[]): Promise { let config: UmaConfig; @@ -226,20 +211,12 @@ export class UmaClient implements SingleThreaded { } /** - * Validates & parses JWT access token - * @param {string} token - the JWT access token - * @return {UmaToken} + * Validates & parses access token + * @param {string} token - the access token + * @param {string} issuer - the token issuer */ public async verifyOpaqueToken(token: string, issuer: string): Promise { - let config: UmaConfig; - - try { - config = await this.fetchUmaConfig(issuer); - } catch (error: unknown) { - const message = `Error verifying UMA access token: ${(error as Error).message}`; - this.logger.warn(message); - throw new Error(message); - } + const config = await this.fetchUmaConfig(issuer); const res = await this.fetcher.fetch(config.introspection_endpoint, { method: 'POST', @@ -255,7 +232,7 @@ export class UmaClient implements SingleThreaded { } const jwt = await res.json(); - if (!('active' in jwt) || jwt.active !== 'true') throw new Error(`The provided UMA RPT is not active.`); + if (jwt.active !== 'true') throw new Error(`The provided UMA RPT is not active.`); return await this.verifyTokenData(jwt, config.issuer, config.jwks_uri); } @@ -311,7 +288,7 @@ export class UmaClient implements SingleThreaded { let { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer); const knownUmaId = await this.umaIdStore.get(resource.path); if (knownUmaId) { - endpoint = joinUrl(endpoint, knownUmaId); + endpoint = joinUrl(endpoint, encodeURIComponent(knownUmaId)); } const description: ResourceDescription = { @@ -403,8 +380,7 @@ export class UmaClient implements SingleThreaded { const umaId = await this.umaIdStore.get(resource.path); if (!umaId) { - console.error('Trying to remove UMA registration that is not known:', resource.path); - return; + throw new Error(`Trying to remove UMA registration that is not known: ${resource.path}`); } const url = joinUrl(endpoint, umaId); diff --git a/packages/css/src/util/OwnerUtil.ts b/packages/css/src/util/OwnerUtil.ts index baa0d943..ac4624f4 100644 --- a/packages/css/src/util/OwnerUtil.ts +++ b/packages/css/src/util/OwnerUtil.ts @@ -1,16 +1,12 @@ import { - AccountStore, getLoggerFor, - KeyValueStorage, + InternalServerError, + joinUrl, PodStore, ResourceIdentifier, StorageLocationStrategy, WrappedSetMultiMap } from '@solid/community-server'; -import { - ACCOUNT_SETTINGS_AUTHZ_SERVER, - UMA_ACCOUNT_STORAGE_TYPE -} from '../identity/interaction/account/util/AccountSettings'; /** * ... @@ -20,25 +16,10 @@ export class OwnerUtil { public constructor( protected podStore: PodStore, - protected accountStore: AccountStore, protected storageStrategy: StorageLocationStrategy, - protected umaPatStore: KeyValueStorage, protected umaServerURL: string, ) {} - /** - * Find the storage resource of the pod containing the given resource. - */ - public async findStorage(resource: ResourceIdentifier): Promise { - this.logger.debug(`Looking up storage containing ${resource.path}`); - - try { - return (await this.storageStrategy.getStorageIdentifier(resource)); - } catch { - throw new Error(`Unable to find root storage for ${resource}`); - } - } - /** * Finds the owners of the given resource. */ @@ -47,12 +28,14 @@ export class OwnerUtil { this.logger.debug(`Looking up pod corresponding to storage ${storage.path}`); const pod = await this.podStore.findByBaseUrl(storage.path); - if (!pod) throw new Error(`Unable to find pod ${storage.path}`); + if (!pod) + throw new InternalServerError(`Unable to find pod ${storage.path}`); this.logger.debug(`Looking up owners of pod ${pod.id}`); const owners = await this.podStore.getOwners(pod.id); - if (!owners) throw new Error(`Unable to find owners for pod ${storage.path}`); + if (!owners) + throw new InternalServerError(`Unable to find owners for pod ${storage.path}`); return owners.map((owner) => owner.webId); } @@ -62,36 +45,24 @@ export class OwnerUtil { const ownerMap = new WrappedSetMultiMap(); for (const target of resourceSet) { - const storage = await this.findStorage(target); - const owners = await this.findOwners(storage); + const owners = await this.findOwners(target); - for (const owner of owners) ownerMap.add(owner, target.path); + for (const owner of owners) + ownerMap.add(owner, target.path); } for (const [owner, targets] of ownerMap.entrySets()) { - if (targets.size === resourceSet.size) return owner; + if (targets.size === resourceSet.size) + return owner; } - throw new Error(`No common owner found for resources: ${Array.from(resources).map(r => r.path).join(', ')}`); + throw new InternalServerError( + `No common owner found for resources: ${Array.from(resources).map(r => r.path).join(', ')}`, + ); } public async findIssuer(webid: string): Promise { - if (!this.umaServerURL) { - this.logger.warn(`No UMA Authorization Server variable set. Falling back on http://localhost:4000/`) - return 'http://localhost:4000/uma'; - } - this.logger.verbose(`Using UMA Authorization Server at ${this.umaServerURL} for WebID ${webid}.`) - return this.umaServerURL.endsWith('/') ? this.umaServerURL + 'uma' : this.umaServerURL + '/uma' - - // Dunno if it makes sense to code this as retrieving it from the WebID at this point? - // I think we are far off from dynamically attaching multiple auth servers to a single solid server. - - // TODO: softcode - - // const profile = await fetchDataset(webid); - // const quads = await readableToQuads(profile.data); - // const issuers = quads.getObjects(namedNode(webid), UMA.terms.as, null); - - // return issuers.length > 0 ? issuers[0].value : undefined; + this.logger.verbose(`Using UMA Authorization Server at ${this.umaServerURL} for WebID ${webid}.`); + return joinUrl(this.umaServerURL, 'uma'); } } diff --git a/packages/css/src/util/fetch/SignedFetcher.ts b/packages/css/src/util/fetch/SignedFetcher.ts index 2fe262d0..4f355c2d 100644 --- a/packages/css/src/util/fetch/SignedFetcher.ts +++ b/packages/css/src/util/fetch/SignedFetcher.ts @@ -52,13 +52,16 @@ export class SignedFetcher implements Fetcher { ...init ?? {}, url, method: init?.method ?? 'GET', - headers: {} as Record - } - ; + headers: {} as Record, + }; new Headers(init?.headers).forEach((value, key) => request.headers[key] = value); request.headers['Authorization'] = `HttpSig cred="${this.baseUrl}"`; - const signed = await httpbis.signMessage({ key, paramValues: { keyid: 'TODO' } }, request); + const signed = await httpbis.signMessage({ + key, + fields: [ '@target-uri', '@method' ], + paramValues: { keyid: 'TODO' } + }, request); return await this.fetcher.fetch(url, signed); } diff --git a/packages/css/test/tsconfig.json b/packages/css/test/tsconfig.json new file mode 100644 index 00000000..f75e3dba --- /dev/null +++ b/packages/css/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "." + ] +} diff --git a/packages/css/test/unit/authentication/UmaTokenExtractor.test.ts b/packages/css/test/unit/authentication/UmaTokenExtractor.test.ts new file mode 100644 index 00000000..2e933cac --- /dev/null +++ b/packages/css/test/unit/authentication/UmaTokenExtractor.test.ts @@ -0,0 +1,100 @@ +import { BadRequestHttpError, HttpRequest, NotImplementedHttpError, TargetExtractor } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { UmaTokenExtractor } from '../../../src/authentication/UmaTokenExtractor'; +import { UmaClaims, UmaClient } from '../../../src/uma/UmaClient'; +import { OwnerUtil } from '../../../src/util/OwnerUtil'; + +describe('UmaTokenExtractor', () => { + let client: Mocked; + let targetExtractor: Mocked; + let ownerUtil: Mocked; + let extractor: UmaTokenExtractor; + + beforeEach(async(): Promise => { + client = { + verifyOpaqueToken: vi.fn().mockResolvedValue({ verified: true }), + verifyJwtToken: vi.fn().mockResolvedValue({ verified: true }), + } satisfies Partial as any; + + targetExtractor = { + handleSafe: vi.fn().mockResolvedValue({ identifier: 'identifier' }), + } satisfies Partial as any; + + ownerUtil = { + findOwners: vi.fn().mockResolvedValue([ 'owner' ]), + findIssuer: vi.fn().mockResolvedValue('issuer'), + } satisfies Partial as any; + + extractor = new UmaTokenExtractor(client, targetExtractor, ownerUtil); + }); + + it('throws an error on a request without Authorization header', async () => { + const request = { + method: 'GET', + headers: { }, + } as any as HttpRequest; + const result = extractor.handleSafe(request); + await expect(result).rejects.toThrow(NotImplementedHttpError); + await expect(result).rejects.toThrow('No Bearer Authorization header specified.'); + }); + + it('throws an error on a request without Bearer Authorization header', async () => { + const request = { + method: 'GET', + headers: {'authorization': 'Token 123'}, + } as any as HttpRequest; + const result = extractor.handleSafe(request); + await expect(result).rejects.toThrow(NotImplementedHttpError); + await expect(result).rejects.toThrow('No Bearer Authorization header specified.'); + }); + + it('verifies the token on a request with Bearer Authorization header', async () => { + const request = { + method: 'GET', + headers: { + authorization: 'Bearer 123', + }, + } as any as HttpRequest; + + await expect(extractor.handleSafe(request)).resolves.toEqual({ uma: { rpt: { verified: true }}}); + expect(client.verifyJwtToken).toHaveBeenCalledTimes(1); + }); + + it('throws an error on a request with Bearer Authorization header and unsupported token', async () => { + const request = { + method: 'GET', + headers: {'authorization': 'Bearer 123'}, + } as any as HttpRequest; + + client.verifyJwtToken.mockRejectedValueOnce(new Error('bad data')); + + const result = extractor.handleSafe(request); + await expect(result).rejects.toThrow(BadRequestHttpError); + await expect(result).rejects.toThrow('Error verifying WebID via Bearer access token: bad data'); + }); + + describe('with introspection', (): void => { + const request = { + method: 'GET', + headers: { + authorization: 'Bearer 123', + }, + } as any as HttpRequest; + + beforeEach(async(): Promise => { + extractor = new UmaTokenExtractor(client, targetExtractor, ownerUtil, true); + }); + + it('validates the token.', async(): Promise => { + await expect(extractor.handleSafe(request)).resolves.toEqual({ uma: { rpt: { verified: true }}}); + expect(client.verifyOpaqueToken).toHaveBeenCalledTimes(1); + expect(client.verifyOpaqueToken).toHaveBeenLastCalledWith('123', 'issuer'); + }); + + it('errors if every valid issuer errors.', async(): Promise => { + client.verifyOpaqueToken.mockRejectedValueOnce(new Error('bad data')); + await expect(extractor.handleSafe(request)).rejects + .toThrow('Error verifying WebID via Bearer access token: Introspection failed: bad data'); + }); + }); +}); diff --git a/packages/css/test/unit/authorization/AuxiliaryModesExtractor.test.ts b/packages/css/test/unit/authorization/AuxiliaryModesExtractor.test.ts new file mode 100644 index 00000000..d2fd357b --- /dev/null +++ b/packages/css/test/unit/authorization/AuxiliaryModesExtractor.test.ts @@ -0,0 +1,66 @@ +import { + AccessMap, AccessMode, + AuxiliaryStrategy, + IdentifierSetMultiMap, + ModesExtractor, + Operation, + ResourceIdentifier +} from '@solid/community-server'; +import { expect, Mocked } from 'vitest'; +import { AuxiliaryModesExtractor } from '../../../src/authorization/AuxiliaryModesExtractor'; + +describe('AuxiliaryModesExtractor', (): void => { + const input: Operation = { key: 'value' } as any; + let source: Mocked; + let strategy: Mocked; + let extractor: AuxiliaryModesExtractor; + + beforeEach(async(): Promise => { + source = { + canHandle: vi.fn(), + handle: vi.fn(), + } satisfies Partial as any; + + strategy = { + isAuxiliaryIdentifier: vi.fn(), + usesOwnAuthorization: vi.fn(), + getSubjectIdentifier: vi.fn(), + } satisfies Partial as any; + + extractor = new AuxiliaryModesExtractor(source, strategy); + }); + + it('can handle results the source can handle.', async(): Promise => { + await expect(extractor.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + + const error = new Error('bad data'); + source.canHandle.mockRejectedValueOnce(error); + await expect(extractor.canHandle(input)).rejects.toThrow(error); + expect(source.canHandle).toHaveBeenCalledTimes(2); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + }); + + it('replaces auxiliary targets with the subject identifier.', async(): Promise => { + const results: Record = { + nonAux: { aux: false, own: true, subject: '' }, + own: { aux: true, own: true, subject: '' }, + aux: { aux: true, own: false, subject: 'subject'}, + }; + strategy.isAuxiliaryIdentifier.mockImplementation((id): boolean => results[id.path].aux); + strategy.usesOwnAuthorization.mockImplementation((id): boolean => results[id.path].own); + strategy.getSubjectIdentifier.mockImplementation((id): ResourceIdentifier => ({ path: results[id.path].subject })); + const output: AccessMap = new IdentifierSetMultiMap([ + [ { path: 'nonAux'}, new Set([ AccessMode.read ]) ], + [ { path: 'own'}, new Set([ AccessMode.write ]) ], + [ { path: 'aux'}, new Set([ AccessMode.create ]) ], + ]); + source.handle.mockResolvedValueOnce(output); + const result = await extractor.handle(input); + expect(result.size).toBe(3); + expect([ ...result.get({ path: 'nonAux' })!]).toEqual([ AccessMode.read ]); + expect([ ...result.get({ path: 'own' })!]).toEqual([ AccessMode.write ]); + expect([ ...result.get({ path: 'subject' })!]).toEqual([ AccessMode.create ]); + }); +}); diff --git a/packages/css/test/unit/authorization/ParentCreateExtractor.test.ts b/packages/css/test/unit/authorization/ParentCreateExtractor.test.ts new file mode 100644 index 00000000..491e09db --- /dev/null +++ b/packages/css/test/unit/authorization/ParentCreateExtractor.test.ts @@ -0,0 +1,78 @@ +import { + AccessMap, AccessMode, + IdentifierSetMultiMap, + IdentifierStrategy, + ModesExtractor, + Operation, + ResourceSet +} from '@solid/community-server'; +import { expect, Mocked } from 'vitest'; +import { ParentCreateExtractor } from '../../../src/authorization/ParentCreateExtractor'; + +describe('ParentCreateExtractor', (): void => { + const input: Operation = { key: 'value' } as any; + let source: Mocked; + let strategy: Mocked; + let resourceSet: Mocked; + let extractor: ParentCreateExtractor; + + beforeEach(async(): Promise => { + source = { + canHandle: vi.fn(), + handle: vi.fn(), + } satisfies Partial as any; + + strategy = { + isRootContainer: vi.fn(), + getParentContainer: vi.fn(), + } satisfies Partial as any; + + resourceSet = { + hasResource: vi.fn(), + }; + + extractor = new ParentCreateExtractor(source, strategy, resourceSet); + }); + + it('can handle results the source can handle.', async(): Promise => { + await expect(extractor.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + + const error = new Error('bad data'); + source.canHandle.mockRejectedValueOnce(error); + await expect(extractor.canHandle(input)).rejects.toThrow(error); + expect(source.canHandle).toHaveBeenCalledTimes(2); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + }); + + it('returns the same result if the resource exists.', async(): Promise => { + const output: AccessMap = new IdentifierSetMultiMap([ + [{ path: 'id' }, new Set([AccessMode.read])], + ]); + source.handle.mockResolvedValueOnce(output); + resourceSet.hasResource.mockResolvedValueOnce(true); + await expect(extractor.handle(input)).resolves.toStrictEqual(output); + expect(strategy.isRootContainer).toHaveBeenCalledTimes(0); + }); + + it('replaces create resources that do not exist with the first existing parent.', async(): Promise => { + const output: AccessMap = new IdentifierSetMultiMap([ + [{ path: 'foo/bar/baz' }, new Set([AccessMode.create])], + [{ path: 'foo/bar/bazz' }, new Set([AccessMode.create, AccessMode.read])], + [{ path: 'foo/bar' }, new Set([AccessMode.write])], + [{ path: 'oof' }, new Set([AccessMode.read])], + ]); + + source.handle.mockResolvedValueOnce(output); + strategy.isRootContainer.mockImplementation((id) => !id.path.includes('/')); + strategy.getParentContainer.mockImplementation((id) => ({ path: id.path.slice(0, id.path.lastIndexOf('/')) })); + resourceSet.hasResource.mockImplementation(async(id) => !id.path.includes('/')); + + const result = await extractor.handle(input); + expect([...result.distinctKeys()]).toHaveLength(3); + expect([...result.get({ path: 'foo' })!]).toEqual([ AccessMode.create ]); + expect([...result.get({ path: 'foo/bar' })!]).toEqual([ AccessMode.write ]); + expect([...result.get({ path: 'oof' })!]).toEqual([ AccessMode.read ]); + }); +}); diff --git a/packages/css/test/unit/authorization/UmaAuthorizer.test.ts b/packages/css/test/unit/authorization/UmaAuthorizer.test.ts new file mode 100644 index 00000000..5c648f3c --- /dev/null +++ b/packages/css/test/unit/authorization/UmaAuthorizer.test.ts @@ -0,0 +1,106 @@ +import { + AccessMap, + AccessMode, + Authorizer, + ForbiddenHttpError, HttpError, + IdentifierSetMultiMap, + InternalServerError +} from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { UmaAuthorizer, WWW_AUTH } from '../../../src/authorization/UmaAuthorizer'; +import { UmaClient } from '../../../src/uma/UmaClient'; +import { OwnerUtil } from '../../../src/util/OwnerUtil'; + +describe('UmaAuthorizer', (): void => { + let source: Mocked; + let ownerUtil: Mocked; + let client: Mocked; + let authorizer: UmaAuthorizer; + + beforeEach(async(): Promise => { + source = { + handleSafe: vi.fn(), + } satisfies Partial as any; + + ownerUtil = { + findCommonOwner: vi.fn(), + findIssuer: vi.fn(), + } satisfies Partial as any; + + client = { + fetchTicket: vi.fn(), + } satisfies Partial as any; + + authorizer = new UmaAuthorizer(source, ownerUtil, client); + }); + + it('does nothing extra if no error is thrown.', async(): Promise => { + const input = { key: 'value' }; + + await expect(authorizer.handle(input as any)).resolves.toBeUndefined(); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe).toHaveBeenLastCalledWith(input); + expect(ownerUtil.findCommonOwner).toHaveBeenCalledTimes(0); + expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(0); + expect(client.fetchTicket).toHaveBeenCalledTimes(0); + }); + + it('re-throws the error if it was not a 401/403.', async(): Promise => { + const error = new InternalServerError('bad data'); + source.handleSafe.mockRejectedValueOnce(error); + + await expect(authorizer.handle({ key: 'value' } as any)).rejects.toThrowError(error); + expect(ownerUtil.findCommonOwner).toHaveBeenCalledTimes(0); + expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(0); + expect(client.fetchTicket).toHaveBeenCalledTimes(0); + }); + + it('errors if no issuer could be found.', async(): Promise => { + source.handleSafe.mockRejectedValueOnce(new ForbiddenHttpError()); + ownerUtil.findCommonOwner.mockResolvedValueOnce('owner'); + const requestedModes: AccessMap = new IdentifierSetMultiMap([[ { path: 'id' }, AccessMode.read ]]); + + await expect(authorizer.handle({ requestedModes } as any)).rejects + .toThrowError(`No UMA authorization server found for owner.`); + expect(ownerUtil.findCommonOwner).toHaveBeenCalledTimes(1); + expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(1); + expect(client.fetchTicket).toHaveBeenCalledTimes(0); + }); + + it('adds the found ticket to the error.', async(): Promise => { + source.handleSafe.mockRejectedValueOnce(new ForbiddenHttpError()); + ownerUtil.findCommonOwner.mockResolvedValueOnce('owner'); + ownerUtil.findIssuer.mockResolvedValueOnce('issuer'); + client.fetchTicket.mockResolvedValueOnce('ticket'); + const requestedModes: AccessMap = new IdentifierSetMultiMap([[ { path: 'id' }, AccessMode.read ]]); + + try { + await authorizer.handle({ requestedModes } as any); + expect(false).toBe(true); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenHttpError) + expect((err as HttpError).metadata.get(WWW_AUTH)?.value) + .toBe(`UMA realm="solid", as_uri="issuer", ticket="ticket"`); + } + }); + + it('resolves if no ticket was received.', async(): Promise => { + source.handleSafe.mockRejectedValueOnce(new ForbiddenHttpError()); + ownerUtil.findCommonOwner.mockResolvedValueOnce('owner'); + ownerUtil.findIssuer.mockResolvedValueOnce('issuer'); + const requestedModes: AccessMap = new IdentifierSetMultiMap([[ { path: 'id' }, AccessMode.read ]]); + + await expect(authorizer.handle({ requestedModes } as any)).resolves.toBeUndefined(); + }); + + it('throws an error if there was an issue fetching the ticket.', async(): Promise => { + source.handleSafe.mockRejectedValueOnce(new ForbiddenHttpError()); + ownerUtil.findCommonOwner.mockResolvedValueOnce('owner'); + ownerUtil.findIssuer.mockResolvedValueOnce('issuer'); + client.fetchTicket.mockRejectedValueOnce(new Error('bad data')); + const requestedModes: AccessMap = new IdentifierSetMultiMap([[ { path: 'id' }, AccessMode.read ]]); + + await expect(authorizer.handle({ requestedModes } as any)).rejects + .toThrow(`Error while requesting UMA header: bad data.`); + }); +}); diff --git a/packages/css/test/unit/authorization/UmaPermissionReader.test.ts b/packages/css/test/unit/authorization/UmaPermissionReader.test.ts new file mode 100644 index 00000000..3da0929b --- /dev/null +++ b/packages/css/test/unit/authorization/UmaPermissionReader.test.ts @@ -0,0 +1,88 @@ +import { IdentifierMap, PermissionReaderInput } from '@solid/community-server'; +import { UmaPermissionReader } from '../../../src/authorization/UmaPermissionReader'; +import { UmaClaims } from '../../../src/uma/UmaClient'; + +describe('UmaPermissionReader', (): void => { + let rpt: UmaClaims = {}; + const input: PermissionReaderInput = { credentials: { uma: { rpt }}} as any; + + const reader = new UmaPermissionReader(); + + it('resolves if the claims are empty.', async(): Promise => { + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap()); + }); + + it('returns the permissions in the token.', async(): Promise => { + rpt.permissions = [ + { resource_id: 'id1', resource_scopes: [ 'urn:example:css:modes:read', 'urn:example:css:modes:write' ]}, + { resource_id: 'id2', resource_scopes: [ 'urn:example:css:modes:create' ]}, + ]; + const result = await reader.handle(input); + expect([ ...result.keys() ]).toEqual([ { path: 'id1' }, { path: 'id2' } ]); + expect(result.get({ path: 'id1' })).toEqual({ read: true, write: true }); + expect(result.get({ path: 'id2' })).toEqual({ create: true }); + }); + + it('returns an empty result if the token has invalid time restrictions.', async(): Promise => { + rpt.permissions = [ + { resource_id: 'id1', resource_scopes: [ 'urn:example:css:modes:read', 'urn:example:css:modes:write' ]}, + { resource_id: 'id2', resource_scopes: [ 'urn:example:css:modes:create' ]}, + ]; + + rpt.iat = Date.now()/1000 + 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap()); + delete rpt.iat; + + rpt.exp = Date.now()/1000 - 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap()); + delete rpt.exp; + + rpt.nbf = Date.now()/1000 + 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap()); + delete rpt.nbf; + + rpt.iat = Date.now()/1000 - 10; + rpt.exp = Date.now()/1000 + 10; + rpt.nbf = Date.now()/1000 - 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap([ + [ { path: 'id1' }, { read: true, write: true } ], + [ { path: 'id2' }, { create: true } ], + ])); + }); + + it('does not allow permission sets with invalid time restrictions.', async(): Promise => { + rpt.permissions = [ + { resource_id: 'id1', resource_scopes: [ 'urn:example:css:modes:read', 'urn:example:css:modes:write' ]}, + { resource_id: 'id2', resource_scopes: [ 'urn:example:css:modes:create' ]}, + ]; + + rpt.permissions[0].iat = Date.now()/1000 + 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap([ + [ { path: 'id1' }, { read: false, write: false } ], + [ { path: 'id2' }, { create: true } ], + ])); + delete rpt.permissions[0].iat; + + rpt.permissions[0].exp = Date.now()/1000 - 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap([ + [ { path: 'id1' }, { read: false, write: false } ], + [ { path: 'id2' }, { create: true } ], + ])); + delete rpt.permissions[0].exp; + + rpt.permissions[0].nbf = Date.now()/1000 + 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap([ + [ { path: 'id1' }, { read: false, write: false } ], + [ { path: 'id2' }, { create: true } ], + ])); + delete rpt.permissions[0].nbf; + + rpt.iat = Date.now()/1000 - 10; + rpt.exp = Date.now()/1000 + 10; + rpt.nbf = Date.now()/1000 - 10; + await expect(reader.handle(input)).resolves.toEqual(new IdentifierMap([ + [ { path: 'id1' }, { read: true, write: true } ], + [ { path: 'id2' }, { create: true } ], + ])); + }); +}); diff --git a/packages/css/test/unit/http/output/metadata/UmaTicketMetadataWriter.test.ts b/packages/css/test/unit/http/output/metadata/UmaTicketMetadataWriter.test.ts new file mode 100644 index 00000000..80d18a34 --- /dev/null +++ b/packages/css/test/unit/http/output/metadata/UmaTicketMetadataWriter.test.ts @@ -0,0 +1,58 @@ +import { HTTP, HttpResponse, RepresentationMetadata } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { WWW_AUTH } from '../../../../../src/authorization/UmaAuthorizer'; +import { UmaTicketMetadataWriter } from '../../../../../src/http/output/metadata/UmaTicketMetadataWriter'; + +describe('A UmaTicketMetadataWriter', () => { + const header = 'UMA realm="solid",as_uri="https://as.example.org",ticket="def"'; + + const writer = new UmaTicketMetadataWriter(); + let response: Mocked; + + beforeEach(() => { + const headers: Record = {}; + response = { + hasHeader: vi.fn().mockImplementation((key: string) => Boolean(headers[key.toLowerCase()])), + getHeader: vi.fn().mockImplementation((key: string) => headers[key.toLowerCase()]), + getHeaders: vi.fn().mockReturnValue(headers), + setHeader: vi.fn().mockImplementation((key: string, value) => headers[key.toLowerCase()] = value), + } satisfies Partial as any; + }); + + it('adds no header if there is no relevant metadata.', async (): Promise => { + const metadata = new RepresentationMetadata(); + await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({ }); + }); + + it('adds a WWW-Authenticate header if the status code is 401.', async (): Promise => { + const metadata = new RepresentationMetadata({ + [HTTP.statusCodeNumber]: '401', + [WWW_AUTH.value]: header}); + await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({ + 'www-authenticate': 'UMA realm=\"solid\",as_uri=\"https://as.example.org\",ticket=\"def\"', + }); + }); + + it('adds a WWW-Authenticate header if the status code is 403.', async (): Promise => { + const metadata = new RepresentationMetadata({ + [HTTP.statusCodeNumber]: '403', + [WWW_AUTH.value]: header}); + await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({ + 'www-authenticate': 'UMA realm=\"solid\",as_uri=\"https://as.example.org\",ticket=\"def\"', + }); + }); + + it('adds no header if the status code is not 401 or 403.', async (): Promise => { + const metadata = new RepresentationMetadata({ + [HTTP.statusCodeNumber]: '400', + [WWW_AUTH.value]: header}); + await expect(writer.handle({response, metadata})).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({}); + }); +}); diff --git a/packages/css/test/unit/init/EmptyContainerInitializer.test.ts b/packages/css/test/unit/init/EmptyContainerInitializer.test.ts new file mode 100644 index 00000000..7a6dc488 --- /dev/null +++ b/packages/css/test/unit/init/EmptyContainerInitializer.test.ts @@ -0,0 +1,38 @@ +import { Mocked } from 'vitest'; +import { EmptyContainerInitializer } from '../../../src/init/EmptyContainerInitializer'; +import { BasicRepresentation, ResourceStore } from '@solid/community-server'; + +describe('EmptyContainerInitializer', (): void => { + const baseUrl = 'http://example.com/'; + const container = 'foo/'; + let store: Mocked; + let initializer: EmptyContainerInitializer; + + beforeEach(async(): Promise => { + store = { + hasResource: vi.fn(), + setRepresentation: vi.fn(), + } satisfies Partial as any; + + initializer = new EmptyContainerInitializer(baseUrl, container, store); + }); + + it('errors if the input container does not end with a slash.', async(): Promise => { + expect(() => new EmptyContainerInitializer(baseUrl, 'foo', store)) + .toThrow('Container paths should end with a slash, instead got foo'); + }); + + it('does nothing if the container already exists.', async(): Promise => { + store.hasResource.mockResolvedValueOnce(true); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(store.setRepresentation).toHaveBeenCalledTimes(0); + }); + + it('creates the container if it does not exist yet.', async(): Promise => { + store.hasResource.mockResolvedValueOnce(false); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(store.setRepresentation).toHaveBeenCalledTimes(1); + expect(store.setRepresentation.mock.calls[0][0].path).toBe('http://example.com/foo/'); + expect(store.setRepresentation.mock.calls[0][1].isEmpty).toBe(true); + }); +}); diff --git a/packages/css/test/unit/init/UmaSeededAccountInitializer.test.ts b/packages/css/test/unit/init/UmaSeededAccountInitializer.test.ts new file mode 100644 index 00000000..e457f605 --- /dev/null +++ b/packages/css/test/unit/init/UmaSeededAccountInitializer.test.ts @@ -0,0 +1,117 @@ +import { AccountStore, PasswordStore, PodCreator } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { + ACCOUNT_SETTINGS_AUTHZ_SERVER, + ACCOUNT_SETTINGS_KEYS +} from '../../../src/identity/interaction/account/util/AccountSettings'; +import { UmaSeededAccountInitializer } from '../../../src/init/UmaSeededAccountInitializer'; +import * as fsExtra from 'fs-extra'; + +vi.mock('fs-extra', () => ({ + readJson: vi.fn(), +})); + +describe('UmaSeededAccountInitializer', (): void => { + const dummyConfig = [ + { + email: 'hello@example.com', + password: 'abc123', + pods: [ + { name: 'pod1' }, + { name: 'pod2' }, + { name: 'pod3' }, + ], + authz: { + server: 'http://example.com', + }, + keys: [ + 'key1', + 'key2', + ] + }, + { + podName: 'example2', + email: 'hello2@example.com', + password: '123abc', + }, + ]; + const configFilePath = './seeded-pod-config.json'; + let accountStore: Mocked; + let passwordStore: Mocked; + let podCreator: Mocked; + let initializer: UmaSeededAccountInitializer; + + beforeEach(async(): Promise => { + let count = 0; + accountStore = { + create: vi.fn(async(): Promise => { + count += 1; + return `account${count}`; + }), + updateSetting: vi.fn(), + } satisfies Partial as any; + + let pwCount = 0; + passwordStore = { + create: vi.fn(async(): Promise => { + pwCount += 1; + return `password${pwCount}`; + }), + confirmVerification: vi.fn(), + } satisfies Partial as any; + + podCreator = { + handleSafe: vi.fn(), + } satisfies Partial as any; + + vi.spyOn(fsExtra, 'readJson').mockResolvedValue(dummyConfig); + + initializer = new UmaSeededAccountInitializer({ + accountStore, + passwordStore, + podCreator, + configFilePath, + }); + }); + + it('does not generate any accounts or pods if no config file is specified.', async(): Promise => { + await expect(new UmaSeededAccountInitializer({ accountStore, passwordStore, podCreator }).handle()) + .resolves.toBeUndefined(); + expect(accountStore.create).toHaveBeenCalledTimes(0); + }); + + it('errors if the seed file is invalid.', async(): Promise => { + vi.spyOn(fsExtra, 'readJson').mockResolvedValueOnce('invalid config'); + await expect(initializer.handle()).rejects + .toThrow('Invalid account seed file: this must be a `array` type, but the final value was: `"invalid config"`.'); + }); + + it('generates an account with the specified settings.', async(): Promise => { + await expect(initializer.handleSafe()).resolves.toBeUndefined(); + expect(accountStore.create).toHaveBeenCalledTimes(2); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(2); + expect(accountStore.updateSetting) + .toHaveBeenNthCalledWith(1, 'account1', ACCOUNT_SETTINGS_KEYS, [ 'key1', 'key2' ]); + expect(accountStore.updateSetting) + .toHaveBeenNthCalledWith(2, 'account1', ACCOUNT_SETTINGS_AUTHZ_SERVER, 'http://example.com'); + expect(passwordStore.create).toHaveBeenCalledTimes(2); + expect(passwordStore.create).toHaveBeenNthCalledWith(1, 'hello@example.com', 'account1', 'abc123'); + expect(passwordStore.create).toHaveBeenNthCalledWith(2, 'hello2@example.com', 'account2', '123abc'); + expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(2); + expect(passwordStore.confirmVerification).toHaveBeenNthCalledWith(1, 'password1'); + expect(passwordStore.confirmVerification).toHaveBeenNthCalledWith(2, 'password2'); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(3); + expect(podCreator.handleSafe).toHaveBeenNthCalledWith(1, { accountId: 'account1', name: 'pod1', settings: {}}); + expect(podCreator.handleSafe).toHaveBeenNthCalledWith(2, { accountId: 'account1', name: 'pod2', settings: {}}); + expect(podCreator.handleSafe).toHaveBeenNthCalledWith(3, { accountId: 'account1', name: 'pod3', settings: {}}); + }); + + it('does not throw exceptions when one of the steps fails.', async(): Promise => { + accountStore.create.mockRejectedValueOnce(new Error('bad data')); + await expect(initializer.handleSafe()).resolves.toBeUndefined(); + expect(accountStore.create).toHaveBeenCalledTimes(2); + // Steps for first account will be skipped due to error + expect(passwordStore.create).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/css/test/unit/server/middleware/JwksHandler.test.ts b/packages/css/test/unit/server/middleware/JwksHandler.test.ts new file mode 100644 index 00000000..be9f2d1d --- /dev/null +++ b/packages/css/test/unit/server/middleware/JwksHandler.test.ts @@ -0,0 +1,47 @@ +import { AlgJwk, HttpRequest, HttpResponse, JwkGenerator } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { JwksHandler } from '../../../../src/server/middleware/JwksHandler'; + +describe('JwksHandler', (): void => { + const key: AlgJwk = { alg: 'ES256' }; + let request: HttpRequest; + let response: Mocked; + let generator: Mocked; + let handler: JwksHandler; + + beforeEach(async(): Promise => { + request = {} as any; + + response = { + writeHead: vi.fn(), + end: vi.fn(), + } as any; + + generator = { + alg: 'ES256', + getPublicKey: vi.fn().mockResolvedValue(key), + getPrivateKey: vi.fn(), + }; + + handler = new JwksHandler(generator); + }); + + it('returns nothing for HEAD requests.', async(): Promise => { + request.method = 'HEAD'; + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(response.writeHead).toHaveBeenCalledTimes(1); + expect(response.writeHead).toHaveBeenLastCalledWith(200, { 'content-type': 'application/json' }); + expect(response.end).toHaveBeenCalledTimes(1); + expect(response.end).toHaveBeenLastCalledWith(); + }); + + it('returns the key for other methods.', async(): Promise => { + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(response.writeHead).toHaveBeenCalledTimes(1); + expect(response.writeHead).toHaveBeenLastCalledWith(200, { 'content-type': 'application/json' }); + expect(response.end).toHaveBeenCalledTimes(1); + expect(response.end).toHaveBeenLastCalledWith(JSON.stringify({ keys: [ + { ...key, kid: 'TODO' }, + ]})); + }); +}); diff --git a/packages/css/test/unit/uma/ResourceRegistrar.test.ts b/packages/css/test/unit/uma/ResourceRegistrar.test.ts new file mode 100644 index 00000000..16e16fa0 --- /dev/null +++ b/packages/css/test/unit/uma/ResourceRegistrar.test.ts @@ -0,0 +1,75 @@ +import { ActivityEmitter, AS } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { ResourceRegistrar } from '../../../src/uma/ResourceRegistrar'; +import { UmaClient } from '../../../src/uma/UmaClient'; +import { OwnerUtil } from '../../../src/util/OwnerUtil'; + +describe('ResourceRegistrar', (): void => { + const target = { path: 'http://example.com/foo' }; + const owners = [ 'owner1', 'owner2' ]; + const issuer = 'issuer'; + let emitter: Mocked; + let ownerUtil: Mocked; + let umaClient: Mocked; + let registrar: ResourceRegistrar; + + beforeEach(async(): Promise => { + emitter = { + on: vi.fn(), + } satisfies Partial as any; + + ownerUtil = { + findOwners: vi.fn().mockResolvedValue(owners), + findIssuer: vi.fn().mockResolvedValue(issuer), + } satisfies Partial as any; + + umaClient = { + registerResource: vi.fn().mockResolvedValue(''), + deleteResource: vi.fn().mockResolvedValue(''), + } satisfies Partial as any; + + registrar = new ResourceRegistrar(emitter, ownerUtil, umaClient); + }); + + it('registers resources on create events.', async(): Promise => { + expect(emitter.on.mock.calls[0][0]).toBe(AS.Create); + const createFn = emitter.on.mock.calls[0][1]; + await expect(createFn(target, null as any)).resolves.toBeUndefined(); + expect(ownerUtil.findOwners).toHaveBeenCalledTimes(1); + expect(ownerUtil.findOwners).toHaveBeenLastCalledWith(target); + expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(2); + expect(ownerUtil.findIssuer).toHaveBeenNthCalledWith(1, 'owner1'); + expect(ownerUtil.findIssuer).toHaveBeenNthCalledWith(2, 'owner2'); + expect(umaClient.registerResource).toHaveBeenCalledTimes(2); + expect(umaClient.registerResource).toHaveBeenNthCalledWith(1, target, 'issuer'); + expect(umaClient.registerResource).toHaveBeenNthCalledWith(2, target, 'issuer'); + }); + + it('catches the error if something goes wrong registering.', async(): Promise => { + umaClient.registerResource.mockRejectedValueOnce(new Error('bad data')); + expect(emitter.on.mock.calls[0][0]).toBe(AS.Create); + const createFn = emitter.on.mock.calls[0][1]; + await expect(createFn(target, null as any)).resolves.toBeUndefined(); + }); + + it('deletes resources on delete events.', async(): Promise => { + expect(emitter.on.mock.calls[1][0]).toBe(AS.Delete); + const createFn = emitter.on.mock.calls[1][1]; + await expect(createFn(target, null as any)).resolves.toBeUndefined(); + expect(ownerUtil.findOwners).toHaveBeenCalledTimes(1); + expect(ownerUtil.findOwners).toHaveBeenLastCalledWith(target); + expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(2); + expect(ownerUtil.findIssuer).toHaveBeenNthCalledWith(1, 'owner1'); + expect(ownerUtil.findIssuer).toHaveBeenNthCalledWith(2, 'owner2'); + expect(umaClient.deleteResource).toHaveBeenCalledTimes(2); + expect(umaClient.deleteResource).toHaveBeenNthCalledWith(1, target, 'issuer'); + expect(umaClient.deleteResource).toHaveBeenNthCalledWith(2, target, 'issuer'); + }); + + it('catches the error if something goes wrong deleting.', async(): Promise => { + umaClient.deleteResource.mockRejectedValueOnce(new Error('bad data')); + expect(emitter.on.mock.calls[1][0]).toBe(AS.Delete); + const createFn = emitter.on.mock.calls[1][1]; + await expect(createFn(target, null as any)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/css/test/unit/uma/UmaClient.test.ts b/packages/css/test/unit/uma/UmaClient.test.ts new file mode 100644 index 00000000..c867f3f8 --- /dev/null +++ b/packages/css/test/unit/uma/UmaClient.test.ts @@ -0,0 +1,511 @@ +import { + AccessMap, + AccessMode, + IdentifierSetMultiMap, + IdentifierStrategy, + KeyValueStorage, + NotFoundHttpError, + ResourceSet +} from '@solid/community-server'; +import { EventEmitter } from 'events'; +import * as jose from 'jose'; +import { Mocked, MockInstance } from 'vitest'; +import { flushPromises } from '../../../../../test/util/Util'; +import { UmaClient, UmaConfig } from '../../../src/uma/UmaClient'; +import { Fetcher } from '../../../src/util/fetch/Fetcher'; + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +class PublicUmaClient extends UmaClient { + public inProgressResources: Set = new Set(); + public registerEmitter: EventEmitter = new EventEmitter(); +} + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(), + decodeJwt: vi.fn(), + jwtVerify: vi.fn(), +})); + +describe('UmaClient', (): void => { + const issuer = 'issuer'; + const umaConfig: UmaConfig = { + issuer, + jwks_uri: 'http://example.com/jwks_uri', + permission_endpoint: 'http://example.com/permission_endpoint', + introspection_endpoint: 'http://example.com/introspection_endpoint', + resource_registration_endpoint: 'http://example.com/resource_registration_endpoint/', + } + let response: Mocked>; + + let umaIdStore: Mocked>; + let fetcher: Mocked; + let identifierStrategy: Mocked; + let resourceSet: Mocked; + + let client: UmaClient; + + beforeEach(async(): Promise => { + response = { + status: 200, + json: vi.fn().mockResolvedValue(umaConfig), + } satisfies Partial as any; + + umaIdStore = { + get: vi.fn(), + set: vi.fn(), + } satisfies Partial> as any; + fetcher = { + fetch: vi.fn().mockResolvedValue(response), + }; + identifierStrategy = { + isRootContainer: vi.fn((id) => id.path === '/'), + getParentContainer: vi.fn((id) => ({ path: id.path.slice(0, id.path.slice(0, -1).lastIndexOf('/') + 1) })), + } satisfies Partial as any; + resourceSet = { + hasResource: vi.fn(), + }; + + client = new UmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + }); + + describe('.fetchUmaConfig', (): void => { + it('errors if there was an issue fetching the UMA configuration.', async(): Promise => { + response.status = 400; + await expect(client.fetchUmaConfig(issuer)).rejects + .toThrow(new Error("Unable to retrieve UMA Configuration for Authorization Server 'issuer'" + + " from 'issuer/.well-known/uma2-configuration'")); + expect(fetcher.fetch).toHaveBeenCalledTimes(1); + expect(fetcher.fetch).toHaveBeenLastCalledWith('issuer/.well-known/uma2-configuration'); + }); + + it('errors if not all required fields are present in the response.', async(): Promise => { + response.json.mockResolvedValueOnce({}); + await expect(client.fetchUmaConfig(issuer)).rejects + .toThrow(new Error("The Authorization Server Metadata of 'issuer' is missing attributes " + + "issuer, jwks_uri, permission_endpoint, introspection_endpoint, resource_registration_endpoint")); + expect(fetcher.fetch).toHaveBeenCalledTimes(1); + expect(fetcher.fetch).toHaveBeenLastCalledWith('issuer/.well-known/uma2-configuration'); + }); + + it('errors if some of the required fields have a non-string value.', async(): Promise => { + response.json.mockResolvedValueOnce({ + issuer: 1, jwks_uri: 2, permission_endpoint: 3, introspection_endpoint: 4, resource_registration_endpoint: 5, + }); + await expect(client.fetchUmaConfig(issuer)).rejects + .toThrow(new Error("The Authorization Server Metadata of 'issuer' should have string attributes " + + "issuer, jwks_uri, permission_endpoint, introspection_endpoint, resource_registration_endpoint")); + expect(fetcher.fetch).toHaveBeenCalledTimes(1); + expect(fetcher.fetch).toHaveBeenLastCalledWith('issuer/.well-known/uma2-configuration'); + }); + + it('returns the UMA configuration.', async(): Promise => { + await expect(client.fetchUmaConfig(issuer)).resolves.toEqual(umaConfig); + }); + }); + + describe('.fetchTicket', (): void => { + let permissions: AccessMap; + + // Creating class to mock the internal resource registration as that testing is handled separately + class SimpleRegistrationUmaClient extends UmaClient { + public registerResource = vi.fn(); + } + + beforeEach(async(): Promise => { + permissions = new IdentifierSetMultiMap([ + [ { path: 'target1' }, AccessMode.read ], + [ { path: 'target2' }, AccessMode.write ], + ]); + + // Config mock for first fetch call + fetcher.fetch.mockResolvedValueOnce(response); + }); + + it('errors if there was an issue getting the configuration.', async(): Promise => { + response.status = 400; + await expect(client.fetchTicket(permissions, issuer)).rejects.toThrow("Error while retrieving ticket: " + + "Unable to retrieve UMA Configuration for Authorization Server 'issuer'" + + " from 'issuer/.well-known/uma2-configuration'"); + }); + + it('fetches the ticket from the UMA server.', async(): Promise => { + // Ticket mock + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 201, + json: vi.fn().mockResolvedValueOnce({ ticket: 'ticket' }), + }); + umaIdStore.get.mockResolvedValueOnce('uma1'); + umaIdStore.get.mockResolvedValueOnce('uma2'); + await expect(client.fetchTicket(permissions, issuer)).resolves.toBe('ticket'); + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).toHaveBeenNthCalledWith(2, umaConfig.permission_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify([ + { resource_id: 'uma1', resource_scopes: [`urn:example:css:modes:read`] }, + { resource_id: 'uma2', resource_scopes: [`urn:example:css:modes:write`] } + ]), + }); + }); + + it('returns undefined if no ticket is needed.', async(): Promise => { + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 200, + json: vi.fn().mockResolvedValueOnce({ ticket: 'ticket' }), + }); + umaIdStore.get.mockResolvedValueOnce('uma1'); + umaIdStore.get.mockResolvedValueOnce('uma2'); + await expect(client.fetchTicket(permissions, issuer)).resolves.toBeUndefined(); + }); + + it('waits for resource registration if it is in progress.', async(): Promise => { + vi.useFakeTimers(); + umaIdStore.get.mockResolvedValueOnce(undefined); + umaIdStore.get.mockResolvedValueOnce('uma1'); + umaIdStore.get.mockResolvedValueOnce('uma2'); + + const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + publicClient.inProgressResources.add('target1'); + const prom = publicClient.fetchTicket(permissions, issuer); + await flushPromises(); + vi.advanceTimersByTime(1000); + publicClient.registerEmitter.emit('target1'); + + await expect(prom).resolves.toBeUndefined(); + expect(fetcher.fetch).toHaveBeenNthCalledWith(2, umaConfig.permission_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify([ + { resource_id: 'uma1', resource_scopes: [`urn:example:css:modes:read`] }, + { resource_id: 'uma2', resource_scopes: [`urn:example:css:modes:write`] } + ]), + }); + + vi.useRealTimers(); + }); + + it('errors if resource registration takes too long.', async(): Promise => { + vi.useFakeTimers(); + umaIdStore.get.mockResolvedValueOnce(undefined); + umaIdStore.get.mockResolvedValueOnce('uma1'); + umaIdStore.get.mockResolvedValueOnce('uma2'); + + const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + publicClient.inProgressResources.add('target1'); + const prom = publicClient.fetchTicket(permissions, issuer); + await flushPromises(); + vi.advanceTimersByTime(3000); + + await expect(prom).rejects.toThrow('Unable to finish registration for target1'); + vi.useRealTimers(); + }); + + it('errors trying to fetch a ticket for a resource that does not exist.', async(): Promise => { + umaIdStore.get.mockResolvedValueOnce(undefined); + resourceSet.hasResource.mockResolvedValueOnce(false); + await expect(client.fetchTicket(permissions, issuer)).rejects.toThrow(NotFoundHttpError); + expect(resourceSet.hasResource).toHaveBeenCalledTimes(1); + expect(resourceSet.hasResource).toHaveBeenLastCalledWith({ path: 'target1' }); + }); + + it('tries to register a resource if it exists without UMA ID.', async(): Promise => { + const registerClient = new SimpleRegistrationUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + umaIdStore.get.mockResolvedValueOnce(undefined); + resourceSet.hasResource.mockResolvedValueOnce(true); + umaIdStore.get.mockResolvedValueOnce('uma1'); + umaIdStore.get.mockResolvedValueOnce('uma2'); + + await expect(registerClient.fetchTicket(permissions, issuer)).resolves.toBeUndefined(); + expect(registerClient.registerResource).toHaveBeenCalledTimes(1); + expect(registerClient.registerResource).toHaveBeenLastCalledWith({ path: 'target1' }, issuer); + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).toHaveBeenNthCalledWith(2, umaConfig.permission_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify([ + { resource_id: 'uma1', resource_scopes: [`urn:example:css:modes:read`] }, + { resource_id: 'uma2', resource_scopes: [`urn:example:css:modes:write`] } + ]), + }); + }); + + it('errors if there is still no UMA ID after registering the resource.', async(): Promise => { + const registerClient = new SimpleRegistrationUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + umaIdStore.get.mockResolvedValue(undefined); + resourceSet.hasResource.mockResolvedValueOnce(true); + + await expect(registerClient.fetchTicket(permissions, issuer)).rejects + .toThrow(`Unable to request ticket: no UMA ID found for target1`); + expect(registerClient.registerResource).toHaveBeenCalledTimes(1); + expect(registerClient.registerResource).toHaveBeenLastCalledWith({ path: 'target1' }, issuer); + }); + }); + + describe('.verifyJwtToken', (): void => { + const token = 'token'; + const issuers = [ 'issuer' ]; + const jwkSet = 'jwkSet'; + let decodeJwt: MockInstance; + let jwtVerify: MockInstance; + let createRemoteJWKSet: MockInstance; + + beforeEach(async(): Promise => { + decodeJwt = vi.spyOn(jose, 'decodeJwt'); + jwtVerify = vi.spyOn(jose, 'jwtVerify'); + createRemoteJWKSet = vi.spyOn(jose, 'createRemoteJWKSet'); + + // Config mock for first fetch call + fetcher.fetch.mockResolvedValueOnce(response); + }); + + it('errors if the token contains no issuer.', async(): Promise => { + decodeJwt.mockReturnValueOnce({}); + await expect(client.verifyJwtToken(token, issuers)).rejects + .toThrow('The JWT does not contain an "iss" parameter.'); + expect(decodeJwt).toHaveBeenCalledTimes(1); + expect(decodeJwt).toHaveBeenLastCalledWith(token); + }); + + it('errors if the issuer is not valid.', async(): Promise => { + decodeJwt.mockReturnValueOnce({ iss: 'other' }); + await expect(client.verifyJwtToken(token, issuers)).rejects + .toThrow(`The JWT wasn't issued by one of the target owners' issuers.`); + expect(decodeJwt).toHaveBeenCalledTimes(1); + expect(decodeJwt).toHaveBeenLastCalledWith(token); + }); + + it('errors if something else goes wrong decoding the token.', async(): Promise => { + decodeJwt.mockImplementationOnce(() => { throw new Error('bad data') }); + await expect(client.verifyJwtToken(token, issuers)).rejects + .toThrow(`Error verifying UMA access token: bad data`); + expect(decodeJwt).toHaveBeenCalledTimes(1); + expect(decodeJwt).toHaveBeenLastCalledWith(token); + }); + + it('returns the payload if it contains no permissions if it is verified.', async(): Promise => { + const decoded = { iss: 'issuer', key: 'value' }; + decodeJwt.mockReturnValueOnce(decoded); + createRemoteJWKSet.mockResolvedValueOnce(jwkSet as any); + jwtVerify.mockResolvedValueOnce({ payload: decoded } as any); + + await expect(client.verifyJwtToken(token, issuers)).resolves.toEqual(decoded); + expect(decodeJwt).toHaveBeenCalledTimes(1); + expect(decodeJwt).toHaveBeenLastCalledWith(token); + expect(createRemoteJWKSet).toHaveBeenCalledTimes(1); + expect(createRemoteJWKSet).toHaveBeenLastCalledWith(new URL(umaConfig.jwks_uri)); + expect(jwtVerify).toHaveBeenCalledTimes(1); + expect(jwtVerify).toHaveBeenLastCalledWith(token, jwkSet, { issuer: issuer, audience: 'solid' }); + }); + + it('errors if the permission array is invalid.', async(): Promise => { + const decoded = { iss: 'issuer', key: 'value', permissions: [{}] }; + decodeJwt.mockReturnValueOnce(decoded); + createRemoteJWKSet.mockResolvedValueOnce(jwkSet as any); + jwtVerify.mockResolvedValueOnce({ payload: decoded } as any); + + await expect(client.verifyJwtToken(token, issuers)).rejects.toThrow("Invalid RPT: 'permissions' array invalid."); + expect(decodeJwt).toHaveBeenCalledTimes(1); + expect(decodeJwt).toHaveBeenLastCalledWith(token); + expect(createRemoteJWKSet).toHaveBeenCalledTimes(1); + expect(createRemoteJWKSet).toHaveBeenLastCalledWith(new URL(umaConfig.jwks_uri)); + expect(jwtVerify).toHaveBeenCalledTimes(1); + expect(jwtVerify).toHaveBeenLastCalledWith(token, jwkSet, { issuer: issuer, audience: 'solid' }); + }); + + it('returns the payload if the permissions are valid.', async(): Promise => { + const decoded = { iss: 'issuer', key: 'value', permissions: [ + { resource_id: 'id1', resource_scopes: [ 'scope1' ] }, + { resource_id: 'id2', resource_scopes: [ 'scope21', 'scope22' ] }, + ] }; + decodeJwt.mockReturnValueOnce(decoded); + createRemoteJWKSet.mockResolvedValueOnce(jwkSet as any); + jwtVerify.mockResolvedValueOnce({ payload: decoded } as any); + + await expect(client.verifyJwtToken(token, issuers)).resolves.toEqual(decoded); + expect(decodeJwt).toHaveBeenCalledTimes(1); + expect(decodeJwt).toHaveBeenLastCalledWith(token); + expect(createRemoteJWKSet).toHaveBeenCalledTimes(1); + expect(createRemoteJWKSet).toHaveBeenLastCalledWith(new URL(umaConfig.jwks_uri)); + expect(jwtVerify).toHaveBeenCalledTimes(1); + expect(jwtVerify).toHaveBeenLastCalledWith(token, jwkSet, { issuer: issuer, audience: 'solid' }); + }); + }); + + describe('verifyOpaqueToken', (): void => { + const token = 'token'; + const jwkSet = 'jwkSet'; + let jwtVerify: MockInstance; + let createRemoteJWKSet: MockInstance; + + beforeEach(async(): Promise => { + jwtVerify = vi.spyOn(jose, 'jwtVerify'); + createRemoteJWKSet = vi.spyOn(jose, 'createRemoteJWKSet'); + + // Config mock for first fetch call + fetcher.fetch.mockResolvedValueOnce(response); + }); + + it('errors if the introspection endpoint returns a 400+ response.', async(): Promise => { + const resp = { ...response, status: 400 }; + fetcher.fetch.mockResolvedValueOnce(resp); + await expect(client.verifyOpaqueToken(token, issuer)) + .rejects.toThrow("Unable to introspect UMA RPT for Authorization Server 'issuer'"); + }); + + it('errors if the token is not active.', async(): Promise => { + const resp = { ...response, status: 200, json: vi.fn().mockResolvedValue({ active: 'false' }) }; + fetcher.fetch.mockResolvedValueOnce(resp); + await expect(client.verifyOpaqueToken(token, issuer)) + .rejects.toThrow('The provided UMA RPT is not active.'); + }); + + it('returns the introspected payload if the token is active and valid.', async(): Promise => { + const resp = { + ...response, + status: 200, + json: vi.fn().mockResolvedValue({ active: 'true' }) + }; + const decoded = { iss: 'issuer', key: 'value' }; + fetcher.fetch.mockResolvedValueOnce(resp); + createRemoteJWKSet.mockResolvedValueOnce(jwkSet as any); + jwtVerify.mockResolvedValueOnce({ payload: decoded } as any); + await expect(client.verifyOpaqueToken(token, issuer)).resolves.toEqual(decoded); + expect(createRemoteJWKSet).toHaveBeenCalledTimes(1); + expect(createRemoteJWKSet).toHaveBeenLastCalledWith(new URL(umaConfig.jwks_uri)); + expect(jwtVerify).toHaveBeenCalledTimes(1); + expect(jwtVerify).toHaveBeenLastCalledWith({ active: 'true' }, jwkSet, { issuer: issuer, audience: 'solid' }); + }); + }); + + describe('.registerResource', (): void => { + const umaId = 'umaId'; + const resource_scopes = [ + 'urn:example:css:modes:read', + 'urn:example:css:modes:append', + 'urn:example:css:modes:create', + 'urn:example:css:modes:delete', + 'urn:example:css:modes:write', + ]; + const resource_defaults = { 'http://www.w3.org/ns/ldp#contains': resource_scopes }; + + beforeEach(async(): Promise => { + fetcher.fetch.mockResolvedValueOnce(response); + }); + + it('can register the root container.', async(): Promise => { + const resp = { ...response, status: 201, json: vi.fn().mockResolvedValueOnce({ _id: umaId }) }; + fetcher.fetch.mockResolvedValueOnce(resp); + await expect(client.registerResource({ path: '/' }, issuer)).resolves.toBeUndefined(); + expect(umaIdStore.get).toHaveBeenCalledTimes(1); + expect(umaIdStore.get).toHaveBeenLastCalledWith('/'); + expect(umaIdStore.set).toHaveBeenCalledTimes(1); + expect(umaIdStore.set).toHaveBeenLastCalledWith('/', umaId); + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).toHaveBeenNthCalledWith(2, umaConfig.resource_registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ name: '/', resource_scopes, resource_defaults }), + }); + }); + + it('updates a resource if it was already registered.', async(): Promise => { + umaIdStore.get.mockResolvedValueOnce(umaId); + await expect(client.registerResource({ path: '/' }, issuer)).resolves.toBeUndefined(); + expect(umaIdStore.get).toHaveBeenCalledTimes(1); + expect(umaIdStore.get).toHaveBeenLastCalledWith('/'); + expect(umaIdStore.set).toHaveBeenCalledTimes(0); + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).toHaveBeenNthCalledWith(2, umaConfig.resource_registration_endpoint + umaId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ name: '/', resource_scopes, resource_defaults }), + }); + }); + + it('includes parent relations if the parent is registered.', async(): Promise => { + umaIdStore.get.mockImplementation(async(id) => id === '/' ? 'parentId' : undefined); + const resp = { ...response, status: 201, json: vi.fn().mockResolvedValueOnce({ _id: umaId }) }; + fetcher.fetch.mockResolvedValueOnce(resp); + await expect(client.registerResource({ path: '/foo' }, issuer)).resolves.toBeUndefined(); + expect(umaIdStore.get).toHaveBeenCalledTimes(2); + expect(umaIdStore.get).nthCalledWith(1, '/foo'); + expect(umaIdStore.get).nthCalledWith(2, '/'); + expect(umaIdStore.set).toHaveBeenCalledTimes(1); + expect(umaIdStore.set).toHaveBeenLastCalledWith('/foo', umaId); + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).toHaveBeenNthCalledWith(2, umaConfig.resource_registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ name: '/foo', resource_scopes, resource_relations: { '@reverse': { 'http://www.w3.org/ns/ldp#contains': [ 'parentId' ] }}}), + }); + }); + + it('updates the relations later if the parent is not registered.', async(): Promise => { + const umaIds: Record = {}; + umaIdStore.get.mockImplementation(async(id) => umaIds[id]); + umaIdStore.set.mockImplementation(async(id, val): Promise => umaIds[id] = val); + + const registerChild = { ...response, status: 201, json: vi.fn().mockResolvedValueOnce({ _id: umaId }) }; + const registerParent = { ...response, status: 201, json: vi.fn().mockResolvedValueOnce({ _id: 'parentId' }) }; + fetcher.fetch.mockImplementation(async(target, req) => { + if (target === umaConfig.resource_registration_endpoint) { + return JSON.parse(req!.body as string).name === '/' ? registerParent : registerChild; + } + return response; + }); + + await expect(client.registerResource({ path: '/foo' }, issuer)).resolves.toBeUndefined(); + expect(umaIdStore.set).toHaveBeenCalledTimes(2); + expect(umaIdStore.set).toHaveBeenCalledWith('/foo', umaId); + expect(umaIdStore.set).toHaveBeenCalledWith('/', 'parentId'); + expect(fetcher.fetch).toHaveBeenCalledTimes(6); + expect(fetcher.fetch).toHaveBeenCalledWith(umaConfig.resource_registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ name: '/foo', resource_scopes }), + }); + expect(fetcher.fetch).toHaveBeenCalledWith(umaConfig.resource_registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ name: '/', resource_scopes, resource_defaults }), + }); + expect(fetcher.fetch).toHaveBeenCalledWith(umaConfig.resource_registration_endpoint + umaId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ name: '/foo', resource_scopes, resource_relations: { '@reverse': { 'http://www.w3.org/ns/ldp#contains': [ 'parentId' ] }}}), + }); + }); + }); + + describe('.deleteResource', (): void => { + beforeEach(async(): Promise => { + fetcher.fetch.mockResolvedValueOnce(response); + }); + + it('errors if there is no matching UMA identifier.', async(): Promise => { + await expect(client.deleteResource({ path: '/foo' }, issuer)).rejects + .toThrow('Trying to remove UMA registration that is not known: /foo'); + }); + + it('performs a DELETE request.', async(): Promise => { + umaIdStore.get.mockResolvedValueOnce('umaId'); + await expect(client.deleteResource({ path: '/foo' }, issuer)).resolves.toBeUndefined(); + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).nthCalledWith(2, umaConfig.resource_registration_endpoint + 'umaId', { + method: 'DELETE', + }); + }); + }); +}); diff --git a/packages/css/test/unit/util/OwnerUtil.test.ts b/packages/css/test/unit/util/OwnerUtil.test.ts new file mode 100644 index 00000000..ceed754d --- /dev/null +++ b/packages/css/test/unit/util/OwnerUtil.test.ts @@ -0,0 +1,96 @@ +import { PodStore, ResourceIdentifier, StorageLocationStrategy } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { OwnerUtil } from '../../../src/util/OwnerUtil'; + +describe('OwnerUtil', (): void => { + const umaServerURL = 'http://example.com/'; + const storage: ResourceIdentifier = { path: 'storage' }; + const owners: { webId: string; visible: boolean }[] = [ + { webId: 'owner1', visible: true }, + { webId: 'owner2', visible: true }, + ]; + const basePod: { id: string, accountId: string } = { id: 'basePodId', accountId: 'accountId' }; + const resource: ResourceIdentifier = { path: 'resource' }; + let podStore: Mocked; + let storageStrategy: Mocked; + let ownerUtil: OwnerUtil; + + beforeEach(async(): Promise => { + podStore = { + findByBaseUrl: vi.fn().mockResolvedValue(basePod), + getOwners: vi.fn().mockResolvedValue(owners), + } satisfies Partial as any; + + storageStrategy = { + getStorageIdentifier: vi.fn().mockResolvedValue(storage), + }; + + ownerUtil = new OwnerUtil(podStore, storageStrategy, umaServerURL); + }); + + it('can find the owners of a resource.', async(): Promise => { + await expect(ownerUtil.findOwners(resource)).resolves.toEqual([ 'owner1', 'owner2' ]); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1); + expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith(resource); + expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(1); + expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(storage.path); + expect(podStore.getOwners).toHaveBeenCalledTimes(1); + expect(podStore.getOwners).toHaveBeenLastCalledWith('basePodId'); + }); + + it('errors if no pod can be found.', async(): Promise => { + podStore.findByBaseUrl.mockRejectedValueOnce(undefined); + await expect(ownerUtil.findOwners(resource)).rejects.toThrow('Unable to find pod storage'); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1); + expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith(resource); + expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(1); + expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(storage.path); + expect(podStore.getOwners).toHaveBeenCalledTimes(0); + }); + + it('errors if no owners can be found.', async(): Promise => { + podStore.getOwners.mockRejectedValueOnce(undefined); + await expect(ownerUtil.findOwners(resource)).rejects.toThrow('Unable to find owners for basePodId'); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1); + expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith(resource); + expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(1); + expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(storage.path); + expect(podStore.getOwners).toHaveBeenCalledTimes(1); + expect(podStore.getOwners).toHaveBeenLastCalledWith('basePodId'); + }); + + it('can find the common owner.', async(): Promise => { + podStore.getOwners.mockResolvedValueOnce([ + { webId: 'owner3', visible: true }, + { webId: 'owner2', visible: true }, + ]); + await expect(ownerUtil.findCommonOwner([ { path: 'resource1' }, { path: 'resource2' } ])).resolves.toBe('owner2'); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(2); + expect(storageStrategy.getStorageIdentifier).toHaveBeenNthCalledWith(1, { path: 'resource1' }); + expect(storageStrategy.getStorageIdentifier).toHaveBeenNthCalledWith(2, { path: 'resource2' }); + expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(2); + expect(podStore.getOwners).toHaveBeenCalledTimes(2); + }); + + it('errors if there is no common owner.', async(): Promise => { + podStore.getOwners.mockResolvedValueOnce([ + { webId: 'owner3', visible: true }, + ]); + await expect(ownerUtil.findCommonOwner([ { path: 'resource1' }, { path: 'resource2' } ])).rejects + .toThrow('No common owner found for resources: resource1, resource2'); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(2); + expect(storageStrategy.getStorageIdentifier).toHaveBeenNthCalledWith(1, { path: 'resource1' }); + expect(storageStrategy.getStorageIdentifier).toHaveBeenNthCalledWith(2, { path: 'resource2' }); + expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(2); + expect(podStore.getOwners).toHaveBeenCalledTimes(2); + }); + + it('returns the stored issuer.', async(): Promise => { + await expect(new OwnerUtil(podStore, storageStrategy, 'http://example.com/').findIssuer('webId')) + .resolves.toBe('http://example.com/uma'); + await expect(new OwnerUtil(podStore, storageStrategy, 'http://example.com').findIssuer('webId')) + .resolves.toBe('http://example.com/uma'); + await expect(new OwnerUtil(podStore, storageStrategy, 'http://example.com/foo').findIssuer('webId')) + .resolves.toBe('http://example.com/foo/uma'); + }); +}); diff --git a/packages/css/test/unit/util/fetch/PausableFetcher.test.ts b/packages/css/test/unit/util/fetch/PausableFetcher.test.ts new file mode 100644 index 00000000..6a5d3b04 --- /dev/null +++ b/packages/css/test/unit/util/fetch/PausableFetcher.test.ts @@ -0,0 +1,28 @@ +import { Mocked } from 'vitest'; +import { flushPromises } from '../../../../../../test/util/Util'; +import { Fetcher } from '../../../../src/util/fetch/Fetcher'; +import { PausableFetcher } from '../../../../src/util/fetch/PausableFetcher'; + +describe('PausableFetcher', (): void => { + let source: Mocked; + let fetcher: PausableFetcher; + + beforeEach(async(): Promise => { + source = { + fetch: vi.fn().mockResolvedValue('result'), + }; + + fetcher = new PausableFetcher(source); + }); + + it('does nothing until it is unpaused.', async(): Promise => { + const prom = fetcher.fetch('http://example.com/', { method: 'DELETE' }); + await flushPromises(); + expect(source.fetch).toHaveBeenCalledTimes(0); + await fetcher.changeStatus(true); + await flushPromises(); + expect(source.fetch).toHaveBeenCalledTimes(1); + expect(source.fetch).toHaveBeenLastCalledWith('http://example.com/', { method: 'DELETE' }); + await expect(prom).resolves.toBe('result'); + }); +}); diff --git a/packages/css/test/unit/util/fetch/RetryingFetcher.test.ts b/packages/css/test/unit/util/fetch/RetryingFetcher.test.ts new file mode 100644 index 00000000..285cc3cb --- /dev/null +++ b/packages/css/test/unit/util/fetch/RetryingFetcher.test.ts @@ -0,0 +1,29 @@ +import { Mocked } from 'vitest'; +import { Fetcher } from '../../../../src/util/fetch/Fetcher'; +import { RetryingFetcher } from '../../../../src/util/fetch/RetryingFetcher'; + +describe('RetryingFetcher', (): void => { + let source: Mocked; + let fetcher: RetryingFetcher; + + beforeEach(async(): Promise => { + source = { + fetch: vi.fn().mockResolvedValue({ status: 200 }), + } + fetcher = new RetryingFetcher(source, 150, 3, [ 503 ]); + }); + + it('performs the request if there was no error.', async(): Promise => { + await expect(fetcher.fetch('http://example.com/', { method: 'DELETE' })).resolves.toEqual({ status: 200 }); + expect(source.fetch).toHaveBeenCalledTimes(1); + expect(source.fetch).toHaveBeenLastCalledWith('http://example.com/', { method: 'DELETE' }); + }); + + it('retries if there was a an invalid response.', async(): Promise => { + source.fetch.mockResolvedValueOnce({ status: 503 } as any); + await expect(fetcher.fetch('http://example.com/', { method: 'DELETE' })).resolves.toEqual({ status: 200 }); + expect(source.fetch).toHaveBeenCalledTimes(2); + expect(source.fetch).toHaveBeenNthCalledWith(1, 'http://example.com/', { method: 'DELETE' }); + expect(source.fetch).toHaveBeenNthCalledWith(2, 'http://example.com/', { method: 'DELETE' }); + }); +}); diff --git a/packages/css/test/unit/util/fetch/SignedFetcher.test.ts b/packages/css/test/unit/util/fetch/SignedFetcher.test.ts new file mode 100644 index 00000000..c4958fb5 --- /dev/null +++ b/packages/css/test/unit/util/fetch/SignedFetcher.test.ts @@ -0,0 +1,73 @@ +import type { JwkGenerator } from '@solid/community-server'; +import { httpbis, SignConfig } from 'http-message-signatures'; +import { Mocked } from 'vitest'; +import { Fetcher } from '../../../../src/util/fetch/Fetcher'; +import { SignedFetcher } from '../../../../src/util/fetch/SignedFetcher'; + +vi.mock('http-message-signatures', () => ({ + httpbis: { signMessage: vi.fn() } +})); + +vi.mock('node:crypto', () => ({ + webcrypto: {}, +})); + +describe('SignedFetcher', (): void => { + const baseUrl = 'http://example.com'; + const jwk = { alg: 'ES256', kid: 'kid' }; + const signedMessage = 'signed'; + const signMessage = vi.mocked(httpbis.signMessage); + let keyGen: Mocked; + let source: Mocked; + let fetcher: SignedFetcher; + + beforeEach(async(): Promise => { + signMessage.mockClear(); + signMessage.mockResolvedValue(signedMessage as any); + + keyGen = { + alg: 'ES256', + getPrivateKey: vi.fn().mockResolvedValue(jwk), + getPublicKey: vi.fn(), + }; + + source = { + fetch: vi.fn().mockResolvedValue('result'), + }; + + fetcher = new SignedFetcher(source, baseUrl, keyGen); + }); + + it('performs the fetch with the signed request.', async(): Promise => { + await expect(fetcher.fetch('http://example.com', { method: 'DELETE', headers: { accept: 'text/turtle' } })).resolves.toBe('result'); + expect(signMessage).toHaveBeenCalledTimes(1); + expect(signMessage).toHaveBeenLastCalledWith( + { + key: expect.objectContaining({ alg: 'ES256', id: 'kid' }), + fields: [ '@target-uri', '@method' ], + paramValues: { keyid: 'TODO' } + }, + { + url: 'http://example.com', + method: 'DELETE', + headers: { accept: 'text/turtle', Authorization: 'HttpSig cred="http://example.com"' }, + }, + ); + expect(source.fetch).toHaveBeenCalledTimes(1); + expect(source.fetch).toHaveBeenLastCalledWith('http://example.com', 'signed'); + + // Testing the internal sign function + const importKeyMock = vitest.spyOn(crypto.subtle, 'importKey').mockResolvedValueOnce('key!' as any); + const signMock = vitest.spyOn(crypto.subtle, 'sign').mockResolvedValueOnce('signed!' as any); + const params = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }; + + const sign = (signMessage.mock.calls[0][0] as SignConfig).key.sign; + const data = Buffer.from('data'); + await expect(sign(data)).resolves.toEqual(Buffer.from('signed!')); + expect(importKeyMock).toHaveBeenCalledTimes(1); + expect(importKeyMock) + .toHaveBeenLastCalledWith('jwk', jwk, params, false, ['sign']); + expect(signMock).toHaveBeenCalledTimes(1); + expect(signMock).toHaveBeenLastCalledWith(params, 'key!', data); + }); +}); diff --git a/packages/css/test/unit/util/fetch/StatusDependantServerConfigurator.test.ts b/packages/css/test/unit/util/fetch/StatusDependantServerConfigurator.test.ts new file mode 100644 index 00000000..2b4f3234 --- /dev/null +++ b/packages/css/test/unit/util/fetch/StatusDependantServerConfigurator.test.ts @@ -0,0 +1,44 @@ +import { EventEmitter } from 'events'; +import { StatusDependant } from '../../../../src/util/fetch/StatusDependant'; +import { StatusDependantServerConfigurator } from '../../../../src/util/fetch/StatusDependantServerConfigurator'; + +describe('StatusDependantServerConfigurator', (): void => { + const statusMap = { + on: true, + off: false, + }; + let dependants: StatusDependant[]; + let configurator: StatusDependantServerConfigurator; + + beforeEach(async(): Promise => { + dependants = [ + { changeStatus: vi.fn() }, + { changeStatus: vi.fn() } + ]; + + configurator = new StatusDependantServerConfigurator(dependants, statusMap); + }); + + it('calls the dependants with the correct status.', async(): Promise => { + const server = new EventEmitter(); + await expect(configurator.handle(server as any)).resolves.toBeUndefined(); + expect(dependants[0].changeStatus).toHaveBeenCalledTimes(0); + expect(dependants[1].changeStatus).toHaveBeenCalledTimes(0); + + server.emit('on'); + expect(dependants[0].changeStatus).toHaveBeenCalledTimes(1); + expect(dependants[1].changeStatus).toHaveBeenCalledTimes(1); + expect(dependants[0].changeStatus).toHaveBeenLastCalledWith(true); + expect(dependants[1].changeStatus).toHaveBeenLastCalledWith(true); + + server.emit('off'); + expect(dependants[0].changeStatus).toHaveBeenCalledTimes(2); + expect(dependants[1].changeStatus).toHaveBeenCalledTimes(2); + expect(dependants[0].changeStatus).toHaveBeenLastCalledWith(false); + expect(dependants[1].changeStatus).toHaveBeenLastCalledWith(false); + + server.emit('other'); + expect(dependants[0].changeStatus).toHaveBeenCalledTimes(2); + expect(dependants[1].changeStatus).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/css/tsconfig.json b/packages/css/tsconfig.json index cba496ee..2927b758 100644 --- a/packages/css/tsconfig.json +++ b/packages/css/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - // "@tsconfig/node16/tsconfig.json" "lib": ["es2021"], "module": "node16", "target": "es2021", @@ -9,7 +8,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "node16", - // + "types": [ "vitest/globals" ], "declaration": true, "isolatedModules": true, "inlineSourceMap": true, @@ -20,6 +19,5 @@ "downlevelIteration": true, }, "include": ["src"], - "exclude": ["node_modules", "**/*.test.ts"] + "exclude": ["node_modules"] } - diff --git a/packages/ucp/package.json b/packages/ucp/package.json index 18995280..d5d65749 100644 --- a/packages/ucp/package.json +++ b/packages/ucp/package.json @@ -41,9 +41,7 @@ "types": "./dist/index.d.ts", "exports": { "./package.json": "./package.json", - ".": { - "require": "./dist/index.js" - } + ".": "./dist/index.js" }, "files": [ ".componentsignore", diff --git a/packages/ucp/src/index.ts b/packages/ucp/src/index.ts index 93fe160c..94830ead 100644 --- a/packages/ucp/src/index.ts +++ b/packages/ucp/src/index.ts @@ -9,6 +9,5 @@ export * from './policy/ODRL' export * from './policy/UsageControlPolicy' // util -export * from './util/Conversion' export * from './util/Util' export * from './util/Vocabularies' diff --git a/packages/ucp/src/storage/ContainerUCRulesStorage.ts b/packages/ucp/src/storage/ContainerUCRulesStorage.ts index 84edca86..57b98d56 100644 --- a/packages/ucp/src/storage/ContainerUCRulesStorage.ts +++ b/packages/ucp/src/storage/ContainerUCRulesStorage.ts @@ -1,8 +1,6 @@ import type { Quad } from '@rdfjs/types'; -import { Store, Writer } from 'n3'; +import { Parser, Store, Writer } from 'n3'; import { randomUUID } from 'node:crypto'; -import path from 'node:path'; -import { storeToString, turtleStringToStore } from '../util/Conversion'; import { extractQuadsRecursive } from '../util/Util'; import { UCRulesStorage } from './UCRulesStorage'; @@ -15,8 +13,8 @@ export class ContainerUCRulesStorage implements UCRulesStorage { protected readonly extraDataUrl: string; /** - * - * @param containerURL The URL to an LDP container + * @param containerURL - The URL to an LDP container + * @param customFetch - Fetch function to use for requests. Default to builtin fetch. */ public constructor( containerURL: string, @@ -24,7 +22,10 @@ export class ContainerUCRulesStorage implements UCRulesStorage { ) { this.containerURL = containerURL; this.fetch = customFetch ?? fetch; - this.extraDataUrl = path.posix.join(containerURL, randomUUID()); + if (!containerURL.endsWith('/')) { + throw new Error(`Invalid container URL ${containerURL}: URL does not end with a slash`); + } + this.extraDataUrl = containerURL + randomUUID(); } public async getStore(): Promise { @@ -41,9 +42,9 @@ export class ContainerUCRulesStorage implements UCRulesStorage { if (rule.size === 0) { return; } - const ruleString = storeToString(rule); + const ruleString = new Writer().quadsToString(rule.getQuads(null, null, null, null)); - let response = await fetch(this.extraDataUrl, { + let response = await this.fetch(this.extraDataUrl, { method: 'PATCH', headers: { 'content-type': 'text/n3' }, body: ` @@ -55,15 +56,14 @@ export class ContainerUCRulesStorage implements UCRulesStorage { }.`, }); if (response.status >= 400) { - throw Error(`Could not add rule to the storage ${response.status} ${await response.text()}`); + throw Error(`Could not add rule to the storage: ${response.status} - ${await response.text()}`); } } public async getRule(identifier: string): Promise { // would be better if there was a cache const allRules = await this.getStore() - const rule = extractQuadsRecursive(allRules, identifier); - return rule + return extractQuadsRecursive(allRules, identifier); } public async deleteRule(identifier: string): Promise { @@ -86,7 +86,7 @@ export class ContainerUCRulesStorage implements UCRulesStorage { } if (matches.length > 0) { const response = await this.fetch(url, { - method: 'PUT', + method: 'PATCH', headers: { 'content-type': 'text/n3' }, body: ` @prefix solid: . @@ -122,23 +122,23 @@ export class ContainerUCRulesStorage implements UCRulesStorage { } protected async readLdpRDFResource(resourceURL: string): Promise { - const containerResponse = await this.fetch(resourceURL, { headers: { 'accept': 'text/turtle' } }); + const response = await this.fetch(resourceURL, { headers: { 'accept': 'text/turtle' } }); - if (containerResponse.status === 404) { + if (response.status === 404) { return new Store(); } - if (containerResponse.status !== 200) { - throw new Error(`Unable to acces policy container ${resourceURL}: ${ - containerResponse.status} - ${await containerResponse.text()}`); + if (response.status !== 200) { + throw new Error(`Unable to access policy resource ${resourceURL}: ${ + response.status} - ${await response.text()}`); } - const contentType = containerResponse.headers.get('content-type'); + const contentType = response.headers.get('content-type'); // TODO: support non-turtle formats if (contentType !== 'text/turtle') { throw new Error(`Only turtle serialization is supported, received ${contentType}`); } - const text = await containerResponse.text(); - return await turtleStringToStore(text, resourceURL); + const text = await response.text(); + return new Store(new Parser({ baseIRI: resourceURL }).parse(text)); } } diff --git a/packages/ucp/src/storage/DirectoryUCRulesStorage.ts b/packages/ucp/src/storage/DirectoryUCRulesStorage.ts index c313c40a..43709b4e 100644 --- a/packages/ucp/src/storage/DirectoryUCRulesStorage.ts +++ b/packages/ucp/src/storage/DirectoryUCRulesStorage.ts @@ -1,3 +1,4 @@ +import { extractQuadsRecursive } from '../util/Util'; import { UCRulesStorage } from "./UCRulesStorage"; import * as path from 'path' import * as fs from 'fs' @@ -20,7 +21,7 @@ export class DirectoryUCRulesStorage implements UCRulesStorage { protected readonly directoryPath: string, protected readonly baseIRI: string, ) { - this.directoryPath = path.resolve(directoryPath); + this.directoryPath = directoryPath; if (!fs.lstatSync(directoryPath).isDirectory()) { throw Error(`${directoryPath} does not resolve to a directory`) } @@ -33,7 +34,7 @@ export class DirectoryUCRulesStorage implements UCRulesStorage { } const parser = new Parser({ baseIRI: this.baseIRI }); - const files = fs.readdirSync(this.directoryPath).map(file => path.join(this.directoryPath, file)) + const files = (await fs.promises.readdir(this.directoryPath)).map(file => path.join(this.directoryPath, file)) for (const file of files) { const quads = parser.parse((await fs.promises.readFile(file)).toString()); this.store.addQuads(quads); @@ -49,11 +50,14 @@ export class DirectoryUCRulesStorage implements UCRulesStorage { public async removeData(data: Store): Promise { + // Make sure the files have been read into memory + await this.getStore(); this.store.removeQuads(data.getQuads(null, null, null, null)); } public async getRule(identifier: string): Promise { - throw Error('not implemented'); + const allRules = await this.getStore() + return extractQuadsRecursive(allRules, identifier); } public async deleteRule(identifier: string): Promise { throw Error('not implemented'); diff --git a/packages/ucp/src/util/Conversion.ts b/packages/ucp/src/util/Conversion.ts deleted file mode 100644 index 648aa605..00000000 --- a/packages/ucp/src/util/Conversion.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { parseStringAsN3Store } from "koreografeye"; -import {DataFactory, Store, Writer} from "n3"; -import { ReadableWebToNodeStream } from "@smessie/readable-web-to-node-stream" -import rdfParser from 'rdf-parse' -import { storeStream } from "rdf-store-stream"; - -/** - * Converts a store to turtle string - * @param store - * @returns {string} - */ -export function storeToString(store: Store): string { - const writer = new Writer(); - return writer.quadsToString(store.getQuads(null, null, null, null)); -} - -export async function turtleStringToStore(text: string, baseIRI?: string): Promise { - return await parseStringAsN3Store(text, {contentType: 'text/turtle', baseIRI}); -} - -export async function rdfToStore(response: Response, uri: string){ - const response2 = response.clone() - const resourceText = await response.text() - let contentType = response.headers.get("content-type") - console.log('contentType', contentType, uri) - if (!contentType) contentType = 'text/turtle' // fallback - const readableWebStream = response2.body; - if(!readableWebStream) throw new Error(); - const bodyStream = new ReadableWebToNodeStream(readableWebStream); - const quadStream = rdfParser.parse(bodyStream, { - contentType, - baseIRI: uri - }) - const store = await storeStream(quadStream) as Store; - return store -} - -export async function isRDFContentType(contentType: string) { - return (await rdfParser.getContentTypes()).indexOf(contentType) !== -1 -} \ No newline at end of file diff --git a/packages/ucp/src/util/Util.ts b/packages/ucp/src/util/Util.ts index 12fd984f..e4f82f9a 100644 --- a/packages/ucp/src/util/Util.ts +++ b/packages/ucp/src/util/Util.ts @@ -6,25 +6,23 @@ import { Store } from "n3"; * @param store * @param subjectIRI * @param existing IRIs that already have done the recursive search (IRIs in there must not be searched for again) - * @returns */ - export function extractQuadsRecursive(store: Store, subjectIRI: string, existing?: string[]): Store { const tempStore = new Store(); const subjectIRIQuads = store.getQuads(subjectIRI, null, null, null); tempStore.addQuads(subjectIRIQuads); - const existingSubjects = existing ?? [subjectIRI]; + const existingSubjects = [ ...existing ?? [] ]; + existingSubjects.push(subjectIRI); for (const subjectIRIQuad of subjectIRIQuads) { if (!existingSubjects.includes(subjectIRIQuad.object.id)) { - tempStore.addQuads(extractQuadsRecursive(store, subjectIRIQuad.object.id, existingSubjects).getQuads(null, null, null, null)); + tempStore.addQuads(extractQuadsRecursive(store, subjectIRIQuad.object.id, existingSubjects) + .getQuads(null, null, null, null)); } else { tempStore.addQuad(subjectIRIQuad); } - existingSubjects.push(subjectIRIQuad.object.id); } return tempStore; -}// instantiated policy consisting of one agreement and one rule - +} diff --git a/packages/ucp/test/tsconfig.json b/packages/ucp/test/tsconfig.json new file mode 100644 index 00000000..f75e3dba --- /dev/null +++ b/packages/ucp/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "." + ] +} diff --git a/packages/ucp/test/unit/policy/ODRL.test.ts b/packages/ucp/test/unit/policy/ODRL.test.ts new file mode 100644 index 00000000..73d43947 --- /dev/null +++ b/packages/ucp/test/unit/policy/ODRL.test.ts @@ -0,0 +1,58 @@ +import { DataFactory as DF, Writer } from 'n3'; +import { basicPolicy } from '../../../src/policy/ODRL'; +import { UCPPolicy } from '../../../src/policy/UsageControlPolicy'; +import { ODRL, RDF, XSD } from '../../../src/util/Vocabularies'; + +const now = new Date(); +vi.useFakeTimers({ now }); + +describe('ODRL', (): void => { + let policy: UCPPolicy; + + beforeEach(async(): Promise => { + policy = { + type: 'http://www.w3.org/ns/odrl/2/Offer', + rules: [{ + type: 'http://www.w3.org/ns/odrl/2/Prohibition', + action: 'http://www.w3.org/ns/odrl/2/use', + resource: 'http://example.com/foo', + requestingParty: 'http://example.com/me', + owner: 'http://example.com/owner', + constraints: [{ + type: 'temporal', + value: new Date(), + operator: 'http://www.w3.org/ns/odrl/2/gt', + }], + }], + }; + }); + + describe('#basicPolicy', (): void => { + it('creates an RDF representation of a policy.', async(): Promise => { + const iri = 'http://example.com/policy'; + const result = basicPolicy(policy, iri); + expect(result.policyIRI).toBe(iri); + expect(result.ruleIRIs).toHaveLength(1); + + const store = result.representation; + const policyTerm = DF.namedNode(iri); + const ruleTerm = result.ruleIRIs[0]; + const odrlTerm = (value: string) => DF.namedNode(ODRL.namespace + value); + expect(store.countQuads(policyTerm, RDF.terms.type, ODRL.terms.Offer, null)).toBe(1); + expect(store.countQuads(ruleTerm, RDF.terms.type, odrlTerm('Prohibition'), null)).toBe(1); + expect(store.countQuads(ruleTerm, ODRL.terms.action, odrlTerm('use'), null)).toBe(1); + expect(store.countQuads(ruleTerm, ODRL.terms.target, DF.namedNode('http://example.com/foo'), null)).toBe(1); + expect(store.countQuads(ruleTerm, ODRL.terms.assignee, DF.namedNode('http://example.com/me'), null)).toBe(1); + expect(store.countQuads(ruleTerm, ODRL.terms.assigner, DF.namedNode('http://example.com/owner'), null)).toBe(1); + + const constraints = result.representation.getObjects(ruleTerm, ODRL.terms.constraint, null); + expect(constraints).toHaveLength(1); + const constraint = constraints[0]; + expect(store.countQuads(constraint, ODRL.terms.leftOperand, ODRL.terms.dateTime, null)).toBe(1); + expect(store.countQuads(constraint, ODRL.terms.operator, ODRL.terms.gt, null)).toBe(1); + expect( + store.countQuads(constraint, ODRL.terms.rightOperand, DF.literal(now.toISOString(), XSD.terms.dateTime), null), + ).toBe(1); + }); + }); +}); diff --git a/packages/ucp/test/unit/storage/ContainerUCRulesStorage.test.ts b/packages/ucp/test/unit/storage/ContainerUCRulesStorage.test.ts new file mode 100644 index 00000000..b49d2698 --- /dev/null +++ b/packages/ucp/test/unit/storage/ContainerUCRulesStorage.test.ts @@ -0,0 +1,201 @@ +import 'jest-rdf'; +import { DataFactory as DF, NamedNode, Parser, Store } from 'n3'; +import { Mock, vi } from 'vitest'; +import { ContainerUCRulesStorage } from '../../../src/storage/ContainerUCRulesStorage'; +import { RDF } from '../../../src/util/Vocabularies'; + +describe('ContainerUCRulesStorage', (): void => { + const containerTtl = ` + <> , . + `; + const foo1Ttl = `<> a .`; + const foo2Ttl = `<> a .`; + const containerUrl = 'http://example.com/policies/'; + const foo1 = DF.namedNode('http://example.com/policies/foo1'); + const foo2 = DF.namedNode('http://example.com/policies/foo2'); + const type1 = DF.namedNode('http://example.com/type1'); + const type2 = DF.namedNode('http://example.com/type2'); + let response: Response; + let fetchMock: Mock; + let storage: ContainerUCRulesStorage; + + beforeEach(async(): Promise => { + response = { + status: 200, + headers: new Headers({ 'content-type': 'text/turtle' }), + text: vi.fn(), + } satisfies Partial as any; + fetchMock = vi.fn().mockResolvedValue(response); + + storage = new ContainerUCRulesStorage(containerUrl, fetchMock); + }); + + describe('.getStore', (): void => { + it('can return a store containing all policies.', async(): Promise => { + vi.mocked(response.text).mockResolvedValueOnce(containerTtl); + vi.mocked(response.text).mockResolvedValueOnce(foo1Ttl); + vi.mocked(response.text).mockResolvedValueOnce(foo2Ttl); + const store = await storage.getStore(); + expect(store.size).toBe(2); + expect(store.countQuads(foo1, RDF.terms.type, type1, null)).toBe(1); + expect(store.countQuads(foo2, RDF.terms.type, type2, null)).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith(1, containerUrl, { headers: { 'accept': 'text/turtle' }}); + expect(fetchMock).toHaveBeenNthCalledWith(2, foo1.value, { headers: { 'accept': 'text/turtle' }}); + expect(fetchMock).toHaveBeenNthCalledWith(3, foo2.value, { headers: { 'accept': 'text/turtle' }}); + }); + + it('returns no data if a resource could not be found.', async(): Promise => { + (response as any).status = 404; + const result = await storage.getStore(); + expect(result.size).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith(1, containerUrl, { headers: { 'accept': 'text/turtle' }}); + }); + + it('errors if an unexpected status code is returned.', async(): Promise => { + (response as any).status = 500; + vi.mocked(response.text).mockResolvedValueOnce('bad data'); + await expect(storage.getStore()).rejects.toThrow(`Unable to access policy resource ${containerUrl + }: 500 - bad data`); + }); + + it('errors if the response is not turtle.', async(): Promise => { + response.headers.set('content-type', 'application/ld+json'); + await expect(storage.getStore()).rejects + .toThrow('Only turtle serialization is supported, received application/ld+json'); + }); + }); + + describe('.addRule', (): void => { + it('adds the rule through a PATCH request.', async(): Promise => { + const rule = new Store([ + DF.quad(foo1, RDF.terms.type, type1), + DF.quad(foo2, RDF.terms.type, type2), + ]); + await expect(storage.addRule(rule)).resolves.toBeUndefined(); + expect(fetchMock.mock.calls[0][0]).toEqual(expect.stringContaining(containerUrl)); + expect(fetchMock.mock.calls[0][1]?.method).toBe('PATCH'); + expect(fetchMock.mock.calls[0][1]?.headers).toEqual({ 'content-type': 'text/n3' }); + const patch = new Store(new Parser({ format: 'n3' }).parse(fetchMock.mock.calls[0][1]!.body as string)); + const subjects = patch.getSubjects(RDF.terms.type, 'http://www.w3.org/ns/solid/terms#InsertDeletePatch', null); + expect(subjects).toHaveLength(1); + const insertFormulas = patch.getObjects(subjects[0], 'http://www.w3.org/ns/solid/terms#inserts', null); + expect(insertFormulas).toHaveLength(1); + const inserts = patch.getQuads(null, null, null, insertFormulas[0]); + expect(inserts).toEqualRdfQuadArray([ + DF.quad(foo1, RDF.terms.type, type1, insertFormulas[0] as NamedNode), + DF.quad(foo2, RDF.terms.type, type2, insertFormulas[0] as NamedNode), + ]); + }); + + it('performs no request if there is no input data.', async(): Promise => { + await expect(storage.addRule(new Store())).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + + it('errors if the fetch fails.', async(): Promise => { + (response as any).status = 500; + vi.mocked(response.text).mockResolvedValueOnce('bad data'); + const rule = new Store([ + DF.quad(foo1, RDF.terms.type, type1), + DF.quad(foo2, RDF.terms.type, type2), + ]); + await expect(storage.addRule(rule)).rejects.toThrow('Could not add rule to the storage: 500 - bad data'); + }); + }); + + describe('.getRule', (): void => { + it('extracts the matching rule.', async(): Promise => { + vi.mocked(response.text).mockResolvedValueOnce(`<> .`); + vi.mocked(response.text).mockResolvedValueOnce(` + ; + . + . + `); + await expect(storage.getRule(`${containerUrl}a`)).resolves.toBeRdfIsomorphic([ + DF.quad(DF.namedNode(`${containerUrl}a`), DF.namedNode(`${containerUrl}b`), DF.namedNode(`${containerUrl}c`)), + DF.quad(DF.namedNode(`${containerUrl}a`), DF.namedNode(`${containerUrl}d`), DF.namedNode(`${containerUrl}e`)), + ]); + }); + }); + + describe('.deleteRule', (): void => { + it('is not supported.', async(): Promise => { + await expect(storage.deleteRule('a')).rejects.toThrow('not implemented'); + }); + }); + + describe('.removeData', (): void => { + it('sends PATCH requests to all resources with relevant data.', async(): Promise => { + vi.mocked(response.text).mockResolvedValueOnce(containerTtl); + vi.mocked(response.text).mockResolvedValueOnce(foo1Ttl); + vi.mocked(response.text).mockResolvedValueOnce(foo2Ttl); + + const removeData = new Store([ + DF.quad(foo1, RDF.terms.type, type1), + DF.quad(foo2, RDF.terms.type, type2), + ]); + await expect(storage.removeData(removeData)).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(5); + expect(fetchMock.mock.calls[3][0]).toBe(foo1.value); + expect(fetchMock.mock.calls[4][0]).toBe(foo2.value); + + // first patch + expect(fetchMock.mock.calls[3][1]?.method).toBe('PATCH'); + expect(fetchMock.mock.calls[3][1]?.headers).toEqual({ 'content-type': 'text/n3' }); + let patch = new Store(new Parser({ format: 'n3' }).parse(fetchMock.mock.calls[3][1]!.body as string)); + let subjects = patch.getSubjects(RDF.terms.type, 'http://www.w3.org/ns/solid/terms#InsertDeletePatch', null); + expect(subjects).toHaveLength(1); + let insertFormulas = patch.getObjects(subjects[0], 'http://www.w3.org/ns/solid/terms#deletes', null); + expect(insertFormulas).toHaveLength(1); + let inserts = patch.getQuads(null, null, null, insertFormulas[0]); + expect(inserts).toEqualRdfQuadArray([ + DF.quad(foo1, RDF.terms.type, type1, insertFormulas[0] as NamedNode), + ]); + + // second patch + expect(fetchMock.mock.calls[4][1]?.method).toBe('PATCH'); + expect(fetchMock.mock.calls[4][1]?.headers).toEqual({ 'content-type': 'text/n3' }); + patch = new Store(new Parser({ format: 'n3' }).parse(fetchMock.mock.calls[4][1]!.body as string)); + subjects = patch.getSubjects(RDF.terms.type, 'http://www.w3.org/ns/solid/terms#InsertDeletePatch', null); + expect(subjects).toHaveLength(1); + insertFormulas = patch.getObjects(subjects[0], 'http://www.w3.org/ns/solid/terms#deletes', null); + expect(insertFormulas).toHaveLength(1); + inserts = patch.getQuads(null, null, null, insertFormulas[0]); + expect(inserts).toEqualRdfQuadArray([ + DF.quad(foo2, RDF.terms.type, type2, insertFormulas[0] as NamedNode), + ]); + }); + + it('only sends requests to resources that have matches.', async(): Promise => { + vi.mocked(response.text).mockResolvedValueOnce(containerTtl); + vi.mocked(response.text).mockResolvedValueOnce(foo1Ttl); + vi.mocked(response.text).mockResolvedValueOnce(foo2Ttl); + + const removeData = new Store([ DF.quad(foo2, RDF.terms.type, type2) ]); + await expect(storage.removeData(removeData)).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[3][0]).toBe(foo2.value); + }); + + it('does no requests if there is no data to remove.', async(): Promise => { + await expect(storage.removeData(new Store())).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + + it('errors if the update fails.', async(): Promise => { + fetchMock.mockResolvedValueOnce({ ...response, text: vi.fn().mockResolvedValue(containerTtl) }); + fetchMock.mockResolvedValueOnce({ ...response, text: vi.fn().mockResolvedValue(foo1Ttl) }); + fetchMock.mockResolvedValueOnce({ ...response, text: vi.fn().mockResolvedValue(foo2Ttl) }); + fetchMock.mockResolvedValueOnce({ ...response, status: 400, text: vi.fn().mockResolvedValue('bad data') }); + + const removeData = new Store([ + DF.quad(foo1, RDF.terms.type, type1), + DF.quad(foo2, RDF.terms.type, type2), + ]); + await expect(storage.removeData(removeData)).rejects.toThrow('Could not update rule resource http://example.com/policies/foo1: 400 - bad data'); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/packages/ucp/test/unit/storage/DirectoryUCRulesStorage.test.ts b/packages/ucp/test/unit/storage/DirectoryUCRulesStorage.test.ts new file mode 100644 index 00000000..2275499f --- /dev/null +++ b/packages/ucp/test/unit/storage/DirectoryUCRulesStorage.test.ts @@ -0,0 +1,101 @@ +import 'jest-rdf'; +import { DataFactory as DF, Store } from 'n3'; +import * as fs from 'node:fs'; +import path from 'node:path'; +import { DirectoryUCRulesStorage } from '../../../src/storage/DirectoryUCRulesStorage'; +import { RDF } from '../../../src/util/Vocabularies'; + +vi.mock('fs', () => ({ + lstatSync: vi.fn().mockReturnValue({ isDirectory: vi.fn().mockReturnValue(true) }), + promises: { + readdir: vi.fn(), + readFile: vi.fn(), + }, +})); + +describe('DirectoryUCRulesStorage', (): void => { + const directoryPath = '/foo/bar'; + const baseIRI = 'http://example.com/'; + const readdirMock = vi.spyOn(fs.promises, 'readdir'); + const readFileMock = vi.spyOn(fs.promises, 'readFile'); + let storage: DirectoryUCRulesStorage; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + storage = new DirectoryUCRulesStorage(directoryPath, baseIRI); + }); + + describe('.getStore', (): void => { + it('returns the policies found in the directory.', async(): Promise => { + readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + await expect(storage.getStore()).resolves.toBeRdfIsomorphic([ + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')), + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')), + ]); + expect(readdirMock).toHaveBeenCalledTimes(1); + expect(readdirMock).lastCalledWith(directoryPath); + expect(readFileMock).toHaveBeenCalledTimes(2); + expect(readFileMock).toHaveBeenNthCalledWith(1, path.join('/foo/bar', 'a')); + expect(readFileMock).toHaveBeenNthCalledWith(2, path.join('/foo/bar', 'b')); + }); + + it('caches the policies in memory.', async(): Promise => { + readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + + // first store call + await storage.getStore(); + await expect(storage.getStore()).resolves.toBeRdfIsomorphic([ + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')), + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')), + ]); + expect(readdirMock).toHaveBeenCalledTimes(1); + expect(readFileMock).toHaveBeenCalledTimes(2); + }); + }); + + it('can add data to the storage.', async(): Promise => { + readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + + await expect(storage.addRule(new Store([ + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type3')), + ]))).resolves.toBeUndefined(); + await expect(storage.getStore()).resolves.toBeRdfIsomorphic([ + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')), + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')), + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type3')), + ]); + }); + + it('can remove data from the storage.', async(): Promise => { + readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + readFileMock.mockResolvedValueOnce(Buffer.from('<> a .')); + + await expect(storage.removeData(new Store([ + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')), + ]))).resolves.toBeUndefined(); + await expect(storage.getStore()).resolves.toBeRdfIsomorphic([ + DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')), + ]); + }); + + it('can return the relevant policy data.', async(): Promise => { + readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any); + readFileMock.mockResolvedValueOnce(Buffer.from(' a .')); + readFileMock.mockResolvedValueOnce(Buffer.from(' a .')); + + await expect(storage.getRule('http://example.com/a')).resolves.toBeRdfIsomorphic([ + DF.quad(DF.namedNode(baseIRI + 'a'), RDF.terms.type, DF.namedNode('http://example.com/type1')), + ]); + }); + + it('does not support deleting rules by identifier.', async(): Promise => { + await expect(storage.deleteRule('a')).rejects.toThrow('not implemented'); + }); +}); diff --git a/packages/ucp/test/unit/storage/MemoryUCRulesStorage.test.ts b/packages/ucp/test/unit/storage/MemoryUCRulesStorage.test.ts index 169e60f9..c243cf21 100644 --- a/packages/ucp/test/unit/storage/MemoryUCRulesStorage.test.ts +++ b/packages/ucp/test/unit/storage/MemoryUCRulesStorage.test.ts @@ -1,7 +1,7 @@ -import { Store } from 'n3'; +import { Parser, Store } from 'n3'; import { MemoryUCRulesStorage } from '../../../src/storage/MemoryUCRulesStorage' -import { turtleStringToStore } from '../../../src/util/Conversion'; import "jest-rdf"; + describe('A MemoryUCRulesStorage', () => { let storage: MemoryUCRulesStorage; const policyString = ` @@ -17,58 +17,51 @@ describe('A MemoryUCRulesStorage', () => { beforeEach(async () => { storage = new MemoryUCRulesStorage(); - policy = await turtleStringToStore(policyString) + policy = new Store(new Parser().parse(policyString)); }) - describe('when getting all rules', () => { - it('returns an empty N3 Store when no rules are added.', async () => { - const store = await storage.getStore(); - expect(store.size).toBe(0); - }) + it('returns an empty N3 Store when no rules are added.', async () => { + const store = await storage.getStore(); + expect(store.size).toBe(0); + }); - it('returns every triple that was added to the storage.', async () => { - await storage.addRule(policy); - const store = await storage.getStore(); - expect(store).toBeRdfIsomorphic(policy); - }) - }) + it('returns every triple that was added to the storage.', async () => { + await expect(storage.addRule(policy)).resolves.toBeUndefined(); + const store = await storage.getStore(); + expect(store).toBeRdfIsomorphic(policy); + }); - describe('when adding a rule', () => { - it('successfully adds a rule.', async () => { - // should actually look into the store of the class. Can't do that right now - const result = await storage.addRule(policy) - expect(result).toBeUndefined(); - }) - }) + it('returns empty N3 store when no such rule is present.', async () => { + const store = await storage.getRule(ruleIRI); + expect(store.size).toBe(0); + }); - describe('when getting a rule', () => { - it('returns empty N3 store when no such rule is present.', async () => { - const store = await storage.getRule(ruleIRI); - expect(store.size === 0); - }) + it('returns the whole graph starting from the identifier given (thus also a rule).', async () => { + await storage.addRule(policy); + const store = await storage.getRule(ruleIRI); + expect(store.size).toBe(5); + }); - it('returns the whole graph starting from the identifier given (thus also a rule).', async () => { - await storage.addRule(policy) - const store = await storage.getRule(ruleIRI); - expect(store.size).toBe(5) - }) + it('deleting an empty storage does nothing.', async () => { + await expect(storage.deleteRule(ruleIRI)).resolves.toBeUndefined(); }) - describe('when deleting a rule', () => { - // as I can not look into the store, this gets a little ugly. Reason being, I use other methods from the storage class to check correct behaviour of delete. - it('deleleting an empty storage does nothing.', async () => { - // tho it runs and doesn't error. - const result = await storage.deleteRule(ruleIRI); - expect(result).toBeUndefined(); - }) + it('successfully deletes a rule.', async () => { + await expect(storage.addRule(policy)).resolves.toBeUndefined(); + await expect(storage.deleteRule(ruleIRI)).resolves.toBeUndefined(); + const ruleStore = await storage.getRule(ruleIRI); + expect(ruleStore.size).toBe(0); + const store = await storage.getStore(); + expect(store.size).toBe(2) + }); - it('successfully deletes a rule.', async () => { - await storage.addRule(policy); - await storage.deleteRule(ruleIRI); - const ruleStore = await storage.getRule(ruleIRI); - expect(ruleStore.size === 0); - const store = await storage.getStore(); - expect(store.size).toBe(2) - }) - }) -}) \ No newline at end of file + it('can delete specific triples.', async(): Promise => { + await expect(storage.addRule(policy)).resolves.toBeUndefined(); + const data = new Parser().parse( + ' .', + ); + await expect(storage.removeData(new Store(data))).resolves.toBeUndefined(); + const store = await storage.getStore(); + expect(store.size).toBe(6); + }); +}); diff --git a/packages/ucp/test/unit/util/Util.test.ts b/packages/ucp/test/unit/util/Util.test.ts new file mode 100644 index 00000000..f8247390 --- /dev/null +++ b/packages/ucp/test/unit/util/Util.test.ts @@ -0,0 +1,36 @@ +import 'jest-rdf'; +import { DataFactory as DF, Store } from 'n3'; +import { extractQuadsRecursive } from '../../../src/util/Util'; + +describe('Util', (): void => { + describe('#extractQuadsRecursive', (): void => { + it('only adds linked quads.', async(): Promise => { + const quads = [ + DF.quad(DF.namedNode('urn:a'), DF.namedNode('urn:b'), DF.namedNode('urn:c')), + DF.quad(DF.namedNode('urn:a'), DF.namedNode('urn:d'), DF.namedNode('urn:e')), + DF.quad(DF.namedNode('urn:c'), DF.namedNode('urn:f'), DF.namedNode('urn:g')), + DF.quad(DF.namedNode('urn:h'), DF.namedNode('urn:i'), DF.namedNode('urn:j')), + ]; + + expect(extractQuadsRecursive(new Store(quads), 'urn:a')).toBeRdfIsomorphic([ + DF.quad(DF.namedNode('urn:a'), DF.namedNode('urn:b'), DF.namedNode('urn:c')), + DF.quad(DF.namedNode('urn:a'), DF.namedNode('urn:d'), DF.namedNode('urn:e')), + DF.quad(DF.namedNode('urn:c'), DF.namedNode('urn:f'), DF.namedNode('urn:g')), + ]); + }); + + it('does not get stuck in infinite loops.', async(): Promise => { + const quads = [ + DF.quad(DF.namedNode('urn:a'), DF.namedNode('urn:b'), DF.namedNode('urn:c')), + DF.quad(DF.namedNode('urn:c'), DF.namedNode('urn:d'), DF.namedNode('urn:e')), + DF.quad(DF.namedNode('urn:e'), DF.namedNode('urn:f'), DF.namedNode('urn:c')), + ]; + + expect(extractQuadsRecursive(new Store(quads), 'urn:a')).toBeRdfIsomorphic([ + DF.quad(DF.namedNode('urn:a'), DF.namedNode('urn:b'), DF.namedNode('urn:c')), + DF.quad(DF.namedNode('urn:c'), DF.namedNode('urn:d'), DF.namedNode('urn:e')), + DF.quad(DF.namedNode('urn:e'), DF.namedNode('urn:f'), DF.namedNode('urn:c')), + ]); + }); + }); +}); diff --git a/packages/ucp/tsconfig.json b/packages/ucp/tsconfig.json index cba496ee..c2aac31c 100644 --- a/packages/ucp/tsconfig.json +++ b/packages/ucp/tsconfig.json @@ -9,7 +9,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "node16", - // + "types": [ "vitest/globals" ], "declaration": true, "isolatedModules": true, "inlineSourceMap": true, @@ -20,6 +20,5 @@ "downlevelIteration": true, }, "include": ["src"], - "exclude": ["node_modules", "**/*.test.ts"] + "exclude": ["node_modules"] } - diff --git a/packages/uma/config/routes/introspection.json b/packages/uma/config/routes/introspection.json index 3827d8de..7189ab99 100644 --- a/packages/uma/config/routes/introspection.json +++ b/packages/uma/config/routes/introspection.json @@ -9,8 +9,7 @@ "methods": [ "POST" ], "handler": { "@type": "IntrospectionHandler", - "tokenStore": { "@id": "urn:uma:default:TokenStore" }, - "jwtTokenFactory": { "@id": "urn:uma:default:TokenFactory" } + "tokenFactory": { "@id": "urn:uma:default:TokenFactory" } }, "path": "/uma/introspect" } diff --git a/packages/uma/package.json b/packages/uma/package.json index 2938203f..6e0971a5 100644 --- a/packages/uma/package.json +++ b/packages/uma/package.json @@ -42,9 +42,7 @@ "types": "./dist/index.d.ts", "exports": { "./package.json": "./package.json", - ".": { - "require": "./dist/index.js" - } + ".": "./dist/index.js" }, "files": [ ".componentsignore", diff --git a/packages/uma/src/credentials/verify/JwtVerifier.ts b/packages/uma/src/credentials/verify/JwtVerifier.ts index fe9c6da1..a8a4c008 100644 --- a/packages/uma/src/credentials/verify/JwtVerifier.ts +++ b/packages/uma/src/credentials/verify/JwtVerifier.ts @@ -53,10 +53,14 @@ export class JwtVerifier implements Verifier { await jwtVerify(credential.token, Object.assign(jwk, { type: 'JWK' })); } - for (const claim of Object.keys(claims)) if (!this.allowedClaims.includes(claim)) { - if (this.errorOnExtraClaims) throw new Error(`Claim '${claim}' not allowed.`); + for (const claim of Object.keys(claims)) { + if (!this.allowedClaims.includes(claim)) { + if (this.errorOnExtraClaims) { + throw new Error(`Claim '${claim}' not allowed.`); + } - delete claims[claim]; + delete claims[claim]; + } } this.logger.debug(`Returning discovered claims: ${JSON.stringify(claims)}`) diff --git a/packages/uma/src/credentials/verify/SolidOidcVerifier.ts b/packages/uma/src/credentials/verify/SolidOidcVerifier.ts index 976f4ae0..5c71265f 100644 --- a/packages/uma/src/credentials/verify/SolidOidcVerifier.ts +++ b/packages/uma/src/credentials/verify/SolidOidcVerifier.ts @@ -1,4 +1,4 @@ -import { getLoggerFor } from '@solid/community-server'; +import { BadRequestHttpError, getLoggerFor } from '@solid/community-server'; import { Verifier } from './Verifier'; import { ClaimSet } from '../ClaimSet'; import { Credential } from "../Credential"; @@ -18,7 +18,7 @@ export class SolidOidcVerifier implements Verifier { public async verify(credential: Credential): Promise { this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`); if (credential.format !== OIDC) { - throw new Error(`Token format ${credential.format} does not match this processor's format.`); + throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`); } try { @@ -35,7 +35,7 @@ export class SolidOidcVerifier implements Verifier { const message = `Error verifying OIDC ID Token: ${(error as Error).message}`; this.logger.debug(message); - throw new Error(message); + throw new BadRequestHttpError(message); } } } diff --git a/packages/uma/src/dialog/BaseNegotiator.ts b/packages/uma/src/dialog/BaseNegotiator.ts index 673b62a7..142adfca 100644 --- a/packages/uma/src/dialog/BaseNegotiator.ts +++ b/packages/uma/src/dialog/BaseNegotiator.ts @@ -23,13 +23,13 @@ import { serializePolicyInstantiation } from '../logging/OperationSerializer'; */ export class BaseNegotiator implements Negotiator { protected readonly logger = getLoggerFor(this); - operationLogger = getOperationLogger(); + protected readonly operationLogger = getOperationLogger(); /** * Construct a new Negotiator * @param verifier - The Verifier used to verify Claims of incoming Credentials. * @param ticketStore - A KeyValueStorage to track Tickets. - * @param ticketManager - The strategy describing the life cycle of a Ticket. + * @param ticketingStrategy - The strategy describing the life cycle of a Ticket. * @param tokenFactory - A factory for minting Access Tokens. */ public constructor( @@ -41,10 +41,6 @@ export class BaseNegotiator implements Negotiator { /** * Performs UMA grant negotiation. - * - * @param {TokenRequest} body - request body - * @param {HttpHandlerContext} context - request context - * @return {Promise} tokens - yielded tokens */ public async negotiate(input: DialogInput): Promise { reType(input, DialogInput); diff --git a/packages/uma/src/dialog/ContractNegotiator.ts b/packages/uma/src/dialog/ContractNegotiator.ts index 3bad32f4..c8ca223e 100644 --- a/packages/uma/src/dialog/ContractNegotiator.ts +++ b/packages/uma/src/dialog/ContractNegotiator.ts @@ -21,14 +21,13 @@ import { DialogOutput } from './Output'; export class ContractNegotiator extends BaseNegotiator { protected readonly logger = getLoggerFor(this); - // protected readonly operationLogger = getOperationLogger(); protected readonly contractManager = new ContractManager(); /** * Construct a new Negotiator * @param verifier - The Verifier used to verify Claims of incoming Credentials. * @param ticketStore - A KeyValueStore to track Tickets. - * @param ticketManager - The strategy describing the life cycle of a Ticket. + * @param ticketingStrategy - The strategy describing the life cycle of a Ticket. * @param tokenFactory - A factory for minting Access Tokens. */ public constructor( @@ -43,15 +42,15 @@ export class ContractNegotiator extends BaseNegotiator { /** * Performs UMA grant negotiation. - * - * @param {TokenRequest} body - request body - * @param {HttpHandlerContext} context - request context - * @return {Promise} tokens - yielded tokens */ public async negotiate(input: DialogInput): Promise { reType(input, DialogInput); - if (!input.permissions && input.permission?.length) - input.permissions = input.permission.map(p => processRequestPermission(p)) + if (!input.permissions && input.permission?.length) { + input = { + ...input, + permissions: input.permission.map(p => processRequestPermission(p)), + }; + } this.logger.debug(`Input. ${JSON.stringify(input)}`); // Create or retrieve ticket const ticket = await this.getTicket(input); @@ -170,7 +169,7 @@ export class ContractNegotiator extends BaseNegotiator { const policyCreationResponse = await fetch(instantiatedPolicyContainer, { method: 'POST', headers: { 'content-type': 'application/ld+json' }, - body: JSON.stringify(contract, null, 2) + body: JSON.stringify(contract), }); if (policyCreationResponse.status !== 201) { diff --git a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts index 69d3831f..e43e8c1e 100644 --- a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts @@ -2,10 +2,9 @@ import { getLoggerFor, KeyValueStorage } from '@solid/community-server'; import { ResourceDescription } from '../../views/ResourceDescription'; import { Authorizer } from './Authorizer'; import { Permission } from '../../views/Permission'; -import { Requirements, type ClaimVerifier } from '../../credentials/Requirements'; +import { Requirements } from '../../credentials/Requirements'; import { ClaimSet } from '../../credentials/ClaimSet'; -const NO_RESOURCE = Symbol(); const namespace = (resource: string) => new URL(resource).pathname.split('/')?.[2] ?? ''; /** @@ -39,8 +38,8 @@ export class NamespacedAuthorizer implements Authorizer { const ns = query[0].resource_id ? await this.findNamespace(query[0].resource_id) : undefined; // Check namespaces of other resources - for (const permission of query) { - if ((permission.resource_id ? namespace(permission.resource_id) : undefined) !== ns) { + for (let i = 1; i < query.length; ++i) { + if ((query[i].resource_id ? await this.findNamespace(query[i].resource_id) : undefined) !== ns) { this.logger.warn(`Cannot calculate permissions over multiple namespaces at once.`); return []; } @@ -64,8 +63,8 @@ export class NamespacedAuthorizer implements Authorizer { const ns = await this.findNamespace(permissions[0].resource_id); // Check namespaces of other resources - for (const permission of permissions) { - if (namespace(permission.resource_id) !== ns) { + for (let i = 1; i < permissions.length; ++i) { + if (await this.findNamespace(permissions[i].resource_id) !== ns) { this.logger.warn(`Cannot calculate credentials over multiple namespaces at once.`); return []; } diff --git a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts index 70bac1d8..c01c9eac 100644 --- a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts @@ -1,6 +1,13 @@ -import { createVocabulary, DC, getLoggerFor, RDF } from '@solid/community-server'; +import { + BadRequestHttpError, + createVocabulary, + DC, + getLoggerFor, + NotImplementedHttpError, + RDF +} from '@solid/community-server'; import { basicPolicy, ODRL, UCPPolicy, UCRulesStorage } from '@solidlab/ucp'; -import { DataFactory, Literal, NamedNode, Quad_Subject, Store, Writer } from 'n3'; +import { DataFactory, Literal, NamedNode, Quad_Subject, Store } from 'n3'; import { EyeReasoner, ODRLEngineMultipleSteps, ODRLEvaluator } from 'odrl-evaluator' import { WEBID } from '../../credentials/Claims'; import { ClaimSet } from '../../credentials/ClaimSet'; @@ -62,19 +69,17 @@ export class OdrlAuthorizer implements Authorizer { // prepare sotw const sotw = new Store(); - sotw.add(quad(namedNode('http://example.com/request/currentTime'), namedNode('http://purl.org/dc/terms/issued'), literal(new Date().toISOString(), namedNode("http://www.w3.org/2001/XMLSchema#dateTime")))); + sotw.add(quad( + namedNode('http://example.com/request/currentTime'), + namedNode('http://purl.org/dc/terms/issued'), + literal(new Date().toISOString(), namedNode("http://www.w3.org/2001/XMLSchema#dateTime"))), + ); const subject = typeof claims[WEBID] === 'string' ? claims[WEBID] : 'urn:solidlab:uma:id:anonymous'; - for (const {resource_id, resource_scopes} of query) { - if (!resource_id) { - this.logger.warn('The OdrlAuthorizer can only calculate permissions for explicit resources.'); - continue; - } - grantedPermissions[resource_id] = []; - const actions = resource_scopes ? transformActionsCssToOdrl(resource_scopes) : ["http://www.w3.org/ns/odrl/2/use"] + const actions = transformActionsCssToOdrl(resource_scopes); for (const action of actions) { this.logger.info(`Evaluating Request [S R AR]: [${subject} ${resource_id} ${action}]`); const requestPolicy: UCPPolicy = { @@ -119,7 +124,7 @@ export class OdrlAuthorizer implements Authorizer { } public async credentials(permissions: Permission[], query?: Requirements | undefined): Promise { - throw new Error("Method not implemented."); + throw new NotImplementedHttpError('Method not implemented.'); } } @@ -141,7 +146,13 @@ function transformActionsCssToOdrl(actions: string[]): string[] { // in UMAPermissionReader, only the last part of the URN will be used, divided by a colon // again, see CSS package - return actions.map(action => scopeCssToOdrl.get(action)!); + return actions.map(action => { + const result = scopeCssToOdrl.get(action); + if (!result) { + throw new BadRequestHttpError(`Unsupported action ${action}`); + } + return result; + }); } /** * Transform ODRL Actions to equivalent Actions enforced by the Community Solid Server diff --git a/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts b/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts index 9151e73b..ecaf4d7b 100644 --- a/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts @@ -14,8 +14,7 @@ export class WebIdAuthorizer implements Authorizer { /** * Creates a PublicNamespaceAuthorizer with the given public namespaces. * - * @param namespaces - A list of namespaces that should be publicly accessible. - * @param authorizer - The Authorizer to use for other resources. + * @param webids - The WebIDs that can be used. */ constructor( protected webids: string[], @@ -44,7 +43,7 @@ export class WebIdAuthorizer implements Authorizer { if (query && !Object.keys(query).includes(WEBID)) return []; return [{ - [WEBID]: async (webid) => typeof webid === 'string' && this.webids.includes(webid), + [WEBID]: async (webid) => typeof webid === 'string' && this.webids.includes(webid), }]; } } diff --git a/packages/uma/src/routes/Config.ts b/packages/uma/src/routes/Config.ts index cefeafb9..c07dee4e 100644 --- a/packages/uma/src/routes/Config.ts +++ b/packages/uma/src/routes/Config.ts @@ -1,6 +1,6 @@ import { ASYMMETRIC_CRYPTOGRAPHIC_ALGORITHM } from '@solid/access-token-verifier/dist/constant/ASYMMETRIC_CRYPTOGRAPHIC_ALGORITHM'; -import { getLoggerFor } from '@solid/community-server'; +import { getLoggerFor, joinUrl } from '@solid/community-server'; import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler'; // eslint-disable no-unused-vars @@ -59,13 +59,13 @@ export class ConfigRequestHandler extends HttpHandler { */ public getConfig(): UmaConfiguration { return { - jwks_uri: `${this.baseUrl}/keys`, - token_endpoint: `${this.baseUrl}/token`, + jwks_uri: joinUrl(this.baseUrl, 'keys'), + token_endpoint: joinUrl(this.baseUrl, 'token'), grant_types_supported: ['urn:ietf:params:oauth:grant-type:uma-ticket'], - issuer: `${this.baseUrl}`, - permission_endpoint: `${this.baseUrl}/ticket`, - introspection_endpoint: `${this.baseUrl}/introspect`, - resource_registration_endpoint: `${this.baseUrl}/resources/`, + issuer: this.baseUrl, + permission_endpoint: joinUrl(this.baseUrl, 'ticket'), + introspection_endpoint: joinUrl(this.baseUrl, 'introspect'), + resource_registration_endpoint: joinUrl(this.baseUrl, 'resources/'), uma_profiles_supported: ['http://openid.net/specs/openid-connect-core-1_0.html#IDToken'], dpop_signing_alg_values_supported: [...ASYMMETRIC_CRYPTOGRAPHIC_ALGORITHM], response_types_supported: [ResponseType.Token], diff --git a/packages/uma/src/routes/Introspection.ts b/packages/uma/src/routes/Introspection.ts index c1eeb96d..2c8a9c6d 100644 --- a/packages/uma/src/routes/Introspection.ts +++ b/packages/uma/src/routes/Introspection.ts @@ -1,10 +1,7 @@ -import { BadRequestHttpError, getLoggerFor, KeyValueStorage, UnauthorizedHttpError } from '@solid/community-server'; -import { AccessToken } from '../tokens/AccessToken'; -import { JwtTokenFactory } from '../tokens/JwtTokenFactory'; -import { SerializedToken } from '../tokens/TokenFactory'; +import { BadRequestHttpError, getLoggerFor, UnauthorizedHttpError } from '@solid/community-server'; +import { TokenFactory } from '../tokens/TokenFactory'; import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler'; import { verifyRequest } from '../util/HttpMessageSignatures'; -import { jwtDecrypt } from 'jose'; type IntrospectionResponse = { @@ -28,71 +25,32 @@ export class IntrospectionHandler extends HttpHandler { /** * Creates an introspection handler for tokens in the given token store. * - * @param tokenStore - The store containing the tokens. - * @param jwtTokenFactory - The factory with which to produce JWT representations of the tokens. + * @param tokenFactory - The factory with which tokens were produced. */ constructor( - private readonly tokenStore: KeyValueStorage, - private readonly jwtTokenFactory: JwtTokenFactory, + private readonly tokenFactory: TokenFactory, ) { super(); } - async handle({request}: HttpHandlerContext): Promise> { + async handle({request}: HttpHandlerContext): Promise> { if (!await verifyRequest(request)) throw new UnauthorizedHttpError(); - if (!request.body /*|| !(request.body instanceof Object) */) { // todo: why was the object check here?? + if (!request.body) { throw new BadRequestHttpError('Missing request body.'); } const token = new URLSearchParams(request.body as Record).get('token'); try { - if(!token) throw new Error('could not extract token from request body') - const unsignedToken = await this.processJWTToken(token) + if (!token) throw new Error('could not extract token from request body') + const unsignedToken = await this.tokenFactory.deserialize(token); return { status: 200, - body: unsignedToken, + body: { ...unsignedToken, active: true }, }; } catch (e) { - // Todo: The JwtTokenFactory DOES NOT STORE THE TOKEN IN THE TOKENSTORE IN A WAY WE CAN RETRIEVE HERE! How to fix? this.logger.warn(`Token introspection failed: ${e}`) throw new BadRequestHttpError('Invalid request body.'); } - - - // Opaque token left-overs - ask Wouter? - - // try { - // const opaqueToken = new URLSearchParams(request.body).get('token'); - // if (!opaqueToken) throw new Error (); - - // const jwt = this.opaqueToJwt(opaqueToken); - // return { - // headers: {'content-type': 'application/json'}, - // status: 200, - // body: jwt, - // }; - // } catch (e) { - // throw new BadRequestHttpError('Invalid request body.'); - // } - - } - - - private async processJWTToken(signedJWT: string): Promise { - this.logger.info(JSON.stringify(this.tokenStore.entries().next(), null, 2)) - const token = (await this.tokenStore.get(signedJWT)) as IntrospectionResponse; - if (!token) throw new Error('Token not found.'); - token.active = true - return token - } - - // todo: check with Wouter what the goal here is? Since the Opaque Token Factory is not used atm? - private async opaqueToJwt(opaque: string): Promise { - const token = await this.tokenStore.get(opaque); - if (!token) throw new Error('Token not found.'); - - return this.jwtTokenFactory.serialize({ ...token, active: true } as AccessToken); } - } diff --git a/packages/uma/src/routes/Log.ts b/packages/uma/src/routes/Log.ts index b359dde1..e4f75ba1 100644 --- a/packages/uma/src/routes/Log.ts +++ b/packages/uma/src/routes/Log.ts @@ -2,12 +2,6 @@ import { getLoggerFor, serializeQuads } from '@solid/community-server'; import { getOperationLogger } from '../logging/OperationLogger'; import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler'; - -export type LogMessage = { - id: string, - message: string, -} - /** * An HttpHandler used for returning the logs * stored in the UMA Authorization Service. @@ -15,7 +9,7 @@ export type LogMessage = { export class LogRequestHandler extends HttpHandler { protected readonly logger = getLoggerFor(this); - operationLogger = getOperationLogger() + protected readonly operationLogger = getOperationLogger() /** * An HttpHandler used for returning the configuration @@ -32,7 +26,7 @@ export class LogRequestHandler extends HttpHandler { * @param {HttpHandlerContext} context - an irrelevant incoming context * @return {Observable} - the mock response */ - async handle(context: HttpHandlerContext): Promise { + public async handle(context: HttpHandlerContext): Promise { this.logger.info(`Received log access request at '${context.request.url}'`); return { @@ -46,7 +40,7 @@ export class LogRequestHandler extends HttpHandler { * Returns UMA Configuration for the AS * @return {UmaConfiguration} - AS Configuration */ - async getLogMessages(): Promise { + protected async getLogMessages(): Promise { let messages = this.operationLogger.getLogEntries(null); let serializedStream = serializeQuads(messages, 'application/trig') return await streamToString(serializedStream) as string diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts index af5430f3..d8f0cdd2 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -3,15 +3,14 @@ import { ConflictHttpError, createErrorMessage, getLoggerFor, + InternalServerError, KeyValueStorage, MethodNotAllowedHttpError, NotFoundHttpError, UnauthorizedHttpError, - UnsupportedMediaTypeHttpError, - XSD, } from '@solid/community-server'; import { ODRL, ODRL_P, OWL, RDF, UCRulesStorage } from '@solidlab/ucp'; -import { DataFactory as DF, NamedNode, Quad, Quad_Object, Quad_Subject, Store, Writer } from 'n3'; +import { DataFactory as DF, NamedNode, Quad, Quad_Object, Quad_Subject, Store } from 'n3'; import { randomUUID } from 'node:crypto'; import { HttpHandler, @@ -61,7 +60,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { case 'POST': return this.handlePost(request); case 'PUT': return this.handlePut(request); case 'DELETE': return this.handleDelete(request); - default: throw new MethodNotAllowedHttpError(); + default: throw new MethodNotAllowedHttpError([ request.method ]); } } @@ -101,17 +100,15 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { }); } - protected async handlePut({ body, headers, parameters }: HttpHandlerRequest): Promise { - if (typeof parameters?.id !== 'string') throw new Error('URI for PUT operation should include an id.'); + protected async handlePut({ body, parameters }: HttpHandlerRequest): Promise { + if (typeof parameters?.id !== 'string') { + throw new InternalServerError('URI for PUT operation should include an id.'); + } if (!await this.resourceStore.has(parameters.id)) { throw new NotFoundHttpError(); } - if (headers['content-type'] !== 'application/json') { - throw new UnsupportedMediaTypeHttpError('Only Media Type "application/json" is supported for this route.'); - } - try { reType(body, ResourceDescription); } catch (e) { @@ -124,18 +121,19 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { return ({ status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ + body: { _id: parameters.id, user_access_policy_uri: 'TODO: implement policy UI', - }), + }, }); } protected async handleDelete({ parameters }: HttpHandlerRequest): Promise { - if (typeof parameters?.id !== 'string') throw new Error('URI for DELETE operation should include an id.'); + if (typeof parameters?.id !== 'string') { + throw new InternalServerError('URI for DELETE operation should include an id.'); + } - if (!await this.resourceStore.delete(parameters.id)) { + if (!await this.resourceStore.has(parameters.id)) { throw new NotFoundHttpError('Registration to be deleted does not exist (id unknown).'); } diff --git a/packages/uma/src/routes/Ticket.ts b/packages/uma/src/routes/Ticket.ts index 0f580d77..dcb6ef42 100644 --- a/packages/uma/src/routes/Ticket.ts +++ b/packages/uma/src/routes/Ticket.ts @@ -23,8 +23,8 @@ export class TicketRequestHandler extends HttpHandler { protected readonly logger = getLoggerFor(this); constructor( - private readonly ticketingStrategy: TicketingStrategy, - private readonly ticketStore: KeyValueStorage, + protected readonly ticketingStrategy: TicketingStrategy, + protected readonly ticketStore: KeyValueStorage, ) { super(); } diff --git a/packages/uma/src/routes/Token.ts b/packages/uma/src/routes/Token.ts index e58bffb9..e4610c1a 100644 --- a/packages/uma/src/routes/Token.ts +++ b/packages/uma/src/routes/Token.ts @@ -1,4 +1,4 @@ -import { BadRequestHttpError, ForbiddenHttpError, getLoggerFor } from '@solid/community-server'; +import { BadRequestHttpError, getLoggerFor } from '@solid/community-server'; import { DialogInput } from '../dialog/Input'; import { Negotiator } from '../dialog/Negotiator'; import { NeedInfoError } from '../errors/NeedInfoError'; @@ -28,9 +28,7 @@ export class TokenRequestHandler extends HttpHandler { } if (params['grant_type'] !== 'urn:ietf:params:oauth:grant-type:uma-ticket') { - throw new BadRequestHttpError( - `Expected 'grant_type' to be set to 'urn:ietf:params:oauth:grant-type:uma-ticket' - `); + throw new BadRequestHttpError(`Expected 'grant_type' to be set to 'urn:ietf:params:oauth:grant-type:uma-ticket'`); } try { diff --git a/packages/uma/src/ticketing/Ticket.ts b/packages/uma/src/ticketing/Ticket.ts index 524a0fd2..d309d183 100644 --- a/packages/uma/src/ticketing/Ticket.ts +++ b/packages/uma/src/ticketing/Ticket.ts @@ -6,4 +6,4 @@ export interface Ticket { permissions: Permission[], required: Requirements[], provided: ClaimSet, -}; +} diff --git a/packages/uma/src/ticketing/strategy/ClaimEliminationStrategy.ts b/packages/uma/src/ticketing/strategy/ClaimEliminationStrategy.ts index d9da2d42..da2e1dc5 100644 --- a/packages/uma/src/ticketing/strategy/ClaimEliminationStrategy.ts +++ b/packages/uma/src/ticketing/strategy/ClaimEliminationStrategy.ts @@ -17,11 +17,11 @@ export class ClaimEliminationStrategy implements TicketingStrategy { protected readonly logger = getLoggerFor(this); constructor( - private authorizer: Authorizer, + protected authorizer: Authorizer, ) {} /** @inheritdoc */ - async initializeTicket(permissions: Permission[]): Promise { + public async initializeTicket(permissions: Permission[]): Promise { this.logger.info(`Initializing ticket. ${JSON.stringify(permissions)}`) return ({ @@ -31,12 +31,12 @@ export class ClaimEliminationStrategy implements TicketingStrategy { }); } - private async calculateRequiredClaims(permissions: Permission[]): Promise { + protected async calculateRequiredClaims(permissions: Permission[]): Promise { return this.authorizer.credentials(permissions); } /** @inheritdoc */ - async validateClaims(ticket: Ticket, claims: ClaimSet): Promise { + public async validateClaims(ticket: Ticket, claims: ClaimSet): Promise { this.logger.debug(`Validating claims. ${JSON.stringify({ ticket, claims })}`); for (const key of Object.keys(claims)) { @@ -55,7 +55,7 @@ export class ClaimEliminationStrategy implements TicketingStrategy { } /** @inheritdoc {@link TicketingStrategy.resolveTicket} */ - async resolveTicket(ticket: Ticket): Promise> { + public async resolveTicket(ticket: Ticket): Promise> { this.logger.debug(`Resolving ticket. ${JSON.stringify(ticket)}`); return ticket.required.some(req => Object.keys(req).length === 0) diff --git a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts index a4ad2614..e9bc921d 100644 --- a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts +++ b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts @@ -15,11 +15,11 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy { protected readonly logger = getLoggerFor(this); constructor( - private authorizer: Authorizer, + protected authorizer: Authorizer, ) {} /** @inheritdoc */ - async initializeTicket(permissions: Permission[]): Promise { + public async initializeTicket(permissions: Permission[]): Promise { this.logger.info(`Initializing ticket. ${JSON.stringify(permissions)}`) return ({ @@ -30,7 +30,7 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy { } /** @inheritdoc */ - async validateClaims(ticket: Ticket, claims: ClaimSet): Promise { + public async validateClaims(ticket: Ticket, claims: ClaimSet): Promise { this.logger.info(`Validating claims. ${JSON.stringify({ ticket, claims })}`); for (const key of Object.keys(claims)) { @@ -41,7 +41,7 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy { } /** @inheritdoc */ - async resolveTicket(ticket: Ticket): Promise> { + public async resolveTicket(ticket: Ticket): Promise> { this.logger.info(`Resolving ticket. ${JSON.stringify(ticket)}`); const permissions = await this.calculatePermissions(ticket); @@ -51,7 +51,7 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy { return Success(permissions); } - private async calculatePermissions(ticket: Ticket): Promise { + protected async calculatePermissions(ticket: Ticket): Promise { return (await this.authorizer.permissions(ticket.provided, ticket.permissions)).filter( permission => permission.resource_scopes.length > 0 ); diff --git a/packages/uma/src/tokens/JwtTokenFactory.ts b/packages/uma/src/tokens/JwtTokenFactory.ts index 4af3a7c1..e29bec07 100644 --- a/packages/uma/src/tokens/JwtTokenFactory.ts +++ b/packages/uma/src/tokens/JwtTokenFactory.ts @@ -1,8 +1,8 @@ import { importJWK, jwtVerify, SignJWT } from 'jose'; import { BadRequestHttpError, + createErrorMessage, getLoggerFor, - HttpErrorClass, JwkGenerator, KeyValueStorage } from '@solid/community-server'; @@ -26,39 +26,41 @@ export class JwtTokenFactory extends TokenFactory { protected readonly logger = getLoggerFor(this); /** - * Construct a new ticket factory - * @param {JwkGenerator} keyGen - key generator to be used in issuance - * @param {KeyValueStore} tokenStore - */ + * Construct a new ticket factory + * @param keyGen - key generator to be used in issuance + * @param issuer - server URL to assign to the issuer field + * @param tokenStore - stores the link between JWT and access token + * @param params - additional parameters for the generated JWT + */ constructor( - private readonly keyGen: JwkGenerator, - private readonly issuer: string, - private readonly params: JwtTokenParams = {expirationTime: '30m', aud: 'solid'}, - private readonly tokenStore: KeyValueStorage + protected readonly keyGen: JwkGenerator, + protected readonly issuer: string, + protected readonly tokenStore: KeyValueStorage, + protected readonly params: JwtTokenParams = { expirationTime: '30m', aud: 'solid' }, ) { super(); } /** * Serializes an Access Token into a JWT - * @param {AccessTokentoken} token - authenticated and authorized principal + * @param {AccessToken} token - authenticated and authorized principal * @return {Promise} - access token response */ public async serialize(token: AccessToken): Promise { const key = await this.keyGen.getPrivateKey(); const jwk = await importJWK(key, key.alg); const jwt = await new SignJWT({ permissions: token.permissions, contract: token.contract }) - .setProtectedHeader({alg: key.alg, kid: key.kid}) + .setProtectedHeader({ alg: key.alg, kid: key.kid }) .setIssuedAt() .setIssuer(this.issuer) - .setAudience(AUD) + .setAudience(this.params.aud ?? AUD) .setExpirationTime(this.params.expirationTime) .setJti(randomUUID()) .sign(jwk); this.logger.debug(`Issued new JWT Token ${JSON.stringify(token)}`); await this.tokenStore.set(jwt, token); - return {token: jwt, tokenType: 'Bearer'}; + return { token: jwt, tokenType: 'Bearer' }; } /** @@ -72,34 +74,22 @@ export class JwtTokenFactory extends TokenFactory { try { const { payload } = await jwtVerify(token, jwk, { issuer: this.issuer, - audience: AUD, + audience: this.params.aud ?? AUD, }); - if (/* !payload.sub ||*/ !payload.aud || !payload.permissions || !payload.azp || !payload.webid) { - throw new Error('Missing JWT parameter(s): {sub, aud, permissions, webid, azp} are required.'); + if (!payload.permissions) { + throw new Error('missing required "permissions" claim.'); } - if (typeof payload.webid !== 'string') throw new Error('JWT claim "webid" is not a string.'); - if (typeof payload.azp !== 'string') throw new Error('JWT claim "azp" is not a string.'); - const permissions = payload.permissions; reType(permissions, array(Permission)); return { permissions }; - } catch (error: any) { - this.error(BadRequestHttpError, `Invalid Access Token provided, error while parsing: ${error.message}`); + } catch (error: unknown) { + const msg = `Invalid Access Token provided, error while parsing: ${createErrorMessage(error)}`; + this.logger.warn(msg); + throw new BadRequestHttpError(msg); } } - - /** - * Logs and throws an error - * - * @param {HttpErrorClass} constructor - the error constructor - * @param {string} message - the error message - */ - private error(constructor: HttpErrorClass, message: string): never { - this.logger.warn(message); - throw new constructor(message); - } } diff --git a/packages/uma/src/util/HttpMessageSignatures.ts b/packages/uma/src/util/HttpMessageSignatures.ts index bcbe1cfc..976177ca 100644 --- a/packages/uma/src/util/HttpMessageSignatures.ts +++ b/packages/uma/src/util/HttpMessageSignatures.ts @@ -1,5 +1,5 @@ -import { UnauthorizedHttpError, type AlgJwk, BadRequestHttpError, InternalServerError } from '@solid/community-server'; -import { httpbis, type SigningKey, type Request as SignRequest } from 'http-message-signatures'; +import { UnauthorizedHttpError, type AlgJwk, BadRequestHttpError } from '@solid/community-server'; +import { httpbis, type SigningKey, type Request as SignRequest, defaultParams } from 'http-message-signatures'; import { verifyMessage } from 'http-message-signatures/lib/httpbis'; import { type SignatureParameters, type VerifierFinder, type VerifyingKey } from 'http-message-signatures/lib/types'; import { HttpHandlerRequest } from './http/models/HttpHandler'; @@ -17,12 +17,13 @@ export async function signRequest( id: jwk.kid, alg: jwk.alg, async sign(data: BufferSource) { - const key = await crypto.subtle.importKey('jwk', jwk, jwk.alg, false, ['sign', 'verify']); - return Buffer.from(await crypto.subtle.sign(jwk.alg, key, data)); + const params = algMap[jwk.alg]; + const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['sign']); + return Buffer.from(await crypto.subtle.sign(params, key, data)); }, }; - return await httpbis.signMessage({ key }, { ...request, url }); + return await httpbis.signMessage({ key, fields: [ '@target-uri', '@method' ] }, { ...request, url }); } export async function extractRequestSigner(request: HttpHandlerRequest): Promise { @@ -40,11 +41,7 @@ export async function extractRequestSigner(request: HttpHandlerRequest): Promise throw new UnauthorizedHttpError(); } - const signer = params.cred; - - if (!signer) throw new UnauthorizedHttpError('No valid HTTPSig authorization header found.'); - - return signer; + return params.cred; } export async function verifyRequest( @@ -66,11 +63,9 @@ export async function verifyRequest( domain: signer!, alg: alg ?? '', kid: keyid ?? '', - }) + }); if (!alg) throw new BadRequestHttpError('Invalid HTTP message Signature parameters.'); - // if (alg === 'EdDSA') throw new InternalServerError('EdDSA signing is not supported'); - // if (alg === 'ES256K') throw new InternalServerError('ES256K signing is not supported'); const verifier: VerifyingKey = { id: keyid, diff --git a/packages/uma/src/util/http/server/JsonFormHttpHandler.ts b/packages/uma/src/util/http/server/JsonFormHttpHandler.ts index 0cb26e90..22c46a13 100644 --- a/packages/uma/src/util/http/server/JsonFormHttpHandler.ts +++ b/packages/uma/src/util/http/server/JsonFormHttpHandler.ts @@ -30,7 +30,7 @@ export class JsonFormHttpHandler extends HttpHandler, } public async canHandle(context: HttpHandlerContext): Promise { - let body: unknown | undefined; + let body: unknown; if (context.request.body) { const contentType = parseContentType(context.request.headers['content-type']); if (contentType.value === APPLICATION_X_WWW_FORM_URLENCODED) { diff --git a/packages/uma/src/util/http/server/JsonHttpErrorHandler.ts b/packages/uma/src/util/http/server/JsonHttpErrorHandler.ts index 4de75ff8..e74013c2 100644 --- a/packages/uma/src/util/http/server/JsonHttpErrorHandler.ts +++ b/packages/uma/src/util/http/server/JsonHttpErrorHandler.ts @@ -58,7 +58,7 @@ export class JsonHttpErrorHandler extends HttpHandler super(); } - async handle(context: HttpHandlerContext): Promise> { + public async handle(context: HttpHandlerContext): Promise> { try { return await this.handler.handleSafe(context); } catch (error) { diff --git a/packages/uma/src/util/http/server/NodeHttpRequestResponseHandler.ts b/packages/uma/src/util/http/server/NodeHttpRequestResponseHandler.ts index 6d6a2109..e78d90df 100644 --- a/packages/uma/src/util/http/server/NodeHttpRequestResponseHandler.ts +++ b/packages/uma/src/util/http/server/NodeHttpRequestResponseHandler.ts @@ -38,7 +38,7 @@ export class NodeHttpRequestResponseHandler extends NodeHttpStreamsHandler { if (!requestStream.method) { // No request method was received, this path is technically impossible to reach this.logger.warn('No method received'); - throw new Error('method of the request cannot be null or undefined.'); + throw new BadRequestHttpError('method of the request cannot be null or undefined.'); } const urlObject: URL = new URL((await this.targetExtractor.handleSafe({ request: requestStream })).path); diff --git a/packages/uma/src/util/rdf/RequestProcessing.ts b/packages/uma/src/util/rdf/RequestProcessing.ts index 9f06294a..c54cf4b1 100644 --- a/packages/uma/src/util/rdf/RequestProcessing.ts +++ b/packages/uma/src/util/rdf/RequestProcessing.ts @@ -1,6 +1,9 @@ -import { ClaimSet } from "../../credentials/ClaimSet"; -import { convertStringOrJsonLdIdentifierToString, ODRLPermission, StringOrJsonLdIdentifier } from "../../views/Contract"; -import { Permission } from "../../views/Permission"; +import { + convertStringOrJsonLdIdentifierToString, + ODRLPermission, + StringOrJsonLdIdentifier +} from '../../views/Contract'; +import { Permission } from '../../views/Permission'; export function switchODRLandCSSPermission(permission: string): string { if(permission.startsWith("urn:example:css:modes:")) { @@ -10,7 +13,6 @@ export function switchODRLandCSSPermission(permission: string): string { } else { throw new Error(`Permission ${permission} not recognized`) } - } export function processRequestPermission(permission: ODRLPermission): Permission { @@ -21,8 +23,3 @@ export function processRequestPermission(permission: ODRLPermission): Permission return { resource_id, resource_scopes } } - -export function extractRequestClaims(permission: ODRLPermission): ClaimSet { - - return {} -} \ No newline at end of file diff --git a/packages/uma/test/authn/BasicClaimTokenProcessor.test.ts b/packages/uma/test/authn/BasicClaimTokenProcessor.test.ts deleted file mode 100644 index efe5a4dc..00000000 --- a/packages/uma/test/authn/BasicClaimTokenProcessor.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {BasicClaimTokenProcessor} from './BasicClaimTokenProcessor'; - -const TOKEN_URL = new URL('https://example.org/token'); - -const claimTokenProcessor = new BasicClaimTokenProcessor(); -describe('Unhappy Flows', () => { - test('Should return undefined for unsupported token type', async () => { - expect(await claimTokenProcessor.process({ - url: TOKEN_URL, - method: 'POST', - claim_token: 'abc', - claim_token_format: 'urn:example:test', - })).toBeUndefined(); - }); - test('Should throw error for invalid formatted token', async () => { - expect(async () => await claimTokenProcessor.process({ - url: TOKEN_URL, - method: 'POST', - claim_token: 'abc:def:ghi', - claim_token_format: 'urn:authorization-agent:dummy-token', - })).rejects.toThrowError('Error verifying Access Token via WebID: ' + - 'Invalid token format, only one \':\' is expected.'); - }); - test('Should throw error for non-URL WebID', async () => { - expect(async () => await claimTokenProcessor.process({ - url: TOKEN_URL, - method: 'POST', - claim_token: `abc:${encodeURIComponent('https://example.org/app')}`, - claim_token_format: 'urn:authorization-agent:dummy-token', - })).rejects.toThrowError('Error verifying Access Token via WebID: ' + - 'Invalid URL'); - }); - test('Should throw error for non-URL ClientID', async () => { - expect(async () => await claimTokenProcessor.process({ - url: TOKEN_URL, - method: 'POST', - claim_token: `${encodeURIComponent('https://example.org/id')}:abc`, - claim_token_format: 'urn:authorization-agent:dummy-token', - })).rejects.toThrowError('Error verifying Access Token via WebID: ' + - 'Invalid URL'); - }); -}); - -describe('Happy Flows', () => { - test('Valid token should return Principal', async () => { - expect(await claimTokenProcessor.process({ - url: TOKEN_URL, - method: 'POST', - claim_token: `${encodeURIComponent('https://example.org/id')}:${encodeURIComponent('https://example.org/app')}`, - claim_token_format: 'urn:authorization-agent:dummy-token', - })).toEqual({webId: 'https://example.org/id', clientId: 'https://example.org/app'}); - }); - test('Valid token without ClientId should return Principal', async () => { - expect(await claimTokenProcessor.process({ - url: TOKEN_URL, - method: 'POST', - claim_token: `${encodeURIComponent('https://example.org/id')}`, - claim_token_format: 'urn:authorization-agent:dummy-token', - })).toEqual({webId: 'https://example.org/id'}); - }); - test('Should return supported claim format', () => { - expect(claimTokenProcessor.claimTokenFormat()).toEqual('urn:authorization-agent:dummy-token'); - }); -}); diff --git a/packages/uma/test/authn/DpopClaimTokenProcessor.test.ts b/packages/uma/test/authn/DpopClaimTokenProcessor.test.ts deleted file mode 100644 index 6bd94f24..00000000 --- a/packages/uma/test/authn/DpopClaimTokenProcessor.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {createSolidTokenVerifier} from '@solid/access-token-verifier'; -import {DpopClaimTokenProcessor} from './DpopClaimTokenProcessor'; - -const solidTokenVerifier = createSolidTokenVerifier() as jest.MockedFunction; - -describe('Unhappy Flows', () => { - const dpopClaimTokenProcessor = new DpopClaimTokenProcessor(); - beforeEach((): void => { - jest.clearAllMocks(); - }); - - test('Should return undefined for unsupported format', async () => { - expect(await dpopClaimTokenProcessor.process({claim_token_format: 'abc', claim_token: 'token-123', - url: new URL('http://example.com/token'), method: 'POST'})).toBeUndefined(); - }); - - test('Should throw error for missing DPoP', async () => { - expect(async () => await dpopClaimTokenProcessor.process({claim_token_format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken', claim_token: 'token-123', - url: new URL('http://example.com/token'), method: 'POST'})).rejects.toThrowError('DPoP Header is missing.'); - }); - test('When token verifier throws error, should throw error as well.', async () => { - solidTokenVerifier.mockImplementationOnce((): void => { - throw new Error('invalid'); - }); - expect(async () => await dpopClaimTokenProcessor.process({claim_token_format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken', claim_token: 'token-123', - url: new URL('http://example.com/token'), dpop: 'token-456', method: 'POST'})).rejects.toThrowError('Error verifying Access Token via WebID: invalid'); - }); -}); - - -describe('Happy Flows', () => { - const dpopClaimTokenProcessor = new DpopClaimTokenProcessor(); - beforeEach((): void => { - jest.clearAllMocks(); - }); - - test('Should call token verifier', async () => { - const result = await dpopClaimTokenProcessor.process({claim_token_format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken', claim_token: 'token-123', - dpop: 'token-456', - url: new URL('http://example.com/token'), method: 'POST'}); - expect(solidTokenVerifier).toHaveBeenCalledTimes(1); - expect(solidTokenVerifier).toHaveBeenCalledWith('DPoP token-123', {header: 'token-456', method: 'POST', url: 'http://example.com/token'}); - expect(result).toEqual({webId: 'http://alice.example/card#me', clientId: 'http://example.com/app'}); - }); - - test('Should return supported claim format', () => { - expect(dpopClaimTokenProcessor.claimTokenFormat()).toEqual('http://openid.net/specs/openid-connect-core-1_0.html#IDToken'); - }); -}); diff --git a/packages/uma/test/authz/AllAuthorizer.test.ts b/packages/uma/test/authz/AllAuthorizer.test.ts deleted file mode 100644 index 8c9ebe32..00000000 --- a/packages/uma/test/authz/AllAuthorizer.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {AllAuthorizer} from './AllAuthorizer'; -import {Authorizer} from '../models/Authorizer'; - -const WEBID = 'https://example.com/profile/alice#me'; -const CLIENT = 'https://projectapp.com'; -const RESOURCE = 'https://pods.example.com/test/123.ttl'; - -test('It should grant all modes in constructor', async () => { - const authorizer: Authorizer = new AllAuthorizer(); - - expect(await authorizer.authorize({ - webId: WEBID, - clientId: CLIENT - }, [{ - resource_id: RESOURCE, - resource_scopes: ['read'] - }])).toEqual(new Set(['read'])); -}); - -test('It should grant all modes by default', async () => { - const authorizer: Authorizer = new AllAuthorizer(); - - expect(await authorizer.authorize({ - webId: WEBID, - clientId: CLIENT - }, [{ - resource_id: RESOURCE, - resource_scopes: ['read'] - }])).toEqual( - new Set(['read', 'write', 'create', 'delete', 'append'])); -}); diff --git a/packages/uma/test/authz/OdrlAuthorizer.test.ts b/packages/uma/test/authz/OdrlAuthorizer.test.ts deleted file mode 100644 index 1bbefa4b..00000000 --- a/packages/uma/test/authz/OdrlAuthorizer.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import {OdrlAuthorizer} from "../../src/policies/authorizers/OdrlAuthorizer"; -import {turtleStringToStore} from "odrl-evaluator"; -import {Store} from "n3"; -import {MemoryUCRulesStorage, UCRulesStorage} from "@solidlab/ucp"; -import {ClaimSet} from "../../src/credentials/ClaimSet"; -import {Permission} from "../../src/views/Permission"; -import {Authorizer} from "../../src/policies/authorizers/Authorizer"; - -const resourceModifyPolicy = ` -@prefix ex: . -@prefix odrl: . - -ex:usagePolicy1 a odrl:Agreement . -ex:usagePolicy1 odrl:permission ex:permission1 . -ex:permission1 a odrl:Permission . -ex:permission1 odrl:action odrl:modify . -ex:permission1 odrl:target . -ex:permission1 odrl:assignee . -ex:permission1 odrl:assigner . -`; - -const resourceCreatePolicy = ` -@prefix ex: . -@prefix odrl: . -ex:usagePolicy1a a odrl:Agreement . -ex:usagePolicy1a odrl:permission ex:permission1a . -ex:permission1a a odrl:Permission . -ex:permission1a odrl:action odrl:create . -ex:permission1a odrl:target . -ex:permission1a odrl:assignee . -ex:permission1a odrl:assigner . -` - -const containerModifyPolicy = ` -@prefix ex: . -@prefix odrl: . -ex:usagePolicy2 a odrl:Agreement . -ex:usagePolicy2 odrl:permission ex:permission2a . -ex:permission2 a odrl:Permission . -ex:permission2 odrl:action odrl:modify . -ex:permission2 odrl:target . -ex:permission2 odrl:assignee . -ex:permission2 odrl:assigner . -` - -const containerCreatePolicy = ` -@prefix ex: . -@prefix odrl: . -ex:usagePolicy2a a odrl:Agreement . -ex:usagePolicy2a odrl:permission ex:permission2 . -ex:permission2a a odrl:Permission . -ex:permission2a odrl:action odrl:create . -ex:permission2a odrl:target . -ex:permission2a odrl:assignee . -ex:permission2a odrl:assigner . -` - -const resourceReadPolicy = ` -@prefix ex: . -@prefix odrl: . -ex:usagePolicy3 a odrl:Agreement . -ex:usagePolicy3 odrl:permission ex:permission3 . -ex:permission3 a odrl:Permission . -ex:permission3 odrl:action odrl:read . -ex:permission3 odrl:target . -ex:permission3 odrl:assignee . -ex:permission3 odrl:assigner . -` -describe('Odrl Authorizer', () => { - let policyStore: Store; - let rulesStorage: UCRulesStorage = new MemoryUCRulesStorage(); - let odrlAuthorizer: Authorizer; - let claims: ClaimSet; - let query: Permission[]; - - beforeAll(async () => { - policyStore = await turtleStringToStore(resourceModifyPolicy); - await rulesStorage.addRule(policyStore); - odrlAuthorizer = new OdrlAuthorizer(rulesStorage); - }) - - beforeEach(async () => { - claims = {'urn:solidlab:uma:claims:types:webid': 'https://woslabbi.pod.knows.idlab.ugent.be/profile/card#me'} - query = [ - { - resource_id: 'http://localhost:3000/alice/other/resource.txt', - resource_scopes: ['urn:example:css:modes:write', 'urn:example:css:modes:create'] - }, - { - resource_id: 'http://localhost:3000/alice/other/', - resource_scopes: ['urn:example:css:modes:create'] - } - ] - }) - - test("for a modify policy, should give only write access when the claims match.", async () => { - const expectedPermission: Permission[] = [ - { - resource_id: 'http://localhost:3000/alice/other/resource.txt', - resource_scopes: ['urn:example:css:modes:write'] - }, - { - resource_id: 'http://localhost:3000/alice/other/', - resource_scopes: [] - } - ] - const calculatedPermissions = await odrlAuthorizer.permissions(claims, query); - expect(calculatedPermissions).toEqual(expectedPermission); - }); - - test("for a modify policy, should give no access due to lack of claims.", async () => { - claims = {} - const expectedPermission: Permission[] = [ - { - resource_id: 'http://localhost:3000/alice/other/resource.txt', - resource_scopes: [] - }, - { - resource_id: 'http://localhost:3000/alice/other/', - resource_scopes: [] - } - ] - const calculatedPermissions = await odrlAuthorizer.permissions(claims, query); - expect(calculatedPermissions).toEqual(expectedPermission); - }); - - test("for appropriate create resource policies, should give all access when the claims match.", async () => { - const policyStore = await turtleStringToStore(resourceModifyPolicy + resourceCreatePolicy + containerCreatePolicy + containerModifyPolicy + resourceReadPolicy); - const ruleStorage = new MemoryUCRulesStorage(); - await ruleStorage.addRule(policyStore); - const odrlAuthorizer = new OdrlAuthorizer(ruleStorage); - const calculatedPermissions = await odrlAuthorizer.permissions(claims, query); - expect(calculatedPermissions).toEqual(query); - - }) -}) diff --git a/packages/uma/test/grant/UmaGrantProcessor.test.ts b/packages/uma/test/grant/UmaGrantProcessor.test.ts deleted file mode 100644 index b524385d..00000000 --- a/packages/uma/test/grant/UmaGrantProcessor.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import {BadRequestHttpError} from '../http/errors/BadRequestHttpError'; -import {HttpHandlerContext} from '../http/models/HttpHandlerContext'; -import {NeedInfoError} from '../error/NeedInfoError'; -import {RequestDeniedError} from '../error/RequestDeniedError'; -import {Ticket} from '../models/Ticket'; -import {Principal} from '../models/AccessToken'; -import {UmaGrantProcessor} from './UmaGrantProcessor'; -import { KeyValueStore } from '../storage/models/KeyValueStore.ts'; -const mockTicketStore: KeyValueStore = { - get: jest.fn(), - has: jest.fn(), - set: jest.fn(), - delete: jest.fn(), - entries: jest.fn(), -}; -const mockTokenFactory = {serialize: jest.fn(), deserialize: jest.fn()}; -const mockAuthorizer = {authorize: jest.fn()}; -const mockClaimTokenProcessor = {process: jest.fn(), - claimTokenFormat: jest.fn(() => 'urn:authorization-agent:dummy-token')}; - -const TOKEN_URI = new URL('https://uma.example.com/token'); -const WEBID = 'https://example.com/profile/alice#me'; -const WEBID_BIS = 'https://example.com/profile/carol#me'; - -const CLIENT = 'https://projectapp.com'; -const RESOURCE = 'https://pods.example.com/test/123.ttl'; - -describe('Happy Flows', () => { - const umaGrantProcessor = new UmaGrantProcessor([mockClaimTokenProcessor], [mockAuthorizer], - mockTicketStore, mockTokenFactory); - let requestContext: HttpHandlerContext; - - const sub = {iri: RESOURCE}; - const ticket: Ticket = [{ - resource_id: RESOURCE, - resource_scopes: ['read'], - }]; - const principal: Principal = {webId: WEBID, clientId: CLIENT}; - const authorizedModes = new Set(['read', 'write']); - - beforeEach(() => { - requestContext = { - request: { - url: TOKEN_URI, - method: 'POST', - body: '', - headers: {'content-type': 'application/x-www-form-urlencoded'}, - }, - }; - jest.resetAllMocks(); - }); - - test('Should support UMA Grant Type', () => { - expect(umaGrantProcessor.getSupportedGrantType()).toEqual('urn:ietf:params:oauth:grant-type:uma-ticket'); - }); - - test('Should process valid request', async () => { - const grantedModes = new Set(['read']); - mockClaimTokenProcessor.process.mockReturnValue(new Promise((resolve) => - resolve(principal))); - // mockTicketStore.deserialize.mockReturnValue(new Promise((resolve) => resolve(ticket))); - mockAuthorizer.authorize.mockReturnValue(new Promise((resolve) => resolve(authorizedModes))); - mockTokenFactory.serialize.mockReturnValue(new Promise((resolve) => resolve({token: 'token123', - tokenType: 'Bearer'}))); - - const body = new Map([['claim_token', 'abc'], - ['claim_token_format', 'token'], - ['ticket', '123'], - ]); - - const result = await umaGrantProcessor.process(body, requestContext); - expect(mockClaimTokenProcessor.process).toHaveBeenCalledWith({url: TOKEN_URI, method: 'POST', - claim_token: 'abc', claim_token_format: 'token'}); - // expect(mockTicketStore.deserialize).toHaveBeenCalledWith('123'); - expect(mockAuthorizer.authorize).toHaveBeenCalledWith(principal, ticket); - expect(mockTokenFactory.serialize).toHaveBeenCalledWith({...principal, modes: grantedModes, sub}); - expect(result).toEqual({access_token: 'token123', token_type: 'Bearer'}); - }); - - test('Should pass through DPoP Header', async () => { - requestContext.request.headers['dpop'] = 'dpop123'; - const grantedModes = new Set(['read']); - mockClaimTokenProcessor.process.mockReturnValue(new Promise((resolve) => - resolve(principal))); - // mockTicketStore.deserialize.mockReturnValue(new Promise((resolve) => resolve(ticket))); - mockAuthorizer.authorize.mockReturnValue(new Promise((resolve) => resolve(authorizedModes))); - mockTokenFactory.serialize.mockReturnValue(new Promise((resolve) => resolve({token: 'token123', - tokenType: 'Bearer'}))); - - const body = new Map([ - ['claim_token', 'abc'], - ['claim_token_format', 'token'], - ['ticket', '123'], - ]); - - const result = await umaGrantProcessor.process(body, requestContext); - expect(mockClaimTokenProcessor.process).toHaveBeenCalledWith({url: TOKEN_URI, method: 'POST', - claim_token: 'abc', claim_token_format: 'token', dpop: 'dpop123'}); - // expect(mockTicketStore.deserialize).toHaveBeenCalledWith('123'); - expect(mockAuthorizer.authorize).toHaveBeenCalledWith(principal, ticket); - expect(mockTokenFactory.serialize).toHaveBeenCalledWith({...principal, modes: grantedModes, sub}); - expect(result).toEqual({access_token: 'token123', token_type: 'Bearer'}); - }); - - test('Should pass through RPT', async () => { - const grantedModes = new Set(['read']); - mockClaimTokenProcessor.process.mockReturnValue(new Promise((resolve) => - resolve(principal))); - // mockTicketStore.deserialize.mockReturnValue(new Promise((resolve) => resolve(ticket))); - mockAuthorizer.authorize.mockReturnValue(new Promise((resolve) => resolve(authorizedModes))); - mockTokenFactory.serialize.mockReturnValue(new Promise((resolve) => resolve({token: 'token123', - tokenType: 'Bearer'}))); - - const body = new Map([ - ['claim_token', 'abc'], - ['claim_token_format', 'token'], - ['ticket', '123'], - ['rpt', 'rpt123'], - ]); - - const result = await umaGrantProcessor.process(body, requestContext); - expect(mockClaimTokenProcessor.process).toHaveBeenCalledWith({url: TOKEN_URI, method: 'POST', - claim_token: 'abc', claim_token_format: 'token', rpt: 'rpt123'}); - // expect(mockTicketStore.deserialize).toHaveBeenCalledWith('123'); - expect(mockAuthorizer.authorize).toHaveBeenCalledWith(principal, ticket); - expect(mockTokenFactory.serialize).toHaveBeenCalledWith({...principal, modes: grantedModes, sub}); - expect(result).toEqual({access_token: 'token123', token_type: 'Bearer'}); - }); -}); - -describe('Sad Flows', () => { - const umaGrantProcessor = new UmaGrantProcessor([mockClaimTokenProcessor], [mockAuthorizer], - mockTicketStore, mockTokenFactory); - let requestContext: HttpHandlerContext; - - const sub = {iri: RESOURCE}; - const ticket: Ticket = [{ - resource_id: RESOURCE, - resource_scopes: ['read'], - }]; - const principal: Principal = {webId: WEBID, clientId: CLIENT}; - - beforeEach(() => { - requestContext = { - request: { - url: TOKEN_URI, - method: 'POST', - body: '', - headers: {'content-type': 'application/x-www-form-urlencoded'}, - }, - }; - jest.resetAllMocks(); - }); - test('Missing `ticket` in body should throw error', () => { - const body = new Map([ - ['claim_token', 'abc'], - ['claim_token_format', 'token'], - ]); - - expect(async () => await umaGrantProcessor.process(body, requestContext)).rejects.toThrowError(BadRequestHttpError); - expect(async () => await umaGrantProcessor.process(body, requestContext)).rejects - .toThrowError('The request is missing one of the required body parameters:'+ - ' {\'ticket\', \'claim_token\', \'claim_token_format\'}'); - }); - - test('Missing `claim_token` in body should throw error', () => { - const body = new Map([ - ['claim_token_format', 'abc'], - ['ticket', '123'], - ]); - - expect(async () => await umaGrantProcessor.process(body, requestContext)).rejects.toThrowError(BadRequestHttpError); - expect(async () => await umaGrantProcessor.process(body, requestContext)).rejects - .toThrowError('The request is missing one of the required body parameters:'+ - ' {\'ticket\', \'claim_token\', \'claim_token_format\'}'); - }); - - test('Missing `claim_token_format` in body should throw error', () => { - const body = new Map([ - ['claim_token', 'abc'], - ['ticket', '123'], - ]); - - expect(async () => await umaGrantProcessor.process(body, requestContext)).rejects.toThrowError(BadRequestHttpError); - expect(async () => await umaGrantProcessor.process(body, requestContext)).rejects - .toThrowError('The request is missing one of the required body parameters:'+ - ' {\'ticket\', \'claim_token\', \'claim_token_format\'}'); - }); - - test('Unauthorized request should throw error', async () => { - mockClaimTokenProcessor.process.mockReturnValue(new Promise((resolve) => - resolve(principal))); - // mockTicketStore.deserialize.mockReturnValue(new Promise((resolve) => resolve(ticket))); - mockAuthorizer.authorize.mockReturnValue(new Promise((resolve) => resolve(new Set()))); - - const body = new Map([ - ['claim_token', 'abc'], - ['claim_token_format', 'token'], - ['ticket', '123'], - ]); - - try { - await umaGrantProcessor.process(body, requestContext); - } catch (e) { - expect(e).toBeInstanceOf(RequestDeniedError); - } - - expect(mockClaimTokenProcessor.process).toHaveBeenCalledWith({url: TOKEN_URI, method: 'POST', - claim_token: 'abc', claim_token_format: 'token'}); - // expect(mockTicketStore.deserialize).toHaveBeenCalledWith('123'); - expect(mockAuthorizer.authorize).toHaveBeenCalledWith(principal, ticket); - }); - - test('Invalid claim_token should re-throw error', async () => { - mockClaimTokenProcessor.process.mockRejectedValueOnce(new Error('invalid')); - const body = new Map([ - ['claim_token', 'abc'], - ['claim_token_format', 'token'], - ['ticket', '123'], - ]); - try { - await umaGrantProcessor.process(body, requestContext); - } catch (e) { - expect(e).toBeInstanceOf(NeedInfoError); - } - }); - - test('Unsupported claim_token_format should throw error', async () => { - mockClaimTokenProcessor.process.mockReturnValue(new Promise((resolve) => - resolve(undefined))); - const body = new Map([ - ['claim_token', 'abc'], - ['claim_token_format', 'token'], - ['ticket', '123'], - ]); - try { - await umaGrantProcessor.process(body, requestContext); - } catch (e) { - expect(e).toBeInstanceOf(NeedInfoError); - } - }); -}); diff --git a/packages/uma/test/routes/Config.test.ts b/packages/uma/test/routes/Config.test.ts deleted file mode 100644 index 39bb51ea..00000000 --- a/packages/uma/test/routes/Config.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {UmaConfigRequestHandler} from './UmaConfigRequestHandler'; -import {lastValueFrom} from 'rxjs'; -import {HttpHandlerContext} from '../http/models/HttpHandlerContext'; - -const BASE_URL = 'https://example.org'; - -describe('Happy flows', () => { - const requestHandler = new UmaConfigRequestHandler(BASE_URL); - let requestContext: HttpHandlerContext; - - beforeEach(() => { - requestContext = { - request: { - url: new URL(BASE_URL), - method: 'GET', - headers: {}, - }, - }; - }); - test('Handles GET request with configuration in body', async () => { - const response = await lastValueFrom(requestHandler.handle(requestContext)); - expect(JSON.parse(response.body)).toEqual({ - 'jwks_uri': `${BASE_URL}/keys`, - 'token_endpoint': `${BASE_URL}/token`, - 'grant_types_supported': [ - 'urn:ietf:params:oauth:grant-type:uma-ticket', - ], - 'dpop_signing_alg_values_supported': [ - 'ES256', - 'ES384', - 'ES512', - 'PS256', - 'PS384', - 'PS512', - 'RS256', - 'RS384', - 'RS512', - ], - 'issuer': 'https://example.org', - 'response_types_supported': [ - 'token', - ], - 'permission_registration_endpoint': 'https://example.org/register', - 'uma_profiles_supported': [ - 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken', - ], - }); - expect(response.status).toEqual(200); - }); -}); diff --git a/packages/uma/test/routes/Default.test.ts b/packages/uma/test/routes/Default.test.ts deleted file mode 100644 index 1b63a2b9..00000000 --- a/packages/uma/test/routes/Default.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {HttpHandlerContext} from '../http/models/HttpHandlerContext'; -import {lastValueFrom} from 'rxjs'; -import {DefaultRouteHandler} from './DefaultRouteHandler'; - -describe('Happy flows', () => { - const routeHandler = new DefaultRouteHandler(); - let requestContext: HttpHandlerContext; - - beforeEach(() => { - requestContext = { - request: { - url: new URL('http://localhost/'), - method: 'GET', - headers: {}, - }, - }; - }); - - test('Should return 404', async () => { - const response = await lastValueFrom(routeHandler.handle(requestContext)); - expect(response.status).toEqual(404); - }); -}); diff --git a/packages/uma/test/routes/Jwks.test.ts b/packages/uma/test/routes/Jwks.test.ts deleted file mode 100644 index c321a1e2..00000000 --- a/packages/uma/test/routes/Jwks.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {JwksRequestHandler} from './JwksRequestHandler'; -import {InMemoryJwksKeyHolder} from '../secrets/InMemoryJwksKeyHolder'; -import {lastValueFrom} from 'rxjs'; -import {HttpHandlerContext} from '../http/models/HttpHandlerContext'; - -describe('Happy flows', () => { - const keyHolder = new InMemoryJwksKeyHolder('ES256'); - const requestHandler = new JwksRequestHandler(keyHolder); - let requestContext: HttpHandlerContext; - - beforeEach(() => { - requestContext = { - request: { - url: new URL('http://localhost/'), - method: 'GET', - headers: {}, - }, - }; - }); - - test('Returns JWKS in response body', async () => { - const response = await lastValueFrom(requestHandler.handle(requestContext)); - expect(response.body).toEqual(JSON.stringify(await keyHolder.getJwks())); - expect(response.status).toEqual(200); - }); -}); diff --git a/packages/uma/test/routes/Ticket.test.ts b/packages/uma/test/routes/Ticket.test.ts deleted file mode 100644 index 23493631..00000000 --- a/packages/uma/test/routes/Ticket.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import {BadRequestHttpError} from '../http/errors/BadRequestHttpError'; -import {HttpHandlerContext} from '../http/models/HttpHandlerContext'; -import {InternalServerError} from '../http/errors/InternalServerError'; -import {UnauthorizedHttpError} from '../http/errors/UnauthorizedHttpError'; -import {UnsupportedMediaTypeHttpError} from '../http/errors/UnsupportedMediaTypeHttpError'; -import {PermissionRegistrationHandler, RequestingPartyRegistration} from './PermissionRegistrationHandler'; -import * as jose from 'jose'; -import {lastValueFrom} from 'rxjs'; - -/* - * Key Generation: - * =============== - * - * openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem - * openssl pkcs8 -topk8 -nocrypt -in private-key.pem -out private-key-pkcs8.pem - * - * openssl ec -in private-key.pem -pubout -out public-key.pem - */ - -const mockedTicketFactory = { - serialize: jest.fn(), - deserialize: jest.fn(), -}; - -const POD_URI = 'https://pod.example.org'; -const MOCK_REGISTRATION = new RequestingPartyRegistration( - POD_URI, - `-----BEGIN PUBLIC KEY----- - MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGJkzO34pMnTx4uxoTD0+dvB4gVaX - s9X+qkguzUCNT9ZzbZ/onTZrvQDLVAdH++c7sS/vmfrNuACUeNhLr9aYFA== - -----END PUBLIC KEY-----`, - 'ES256', -); - -const POD_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/cteLEDr0AH+7mA3 -lvCtf2pY32NMVpy2yWCk8LbfJ+WhRANCAAQYmTM7fikydPHi7GhMPT528HiBVpez -1f6qSC7NQI1P1nNtn+idNmu9AMtUB0f75zuxL++Z+s24AJR42Euv1pgU ------END PRIVATE KEY----`; - -const WEBID = 'http://pod.example.org/alice/profile/card#me'; - -const getJwt = async (ecPrivateKey: string, ecAlgorithm: string, iss: string, aud: string) => { - const privateKey = await jose.importPKCS8(ecPrivateKey, ecAlgorithm); - return new jose.SignJWT({}) - .setProtectedHeader({alg: ecAlgorithm}) - .setIssuedAt() - .setIssuer(iss) - .setAudience(aud) - .setExpirationTime('1m') - .sign(privateKey); -}; - -const AS_URI = 'https://as.example.org'; -const MOCK_TICKET = 'abcd'; - -describe('Happy flows', () => { - const requestHandler = new PermissionRegistrationHandler(AS_URI, mockedTicketFactory, [MOCK_REGISTRATION]); - let requestContext: HttpHandlerContext; - - beforeEach(async () => { - mockedTicketFactory.serialize.mockResolvedValueOnce(MOCK_TICKET); - requestContext = { - request: { - url: new URL('http://localhost/'), - method: 'POST', - headers: {'content-type': 'application/json', - 'authorization': `Bearer ${await getJwt(POD_PRIVATE_KEY, 'ES256', POD_URI, AS_URI)}`}, - }, - }; - }); - - test('Returns ticket in response body', async () => { - requestContext.request.body = { - resource_set_id: '/example/123', - owner: WEBID, - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - const response = await lastValueFrom(requestHandler.handle(requestContext)); - expect(mockedTicketFactory.serialize).toBeCalled(); - expect(response.status).toEqual(200); - expect(response.body?.ticket).toEqual(MOCK_TICKET); - }); -}); - -describe('Unhappy flows', () => { - const requestHandler = new PermissionRegistrationHandler(AS_URI, mockedTicketFactory, [MOCK_REGISTRATION]); - let requestContext: HttpHandlerContext; - - beforeEach(async () => { - mockedTicketFactory.serialize.mockResolvedValueOnce(MOCK_TICKET); - requestContext = { - request: { - url: new URL('http://localhost/'), - method: 'POST', - headers: {'content-type': 'application/json', - 'authorization': `Bearer ${await getJwt(POD_PRIVATE_KEY, 'ES256', POD_URI, AS_URI)}`}, - }, - }; - }); - - test('Rejected ticket serialization should throw error', async () => { - mockedTicketFactory.serialize.mockReset(); - mockedTicketFactory.serialize.mockRejectedValueOnce(new Error('Some error.')); - - requestContext.request.body = { - resource_set_id: '/example/123', - owner: WEBID, - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(InternalServerError); - }); - - test('Invalid Authorization header should throw error', async () => { - requestContext.request.headers['authorization'] = 'abc'; - requestContext.request.body = { - resource_set_id: '/example/123', - owner: WEBID, - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Missing Authorization header should throw error', async () => { - delete requestContext.request.headers['authorization']; - requestContext.request.body = { - resource_set_id: '/example/123', - owwner: WEBID, - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(UnauthorizedHttpError); - }); - - test('Unregistered Signer Authorization header should throw error', async () => { - requestContext.request.headers['authorization'] = 'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdW' + - 'IiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tyh-VfuzIxC' + - 'yGYDlkBA7DfyjrqmSHu6pQ2hoZuFqUSLPNY2N0mpHb3nk5K17HWP_3cYHBw7AhHale5wky6-sVA'; - requestContext.request.body = { - resource_set_id: '/example/123', - owner: WEBID, - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError('Bearer token is invalid'); - }); - - test('Missing key "resource_set_id" should throw error', async () => { - requestContext.request.body = { - owner: WEBID, - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Non-string key "resource_set_id" should throw error', async () => { - requestContext.request.body = { - resource_set_id: 123, - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Invalid "scopes" should throw error', async () => { - requestContext.request.body = { - owner: WEBID, - resource_set_id: '/example/123', - scopes: ['http://www.w3.org/ns/auth/acl#Teleport'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Missing key "scopes" should throw error', async () => { - requestContext.request.body = { - owner: WEBID, - resource_set_id: '/example/123', - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Invalid key "scopes" should throw error', async () => { - requestContext.request.body = { - owner: WEBID, - resource_set_id: '/example/123', - scopes: 'abc', - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Missing key "owner" should throw error', async () => { - requestContext.request.body = { - resource_set_id: '/example/123', - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Non-URL key "owner" should throw error', async () => { - requestContext.request.body = { - owner: 'abc', - resource_set_id: '/example/123', - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Non-HTTP URL key "owner" should throw error', async () => { - requestContext.request.body = { - owner: 'ftp://localhost:3000', - resource_set_id: '/example/123', - scopes: ['http://www.w3.org/ns/auth/acl#Read'], - }; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Missing request body should throw error', async () => { - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(BadRequestHttpError); - }); - - test('Invalid media type should throw error', async () => { - requestContext.request.headers['content-type'] = 'text/plain'; - expect(lastValueFrom(requestHandler.handle(requestContext))).rejects.toThrowError(UnsupportedMediaTypeHttpError); - }); -}); diff --git a/packages/uma/test/routes/Token.test.ts b/packages/uma/test/routes/Token.test.ts deleted file mode 100644 index 2ee9388e..00000000 --- a/packages/uma/test/routes/Token.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {TokenRequestHandler} from './TokenRequestHandler'; -import {lastValueFrom} from 'rxjs'; -import {BadRequestHttpError} from '../http/errors/BadRequestHttpError'; -import {HttpHandlerContext} from '../http/models/HttpHandlerContext'; -import {UnsupportedMediaTypeHttpError} from '../http/errors/UnsupportedMediaTypeHttpError'; - -describe('Happy flows', () => { - let requestHandler: TokenRequestHandler; - let requestContext: HttpHandlerContext; - const getSupportedGrantType = jest.fn(); - const process = jest.fn(); - - beforeEach(() => { - jest.resetAllMocks(); - getSupportedGrantType.mockReturnValue('urn:ietf:params:oauth:grant-type:uma-ticket'); - requestHandler = new TokenRequestHandler([{process, getSupportedGrantType}]); - requestContext = { - request: { - url: new URL('http://localhost/token'), - method: 'POST', - body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Auma-ticket` + - `&ticket=016f84e8-f9b9-11e0-bd6f-0021cc6004de` + - `&claim_token=abc` + - `&claim_token_format=http%3A%2F%2Fopenid.net%2Fspecs%2Fopenid-connect-core-1_0.html%23IDToken`, - headers: {'content-type': 'application/x-www-form-urlencoded'}, - }, - }; - }); - - test('Returns Token in response body', async () => { - process.mockReturnValueOnce(new Promise((resolve, reject) => { - resolve({token_type: 'Bearer', access_token: 'abc'}); - })); - const response = await lastValueFrom(requestHandler.handle(requestContext)); - expect(response.body).toEqual(JSON.stringify({token_type: 'Bearer', access_token: 'abc'})); - expect(response.status).toEqual(200); - }); -}); - -describe('Unhappy flows', () => { - let requestHandler: TokenRequestHandler; - const getSupportedGrantType = jest.fn(); - const process = jest.fn(); - - beforeEach(() => { - jest.resetAllMocks(); - getSupportedGrantType.mockReturnValue('urn:ietf:params:oauth:grant-type:uma-ticket'); - requestHandler = new TokenRequestHandler([{process, getSupportedGrantType}]); - }); - - test('Invalid media type', async () => { - expect(lastValueFrom(requestHandler.handle({ - request: { - url: new URL('http://localhost/token'), - method: 'POST', - headers: {'content-type': 'application/json'}, - }, - }))).rejects.toThrow(UnsupportedMediaTypeHttpError); - }); - test('Missing \'grant_type\' in body.', async () => { - expect(lastValueFrom(requestHandler.handle({ - request: { - url: new URL('http://localhost/token'), - method: 'POST', - body: ``, - headers: {'content-type': 'application/x-www-form-urlencoded'}, - }, - }))).rejects.toThrow(BadRequestHttpError); - }); - test('Empty \'grant_type\' in body.', async () => { - expect(lastValueFrom(requestHandler.handle({ - request: { - url: new URL('http://localhost/token'), - method: 'POST', - body: `grant_type=`, - headers: {'content-type': 'application/x-www-form-urlencoded'}, - }, - }))).rejects.toThrow(BadRequestHttpError); - }); - test('Unsupported \'grant_type\' in body.', async () => { - expect(lastValueFrom(requestHandler.handle({ - request: { - url: new URL('http://localhost/token'), - method: 'POST', - body: `grant_type=abc`, - headers: {'content-type': 'application/x-www-form-urlencoded'}, - }, - }))).rejects.toThrow('Unsupported grant type: \'abc\''); - }); - test('When `process` throws an error, should rethrow it.', async () => { - process.mockReturnValue(new Promise((resolve, reject) => { - reject(new Error('Test')); - })); - expect(lastValueFrom(requestHandler.handle({ - request: { - url: new URL('http://localhost/token'), - method: 'POST', - body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Auma-ticket`, - headers: {'content-type': 'application/x-www-form-urlencoded'}, - }, - }))).rejects.toThrow('Test'); - }); -}); diff --git a/packages/uma/test/secrets/InMemoryJwksKeyHolder.test.ts b/packages/uma/test/secrets/InMemoryJwksKeyHolder.test.ts deleted file mode 100644 index c0dcfb56..00000000 --- a/packages/uma/test/secrets/InMemoryJwksKeyHolder.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {InMemoryJwksKeyHolder} from './InMemoryJwksKeyHolder'; - -describe('Testing errors on holder', () => { - const keyholder = new InMemoryJwksKeyHolder('ES256'); - test('holder should throw error when retrieving unkown private key', () => { - expect(() => keyholder.getPrivateKey('abc')) - .toThrowError('The specified kid \'abc\' does not exist in the holder.'); - }); - test('holder should throw error when retrieving unkown public key', () => { - expect(() => keyholder.getPublicKey('abc')) - .toThrowError('The specified kid \'abc\' does not exist in the holder.'); - }); - test('holder should throw error when retrieving unkown JWK', async () => { - expect.assertions(1); - try { - await keyholder.toPublicJwk('abc'); - } catch (e:any) { - expect(e.message).toEqual('The specified kid \'abc\' does not exist in the holder.'); - } - }); -}); - -describe('Validity of constructor arguments', () => { - test('Unsupported algorithm should throw error', () => { - expect(() => new InMemoryJwksKeyHolder('abc')) - .toThrowError('The chosen algorithm \'abc\' is not supported by the InMemoryJwksKeyHolder.'); - }); -}); - - -describe('Testing operations on filled holder.', () => { - const keyholder = new InMemoryJwksKeyHolder('ES256'); - let kid:string; - test('Keyholder should return algorithm', () => { - expect(keyholder.getAlg()).toEqual('ES256'); - }); - test('Holder should return default kid upon creation', async () => { - kid = await keyholder.getDefaultKey(); - expect(kid).toBeTruthy(); - }); - test('Holder should have one key ID', async () => { - expect(keyholder.getKids()).toStrictEqual([kid]); - }); - test('Holder should have a default key', async () => { - expect(await keyholder.getDefaultKey()).toStrictEqual(kid); - }); - test('Holder should have one key in JWKS', async () => { - expect((await keyholder.getJwks()).keys.length).toStrictEqual(1); - }); - test('Holder should have public key', async () => { - expect(keyholder.getPublicKey(kid)).toBeTruthy(); - }); - test('Holder should have private key', async () => { - expect(keyholder.getPrivateKey(kid)).toBeTruthy(); - }); -}); diff --git a/packages/uma/test/token/JwtTokenFactory.test.ts b/packages/uma/test/token/JwtTokenFactory.test.ts deleted file mode 100644 index 04877803..00000000 --- a/packages/uma/test/token/JwtTokenFactory.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import {InMemoryJwksKeyHolder} from '../secrets/InMemoryJwksKeyHolder'; -import {TokenFactory} from './TokenFactory'; -import {JwtTokenFactory} from './JwtTokenFactory'; -import {decodeJwt, decodeProtectedHeader, generateKeyPair, JWTPayload, KeyLike, SignJWT} from 'jose'; -import {BadRequestHttpError} from '../http/errors/BadRequestHttpError'; -import {v4} from 'uuid'; - -const ISSUER = 'https://example.com'; -const WEBID = 'https://example.com/profile/alice#me'; -const CLIENT = 'https://projectapp.com'; -const RESOURCE = 'https://pods.example.com/test/123.ttl'; -const ALG = 'ES256'; - -describe('JWT Access Token Issuance', () => { - const keyholder = new InMemoryJwksKeyHolder(ALG); - const tokenFactory: TokenFactory = new JwtTokenFactory(keyholder, ISSUER); - - test('Should yield JWT for access token', async () => { - // const accessToken = await tokenFactory.serialize({webId: WEBID, clientId: CLIENT, sub: {iri: RESOURCE}, modes: new Set(['read', 'write'])}); - const accessToken = await tokenFactory.serialize({ - webId: WEBID, - clientId: CLIENT, - permissions: [{ - resource_id: RESOURCE, - resource_scopes: ['read', 'write'], - }], - }); - - expect(accessToken.token).toBeTruthy(); - expect(decodeProtectedHeader(accessToken.token)).toEqual({alg: ALG, kid: await keyholder.getDefaultKey()}); - const payload = decodeJwt(accessToken.token); - - expect(payload).toBeTruthy(); - // expect('sub' in payload).toBeTruthy(); - expect('aud' in payload).toBeTruthy(); - expect('permissions' in payload).toBeTruthy(); - expect('webid' in payload).toBeTruthy(); - expect('azp' in payload).toBeTruthy(); - expect('iss' in payload).toBeTruthy(); - expect('jti' in payload).toBeTruthy(); - - expect(payload.iss).toEqual(ISSUER); - expect(payload.aud).toEqual('solid'); - - expect(payload.modes).toEqual(['http://www.w3.org/ns/auth/acl#Read', 'http://www.w3.org/ns/auth/acl#Write']); - expect(payload.webid).toEqual(WEBID); - expect(payload.azp).toEqual(CLIENT); - expect(payload.sub).toEqual(RESOURCE); - }); -}); - -describe('Deserialization tests', () => { - const keyholder = new InMemoryJwksKeyHolder(ALG); - const tokenFactory: TokenFactory = new JwtTokenFactory(keyholder, ISSUER); - - test('E2E', async () => { - const accessToken = { - webId: WEBID, - clientId: CLIENT, - permissions: [{ - resource_id: RESOURCE, - resource_scopes: ['read', 'write', 'create', 'append', 'delete'], - }], - }; - const jwt = (await tokenFactory.serialize(accessToken)).token; - - expect(await tokenFactory.deserialize(jwt)).toEqual(accessToken); - }); - - test('Invalid JWT should throw error', async () => { - expect(async () => await tokenFactory.deserialize('abc')).rejects.toThrow(BadRequestHttpError); - }); - - test('Invalid Signature should throw error', async () => { - const key = await generateKeyPair(ALG); - const jwt = await createJwt({}, key.privateKey); - - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing: signature verification failed'); - }); - - test('Missing payload claim `webid` should throw error', async () => { - const jwt = await createJwt({azp: CLIENT, sub: RESOURCE, aud: 'solid', - modes: new Set(['read', 'write', 'create', 'append', 'delete'])}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' Missing JWT parameter(s): {sub, aud, modes, webid, azp} are required.'); - }); - - test('Missing payload claim `azp` should throw error', async () => { - const jwt = await createJwt({webid: WEBID, sub: RESOURCE, aud: 'solid', - modes: new Set(['read', 'write', 'create', 'append', 'delete'])}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' Missing JWT parameter(s): {sub, aud, modes, webid, azp} are required.'); - }); - - test('Missing payload claim `sub` should throw error', async () => { - const jwt = await createJwt({webid: WEBID, azp: CLIENT, aud: 'solid', - modes: new Set(['read', 'write', 'create', 'append', 'delete'])}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' Missing JWT parameter(s): {sub, aud, modes, webid, azp} are required.'); - }); - - test('Missing payload claim `aud` should throw error', async () => { - const jwt = await createJwt({webid: WEBID, azp: CLIENT, sub: RESOURCE, - modes: new Set(['read', 'write', 'create', 'append', 'delete'])}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' unexpected \"aud\" claim value'); - }); - - test('Missing payload claim `modes` should throw error', async () => { - const jwt = await createJwt({webid: WEBID, azp: CLIENT, sub: RESOURCE, aud: 'solid'}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' Missing JWT parameter(s): {sub, aud, modes, webid, azp} are required.'); - }); - - test('Non-string claim `webid` should throw error', async () => { - const jwt = await createJwt({webid: 123, azp: CLIENT, sub: RESOURCE, aud: 'solid', - modes: new Set(['read', 'write', 'create', 'append', 'delete'])}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' JWT claim "webid" is not a string.'); - }); - test('Non-array claim `modes` should throw error', async () => { - const jwt = await createJwt({webid: WEBID, azp: CLIENT, sub: RESOURCE, aud: 'solid', - modes: 123}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' JWT claim "modes" is not an array.'); - }); - - test('Non-string claim `azp` should throw error', async () => { - const jwt = await createJwt({webid: WEBID, azp: 123, sub: RESOURCE, aud: 'solid', - modes: new Set(['read', 'write', 'create', 'append', 'delete'])}, - keyholder.getPrivateKey(await keyholder.getDefaultKey())); - expect(async () => await tokenFactory.deserialize(jwt)).rejects.toThrow(BadRequestHttpError); - expect(async () => await tokenFactory.deserialize(jwt)).rejects - .toThrow('Invalid Access Token provided, error while parsing:' + - ' JWT claim "azp" is not a string.'); - }); -}); - -describe('Test anonymous client', () => { - const keyholder = new InMemoryJwksKeyHolder(ALG); - const tokenFactory: TokenFactory = new JwtTokenFactory(keyholder, ISSUER); - - test('Should yield JWT for access token', async () => { - const accessToken = await tokenFactory.serialize({ - webId: WEBID, - clientId: CLIENT, - permissions: [{ - resource_id: RESOURCE, - resource_scopes: ['read', 'write'], - }], - }); - - expect(accessToken.token).toBeTruthy(); - expect(decodeProtectedHeader(accessToken.token)).toEqual({alg: ALG, kid: await keyholder.getDefaultKey()}); - const payload = decodeJwt(accessToken.token); - - expect(payload).toBeTruthy(); - expect('sub' in payload).toBeTruthy(); - expect('aud' in payload).toBeTruthy(); - expect('modes' in payload).toBeTruthy(); - expect('webid' in payload).toBeTruthy(); - expect('azp' in payload).toBeTruthy(); - expect('iss' in payload).toBeTruthy(); - expect('jti' in payload).toBeTruthy(); - - expect(payload.iss).toEqual(ISSUER); - expect(payload.aud).toEqual('solid'); - - expect(payload.modes).toEqual(['http://www.w3.org/ns/auth/acl#Read', 'http://www.w3.org/ns/auth/acl#Write']); - expect(payload.webid).toEqual(WEBID); - expect(payload.azp).toEqual('http://www.w3.org/ns/auth/acl#Origin'); - expect(payload.sub).toEqual(RESOURCE); - }); -}); - -const createJwt = async (payload: JWTPayload, key: KeyLike, issuer: string = ISSUER) => { - return new SignJWT(payload) - .setProtectedHeader({alg: ALG}) - .setIssuedAt() - .setIssuer(issuer) - .setExpirationTime('30m') - .setJti(v4()) - .sign(key); -}; - - diff --git a/packages/uma/test/tsconfig.json b/packages/uma/test/tsconfig.json new file mode 100644 index 00000000..f75e3dba --- /dev/null +++ b/packages/uma/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "." + ] +} diff --git a/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts b/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts new file mode 100644 index 00000000..83155cc0 --- /dev/null +++ b/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts @@ -0,0 +1,99 @@ +import * as getJwks from 'get-jwks'; +import * as jose from 'jose'; +import { Credential } from '../../../../src/credentials/Credential'; +import { JwtVerifier } from '../../../../src/credentials/verify/JwtVerifier'; + +vi.mock('get-jwks'); +vi.mock('jose'); + +describe('JwtVerifier', (): void => { + const getJwkMock = vi.fn(); + const getJwksMock = vi.spyOn(getJwks, 'default').mockReturnValue({ + getJwk: getJwkMock, + } as any); + const decodeMock = vi.spyOn(jose, 'decodeJwt'); + const decodeHeaderMock = vi.spyOn(jose, 'decodeProtectedHeader'); + const verifyMock = vi.spyOn(jose, 'jwtVerify'); + + const issuer = 'issuer'; + const credential: Credential = { + format: 'urn:solidlab:uma:claims:formats:jwt', + token: 'token', + }; + const allowedClaims: string[] = [ 'iss', 'claim1' ]; + let verifier: JwtVerifier; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + + decodeMock.mockReturnValue({ + iss: issuer, + claim1: 'val1', + claim2: 'val2', + }); + + decodeHeaderMock.mockReturnValue({ + alg: 'alg', + kid: 'kid', + }); + + getJwkMock.mockResolvedValue({ key: 'value' }); + + verifier = new JwtVerifier(allowedClaims, false, false); + }); + + it('errors on non-JWT credentials.', async(): Promise => { + await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects + .toThrow("Token format 'wrong' does not match this processor's format."); + }); + + it('returns the allowed claims.', async(): Promise => { + await expect(verifier.verify(credential)).resolves.toEqual({ iss: issuer, claim1: 'val1', }); + expect(decodeMock).toHaveBeenCalledTimes(1); + expect(decodeMock).toHaveBeenLastCalledWith(credential.token); + + // Verification is off + expect(decodeHeaderMock).toHaveBeenCalledTimes(0); + expect(getJwkMock).toHaveBeenCalledTimes(0); + expect(verifyMock).toHaveBeenCalledTimes(0); + }); + + it('errors on extra claims if the option is enabled.', async(): Promise => { + verifier = new JwtVerifier(allowedClaims, true, false); + await expect(verifier.verify(credential)).rejects.toThrow("Claim 'claim2' not allowed"); + }); + + describe('with verification enabled.', (): void => { + + beforeEach(async(): Promise => { + verifier = new JwtVerifier(allowedClaims, false, true); + }); + + it('errors if the token does not contain an iss.', async(): Promise => { + decodeMock.mockReturnValueOnce({ claim1: 'val1', claim2: 'val2' }); + await expect(verifier.verify(credential)).rejects.toThrow("JWT should contain 'iss' claim."); + }); + + it('errors if the header does not contain an alg.', async(): Promise => { + decodeHeaderMock.mockReturnValueOnce({ kid: 'kid' }); + await expect(verifier.verify(credential)).rejects.toThrow("JWT should contain 'alg' header."); + }); + + it('errors if the header does not contain a kid.', async(): Promise => { + decodeHeaderMock.mockReturnValueOnce({ alg: 'alg' }); + await expect(verifier.verify(credential)).rejects.toThrow("JWT should contain 'kid' header."); + }); + + it('verifies the token.', async(): Promise => { + await expect(verifier.verify(credential)).resolves.toEqual({ iss: issuer, claim1: 'val1', }); + expect(decodeMock).toHaveBeenCalledTimes(1); + expect(decodeMock).toHaveBeenLastCalledWith(credential.token); + expect(decodeHeaderMock).toHaveBeenCalledTimes(1); + expect(decodeHeaderMock).toHaveBeenLastCalledWith(credential.token); + expect(getJwkMock).toHaveBeenCalledTimes(1); + expect(getJwkMock).toHaveBeenLastCalledWith({ domain: 'issuer', alg: 'alg', kid: 'kid' }); + expect(verifyMock).toHaveBeenCalledTimes(1); + expect(verifyMock).toHaveBeenLastCalledWith(credential.token, { key: 'value', type: 'JWK' }); + }); + }); +}); diff --git a/packages/uma/test/unit/credentials/verify/SolidOidcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/SolidOidcVerifier.test.ts new file mode 100644 index 00000000..840b602f --- /dev/null +++ b/packages/uma/test/unit/credentials/verify/SolidOidcVerifier.test.ts @@ -0,0 +1,45 @@ +import * as accessTokenVerifier from '@solid/access-token-verifier'; +import { Credential } from '../../../../src/credentials/Credential'; +import { SolidOidcVerifier } from '../../../../src/credentials/verify/SolidOidcVerifier'; + +describe('SolidOidcVerifier', (): void => { + const credential: Credential = { + format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken', + token: 'token', + }; + + const verifierMock = vi.fn(); + vi.spyOn(accessTokenVerifier, 'createSolidTokenVerifier').mockReturnValue(verifierMock); + let verifier: SolidOidcVerifier; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + verifierMock.mockResolvedValue({ + webid: 'webId', + client_id: 'clientId', + aud: 'solid', + iss: 'issuer', + iat: 5, + exp: 6, + }); + + verifier = new SolidOidcVerifier() + }); + + it('errors on non-OIDC credentials.', async(): Promise => { + await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects + .toThrow("Token format wrong does not match this processor's format."); + }); + + it('returns the extracted WebID.', async(): Promise => { + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'webId', + ['urn:solidlab:uma:claims:types:clientid']: 'clientId', + }); + }); + + it('throws an error if the token could not be verified.', async(): Promise => { + verifierMock.mockRejectedValueOnce(new Error('bad data')); + await expect(verifier.verify(credential)).rejects.toThrow('Error verifying OIDC ID Token: bad data'); + }); +}); diff --git a/packages/uma/test/unit/credentials/verify/TypedVerifier.test.ts b/packages/uma/test/unit/credentials/verify/TypedVerifier.test.ts new file mode 100644 index 00000000..a996510f --- /dev/null +++ b/packages/uma/test/unit/credentials/verify/TypedVerifier.test.ts @@ -0,0 +1,35 @@ +import { ClaimSet } from '../../../../src/credentials/ClaimSet'; +import { TypedVerifier } from '../../../../src/credentials/verify/TypedVerifier'; +import { Verifier } from '../../../../src/credentials/verify/Verifier'; + +describe('TypedVerifier', (): void => { + const claims: ClaimSet[] = [ + { key: 'value1' }, + { key: 'value2' }, + ] + let verifiers: Record; + let verifier: TypedVerifier; + + beforeEach(async(): Promise => { + verifiers = { + 'type1': { verify: vi.fn().mockResolvedValue(claims[0]) }, + 'type2': { verify: vi.fn().mockResolvedValue(claims[1]) }, + }; + + verifier = new TypedVerifier(verifiers); + }); + + it('calls the matching verifier.', async(): Promise => { + await expect(verifier.verify({ format: 'type2', token: 'token' })).resolves.toEqual(claims[1]); + expect(verifiers['type1'].verify).toHaveBeenCalledTimes(0); + expect(verifiers['type2'].verify).toHaveBeenCalledTimes(1); + expect(verifiers['type2'].verify).toHaveBeenLastCalledWith({ format: 'type2', token: 'token' }); + }); + + it('errors if there is no matching verifier.', async(): Promise => { + await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects + .toThrow('The provided "claim_token_format" is not supported.'); + expect(verifiers['type1'].verify).toHaveBeenCalledTimes(0); + expect(verifiers['type2'].verify).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/uma/test/unit/credentials/verify/UnsecureVerifier.test.ts b/packages/uma/test/unit/credentials/verify/UnsecureVerifier.test.ts new file mode 100644 index 00000000..492de248 --- /dev/null +++ b/packages/uma/test/unit/credentials/verify/UnsecureVerifier.test.ts @@ -0,0 +1,42 @@ +import { UnsecureVerifier } from '../../../../src/credentials/verify/UnsecureVerifier'; + +describe('UnsecureVerifier', (): void => { + const verifier = new UnsecureVerifier(); + + it('errors if the format is not correct.', async(): Promise => { + await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects + .toThrow("Token format wrong does not match this processor's format."); + }); + + it('errors if the token cannot be split correctly.', async(): Promise => { + await expect(verifier.verify({ format: 'urn:solidlab:uma:claims:formats:webid', token: 'to:k:en' })).rejects + .toThrow("Invalid token format, only one ':' is expected."); + }); + + it('parses the token as a WebID.', async(): Promise => { + await expect(verifier.verify({ + format: 'urn:solidlab:uma:claims:formats:webid', + token: encodeURIComponent('http://example.com/#me'), + })).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'http://example.com/#me', + ['urn:solidlab:uma:claims:types:clientid']: false, + }); + }); + + it('parses the second part of the token as client ID if there is one.', async(): Promise => { + await expect(verifier.verify({ + format: 'urn:solidlab:uma:claims:formats:webid', + token: `${encodeURIComponent('http://example.com/#me')}:${encodeURIComponent('http://example.com/#client')}`, + })).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'http://example.com/#me', + ['urn:solidlab:uma:claims:types:clientid']: 'http://example.com/#client', + }); + }); + + it('throws an error if something goes wrong.', async(): Promise => { + await expect(verifier.verify({ + format: 'urn:solidlab:uma:claims:formats:webid', + token: 'notAURL', + })).rejects.toThrow('Error verifying Access Token via WebID: Invalid URL'); + }); +}); diff --git a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts new file mode 100644 index 00000000..b88a3bdc --- /dev/null +++ b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts @@ -0,0 +1,159 @@ +import { BadRequestHttpError, ForbiddenHttpError, KeyValueStorage } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { ClaimSet } from '../../../src/credentials/ClaimSet'; +import { Verifier } from '../../../src/credentials/verify/Verifier'; +import { BaseNegotiator } from '../../../src/dialog/BaseNegotiator'; +import { DialogInput } from '../../../src/dialog/Input'; +import { NeedInfoError, RequiredClaimsInfo } from '../../../src/errors/NeedInfoError'; +import { TicketingStrategy } from '../../../src/ticketing/strategy/TicketingStrategy'; +import { Ticket } from '../../../src/ticketing/Ticket'; +import { SerializedToken, TokenFactory } from '../../../src/tokens/TokenFactory'; + +describe('BaseNegotiator', (): void => { + const input: DialogInput = { + permissions: [ + { resource_id: 'id1', resource_scopes: [ 'scope1' ] }, + { resource_id: 'id2', resource_scopes: [ 'scope2' ] }, + ] + }; + const claims: ClaimSet = { claim1: 'value1', claim2: 'value2' }; + const ticket: Ticket = { + permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ], + required: [], + provided: { claim: 'value' }, + }; + const token: SerializedToken = { token: 'token', tokenType: 'type' }; + let ticketData: Map; + + let verifier: Mocked + let ticketStore: Mocked>; + let ticketingStrategy: Mocked; + let tokenFactory: Mocked; + let negotiator: BaseNegotiator; + + beforeEach(async(): Promise => { + verifier = { + verify: vi.fn().mockResolvedValue(claims), + }; + + ticketData = new Map(); + ticketStore = { + has: vi.fn().mockImplementation(key => ticketData.has(key)), + get: vi.fn().mockImplementation(key => ticketData.get(key)), + set: vi.fn().mockImplementation((key, value) => ticketData.set(key, value)), + delete: vi.fn().mockImplementation(key => ticketData.delete(key)), + entries: vi.fn().mockImplementation(() => ticketData.entries()), + }; + + ticketingStrategy = { + initializeTicket: vi.fn().mockResolvedValue(ticket), + validateClaims: vi.fn().mockResolvedValue(ticket), + resolveTicket: vi.fn().mockResolvedValue({ + success: true, + value: { resource_id: 'id1', resource_scopes: [ 'scope1' ] }, + }), + }; + + tokenFactory = { + serialize: vi.fn().mockResolvedValue(token), + deserialize: vi.fn(), + }; + + negotiator = new BaseNegotiator(verifier, ticketStore, ticketingStrategy, tokenFactory); + }); + + it('errors if the input is in the wrong type.', async(): Promise => { + await expect(negotiator.negotiate({ ticket: 5 } as any)).rejects.toThrow('value is neither of the union types'); + }); + + it('returns the token if everything was successful.', async(): Promise => { + await expect(negotiator.negotiate(input)).resolves.toEqual({ access_token: 'token', token_type: 'type' }); + expect(ticketStore.get).toHaveBeenCalledTimes(0); + expect(ticketStore.set).toHaveBeenCalledTimes(0); + expect(ticketStore.delete).toHaveBeenCalledTimes(0); + expect(ticketingStrategy.initializeTicket).toHaveBeenCalledTimes(1); + expect(ticketingStrategy.initializeTicket).toHaveBeenLastCalledWith(input.permissions); + expect(verifier.verify).toHaveBeenCalledTimes(0); + expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(0); + expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); + expect(tokenFactory.serialize).toHaveBeenLastCalledWith( + { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + }); + + it('errors if there is no existing ticket and no permission request.', async(): Promise => { + await expect(negotiator.negotiate({})).rejects + .toThrow('A token request without existing ticket should include requested permissions.'); + }); + + it('throws an empty 403 if the token is rejected with no requirements.', async(): Promise => { + ticketingStrategy.resolveTicket.mockResolvedValueOnce({ success: false, value: [] }); + await expect(negotiator.negotiate(input)).rejects.toThrow(ForbiddenHttpError); + expect(ticketStore.set).toHaveBeenCalledTimes(0); + }); + + it('throws an error with an info request if there are still requirements.', async(): Promise => { + ticketingStrategy.initializeTicket.mockResolvedValueOnce({ + permissions: [], + provided: {}, + required: [{ fn: async() => false }], + }) + ticketingStrategy.resolveTicket.mockResolvedValueOnce({ success: false, value: [] }); + try { + await negotiator.negotiate(input); + } catch (error) { + expect(error).toBeInstanceOf(NeedInfoError); + expect((error as NeedInfoError).additionalParams).toEqual({ + required_claims: { claim_token_format: [['fn']] }, + }); + } + expect(ticketStore.set).toHaveBeenCalledTimes(1); + }); + + it('errors if an invalid ticket is provided.', async(): Promise => { + await expect(negotiator.negotiate({ ...input, ticket: 'ticket' })) + .rejects.toThrow('The provided ticket is not valid.'); + expect(ticketStore.get).toHaveBeenCalledTimes(1); + expect(ticketStore.get).toHaveBeenLastCalledWith('ticket'); + }); + + it('uses the stored ticket if it is known.', async(): Promise => { + ticketData.set('ticket', ticket); + await expect(negotiator.negotiate({ ...input, ticket: 'ticket' })).resolves + .toEqual({ access_token: 'token', token_type: 'type' }); + expect(ticketStore.get).toHaveBeenCalledTimes(1); + expect(ticketStore.get).toHaveBeenLastCalledWith('ticket'); + expect(ticketStore.set).toHaveBeenCalledTimes(0); + expect(ticketStore.delete).toHaveBeenCalledTimes(1); + expect(ticketStore.delete).toHaveBeenLastCalledWith('ticket'); + expect(ticketingStrategy.initializeTicket).toHaveBeenCalledTimes(0); + expect(verifier.verify).toHaveBeenCalledTimes(0); + expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(0); + expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); + expect(tokenFactory.serialize).toHaveBeenLastCalledWith( + { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + }); + + it('errors if invalid credentials are provided.', async(): Promise => { + await expect(negotiator.negotiate({ ...input, claim_token: 'token' })) + .rejects.toThrow('Request with a "claim_token" must contain a "claim_token_format".'); + await expect(negotiator.negotiate({ ...input, claim_token_format: 'format' })) + .rejects.toThrow('Request with a "claim_token_format" must contain a "claim_token".'); + }); + + it('processes the credentials if they are provided.', async(): Promise => { + await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves + .toEqual({ access_token: 'token', token_type: 'type' }); + expect(ticketStore.get).toHaveBeenCalledTimes(0); + expect(ticketStore.set).toHaveBeenCalledTimes(0); + expect(ticketStore.delete).toHaveBeenCalledTimes(0); + expect(ticketingStrategy.initializeTicket).toHaveBeenCalledTimes(1); + expect(ticketingStrategy.initializeTicket).toHaveBeenLastCalledWith(input.permissions); + expect(verifier.verify).toHaveBeenCalledTimes(1); + expect(verifier.verify).toHaveBeenLastCalledWith({ token: 'token', format: 'format' }); + expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(1); + expect(ticketingStrategy.validateClaims).toHaveBeenLastCalledWith(ticket, claims); + expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); + expect(tokenFactory.serialize).toHaveBeenLastCalledWith( + { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + }); +}); diff --git a/packages/uma/test/unit/dialog/ContractNegotiator.test.ts b/packages/uma/test/unit/dialog/ContractNegotiator.test.ts new file mode 100644 index 00000000..b95ff629 --- /dev/null +++ b/packages/uma/test/unit/dialog/ContractNegotiator.test.ts @@ -0,0 +1,145 @@ +import { ForbiddenHttpError, KeyValueStorage } from '@solid/community-server'; +import { Mocked, MockInstance } from 'vitest'; +import { ClaimSet } from '../../../src/credentials/ClaimSet'; +import { Verifier } from '../../../src/credentials/verify/Verifier'; +import { ContractNegotiator } from '../../../src/dialog/ContractNegotiator'; +import { DialogInput } from '../../../src/dialog/Input'; +import { ContractManager } from '../../../src/policies/contracts/ContractManager'; +import { TicketingStrategy } from '../../../src/ticketing/strategy/TicketingStrategy'; +import { Ticket } from '../../../src/ticketing/Ticket'; +import { SerializedToken, TokenFactory } from '../../../src/tokens/TokenFactory'; +import { ODRLContract } from '../../../src/views/Contract'; + +// vi.mock('../../../src/policies/contracts/ContractManager', () => ({ +// ContractManager: vi.fn().mockReturnValue({ +// createContract: vi.fn(), +// findContract: vi.fn(), +// }), +// })) + +describe('ContractNegotiator', (): void => { + const input: DialogInput = { + permissions: [ + { resource_id: 'id1', resource_scopes: [ 'scope1' ] }, + { resource_id: 'id2', resource_scopes: [ 'scope2' ] }, + ] + }; + const claims: ClaimSet = { claim1: 'value1', claim2: 'value2' }; + const ticket: Ticket = { + permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ], + required: [], + provided: { claim: 'value' }, + }; + const token: SerializedToken = { token: 'token', tokenType: 'type' }; + const contract: ODRLContract = { + uid: 'contractId', + permission: [{ + action: 'urn:example:css:modes:action', + target: 'target', + assigner: 'assigner', + assignee: 'assignee', + }], + }; + + let ticketData: Map; + + let findContractMock: MockInstance; + let createContractMock: MockInstance; + let fetchMock: MockInstance; + + let verifier: Mocked + let ticketStore: Mocked>; + let ticketingStrategy: Mocked; + let tokenFactory: Mocked; + + let negotiator: ContractNegotiator; + + beforeEach(async(): Promise => { + findContractMock = vi.spyOn(ContractManager.prototype, 'findContract').mockReturnValue(contract); + createContractMock = vi.spyOn(ContractManager.prototype, 'createContract').mockReturnValue(contract); + fetchMock = vi.spyOn(global, 'fetch').mockImplementation(vi.fn()); + + verifier = { + verify: vi.fn().mockResolvedValue(claims), + }; + + ticketData = new Map(); + ticketStore = { + has: vi.fn().mockImplementation(key => ticketData.has(key)), + get: vi.fn().mockImplementation(key => ticketData.get(key)), + set: vi.fn().mockImplementation((key, value) => ticketData.set(key, value)), + delete: vi.fn().mockImplementation(key => ticketData.delete(key)), + entries: vi.fn().mockImplementation(() => ticketData.entries()), + }; + + ticketingStrategy = { + initializeTicket: vi.fn().mockResolvedValue(ticket), + validateClaims: vi.fn().mockResolvedValue(ticket), + resolveTicket: vi.fn().mockResolvedValue({ + success: true, + value: { resource_id: 'id1', resource_scopes: [ 'scope1' ] }, + }), + }; + + tokenFactory = { + serialize: vi.fn().mockResolvedValue(token), + deserialize: vi.fn(), + }; + + negotiator = new ContractNegotiator(verifier, ticketStore, ticketingStrategy, tokenFactory); + }); + + it('adds an existing contract if there is one.', async(): Promise => { + await expect(negotiator.negotiate(input)).resolves.toEqual({ access_token: 'token', token_type: 'type' }); + expect(findContractMock).toHaveBeenCalledTimes(1); + expect(findContractMock).toHaveBeenLastCalledWith(ticket); + expect(createContractMock).toHaveBeenCalledTimes(0); + expect(ticketingStrategy.initializeTicket).toHaveBeenCalledTimes(1); + expect(ticketingStrategy.resolveTicket).toHaveBeenCalledTimes(0); + expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); + expect(tokenFactory.serialize).toHaveBeenLastCalledWith({ + contract: contract, + permissions: [{ resource_id: 'target', resource_scopes: [ 'https://w3id.org/oac#action' ] }], + }); + + // Sending to hardcoded URL + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith('http://localhost:3000/ruben/settings/policies/instantiated/', { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(contract), + }); + }); + + it('creates a new contract when needed.', async(): Promise => { + findContractMock.mockReturnValueOnce(undefined); + await expect(negotiator.negotiate(input)).resolves.toEqual({ access_token: 'token', token_type: 'type' }); + expect(findContractMock).toHaveBeenCalledTimes(1); + expect(findContractMock).toHaveBeenLastCalledWith(ticket); + expect(createContractMock).toHaveBeenCalledTimes(1); + expect(createContractMock).toHaveBeenLastCalledWith({ resource_id: 'id1', resource_scopes: [ 'scope1' ] }); + expect(ticketingStrategy.initializeTicket).toHaveBeenCalledTimes(1); + expect(ticketingStrategy.resolveTicket).toHaveBeenCalledTimes(1); + expect(ticketingStrategy.resolveTicket).toHaveBeenLastCalledWith(ticket); + expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); + expect(tokenFactory.serialize).toHaveBeenLastCalledWith({ + contract: contract, + permissions: [{ resource_id: 'target', resource_scopes: [ 'https://w3id.org/oac#action' ] }], + }); + + // Sending to hardcoded URL + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith('http://localhost:3000/ruben/settings/policies/instantiated/', { + method: 'POST', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(contract), + }); + }); + + it('throws an empty 403 if the token is rejected with no requirements.', async(): Promise => { + findContractMock.mockReturnValueOnce(undefined); + ticketingStrategy.resolveTicket.mockResolvedValueOnce({ success: false, value: [] }); + await expect(negotiator.negotiate(input)).rejects.toThrow(ForbiddenHttpError); + expect(ticketStore.set).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/uma/test/unit/policies/authorizers/AllAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/AllAuthorizer.test.ts new file mode 100644 index 00000000..8fddb2b1 --- /dev/null +++ b/packages/uma/test/unit/policies/authorizers/AllAuthorizer.test.ts @@ -0,0 +1,28 @@ +import { AllAuthorizer } from '../../../../src/policies/authorizers/AllAuthorizer'; +import { Permission } from '../../../../src/views/Permission'; + +describe('AllAuthorizer', (): void => { + const authorizer = new AllAuthorizer(); + + it('allows all scopes for all resources.', async(): Promise => { + await expect(authorizer.permissions({})).resolves + .toEqual([{ resource_id: 'urn:solidlab:uma:resources:any', resource_scopes: [ 'urn:solidlab:uma:scopes:any' ] }]); + }); + + it('allows all query permissions/scopes if there is one.', async(): Promise => { + const query: Partial[] = [ + { resource_id: 'id1', resource_scopes: [ 'scope1' ]}, + { resource_id: 'id2' }, + { resource_scopes: [ 'scope3' ]}, + ]; + await expect(authorizer.permissions({}, query)).resolves.toEqual([ + { resource_id: 'id1', resource_scopes: [ 'scope1' ]}, + { resource_id: 'id2', resource_scopes: [ 'urn:solidlab:uma:scopes:any' ]}, + { resource_id: 'urn:solidlab:uma:resources:any', resource_scopes: [ 'scope3' ]}, + ]); + }); + + it('has no requirements.', async(): Promise => { + await expect(authorizer.credentials([])).resolves.toEqual([{}]); + }); +}); diff --git a/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts new file mode 100644 index 00000000..270e0502 --- /dev/null +++ b/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts @@ -0,0 +1,102 @@ +import { KeyValueStorage } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { ClaimSet } from '../../../../src/credentials/ClaimSet'; +import { Authorizer } from '../../../../src/policies/authorizers/Authorizer'; +import { NamespacedAuthorizer } from '../../../../src/policies/authorizers/NamespacedAuthorizer'; +import { ResourceDescription } from '../../../../src/views/ResourceDescription'; + +describe('NamespacedAuthorizer', (): void => { + const claims: ClaimSet = { claim: 'set' }; + + let authorizers: Record>; + let fallback: Mocked; + let resourceStore: Mocked>; + let authorizer: NamespacedAuthorizer; + + beforeEach(async(): Promise => { + authorizers = { + ns1: { permissions: vi.fn().mockResolvedValue('perm1'), credentials: vi.fn().mockResolvedValue('cred1'), }, + ns2: { permissions: vi.fn().mockResolvedValue('perm2'), credentials: vi.fn().mockResolvedValue('cred2'), }, + }; + + fallback = { permissions: vi.fn().mockResolvedValue('perm'), credentials: vi.fn().mockResolvedValue('cred'), }; + + const descriptions: Record = { + res1: { name: 'http://example.com/foo/ns1/res' }, + res2: { name: 'http://example.com/foo/ns2/res' }, + res3: { name: 'http://example.com/foo/ns3/res' }, + } + resourceStore = { + get: vi.fn((id: string): any => descriptions[id]), + } satisfies Partial> as any; + + authorizer = new NamespacedAuthorizer(authorizers, fallback, resourceStore); + }); + + describe('.permissions', (): void => { + it('returns an empty list if there is no query or multiple identifiers.', async(): Promise => { + await expect(authorizer.permissions(claims)).resolves.toEqual([]); + await expect(authorizer.permissions(claims, [])).resolves.toEqual([]); + const query = [{ resource_id: 'res1' }, { resource_id: 'res2' }]; + await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); + expect(authorizers.ns1.permissions).toHaveBeenCalledTimes(0); + expect(authorizers.ns2.permissions).toHaveBeenCalledTimes(0); + expect(fallback.permissions).toHaveBeenCalledTimes(0); + }); + + it('calls the matching authorizer.', async(): Promise => { + const query = [{ resource_id: 'res2', resource_scopes: [ 'scope1' ] }]; + await expect(authorizer.permissions(claims, query)).resolves.toEqual('perm2'); + expect(authorizers.ns1.permissions).toHaveBeenCalledTimes(0); + expect(authorizers.ns2.permissions).toHaveBeenCalledTimes(1); + expect(authorizers.ns2.permissions).toHaveBeenLastCalledWith(claims, query); + expect(fallback.permissions).toHaveBeenCalledTimes(0); + }); + + it('calls the fallback authorizer if there is no match.', async(): Promise => { + const query1 = [{ resource_id: 'res3' }]; + const query2 = [{ resource_id: 'unknown' }]; + await expect(authorizer.permissions(claims, query1)).resolves.toEqual('perm'); + await expect(authorizer.permissions(claims, query2)).resolves.toEqual('perm'); + expect(authorizers.ns1.permissions).toHaveBeenCalledTimes(0); + expect(authorizers.ns2.permissions).toHaveBeenCalledTimes(0); + expect(fallback.permissions).toHaveBeenCalledTimes(2); + expect(fallback.permissions).toHaveBeenCalledWith(claims, query1); + expect(fallback.permissions).toHaveBeenCalledWith(claims, query2); + }); + }); + + describe('.credentials', (): void => { + const query = { key: vi.fn() }; + + it('returns an empty list if there are no permissions or multiple identifiers.', async(): Promise => { + await expect(authorizer.credentials([])).resolves.toEqual([]); + const permissions = [{ resource_id: 'res1', resource_scopes: [] }, { resource_id: 'res2', resource_scopes: [] }]; + await expect(authorizer.credentials(permissions, query)).resolves.toEqual([]); + expect(authorizers.ns1.credentials).toHaveBeenCalledTimes(0); + expect(authorizers.ns2.credentials).toHaveBeenCalledTimes(0); + expect(fallback.credentials).toHaveBeenCalledTimes(0); + }); + + it('calls the matching authorizer.', async(): Promise => { + const permissions = [{ resource_id: 'res2', resource_scopes: [ 'scope1' ] }]; + await expect(authorizer.credentials(permissions, query)).resolves.toEqual('cred2'); + expect(authorizers.ns1.credentials).toHaveBeenCalledTimes(0); + expect(authorizers.ns2.credentials).toHaveBeenCalledTimes(1); + expect(authorizers.ns2.credentials).toHaveBeenLastCalledWith(permissions, query); + expect(fallback.credentials).toHaveBeenCalledTimes(0); + }); + + it('calls the fallback authorizer if there is no match.', async(): Promise => { + const perms1 = [{ resource_id: 'res3', resource_scopes: [ 'scope' ] }]; + const perms2 = [{ resource_id: 'unknown', resource_scopes: [ 'scope' ] }]; + await expect(authorizer.credentials(perms1, query)).resolves.toEqual('cred'); + await expect(authorizer.credentials(perms2, query)).resolves.toEqual('cred'); + expect(authorizers.ns1.credentials).toHaveBeenCalledTimes(0); + expect(authorizers.ns2.credentials).toHaveBeenCalledTimes(0); + expect(fallback.credentials).toHaveBeenCalledTimes(2); + expect(fallback.credentials).toHaveBeenCalledWith(perms1, query); + expect(fallback.credentials).toHaveBeenCalledWith(perms2, query); + }); + }); +}); diff --git a/packages/uma/test/unit/policies/authorizers/NoneAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/NoneAuthorizer.test.ts new file mode 100644 index 00000000..15487ed3 --- /dev/null +++ b/packages/uma/test/unit/policies/authorizers/NoneAuthorizer.test.ts @@ -0,0 +1,13 @@ +import { NoneAuthorizer } from '../../../../src/policies/authorizers/NoneAuthorizer'; + +describe('NoneAuthorizer', (): void => { + const authorizer = new NoneAuthorizer(); + + it('returns an empty list of permissions.', async(): Promise => { + await expect(authorizer.permissions({})).resolves.toEqual([]); + }); + + it('returns an empty list of requirements.', async(): Promise => { + await expect(authorizer.credentials([])).resolves.toEqual([]); + }); +}); diff --git a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts new file mode 100644 index 00000000..be082cd9 --- /dev/null +++ b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts @@ -0,0 +1,214 @@ +import { NotImplementedHttpError, RDF, XSD } from '@solid/community-server'; +import { basicPolicy, ODRL, UCRulesStorage } from '@solidlab/ucp'; +import { DataFactory as DF, Parser, Store } from 'n3'; +import { ODRLEvaluator } from 'odrl-evaluator'; +import { Mocked } from 'vitest'; +import { OdrlAuthorizer } from '../../../../src/policies/authorizers/OdrlAuthorizer'; +import { Permission } from '../../../../src/views/Permission'; + +const now = new Date(); +vi.useFakeTimers({ now }); + +vi.mock('@solidlab/ucp', async(importOriginal) => ({ + ...await importOriginal(), + basicPolicy: vi.fn(), +})); + +describe('OdrlAuthorizer', (): void => { + const sotw = [ DF.quad( + DF.namedNode('http://example.com/request/currentTime'), + DF.namedNode('http://purl.org/dc/terms/issued'), + DF.literal(now.toISOString(), XSD.terms.dateTime), + )]; + + const evaluate = vi.spyOn(ODRLEvaluator.prototype, 'evaluate'); + + const requestQuads = [ DF.quad(DF.namedNode('req'), RDF.terms.type, DF.namedNode('Request')) ]; + let policyStore = new Store([ DF.quad(DF.namedNode('policy'), RDF.terms.type, DF.namedNode('Policy')) ]); + let policies: Mocked; + let authorizer: OdrlAuthorizer; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + evaluate.mockResolvedValue([]); + + vi.mocked(basicPolicy).mockReturnValueOnce({ + ruleIRIs:[], + policyIRI: '', + representation: new Store(requestQuads), + }); + + policyStore = new Store(); + policies = { + getStore: vi.fn().mockResolvedValue(policyStore), + } satisfies Partial as any; + + authorizer = new OdrlAuthorizer(policies); + }); + + it('does not support credentials requests.', async(): Promise => { + await expect(authorizer.credentials([])).rejects.toThrow(NotImplementedHttpError); + }); + + it('returns an empty result if there is no query.', async(): Promise => { + await expect(authorizer.permissions({})).resolves.toEqual([]); + expect(evaluate).toHaveBeenCalledTimes(0); + }); + + it('calls the evaluator with the generated policy request.', async(): Promise => { + const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; + + // No result as the current evaluate mock returns an empty list + await expect(authorizer.permissions({}, query)).resolves.toEqual([{ resource_id: 'rid', resource_scopes: [] }]); + expect(basicPolicy).toHaveBeenCalledTimes(1); + expect(basicPolicy).toHaveBeenLastCalledWith({ + type: 'http://www.w3.org/ns/odrl/2/Request', + rules: [{ + action: 'http://www.w3.org/ns/odrl/2/read', + resource: 'rid', + requestingParty: 'urn:solidlab:uma:id:anonymous' + }], + }); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenLastCalledWith( + policyStore.getQuads(null, null, null, null), + requestQuads, + sotw, + ); + }); + + it('calls the evaluator with the WebID claim if there is one.', async(): Promise => { + const claims = { 'urn:solidlab:uma:claims:types:webid': 'http://example.com/#me' }; + const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; + + // No result as the current evaluate mock returns an empty list + await expect(authorizer.permissions(claims, query)).resolves.toEqual([{ resource_id: 'rid', resource_scopes: [] }]); + expect(basicPolicy).toHaveBeenCalledTimes(1); + expect(basicPolicy).toHaveBeenLastCalledWith({ + type: 'http://www.w3.org/ns/odrl/2/Request', + rules: [{ + action: 'http://www.w3.org/ns/odrl/2/read', + resource: 'rid', + requestingParty: 'http://example.com/#me' + }], + }); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenLastCalledWith( + policyStore.getQuads(null, null, null, null), + requestQuads, + sotw, + ); + }); + + it('extracts the allowed scopes from the resulting report.', async(): Promise => { + const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; + + const report = ` + @prefix cr: . + @prefix dc: . + @prefix xsd: . + a cr:PolicyReport ; + cr:ruleReport . + a cr:PermissionReport ; + cr:activationState cr:Active ; + cr:premiseReport . + a cr:Target-Report ; + cr:satisfactionState cr:Satisfied . + `; + evaluate.mockResolvedValueOnce(new Parser().parse(report)); + + await expect(authorizer.permissions({}, query)).resolves + .toEqual([{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]); + expect(basicPolicy).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledTimes(1); + }); + + it('does not grant scopes if the report is inactive.', async(): Promise => { + const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; + + const report = ` + @prefix cr: . + @prefix dc: . + @prefix xsd: . + a cr:PolicyReport ; + cr:ruleReport . + a cr:PermissionReport ; + cr:activationState cr:Inactive ; + cr:premiseReport . + a cr:Target-Report ; + cr:satisfactionState cr:Satisfied . + `; + evaluate.mockResolvedValueOnce(new Parser().parse(report)); + + await expect(authorizer.permissions({}, query)).resolves + .toEqual([{ resource_id: 'rid', resource_scopes: [] }]); + expect(basicPolicy).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledTimes(1); + }); + + it('does not grant scopes if the report is a prohibition.', async(): Promise => { + const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; + + const report = ` + @prefix cr: . + @prefix dc: . + @prefix xsd: . + a cr:PolicyReport ; + cr:ruleReport . + a cr:ProhibitionReport ; + cr:activationState cr:Active ; + cr:premiseReport . + a cr:Target-Report ; + cr:satisfactionState cr:Satisfied . + `; + evaluate.mockResolvedValueOnce(new Parser().parse(report)); + + await expect(authorizer.permissions({}, query)).resolves + .toEqual([{ resource_id: 'rid', resource_scopes: [] }]); + expect(basicPolicy).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledTimes(1); + }); + + it('performs a query for every resource and scope.', async(): Promise => { + const query: Permission[] = [ + { resource_id: 'rid1', resource_scopes: [ 'urn:example:css:modes:read' ] }, + { resource_id: 'rid2', resource_scopes: [ 'urn:example:css:modes:write', 'urn:example:css:modes:create' ] }, + ]; + + const permissionReport = ` + @prefix cr: . + @prefix dc: . + @prefix xsd: . + a cr:PolicyReport ; + cr:ruleReport . + a cr:PermissionReport ; + cr:activationState cr:Active ; + cr:premiseReport . + a cr:Target-Report ; + cr:satisfactionState cr:Satisfied . + `; + const prohibitionReport = ` + @prefix cr: . + @prefix dc: . + @prefix xsd: . + a cr:PolicyReport ; + cr:ruleReport . + a cr:ProhibitionReport ; + cr:activationState cr:Active ; + cr:premiseReport . + a cr:Target-Report ; + cr:satisfactionState cr:Satisfied . + `; + evaluate.mockResolvedValueOnce(new Parser().parse(permissionReport)); + evaluate.mockResolvedValueOnce(new Parser().parse(prohibitionReport)); + evaluate.mockResolvedValueOnce(new Parser().parse(permissionReport)); + + await expect(authorizer.permissions({}, query)).resolves + .toEqual([ + { resource_id: 'rid1', resource_scopes: [ 'urn:example:css:modes:read' ] }, + { resource_id: 'rid2', resource_scopes: [ 'urn:example:css:modes:create' ] + }]); + expect(basicPolicy).toHaveBeenCalledTimes(3); + expect(evaluate).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts new file mode 100644 index 00000000..1fe70549 --- /dev/null +++ b/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts @@ -0,0 +1,43 @@ +import { WEBID } from '../../../../src/credentials/Claims'; +import { WebIdAuthorizer } from '../../../../src/policies/authorizers/WebIdAuthorizer'; +import { Permission } from '../../../../src/views/Permission'; + +describe('WebIdAuthorizer', (): void => { + const webIds = [ + 'http://example.com/foo1', + 'http://example.com/foo2', + ]; + + let authorizer = new WebIdAuthorizer(webIds); + + it('returns empty permissions if there is no WebID match.', async(): Promise => { + await expect(authorizer.permissions({})).resolves.toEqual([]); + await expect(authorizer.permissions({ [WEBID]: 'unknown' })).resolves.toEqual([]); + }); + + it('returns full permissions if there is a matching WebID.', async(): Promise => { + const query: Partial[] = [ + { resource_id: 'id1', resource_scopes: [ 'scope1' ]}, + { resource_id: 'id2' }, + { resource_scopes: [ 'scope3' ]}, + ]; + await expect(authorizer.permissions({ [WEBID]: webIds[0] }, query)).resolves.toEqual([ + { resource_id: 'id1', resource_scopes: [ 'scope1' ]}, + { resource_id: 'id2', resource_scopes: [ 'urn:solidlab:uma:scopes:any' ]}, + { resource_id: 'urn:solidlab:uma:resources:any', resource_scopes: [ 'scope3' ]}, + ]); + }); + + it('returns an empty requirements result if there a query without WebID', async(): Promise => { + await expect(authorizer.credentials([], {})).resolves.toEqual([]); + }); + + it('requires a WebID match.', async(): Promise => { + const result = await authorizer.credentials([], { [WEBID]: vi.fn() }); + expect(result).toHaveLength(1); + expect(result[0][WEBID]).toBeDefined(); + await expect(result[0][WEBID]!(webIds[0])).resolves.toBe(true); + await expect(result[0][WEBID]!(webIds[1])).resolves.toBe(true); + await expect(result[0][WEBID]!('wrong')).resolves.toBe(false); + }); +}); diff --git a/packages/uma/test/unit/routes/Config.test.ts b/packages/uma/test/unit/routes/Config.test.ts new file mode 100644 index 00000000..64d48b99 --- /dev/null +++ b/packages/uma/test/unit/routes/Config.test.ts @@ -0,0 +1,26 @@ +import { ConfigRequestHandler } from '../../../src/routes/Config'; + +describe('Config', (): void => { + const baseUrl = 'http://example.com/uma'; + + let config = new ConfigRequestHandler(baseUrl); + + it('returns the configuration.', async(): Promise => { + await expect(config.handle({ request: { url: 'url' } } as any)).resolves.toEqual({ + status: 200, + body: { + jwks_uri: 'http://example.com/uma/keys', + token_endpoint: 'http://example.com/uma/token', + grant_types_supported: ['urn:ietf:params:oauth:grant-type:uma-ticket'], + issuer: 'http://example.com/uma', + permission_endpoint: 'http://example.com/uma/ticket', + introspection_endpoint: 'http://example.com/uma/introspect', + resource_registration_endpoint: 'http://example.com/uma/resources/', + uma_profiles_supported: ['http://openid.net/specs/openid-connect-core-1_0.html#IDToken'], + dpop_signing_alg_values_supported: + expect.arrayContaining(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512']), + response_types_supported: ['token'], + } + }); + }); +}); diff --git a/packages/uma/test/unit/routes/Default.test.ts b/packages/uma/test/unit/routes/Default.test.ts new file mode 100644 index 00000000..bc954685 --- /dev/null +++ b/packages/uma/test/unit/routes/Default.test.ts @@ -0,0 +1,15 @@ +import { DefaultRequestHandler } from '../../../src/routes/Default'; + +describe('Default', (): void => { + const handler = new DefaultRequestHandler(); + + it('returns a 404.', async(): Promise => { + await expect(handler.handle({} as any)).resolves.toEqual({ + status: 404, + body: { + 'status': 404, + 'error': 'Not Found', + }, + }) + }); +}); diff --git a/packages/uma/test/unit/routes/Introspection.test.ts b/packages/uma/test/unit/routes/Introspection.test.ts new file mode 100644 index 00000000..6fc02531 --- /dev/null +++ b/packages/uma/test/unit/routes/Introspection.test.ts @@ -0,0 +1,58 @@ +import { KeyValueStorage, UnauthorizedHttpError } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { IntrospectionHandler } from '../../../src/routes/Introspection'; +import { AccessToken } from '../../../src/tokens/AccessToken'; +import { TokenFactory } from '../../../src/tokens/TokenFactory'; +import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler'; +import * as signatures from '../../../src/util/HttpMessageSignatures'; + +describe('Introspection', (): void => { + const request: HttpHandlerContext = { request: { body: { token: 'token' } } } as any; + const token = { key: 'value' }; + let verifyRequest = vi.spyOn(signatures, 'verifyRequest'); + let factory: Mocked; + let handler: IntrospectionHandler; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + verifyRequest.mockResolvedValue(true); + + factory = { + deserialize: vi.fn().mockResolvedValue(token), + } satisfies Partial as any; + + handler = new IntrospectionHandler(factory); + }); + + it('errors if the request is not authorized.', async(): Promise => { + verifyRequest.mockResolvedValueOnce(false); + await expect(handler.handle(request)).rejects.toThrow(UnauthorizedHttpError); + expect(verifyRequest).toHaveBeenCalledTimes(1); + expect(verifyRequest).toHaveBeenLastCalledWith(request.request); + }); + + it('throws an error if there is no body.', async(): Promise => { + const emptyRequest = { request: {} } as any; + await expect(handler.handle(emptyRequest)).rejects.toThrow('Missing request body.'); + expect(verifyRequest).toHaveBeenCalledTimes(1); + expect(verifyRequest).toHaveBeenLastCalledWith({}); + }); + + it('returns the token.', async(): Promise => { + await expect(handler.handle(request)).resolves.toEqual({ + status: 200, + body: { ...token, active: true }, + }); + expect(verifyRequest).toHaveBeenCalledTimes(1); + expect(factory.deserialize).toHaveBeenCalledTimes(1); + expect(factory.deserialize).toHaveBeenLastCalledWith('token'); + }); + + it('errors if the token could not be deserialized.', async(): Promise => { + factory.deserialize.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.handle(request)).rejects.toThrow('Invalid request body.'); + expect(verifyRequest).toHaveBeenCalledTimes(1); + expect(factory.deserialize).toHaveBeenCalledTimes(1); + expect(factory.deserialize).toHaveBeenLastCalledWith('token'); + }); +}); diff --git a/packages/uma/test/unit/routes/Jwks.test.ts b/packages/uma/test/unit/routes/Jwks.test.ts new file mode 100644 index 00000000..4786218a --- /dev/null +++ b/packages/uma/test/unit/routes/Jwks.test.ts @@ -0,0 +1,26 @@ +import { JwkGenerator } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { JwksRequestHandler } from '../../../src/routes/Jwks'; +import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler'; + +describe('Jwks', (): void => { + const context: HttpHandlerContext = { request: { url: 'url' }} as any; + const key = { key: 'value' }; + let generator: Mocked + let handler: JwksRequestHandler; + + beforeEach(async(): Promise => { + generator = { + getPublicKey: vi.fn().mockResolvedValue(key), + } satisfies Partial as any; + + handler = new JwksRequestHandler(generator); + }); + + it('returns the public key.', async(): Promise => { + await expect(handler.handle(context)).resolves.toEqual({ + status: 200, + body: { keys: [ key ]}, + }); + }); +}); diff --git a/packages/uma/test/unit/routes/ResourceRegistration.test.ts b/packages/uma/test/unit/routes/ResourceRegistration.test.ts new file mode 100644 index 00000000..59340e35 --- /dev/null +++ b/packages/uma/test/unit/routes/ResourceRegistration.test.ts @@ -0,0 +1,274 @@ +import 'jest-rdf'; +import { + KeyValueStorage, + MethodNotAllowedHttpError, + NotFoundHttpError, + RDF, + UnauthorizedHttpError +} from '@solid/community-server'; +import { ODRL, ODRL_P, OWL, UCRulesStorage } from '@solidlab/ucp'; +import { DataFactory as DF, Store } from 'n3'; +import { Mocked } from 'vitest'; +import { ResourceRegistrationRequestHandler } from '../../../src/routes/ResourceRegistration'; +import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler'; +import * as signatures from '../../../src/util/HttpMessageSignatures'; +import { ResourceDescription } from '../../../src/views/ResourceDescription'; + +vi.mock('../../../src/util/HttpMessageSignatures', async() => ({ + extractRequestSigner: vi.fn().mockResolvedValue('signer'), + verifyRequest: vi.fn().mockResolvedValue(true), +})); + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(), +})); + +describe('ResourceRegistration', (): void => { + let input: HttpHandlerContext; + let policyStore: Store; + + let resourceStore: Mocked>; + let policies: Mocked; + + let handler: ResourceRegistrationRequestHandler; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + + input = { request: { + url: new URL('http://example.com/foo'), + method: 'GET', + headers: {}, + body: { + name: 'name', + resource_scopes: [ 'scope1', 'scope2' ], + } + }}; + + policyStore = new Store(); + + resourceStore = { + has: vi.fn().mockResolvedValue(false), + set: vi.fn(), + delete: vi.fn(), + } satisfies Partial> as any; + + policies = { + getStore: vi.fn().mockResolvedValue(policyStore), + addRule: vi.fn(), + removeData: vi.fn(), + } satisfies Partial as any; + + handler = new ResourceRegistrationRequestHandler(resourceStore, policies); + }); + + it('errors if the request is not authorized.', async(): Promise => { + const verifyRequest = vi.spyOn(signatures, 'verifyRequest'); + verifyRequest.mockResolvedValueOnce(false); + await expect(handler.handle(input)).rejects.toThrow(UnauthorizedHttpError); + expect(verifyRequest).toHaveBeenCalledTimes(1); + expect(verifyRequest).toHaveBeenLastCalledWith(input.request, 'signer'); + }); + + it('throws an error if the method is not allowed.', async(): Promise => { + await expect(handler.handle(input)).rejects.toThrow(MethodNotAllowedHttpError); + }); + + describe('with POST requests', (): void => { + beforeEach(async(): Promise => { + input.request.method = 'POST'; + }); + + it('errors if the body syntax is wrong.', async(): Promise => { + (input.request.body as any).resource_scopes = 'apple'; + await expect(handler.handle(input)).rejects.toThrow('Request has bad syntax: value is not an array'); + }); + + it('throws an error when trying to register a resource with a known name.', async(): Promise => { + resourceStore.has.mockResolvedValueOnce(true); + await expect(handler.handle(input)).rejects + .toThrow('A resource with name name is already registered. Use PUT to update existing registrations.'); + expect(resourceStore.has).toHaveBeenCalledTimes(1); + expect(resourceStore.has).toHaveBeenLastCalledWith('name'); + expect(resourceStore.set).toHaveBeenCalledTimes(0); + }); + + it('registers the resource using the name as identifier.', async(): Promise => { + await expect(handler.handle(input)).resolves.toEqual({ + status: 201, + body: { _id: 'name', user_access_policy_uri: 'TODO: implement policy UI' }, + }); + expect(resourceStore.set).toHaveBeenCalledTimes(1); + expect(resourceStore.set).lastCalledWith('name', input.request.body); + }); + + it('stores newly created asset collections.', async(): Promise => { + const crypto = await import('node:crypto'); + let count = 0; + vi.mocked(crypto.randomUUID).mockImplementation(() => `${++count}` as any); + input.request.body!.resource_defaults = { pred: [ 'scope' ], '@reverse': { 'rPred': [ 'otherScope' ]}}; + await expect(handler.handle(input)).resolves.toEqual({ + status: 201, + body: { _id: 'name', user_access_policy_uri: 'TODO: implement policy UI' }, + }); + expect(policies.addRule).toHaveBeenCalledTimes(1); + const newStore = policies.addRule.mock.calls[0][0]; + expect(newStore).toBeRdfIsomorphic([ + DF.quad(DF.namedNode('collection:1'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:1'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:1'), ODRL_P.terms.relation, DF.namedNode('pred')), + DF.quad(DF.namedNode('collection:2'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:2'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:2'), ODRL_P.terms.relation, DF.blankNode('n3-0')), + DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')), + ]); + }); + + it('errors when trying to register a relation when the collection does not exist.', async(): Promise => { + input.request.body!.resource_relations = { rPred: [ 'name' ] }; + await expect(handler.handle(input)).rejects + .toThrow('Registering resource with relation rPred to name while there is no matching collection.'); + }); + + it('stores the relation triples.', async(): Promise => { + policyStore.addQuads([ + DF.quad(DF.namedNode('collection:1'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:1'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:1'), ODRL_P.terms.relation, DF.namedNode('pred')), + DF.quad(DF.namedNode('collection:2'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:2'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:2'), ODRL_P.terms.relation, DF.blankNode('n3-0')), + DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')), + ]); + input.request.body!.resource_relations = { rPred: [ 'name' ], '@reverse': { pred: [ 'name' ] }}; + input.request.body!.name = 'entry'; + await expect(handler.handle(input)).resolves.toEqual({ + status: 201, + body: { _id: 'entry', user_access_policy_uri: 'TODO: implement policy UI' }, + }); + expect(policies.addRule).toHaveBeenCalledTimes(1); + const newStore = policies.addRule.mock.calls[0][0]; + expect(newStore).toBeRdfIsomorphic([ + // TODO: because of issue in ODRL evaluator + // DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:1')), + // DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:2')), + DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('name')), + ]); + }); + }); + + + describe('with PUT requests', (): void => { + beforeEach(async(): Promise => { + input.request.method = 'PUT'; + input.request.parameters = { id: 'name' }; + + resourceStore.has.mockResolvedValue(true); + }); + + it('errors if no id parameter is provided.', async(): Promise => { + input.request.parameters = {}; + await expect(handler.handle(input)).rejects.toThrow('URI for PUT operation should include an id.'); + }); + + it('errors if the resource is not known.', async(): Promise => { + resourceStore.has.mockResolvedValueOnce(false); + await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError); + }); + + it('errors if the body syntax is wrong.', async(): Promise => { + (input.request.body as any).resource_scopes = 'apple'; + await expect(handler.handle(input)).rejects.toThrow('Request has bad syntax: value is not an array'); + }); + + it('updates the resource metadata.', async(): Promise => { + await expect(handler.handle(input)).resolves.toEqual({ + status: 200, + body: { _id: 'name', user_access_policy_uri: 'TODO: implement policy UI' }, + }); + expect(resourceStore.set).toHaveBeenCalledTimes(1); + expect(resourceStore.set).lastCalledWith('name', input.request.body); + }); + + it('stores newly created asset collections.', async(): Promise => { + const crypto = await import('node:crypto'); + let count = 0; + vi.mocked(crypto.randomUUID).mockImplementation(() => `${++count}` as any); + input.request.body!.resource_defaults = { pred: [ 'scope' ], '@reverse': { 'rPred': [ 'otherScope' ]}}; + await expect(handler.handle(input)).resolves.toEqual({ + status: 200, + body: { _id: 'name', user_access_policy_uri: 'TODO: implement policy UI' }, + }); + expect(policies.addRule).toHaveBeenCalledTimes(1); + const newStore = policies.addRule.mock.calls[0][0]; + expect(newStore).toBeRdfIsomorphic([ + DF.quad(DF.namedNode('collection:1'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:1'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:1'), ODRL_P.terms.relation, DF.namedNode('pred')), + DF.quad(DF.namedNode('collection:2'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:2'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:2'), ODRL_P.terms.relation, DF.blankNode('n3-0')), + DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')), + ]); + }); + + it('errors when trying to register a relation when the collection does not exist.', async(): Promise => { + input.request.body!.resource_relations = { rPred: [ 'name' ] }; + await expect(handler.handle(input)).rejects + .toThrow('Registering resource with relation rPred to name while there is no matching collection.'); + }); + + it('stores the relation triples.', async(): Promise => { + policyStore.addQuads([ + DF.quad(DF.namedNode('collection:1'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:1'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:1'), ODRL_P.terms.relation, DF.namedNode('pred')), + DF.quad(DF.namedNode('collection:2'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:2'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:2'), ODRL_P.terms.relation, DF.blankNode('n3-0')), + DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')), + ]); + input.request.body!.resource_relations = { rPred: [ 'name' ], '@reverse': { pred: [ 'name' ] }}; + input.request.parameters = { id: 'entry' }; + await expect(handler.handle(input)).resolves.toEqual({ + status: 200, + body: { _id: 'entry', user_access_policy_uri: 'TODO: implement policy UI' }, + }); + expect(policies.addRule).toHaveBeenCalledTimes(1); + const newStore = policies.addRule.mock.calls[0][0]; + expect(newStore).toBeRdfIsomorphic([ + // TODO: because of issue in ODRL evaluator + // DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:1')), + // DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:2')), + DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('name')), + ]); + }); + }); + + describe('with DELETE requests', (): void => { + beforeEach(async(): Promise => { + input.request.method = 'DELETE'; + input.request.parameters = { id: 'name' }; + + resourceStore.has.mockResolvedValue(true); + }); + + it('errors if no id parameter is provided.', async(): Promise => { + input.request.parameters = {}; + await expect(handler.handle(input)).rejects.toThrow('URI for DELETE operation should include an id.'); + expect(resourceStore.delete).toHaveBeenCalledTimes(0); + }); + + it('errors if the resource is not known.', async(): Promise => { + resourceStore.has.mockResolvedValueOnce(false); + await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError); + expect(resourceStore.delete).toHaveBeenCalledTimes(0); + }); + + it('deletes the resource.', async(): Promise => { + await expect(handler.handle(input)).resolves.toEqual({ status: 204 }); + expect(resourceStore.delete).toHaveBeenCalledTimes(1); + expect(resourceStore.delete).toHaveBeenLastCalledWith('name'); + }); + }); +}); diff --git a/packages/uma/test/unit/routes/Ticket.test.ts b/packages/uma/test/unit/routes/Ticket.test.ts new file mode 100644 index 00000000..45cd4bd3 --- /dev/null +++ b/packages/uma/test/unit/routes/Ticket.test.ts @@ -0,0 +1,67 @@ +import { KeyValueStorage, UnauthorizedHttpError } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { TicketRequestHandler } from '../../../src/routes/Ticket'; +import { TicketingStrategy } from '../../../src/ticketing/strategy/TicketingStrategy'; +import { Ticket } from '../../../src/ticketing/Ticket'; +import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler'; +import * as signatures from '../../../src/util/HttpMessageSignatures'; + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn().mockReturnValue('1-2-3-4-5'), +})); + +describe('Ticket', (): void => { + let request: HttpHandlerContext; + const verifyRequest = vi.spyOn(signatures, 'verifyRequest'); + + let ticketingStrategy: Mocked; + let ticketStore: Mocked>; + let handler: TicketRequestHandler; + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + verifyRequest.mockResolvedValue(true); + + request = { request: { body: [{ + resource_id: 'id', + resource_scopes: [ 'scope' ], + }]}} as any; + + ticketingStrategy = { + initializeTicket: vi.fn().mockResolvedValue('ticket'), + resolveTicket: vi.fn(), + validateClaims: vi.fn(), + }; + + ticketStore = { + set: vi.fn(), + } satisfies Partial> as any; + + handler = new TicketRequestHandler(ticketingStrategy, ticketStore); + }); + + it('errors if the request is not authorized.', async(): Promise => { + verifyRequest.mockResolvedValueOnce(false); + await expect(handler.handle(request)).rejects.toThrow(UnauthorizedHttpError); + expect(verifyRequest).toHaveBeenCalledTimes(1); + expect(verifyRequest).toHaveBeenLastCalledWith(request.request); + }); + + it('throws an error if the body is invalid.', async(): Promise => { + request.request.body = 'apple'; + await expect(handler.handle(request)).rejects.toThrow('Request has bad syntax: value is not an array'); + }); + + it('sends a 200 response if the initialized ticket is immediately resolved.', async(): Promise => { + ticketingStrategy.resolveTicket.mockResolvedValue({ success: true, value: [] }); + await expect(handler.handle(request)).resolves.toEqual({ status: 200 }); + expect(ticketStore.set).toHaveBeenCalledTimes(0); + }); + + it('sends a 201 response with the initialized ticket.', async(): Promise => { + ticketingStrategy.resolveTicket.mockResolvedValue({ success: false, value: [] }); + await expect(handler.handle(request)).resolves.toEqual({ status: 201, body: { ticket: '1-2-3-4-5' }}); + expect(ticketStore.set).toHaveBeenCalledTimes(1); + expect(ticketStore.set).toHaveBeenLastCalledWith('1-2-3-4-5', 'ticket'); + }); +}); diff --git a/packages/uma/test/unit/routes/Token.test.ts b/packages/uma/test/unit/routes/Token.test.ts new file mode 100644 index 00000000..747a3674 --- /dev/null +++ b/packages/uma/test/unit/routes/Token.test.ts @@ -0,0 +1,57 @@ +import { Mocked } from 'vitest'; +import { Negotiator } from '../../../src/dialog/Negotiator'; +import { NeedInfoError } from '../../../src/errors/NeedInfoError'; +import { TokenRequestHandler } from '../../../src/routes/Token'; +import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler'; + +describe('Token', (): void => { + let request: HttpHandlerContext; + + let negotiator: Mocked; + let handler: TokenRequestHandler; + + beforeEach(async(): Promise => { + request = { request: { body: { + ticket: 'ticket', + grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket' + }}} as any; + + negotiator = { + negotiate: vi.fn().mockResolvedValue('response'), + }; + + handler = new TokenRequestHandler(negotiator); + }); + + it('throws an error if the body is invalid.', async(): Promise => { + request.request.body = { ticket: 5 }; + await expect(handler.handle(request)).rejects + .toThrow('Invalid token request body: value is neither of the union types'); + }); + + it('throws an error if the grant type is not supported.', async(): Promise => { + request.request.body = { grant_type: 'not supported' }; + await expect(handler.handle(request)).rejects + .toThrow("Expected 'grant_type' to be set to 'urn:ietf:params:oauth:grant-type:uma-ticket'") + }); + + it('returns the negotiated response.', async(): Promise => { + await expect(handler.handle(request)).resolves.toEqual({ status: 200, body: 'response' }); + expect(negotiator.negotiate).toHaveBeenCalledTimes(1); + expect(negotiator.negotiate).toHaveBeenLastCalledWith(request.request.body); + }); + + it('returns a 403 with the ticket if negotiation needs more info.', async(): Promise => { + const needInfo = new NeedInfoError('msg', 'ticket', { required_claims: { claim_token_format: [[ 'format' ]] } }); + negotiator.negotiate.mockRejectedValueOnce(needInfo); + await expect(handler.handle(request)).resolves.toEqual({ status: 403, body: { + ticket: 'ticket', + required_claims: { claim_token_format: [[ 'format' ]] }, + }}); + }); + + it('throws an error if something else goes wrong.', async(): Promise => { + negotiator.negotiate.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.handle(request)).rejects.toThrow('bad data'); + }); +}); diff --git a/packages/uma/test/unit/ticketing/strategy/ClaimEliminationStrategy.test.ts b/packages/uma/test/unit/ticketing/strategy/ClaimEliminationStrategy.test.ts new file mode 100644 index 00000000..ebeab5f4 --- /dev/null +++ b/packages/uma/test/unit/ticketing/strategy/ClaimEliminationStrategy.test.ts @@ -0,0 +1,79 @@ +import { Mocked } from 'vitest'; +import { ClaimSet } from '../../../../src/credentials/ClaimSet'; +import { Requirements } from '../../../../src/credentials/Requirements'; +import { Authorizer } from '../../../../src/policies/authorizers/Authorizer'; +import { ClaimEliminationStrategy } from '../../../../src/ticketing/strategy/ClaimEliminationStrategy'; +import { Ticket } from '../../../../src/ticketing/Ticket'; +import { Permission } from '../../../../src/views/Permission'; + +describe('ClaimEliminationStrategy', (): void => { + const requirements: Requirements = { key: async() => true }; + const permissions: Permission[] = [ { resource_id: 'id', resource_scopes: [ 'scopes' ] } ]; + + let authorizer: Mocked; + let strategy: ClaimEliminationStrategy; + + beforeEach(async(): Promise => { + authorizer = { + credentials: vi.fn().mockResolvedValue(requirements), + permissions: vi.fn(), + }; + + strategy = new ClaimEliminationStrategy(authorizer); + }); + + it('can initialize a ticket.', async(): Promise => { + await expect(strategy.initializeTicket(permissions)).resolves.toEqual({ + permissions, + required: requirements, + provided: {}, + }); + }); + + it('can validate claims.', async(): Promise => { + const req1 = vi.fn().mockResolvedValue(true); + const req2 = vi.fn().mockResolvedValue(false); + const req3 = vi.fn().mockResolvedValue(false); + const ticket: Ticket = { + permissions, + provided: {}, + required: [ + { claim1: req1, claim2: req2 }, + { claim3: req3 }, + ], + }; + const claims: ClaimSet = { claim1: 'val1', claim2: 'val2' }; + await expect(strategy.validateClaims(ticket, claims)).resolves.toBe(ticket); + expect(ticket).toEqual({ + permissions, + provided: { claim1: 'val1', claim2: 'val2' }, + required: [ + { claim2: req2 }, + { claim3: req3 }, + ], + }); + expect(req1).toHaveBeenCalledTimes(1); + expect(req1).toHaveBeenLastCalledWith('val1'); + expect(req2).toHaveBeenCalledTimes(1); + expect(req2).toHaveBeenLastCalledWith('val2'); + expect(req3).toHaveBeenCalledTimes(0); + }); + + it('successfully resolves a ticket if there are no requirements left.', async(): Promise => { + const ticket: Ticket = { + permissions, + provided: {}, + required: [{}], + }; + await expect(strategy.resolveTicket(ticket)).resolves.toEqual({ success: true, value: permissions }) + }); + + it('rejects a ticket if there are still requirements.', async(): Promise => { + const ticket: Ticket = { + permissions, + provided: {}, + required: [ requirements], + }; + await expect(strategy.resolveTicket(ticket)).resolves.toEqual({ success: false, value: ticket.required }) + }); +}); diff --git a/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts new file mode 100644 index 00000000..054fd840 --- /dev/null +++ b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts @@ -0,0 +1,74 @@ +import { Mocked } from 'vitest'; +import { ClaimSet } from '../../../../src/credentials/ClaimSet'; +import { Requirements } from '../../../../src/credentials/Requirements'; +import { Authorizer } from '../../../../src/policies/authorizers/Authorizer'; +import { ImmediateAuthorizerStrategy } from '../../../../src/ticketing/strategy/ImmediateAuthorizerStrategy'; +import { Ticket } from '../../../../src/ticketing/Ticket'; +import { Permission } from '../../../../src/views/Permission'; + +describe('ImmediateAuthorizerStrategy', (): void => { + const permissions: Permission[] = [ { resource_id: 'id', resource_scopes: [ 'scopes' ] } ]; + + let authorizer: Mocked; + let strategy: ImmediateAuthorizerStrategy; + + beforeEach(async(): Promise => { + authorizer = { + credentials: vi.fn(), + permissions: vi.fn().mockResolvedValue(permissions), + }; + + strategy = new ImmediateAuthorizerStrategy(authorizer) + }); + + it('initializes a ticket with empty requirements.', async(): Promise => { + await expect(strategy.initializeTicket(permissions)).resolves.toEqual({ + permissions, + required: [{}], + provided: {}, + }); + }); + + it('validates the claims by adding them to the provided field.', async(): Promise => { + const ticket: Ticket = { + permissions, + provided: {}, + required: [], + }; + const claims: ClaimSet = { claim1: 'val1', claim2: 'val2' }; + await expect(strategy.validateClaims(ticket, claims)).resolves.toEqual({ + permissions, + provided: { claim1: 'val1', claim2: 'val2' }, + required: [], + }); + }); + + it('resolves tickets if it provides permissions.', async(): Promise => { + const ticket: Ticket = { + permissions, + provided: {}, + required: [], + }; + const authResponse: Permission[] = [ + { resource_id: 'id1', resource_scopes: [ 'scopes' ] }, + { resource_id: 'id2', resource_scopes: [] } + ]; + authorizer.permissions.mockResolvedValueOnce(authResponse); + await expect(strategy.resolveTicket(ticket)).resolves + .toEqual({ success: true, value: [{ resource_id: 'id1', resource_scopes: [ 'scopes' ] }] }); + }); + + it('rejects a ticket if it does not provide permissions.', async(): Promise => { + const ticket: Ticket = { + permissions, + provided: {}, + required: [], + }; + const authResponse: Permission[] = [ + { resource_id: 'id1', resource_scopes: [] }, + { resource_id: 'id2', resource_scopes: [] } + ]; + authorizer.permissions.mockResolvedValueOnce(authResponse); + await expect(strategy.resolveTicket(ticket)).resolves.toEqual({ success: false, value: [] }); + }); +}); diff --git a/packages/uma/test/unit/tokens/JwtTokenFactory.test.ts b/packages/uma/test/unit/tokens/JwtTokenFactory.test.ts new file mode 100644 index 00000000..ce3e265a --- /dev/null +++ b/packages/uma/test/unit/tokens/JwtTokenFactory.test.ts @@ -0,0 +1,113 @@ +import { AlgJwk, JwkGenerator, KeyValueStorage } from '@solid/community-server'; +import { exportJWK, generateKeyPair, GenerateKeyPairResult, jwtVerify, KeyLike, SignJWT } from 'jose'; +import { beforeAll, Mocked } from 'vitest'; +import { AccessToken } from '../../../src/tokens/AccessToken'; +import { JwtTokenFactory } from '../../../src/tokens/JwtTokenFactory'; + +const now = new Date(); +vi.useFakeTimers({ now }); + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn().mockReturnValue('1-2-3-4-5'), +})); + +describe('JwtTokenFactory', (): void => { + const alg = 'ES256'; + const issuer = 'https://example.com/'; + let keys: GenerateKeyPairResult; + let publicKey: AlgJwk; + let privateKey: AlgJwk; + + const token: AccessToken = { + permissions: [ { resource_id: 'id', resource_scopes: [ 'scopes' ]} ], + contract: { + uid: 'uid', + permission: [{ + action: 'urn:example:css:modes:action', + target: 'target', + assigner: 'assigner', + assignee: 'assignee', + }], + }, + }; + + let keyGen: Mocked; + let tokenStore: Mocked>; + let factory: JwtTokenFactory; + + beforeAll(async(): Promise => { + keys = await generateKeyPair(alg); + publicKey = { ...await exportJWK(keys.publicKey), alg }; + privateKey = { ...await exportJWK(keys.privateKey), alg }; + }); + + beforeEach(async(): Promise => { + keyGen = { + alg: alg, + getPublicKey: vi.fn().mockResolvedValue(publicKey), + getPrivateKey: vi.fn().mockResolvedValue(privateKey), + }; + + tokenStore = { + set: vi.fn(), + } satisfies Partial> as any; + + factory = new JwtTokenFactory(keyGen, issuer, tokenStore); + }); + + it('serializes an access token to a JWT.', async(): Promise => { + const result = await factory.serialize(token); + expect(result.tokenType).toBe('Bearer'); + const parsed = await jwtVerify(result.token, keys.publicKey); + expect(parsed.payload).toEqual({ + ...token, + iat: Math.floor(now.getTime()/1000), + iss: issuer, + aud: 'solid', + exp: Math.floor(now.getTime()/1000) + 30 * 60, + jti: '1-2-3-4-5', + }); + expect(parsed.protectedHeader.alg).toBe(alg); + expect(parsed.protectedHeader.kid).toBe(privateKey.kid); + expect(tokenStore.set).toHaveBeenCalledTimes(1); + expect(tokenStore.set).toHaveBeenLastCalledWith(result.token, token); + }); + + it('returns the permissions of the deserialized token.', async(): Promise => { + const jwt = await new SignJWT(token) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(issuer) + .setAudience('solid') + .sign(keys.privateKey); + await expect(factory.deserialize(jwt)).resolves.toEqual({ permissions: token.permissions }); + }); + + it('errors deserializing tokens with no aud field.', async(): Promise => { + const jwt = await new SignJWT(token) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(issuer) + .sign(keys.privateKey); + await expect(factory.deserialize(jwt)).rejects + .toThrow('Invalid Access Token provided, error while parsing: missing required "aud" claim'); + }); + + it('errors deserializing tokens with no permissions.', async(): Promise => { + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(issuer) + .setAudience('solid') + .sign(keys.privateKey); + await expect(factory.deserialize(jwt)).rejects + .toThrow('Invalid Access Token provided, error while parsing: missing required "permissions" claim'); + }); + + it('errors deserializing if permissions have the wrong format.', async(): Promise => { + const jwt = await new SignJWT({ permissions: 'apple' }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(issuer) + .setAudience('solid') + .sign(keys.privateKey); + await expect(factory.deserialize(jwt)).rejects + .toThrow('Invalid Access Token provided, error while parsing: value is not an array'); + }); +}); diff --git a/packages/uma/test/unit/util/ConvertUtil.test.ts b/packages/uma/test/unit/util/ConvertUtil.test.ts new file mode 100644 index 00000000..8a21f9a2 --- /dev/null +++ b/packages/uma/test/unit/util/ConvertUtil.test.ts @@ -0,0 +1,36 @@ +import { isPrimitive, formToJson, jsonToForm } from '../../../src/util/ConvertUtil'; + +describe('ConvertUtil', (): void => { + describe('#formToJson', (): void => { + it('converts a form encoded string to JSON.', async(): Promise => { + expect(formToJson('a=b&c=sp%20ace&d=1&d=2')).toEqual({ + a: 'b', + c: 'sp ace', + d: [ '1', '2' ], + }); + }); + }); + + describe('#jsonToForm', (): void => { + it('converts a JSON object to a form encoded string.', async(): Promise => { + expect(jsonToForm({ a: 'b', c: 'sp ace', d: [ 1, 2 ] })).toEqual('a=b&c=sp%20ace&d=1&d=2'); + }); + + it('errors for non-primitive values.', async(): Promise => { + expect(() => jsonToForm({ a: { b: 'c' }})) + .toThrow('Can only convert JSON objects with primitives or arrays of primitives to urlencoded string.'); + expect(() => jsonToForm({ a: [{ b: 'c' }]})) + .toThrow('Can only convert JSON objects with primitives or arrays of primitives to urlencoded string.'); + }); + }); + + describe('#isPrimitive', (): void => { + it('returns true for strings, numbers, and booleans.', async(): Promise => { + expect(isPrimitive(5)).toBe(true); + expect(isPrimitive(false)).toBe(true); + expect(isPrimitive('apple')).toBe(true); + expect(isPrimitive([])).toBe(false); + expect(isPrimitive({})).toBe(false); + }); + }); +}); diff --git a/packages/uma/test/unit/util/HttpMessageSignatures.test.ts b/packages/uma/test/unit/util/HttpMessageSignatures.test.ts new file mode 100644 index 00000000..cc1557c3 --- /dev/null +++ b/packages/uma/test/unit/util/HttpMessageSignatures.test.ts @@ -0,0 +1,98 @@ +import { AlgJwk, UnauthorizedHttpError } from '@solid/community-server'; +import { httpbis } from 'http-message-signatures'; +import { exportJWK, generateKeyPair, GenerateKeyPairResult } from 'jose'; +import crypto from 'node:crypto'; +import { beforeAll, Mock } from 'vitest'; +import { HttpHandlerRequest } from '../../../src/util/http/models/HttpHandler'; +import { extractRequestSigner, signRequest, verifyRequest } from '../../../src/util/HttpMessageSignatures'; + +vi.mock('get-jwks'); + +describe('HttpMessageSignatures', (): void => { + const url = 'https://example.com/foo'; + const alg = 'ES256'; + let keys: GenerateKeyPairResult; + let publicKey: AlgJwk; + let privateKey: AlgJwk; + + beforeAll(async(): Promise => { + keys = await generateKeyPair(alg); + publicKey = { ...await exportJWK(keys.publicKey), alg, kid: 'public' }; + privateKey = { ...await exportJWK(keys.privateKey), alg, kid: 'private' }; + }); + + describe('#signRequest', (): void => { + it('adds the signature headers to the request.', async(): Promise => { + const request = { method: 'GET', headers: { accept: 'text/plain' }, body: 'text' }; + const signedRequest = await signRequest(url, request, privateKey); + expect(signedRequest).toMatchObject(request); + expect(signedRequest.headers['Signature-Input']).includes('sig=("@target-uri" "@method")'); + const verified = await httpbis.verifyMessage({ + keyLookup: async() => ({ + async verify(data: Buffer, signature: Buffer) { + const params = { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' }; + const key = await crypto.subtle.importKey('jwk', publicKey, params, false, ['verify']); + return await crypto.subtle.verify(params, key, signature, data); + }, + })}, + signedRequest, + ); + expect(verified).toBe(true); + }); + }); + + describe('#extractRequestSigner', (): void => { + it('extracts the request signer.', async(): Promise => { + const request: HttpHandlerRequest = { + url: new URL('https://example.com/foo'), + method: 'GET', + headers: { accept: 'text/turtle', authorization: 'HttpSig cred="https://example.com/bar"' }, + }; + await expect(extractRequestSigner(request)).resolves.toBe('"https://example.com/bar"'); + }); + + it('errors if there is no authorization header.', async(): Promise => { + const request: HttpHandlerRequest = { + url: new URL('https://example.com/foo'), + method: 'GET', + headers: { accept: 'text/turtle' }, + }; + await expect(extractRequestSigner(request)).rejects.toThrow(UnauthorizedHttpError); + }); + + it('errors if the credentials are missing.', async(): Promise => { + const request: HttpHandlerRequest = { + url: new URL('https://example.com/foo'), + method: 'GET', + headers: { accept: 'text/turtle', authorization: 'HttpSig' }, + }; + await expect(extractRequestSigner(request)).rejects.toThrow(UnauthorizedHttpError); + }); + }); + + describe('#verifyRequest', (): void => { + let getJwk: Mock<() => unknown>; + beforeEach(async(): Promise => { + const getJwks = await import('get-jwks'); + getJwk = vi.fn().mockResolvedValue(publicKey); + (getJwks.default as unknown as Mock).mockReturnValue({ getJwk } as any); + }); + + it('verifies the request.', async(): Promise => { + const request = { method: 'GET', headers: { accept: 'text/plain' }, body: 'text' }; + const signedRequest = await signRequest(url, request, privateKey); + await expect(verifyRequest(signedRequest as any, 'signer')).resolves.toBe(true); + expect(getJwk).toHaveBeenCalledTimes(1); + expect(getJwk).toHaveBeenLastCalledWith({ domain: 'signer', alg: 'ES256', kid: 'private' }); + }); + + it('returns false if the request could not be verified.', async(): Promise => { + const request = { method: 'GET', headers: { accept: 'text/plain' }, body: 'text' }; + const signedRequest = await signRequest(url, request, privateKey); + const badRequest = { ...signedRequest, url: 'https://example.com/wrong' }; + await expect(verifyRequest(badRequest as any, 'signer')).resolves.toBe(false); + expect(getJwk).toHaveBeenCalledTimes(1); + expect(getJwk).toHaveBeenLastCalledWith({ domain: 'signer', alg: 'ES256', kid: 'private' }); + }); + }); +}); diff --git a/packages/uma/test/unit/util/http/identifier/BaseTargetExtractor.test.ts b/packages/uma/test/unit/util/http/identifier/BaseTargetExtractor.test.ts new file mode 100644 index 00000000..248fbd15 --- /dev/null +++ b/packages/uma/test/unit/util/http/identifier/BaseTargetExtractor.test.ts @@ -0,0 +1,14 @@ +import { HttpRequest } from '@solid/community-server'; +import { BaseTargetExtractor } from '../../../../../src/util/http/identifier/BaseTargetExtractor'; + +describe('BaseTargetExtractor', (): void => { + it('creates a target extractor without needing an identifier strategy.', async(): Promise => { + let extractor = new BaseTargetExtractor(); + const request: HttpRequest = { url: 'foo?key=val', headers: { host: 'example.com' }} as any; + await expect(extractor.canHandle({ request })).resolves.toBeUndefined(); + await expect(extractor.handle({ request })).resolves.toEqual({ path: 'http://example.com/foo' }); + + extractor = new BaseTargetExtractor(true); + await expect(extractor.handle({ request })).resolves.toEqual({ path: 'http://example.com/foo?key=val' }); + }); +}); diff --git a/packages/uma/test/unit/util/http/server/JsonFormHttpHandler.test.ts b/packages/uma/test/unit/util/http/server/JsonFormHttpHandler.test.ts new file mode 100644 index 00000000..b8a18c11 --- /dev/null +++ b/packages/uma/test/unit/util/http/server/JsonFormHttpHandler.test.ts @@ -0,0 +1,124 @@ +import { UnsupportedMediaTypeHttpError } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../../../../../src/util/http/models/HttpHandler'; +import { JsonFormHttpHandler } from '../../../../../src/util/http/server/JsonFormHttpHandler'; + +describe('JsonFormHttpHandler', (): void => { + const formString = 'key=form'; + const jsonString = '{ "key": "json" }'; + let context: HttpHandlerContext; + let response: HttpHandlerResponse; + let source: Mocked; + let handler: JsonFormHttpHandler; + + beforeEach(async(): Promise => { + context = { + request: { + url: new URL('http://example.com/foo'), + method: 'GET', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + body: Buffer.from(formString), + }, + }; + + response = { + body: { key: 'response' }, + headers: {}, + status: 200, + } + + source = { + canHandle: vi.fn(), + handle: vi.fn().mockResolvedValue(response), + handleSafe: vi.fn(), + }; + + handler = new JsonFormHttpHandler(source); + }); + + it('can handle form data.', async(): Promise => { + context.request.headers['content-type'] = 'application/x-www-form-urlencoded'; + context.request.body = Buffer.from(formString); + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith({ request: { ...context.request, body: { key: 'form' }} }); + }); + + it('can handle json data.', async(): Promise => { + context.request.headers['content-type'] = 'application/json'; + context.request.body = Buffer.from(jsonString); + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith({ request: { ...context.request, body: { key: 'json' }} }); + }); + + it('can not handle other data types.', async(): Promise => { + context.request.headers['content-type'] = 'text/plain'; + context.request.body = Buffer.from(jsonString); + await expect(handler.canHandle(context)).rejects.toThrow(UnsupportedMediaTypeHttpError); + expect(source.canHandle).toHaveBeenCalledTimes(0); + }); + + it('errors calling the handle function before calling canHandle', async(): Promise => { + await expect(handler.handle(context)).rejects.toThrow('Calling handle before calling canHandle.'); + }); + + it('returns the source response if it has no body.', async(): Promise => { + delete response.body; + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toEqual(response); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith({ request: { ...context.request, body: { key: 'form' }} }); + }); + + it('returns the string response as buffer if content-type is provided.', async(): Promise => { + response.body = 'text'; + response.headers!['content-type'] = 'text/plain'; + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toEqual({ ...response, body: Buffer.from('text') }); + }); + + it('returns the buffer response if content-type is provided.', async(): Promise => { + response.body = Buffer.from('text'); + response.headers!['content-type'] = 'text/plain'; + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toEqual(response); + }); + + it('converts output objects to form data if requested.', async(): Promise => { + context.request.headers.accept = 'application/x-www-form-urlencoded'; + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toEqual({ + ...response, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: Buffer.from('key=response'), + }); + }); + + it('converts output objects to JSON data if requested.', async(): Promise => { + context.request.headers.accept = 'application/json'; + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toEqual({ + ...response, + headers: { 'content-type': 'application/json' }, + body: Buffer.from('{"key":"response"}'), + }); + }); + + it('converts output objects to JSON if there was no accept header.', async(): Promise => { + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toEqual({ + ...response, + headers: { 'content-type': 'application/json' }, + body: Buffer.from('{"key":"response"}'), + }); + }); + + it('errors if the output data cannot be converted to the requested type.', async(): Promise => { + context.request.headers.accept = 'text/plain'; + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).rejects.toThrow('Unable to convert output to JSON or urlencoded data.'); + }); +}); diff --git a/packages/uma/test/unit/util/http/server/JsonHttpErrorHandler.test.ts b/packages/uma/test/unit/util/http/server/JsonHttpErrorHandler.test.ts new file mode 100644 index 00000000..ce5d8e29 --- /dev/null +++ b/packages/uma/test/unit/util/http/server/JsonHttpErrorHandler.test.ts @@ -0,0 +1,59 @@ +import { BadRequestHttpError } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { NeedInfoError } from '../../../../../src/errors/NeedInfoError'; +import { HttpHandler, HttpHandlerContext } from '../../../../../src/util/http/models/HttpHandler'; +import { JsonHttpErrorHandler } from '../../../../../src/util/http/server/JsonHttpErrorHandler'; + +describe('JsonHttpErrorHandler', (): void => { + const context: HttpHandlerContext = { + request: { + url: new URL('https://example.com/foo'), + method: 'GET', + headers: {}, + } + } + let source: Mocked, Buffer>>; + let handler: JsonHttpErrorHandler; + + beforeEach(async(): Promise => { + source = { + canHandle: vi.fn(), + handle: vi.fn(), + handleSafe: vi.fn().mockResolvedValue('handleSafe') + } + + handler = new JsonHttpErrorHandler(source); + }); + + it('calls the source handleSafe function.', async(): Promise => { + await expect(handler.handle(context)).resolves.toBe('handleSafe'); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe).toHaveBeenLastCalledWith(context); + }); + + it('returns an error response if there is an error.', async(): Promise => { + source.handleSafe.mockRejectedValueOnce(new NeedInfoError('bad data', 'ticket', { redirect_user: 'user' })); + const response = await handler.handle(context); + expect(response.status).toBe(403); + expect(response.headers).toEqual({ 'content-type': 'application/json' }); + expect(JSON.parse(response.body!.toString())).toEqual({ + status: 403, + description: 'Forbidden', + error: 'request_denied', + message: 'bad data', + redirect_user: 'user', + }); + }); + + it('defaults to 500 errors if no information is provided.', async(): Promise => { + source.handleSafe.mockRejectedValueOnce(new Error('bad data')); + const response = await handler.handle(context); + expect(response.status).toBe(500); + expect(response.headers).toEqual({ 'content-type': 'application/json' }); + expect(JSON.parse(response.body!.toString())).toEqual({ + status: 500, + description: 'Internal Server Error', + message: 'bad data', + }); + }); +}); diff --git a/packages/uma/test/unit/util/http/server/NodeHttpRequestResponseHandler.test.ts b/packages/uma/test/unit/util/http/server/NodeHttpRequestResponseHandler.test.ts new file mode 100644 index 00000000..068b505d --- /dev/null +++ b/packages/uma/test/unit/util/http/server/NodeHttpRequestResponseHandler.test.ts @@ -0,0 +1,91 @@ +import { + HttpHandlerInput, + HttpRequest, + HttpResponse, + readableToString, + TargetExtractor +} from '@solid/community-server'; +import { PassThrough, Readable } from 'node:stream'; +import { Mocked } from 'vitest'; +import { HttpHandler, HttpHandlerContext } from '../../../../../src/util/http/models/HttpHandler'; +import { NodeHttpRequestResponseHandler } from '../../../../../src/util/http/server/NodeHttpRequestResponseHandler'; + +describe('NodeHttpRequestResponseHandler', (): void => { + const path = 'https://example.com/foo'; + + let input: HttpHandlerInput; + + let source: Mocked, Buffer>>; + let extractor: Mocked; + let handler: NodeHttpRequestResponseHandler; + + beforeEach(async(): Promise => { + const request: HttpRequest = Readable.from('body') as any; + request.method = 'GET'; + request.headers = { + 'content-type': 'text/plain', + 'content-length': '5', + 'transfer-encoding': 'encoding', + }; + + const response: HttpResponse = new PassThrough() as any; + response.writeHead = vi.fn(); + + input = { request, response }; + + source = { + canHandle: vi.fn(), + handle: vi.fn(), + handleSafe: vi.fn().mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'text/plain', + }, + body: Buffer.from('response'), + }), + } + + extractor = { + canHandle: vi.fn(), + handle: vi.fn(), + handleSafe: vi.fn().mockResolvedValue({ path }), + }; + + handler = new NodeHttpRequestResponseHandler(source, extractor); + }); + + it('sends the request to its source and writes out the output.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBeUndefined(); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe).toHaveBeenLastCalledWith({ + request: { + url: new URL(path), + method: 'GET', + headers: { + 'content-type': 'text/plain', + 'content-length': '5', + 'transfer-encoding': 'encoding', + }, + body: Buffer.from('body'), + } + }); + await expect(readableToString(input.response as any)).resolves.toEqual('response'); + expect(input.response.writeHead).toHaveBeenCalledTimes(1); + expect(input.response.writeHead).toHaveBeenLastCalledWith(200, { + 'content-type': 'text/plain', + 'content-length': '8' + }); + }); + + it('errors if there is no request method.', async(): Promise => { + delete input.request.method; + await expect(handler.handle(input)).rejects.toThrow('method of the request cannot be null or undefined.'); + expect(source.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('errors if there is an input body without content type.', async(): Promise => { + delete input.request.headers['content-type']; + await expect(handler.handle(input)).rejects.toThrow('HTTP request body was passed without a Content-Type header'); + expect(source.handleSafe).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/uma/test/unit/util/http/server/RoutedHttpRequestHandler.test.ts b/packages/uma/test/unit/util/http/server/RoutedHttpRequestHandler.test.ts new file mode 100644 index 00000000..19d0480d --- /dev/null +++ b/packages/uma/test/unit/util/http/server/RoutedHttpRequestHandler.test.ts @@ -0,0 +1,102 @@ +import { NotImplementedHttpError } from '@solid/community-server'; +import { HttpHandlerContext } from '../../../../../src/util/http/models/HttpHandler'; +import { HttpHandlerRoute } from '../../../../../src/util/http/models/HttpHandlerRoute'; +import { RoutedHttpRequestHandler } from '../../../../../src/util/http/server/RoutedHttpRequestHandler'; + +describe('RoutedHttpRequestHandler', (): void => { + let context: HttpHandlerContext; + let routes: HttpHandlerRoute[]; + let handler: RoutedHttpRequestHandler; + + beforeEach(async(): Promise => { + context = { + request: { + url: new URL('https://example.com/foo'), + method: 'GET', + headers: {}, + }, + }; + + routes = [ + { path: '/foo', handler: { handleSafe: vi.fn().mockResolvedValue(1) } as any }, + { path: '/foo/{id}', handler: { handleSafe: vi.fn().mockResolvedValue(2) } as any }, + { path: '/bar', methods: [ 'GET' ], handler: { handleSafe: vi.fn().mockResolvedValue(3) } as any }, + ]; + + handler = new RoutedHttpRequestHandler(routes); + }); + + it('can handle requests where there is a match.', async(): Promise => { + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + + context.request.url = new URL('https://example.com/foo/id'); + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + + context.request.url = new URL('https://example.com/bar'); + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + + context.request.method = 'POST'; + await expect(handler.canHandle(context)).rejects.toThrow('POST is not allowed.'); + + context.request.url = new URL('https://example.com/bad'); + await expect(handler.canHandle(context)).rejects.toThrow(NotImplementedHttpError); + + context.request.url = new URL('https://example.com/bar/foo'); + await expect(handler.canHandle(context)).rejects.toThrow(NotImplementedHttpError); + }); + + it('errors calling handle before calling canHandle.', async(): Promise => { + await expect(handler.handle(context)).rejects.toThrow('Calling handle without successful canHandle'); + }); + + it('calls the matched handler.', async(): Promise => { + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toBe(1); + expect(routes[0].handler.handleSafe).toHaveBeenCalledTimes(1); + expect(routes[0].handler.handleSafe).toHaveBeenLastCalledWith({ + request: { + url: new URL('https://example.com/foo'), + method: 'GET', + headers: {}, + parameters: {}, + }, + }); + }); + + it('calls the matched handler with the parsed properties.', async(): Promise => { + context.request.url = new URL('https://example.com/foo/123'); + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toBe(2); + expect(routes[1].handler.handleSafe).toHaveBeenCalledTimes(1); + expect(routes[1].handler.handleSafe).toHaveBeenLastCalledWith({ + request: { + url: new URL('https://example.com/foo/123'), + method: 'GET', + headers: {}, + parameters: { id: '123'}, + }, + }); + }); + + it('can support routes only matching the route tail.', async(): Promise => { + routes = [ + { path: '/foo', handler: { handleSafe: vi.fn().mockResolvedValue(1) } as any }, + ]; + handler = new RoutedHttpRequestHandler(routes, true); + + context.request.url = new URL('https://example.com/bar/foo'); + await expect(handler.canHandle(context)).resolves.toBeUndefined(); + await expect(handler.handle(context)).resolves.toBe(1); + expect(routes[0].handler.handleSafe).toHaveBeenCalledTimes(1); + expect(routes[0].handler.handleSafe).toHaveBeenLastCalledWith({ + request: { + url: new URL('https://example.com/bar/foo'), + method: 'GET', + headers: {}, + parameters: { + _prefix: '/bar', + }, + }, + }); + }); +}); diff --git a/packages/uma/test/util/RoutePath.test.ts b/packages/uma/test/util/RoutePath.test.ts deleted file mode 100644 index 15ad7f61..00000000 --- a/packages/uma/test/util/RoutePath.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {RoutePath} from './RoutePath'; - -describe('A RoutePath', () => { - it('should resolve to a URL', () => { - const path = new RoutePath('http://example.com', '/idp/'); - expect(path.getUri()).toEqual('http://example.com/idp/'); - }); -}); diff --git a/packages/uma/test/util/StringGuard.test.ts b/packages/uma/test/util/StringGuard.test.ts deleted file mode 100644 index a6ba0fe9..00000000 --- a/packages/uma/test/util/StringGuard.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {isString} from './StringGuard'; - -describe('StringGuard', () => { - it('should return true for a valid string', () => { - expect(isString('abc')).toBe(true); - }); - it('should return true for a valid String object', () => { - expect(isString(String())).toBe(true); - }); -}); diff --git a/packages/uma/tsconfig.json b/packages/uma/tsconfig.json index 2da8ab54..cf953919 100644 --- a/packages/uma/tsconfig.json +++ b/packages/uma/tsconfig.json @@ -10,9 +10,11 @@ "downlevelIteration": true, "module": "node16", "moduleResolution": "node16", + "types": [ "vitest/globals" ], "typeRoots": [ "types", - "node_modules/@types" + "node_modules/@types", + "../../node_modules" ], "skipLibCheck": true, // voor ESM typings in CSS (@types/rdf-validate-shacl) "esModuleInterop": true, @@ -20,5 +22,5 @@ "lib": ["ES2022"] }, "include": ["src", "types"], - "exclude": ["node_modules", "**/*.test.ts"] + "exclude": ["node_modules"] } diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts index d6264ac5..081d4b38 100644 --- a/test/integration/Base.test.ts +++ b/test/integration/Base.test.ts @@ -66,7 +66,7 @@ describe('A server setup', (): void => { }); expect(noTokenResponse.status).toBe(401); - wwwAuthenticateHeader = noTokenResponse.headers.get('WWW-Authenticate'); + wwwAuthenticateHeader = noTokenResponse.headers.get('WWW-Authenticate')!; expect(typeof wwwAuthenticateHeader).toBe('string'); }); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..2950996a --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + // "@tsconfig/node16/tsconfig.json" + "lib": ["es2021"], + "module": "node16", + "target": "es2021", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node16", + "types": [ "vitest/globals" ], + "declaration": true, + "isolatedModules": true, + "inlineSourceMap": true, + "preserveConstEnums": true, + "outDir": "dist", + "noImplicitAny": true, + "experimentalDecorators": true, + "downlevelIteration": true, + }, + "include": [ + "." + ] +} diff --git a/test/util/Util.ts b/test/util/Util.ts new file mode 100644 index 00000000..c6522b7f --- /dev/null +++ b/test/util/Util.ts @@ -0,0 +1,15 @@ +import { KeyValueStorage } from '@solid/community-server'; +import { UmaConfig } from '@solidlab/uma-css'; +import { Mocked } from 'vitest'; + + +/** + * This is needed when you want to wait for all promises to resolve. + * Also works when using vi.useFakeTimers(). + * For more details see the links below + * - https://github.com/facebook/jest/issues/2157 + * - https://stackoverflow.com/questions/52177631/jest-timer-and-promise-dont-work-well-settimeout-and-async-function + */ +export async function flushPromises(): Promise { + return new Promise((await vi.importActual('timers')).setImmediate as any); +} diff --git a/vitest.config.mts b/vitest.config.mts index 4a280afd..b78bba3e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,6 +6,13 @@ export default defineConfig({ // TODO: prolly want to put integration tests in separate package/folder/something // projects: [ 'packages/*' ], include: [ '**/test/(unit|integration)/**/*.test.ts' ], + coverage: { + enabled: true, + include: [ 'packages/**/src/**' ] + }, + chaiConfig: { + truncateThreshold: 0, + }, hookTimeout: 60000, testTimeout: 60000, }, diff --git a/yarn.lock b/yarn.lock index 395e78df..257704bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,17 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13": +"@ampproject/remapping@npm:^2.3.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.0.0": version: 7.23.4 resolution: "@babel/code-frame@npm:7.23.4" dependencies: @@ -22,6 +32,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.22.20": version: 7.22.20 resolution: "@babel/helper-validator-identifier@npm:7.22.20" @@ -29,6 +46,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-identifier@npm:7.27.1" + checksum: 10c0/c558f11c4871d526498e49d07a84752d1800bf72ac0d3dad100309a2eaba24efbf56ea59af5137ff15e3a00280ebe588560534b0e894a4750f8b1411d8f78b84 + languageName: node + linkType: hard + "@babel/highlight@npm:^7.23.4": version: 7.23.4 resolution: "@babel/highlight@npm:7.23.4" @@ -40,6 +64,34 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.25.4": + version: 7.28.0 + resolution: "@babel/parser@npm:7.28.0" + dependencies: + "@babel/types": "npm:^7.28.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/c2ef81d598990fa949d1d388429df327420357cb5200271d0d0a2784f1e6d54afc8301eb8bdf96d8f6c77781e402da93c7dc07980fcc136ac5b9d5f1fce701b5 + languageName: node + linkType: hard + +"@babel/types@npm:^7.25.4, @babel/types@npm:^7.28.0": + version: 7.28.2 + resolution: "@babel/types@npm:7.28.2" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + checksum: 10c0/24b11c9368e7e2c291fe3c1bcd1ed66f6593a3975f479cbb9dd7b8c8d8eab8a962b0d2fca616c043396ce82500ac7d23d594fbbbd013828182c01596370a0b10 + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@bergos/jsonparse@npm:^1.4.0, @bergos/jsonparse@npm:^1.4.1": version: 1.4.1 resolution: "@bergos/jsonparse@npm:1.4.1" @@ -3029,35 +3081,10 @@ __metadata: languageName: node linkType: hard -"@jest/expect-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/expect-utils@npm:29.7.0" - dependencies: - jest-get-type: "npm:^29.6.3" - checksum: 10c0/60b79d23a5358dc50d9510d726443316253ecda3a7fb8072e1526b3e0d3b14f066ee112db95699b7a43ad3f0b61b750c72e28a5a1cac361d7a2bb34747fa938a - languageName: node - linkType: hard - -"@jest/schemas@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/schemas@npm:29.6.3" - dependencies: - "@sinclair/typebox": "npm:^0.27.8" - checksum: 10c0/b329e89cd5f20b9278ae1233df74016ebf7b385e0d14b9f4c1ad18d096c4c19d1e687aa113a9c976b16ec07f021ae53dea811fb8c1248a50ac34fbe009fdf6be - languageName: node - linkType: hard - -"@jest/types@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/types@npm:29.6.3" - dependencies: - "@jest/schemas": "npm:^29.6.3" - "@types/istanbul-lib-coverage": "npm:^2.0.0" - "@types/istanbul-reports": "npm:^3.0.0" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.8" - chalk: "npm:^4.0.0" - checksum: 10c0/ea4e493dd3fb47933b8ccab201ae573dcc451f951dc44ed2a86123cd8541b82aa9d2b1031caf9b1080d6673c517e2dcc25a44b2dc4f3fbc37bfc965d444888c0 +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a languageName: node linkType: hard @@ -3074,6 +3101,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.12 + resolution: "@jridgewell/gen-mapping@npm:0.3.12" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/32f771ae2467e4d440be609581f7338d786d3d621bac3469e943b9d6d116c23c4becb36f84898a92bbf2f3c0511365c54a945a3b86a83141547a2a360a5ec0c7 + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:^3.0.3": version: 3.1.1 resolution: "@jridgewell/resolve-uri@npm:3.1.1" @@ -3081,6 +3118,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + "@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.5.0": version: 1.5.0 resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" @@ -3088,6 +3132,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.4.14": + version: 1.5.4 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.4" + checksum: 10c0/c5aab3e6362a8dd94ad80ab90845730c825fc4c8d9cf07ebca7a2eb8a832d155d62558800fc41d42785f989ddbb21db6df004d1786e8ecb65e428ab8dff71309 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -3098,6 +3149,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": + version: 0.3.29 + resolution: "@jridgewell/trace-mapping@npm:0.3.29" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/fb547ba31658c4d74eb17e7389f4908bf7c44cef47acb4c5baa57289daf68e6fe53c639f41f751b3923aca67010501264f70e7b49978ad1f040294b22c37b333 + languageName: node + linkType: hard + "@koa/cors@npm:^5.0.0": version: 5.0.0 resolution: "@koa/cors@npm:5.0.0" @@ -3413,13 +3474,6 @@ __metadata: languageName: node linkType: hard -"@sinclair/typebox@npm:^0.27.8": - version: 0.27.8 - resolution: "@sinclair/typebox@npm:0.27.8" - checksum: 10c0/ef6351ae073c45c2ac89494dbb3e1f87cc60a93ce4cde797b782812b6f97da0d620ae81973f104b43c9b7eaa789ad20ba4f6a1359f1cc62f63729a55a7d22d4e - languageName: node - linkType: hard - "@sindresorhus/is@npm:^5.2.0": version: 5.6.0 resolution: "@sindresorhus/is@npm:5.6.0" @@ -3611,10 +3665,10 @@ __metadata: dependencies: "@commitlint/cli": "npm:^16.1.0" "@commitlint/config-conventional": "npm:^16.0.0" - "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.19.1" "@typescript-eslint/eslint-plugin": "npm:^5.12.1" "@typescript-eslint/parser": "npm:^5.12.1" + "@vitest/coverage-v8": "npm:^3.2.4" chalk: "npm:^5.4.1" componentsjs-generator: "npm:^3.1.2" eslint: "npm:^8.10.0" @@ -3873,41 +3927,6 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0": - version: 2.0.6 - resolution: "@types/istanbul-lib-coverage@npm:2.0.6" - checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 - languageName: node - linkType: hard - -"@types/istanbul-lib-report@npm:*": - version: 3.0.3 - resolution: "@types/istanbul-lib-report@npm:3.0.3" - dependencies: - "@types/istanbul-lib-coverage": "npm:*" - checksum: 10c0/247e477bbc1a77248f3c6de5dadaae85ff86ac2d76c5fc6ab1776f54512a745ff2a5f791d22b942e3990ddbd40f3ef5289317c4fca5741bedfaa4f01df89051c - languageName: node - linkType: hard - -"@types/istanbul-reports@npm:^3.0.0": - version: 3.0.4 - resolution: "@types/istanbul-reports@npm:3.0.4" - dependencies: - "@types/istanbul-lib-report": "npm:*" - checksum: 10c0/1647fd402aced5b6edac87274af14ebd6b3a85447ef9ad11853a70fd92a98d35f81a5d3ea9fcb5dbb5834e800c6e35b64475e33fcae6bfa9acc70d61497c54ee - languageName: node - linkType: hard - -"@types/jest@npm:^29.5.12": - version: 29.5.12 - resolution: "@types/jest@npm:29.5.12" - dependencies: - expect: "npm:^29.0.0" - pretty-format: "npm:^29.0.0" - checksum: 10c0/25fc8e4c611fa6c4421e631432e9f0a6865a8cb07c9815ec9ac90d630271cad773b2ee5fe08066f7b95bebd18bb967f8ce05d018ee9ab0430f9dfd1d84665b6f - languageName: node - linkType: hard - "@types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -4177,13 +4196,6 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0": - version: 2.0.3 - resolution: "@types/stack-utils@npm:2.0.3" - checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c - languageName: node - linkType: hard - "@types/triple-beam@npm:^1.3.2": version: 1.3.5 resolution: "@types/triple-beam@npm:1.3.5" @@ -4235,7 +4247,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.24, @types/yargs@npm:^17.0.28, @types/yargs@npm:^17.0.8": +"@types/yargs@npm:^17.0.24, @types/yargs@npm:^17.0.28": version: 17.0.32 resolution: "@types/yargs@npm:17.0.32" dependencies: @@ -4372,6 +4384,33 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:^3.2.4": + version: 3.2.4 + resolution: "@vitest/coverage-v8@npm:3.2.4" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@bcoe/v8-coverage": "npm:^1.0.2" + ast-v8-to-istanbul: "npm:^0.3.3" + debug: "npm:^4.4.1" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magic-string: "npm:^0.30.17" + magicast: "npm:^0.3.5" + std-env: "npm:^3.9.0" + test-exclude: "npm:^7.0.1" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + "@vitest/browser": 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/cae3e58d81d56e7e1cdecd7b5baab7edd0ad9dee8dec9353c52796e390e452377d3f04174d40b6986b17c73241a5e773e422931eaa8102dcba0605ff24b25193 + languageName: node + linkType: hard + "@vitest/expect@npm:3.2.4": version: 3.2.4 resolution: "@vitest/expect@npm:3.2.4" @@ -4588,13 +4627,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": - version: 5.2.0 - resolution: "ansi-styles@npm:5.2.0" - checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df - languageName: node - linkType: hard - "ansi-styles@npm:^6.1.0": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -4663,6 +4695,17 @@ __metadata: languageName: node linkType: hard +"ast-v8-to-istanbul@npm:^0.3.3": + version: 0.3.3 + resolution: "ast-v8-to-istanbul@npm:0.3.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^9.0.1" + checksum: 10c0/ffc39bc3ab4b8c1f7aea945960ce6b1e518bab3da7c800277eab2da07d397eeae4a2cb8a5a5f817225646c8ea495c1e4434fbe082c84bae8042abddef53f50b2 + languageName: node + linkType: hard + "async-lock@npm:^1.4.0": version: 1.4.1 resolution: "async-lock@npm:1.4.1" @@ -4942,13 +4985,6 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^3.2.0": - version: 3.9.0 - resolution: "ci-info@npm:3.9.0" - checksum: 10c0/6f0109e36e111684291d46123d491bc4e7b7a1934c3a20dea28cba89f1d4a03acd892f5f6a81ed3855c38647e285a150e3c9ba062e38943bef57fee6c1554c3a - languageName: node - linkType: hard - "clean-stack@npm:^2.0.0": version: 2.2.0 resolution: "clean-stack@npm:2.2.0" @@ -5433,13 +5469,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^29.6.3": - version: 29.6.3 - resolution: "diff-sequences@npm:29.6.3" - checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 - languageName: node - linkType: hard - "diff@npm:^4.0.1": version: 4.0.2 resolution: "diff@npm:4.0.2" @@ -5775,13 +5804,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^2.0.0": - version: 2.0.0 - resolution: "escape-string-regexp@npm:2.0.0" - checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 - languageName: node - linkType: hard - "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -5968,19 +5990,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0": - version: 29.7.0 - resolution: "expect@npm:29.7.0" - dependencies: - "@jest/expect-utils": "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10c0/2eddeace66e68b8d8ee5f7be57f3014b19770caaf6815c7a08d131821da527fb8c8cb7b3dcd7c883d2d3d8d184206a4268984618032d1e4b16dc8d6596475d41 - languageName: node - linkType: hard - "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -6406,6 +6415,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.4.1": + version: 10.4.5 + resolution: "glob@npm:10.4.5" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + languageName: node + linkType: hard + "glob@npm:^7.0.0, glob@npm:^7.1.3": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -6485,7 +6510,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -6632,6 +6657,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "htmlparser2@npm:^8.0.0": version: 8.0.2 resolution: "htmlparser2@npm:8.0.2" @@ -7029,6 +7061,45 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.7": + version: 3.1.7 + resolution: "istanbul-reports@npm:3.1.7" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 + languageName: node + linkType: hard + "jackspeak@npm:^2.3.5": version: 2.3.6 resolution: "jackspeak@npm:2.3.6" @@ -7042,6 +7113,19 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^3.1.2": + version: 3.4.3 + resolution: "jackspeak@npm:3.4.3" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 10c0/6acc10d139eaefdbe04d2f679e6191b3abf073f111edf10b1de5302c97ec93fffeb2fdd8681ed17f16268aa9dd4f8c588ed9d1d3bffbbfa6e8bf897cbb3149b9 + languageName: node + linkType: hard + "jake@npm:^10.8.5": version: 10.8.7 resolution: "jake@npm:10.8.7" @@ -7056,54 +7140,6 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-diff@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^29.6.3" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10c0/89a4a7f182590f56f526443dde69acefb1f2f0c9e59253c61d319569856c4931eae66b8a3790c443f529267a0ddba5ba80431c585deed81827032b2b2a1fc999 - languageName: node - linkType: hard - -"jest-get-type@npm:^29.6.3": - version: 29.6.3 - resolution: "jest-get-type@npm:29.6.3" - checksum: 10c0/552e7a97a983d3c2d4e412a44eb7de0430ff773dd99f7500962c268d6dfbfa431d7d08f919c9d960530e5f7f78eb47f267ad9b318265e5092b3ff9ede0db7c2b - languageName: node - linkType: hard - -"jest-matcher-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-matcher-utils@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10c0/0d0e70b28fa5c7d4dce701dc1f46ae0922102aadc24ed45d594dd9b7ae0a8a6ef8b216718d1ab79e451291217e05d4d49a82666e1a3cc2b428b75cd9c933244e - languageName: node - linkType: hard - -"jest-message-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-message-util@npm:29.7.0" - dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^29.6.3" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10c0/850ae35477f59f3e6f27efac5215f706296e2104af39232bb14e5403e067992afb5c015e87a9243ec4d9df38525ef1ca663af9f2f4766aa116f127247008bd22 - languageName: node - linkType: hard - "jest-rdf@npm:^1.8.1": version: 1.8.1 resolution: "jest-rdf@npm:1.8.1" @@ -7116,20 +7152,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-util@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - graceful-fs: "npm:^4.2.9" - picomatch: "npm:^2.2.3" - checksum: 10c0/bc55a8f49fdbb8f51baf31d2a4f312fb66c9db1483b82f602c9c990e659cdd7ec529c8e916d5a89452ecbcfae4949b21b40a7a59d4ffc0cd813a973ab08c8150 - languageName: node - linkType: hard - "jose@npm:^4.15.2, jose@npm:^4.7.0": version: 4.15.4 resolution: "jose@npm:4.15.4" @@ -7595,6 +7617,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^10.2.0": + version: 10.4.3 + resolution: "lru-cache@npm:10.4.3" + checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb + languageName: node + linkType: hard + "lru-cache@npm:^6.0.0": version: 6.0.0 resolution: "lru-cache@npm:6.0.0" @@ -7613,6 +7642,26 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.3.5": + version: 0.3.5 + resolution: "magicast@npm:0.3.5" + dependencies: + "@babel/parser": "npm:^7.25.4" + "@babel/types": "npm:^7.25.4" + source-map-js: "npm:^1.2.0" + checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -7722,7 +7771,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -7797,7 +7846,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:9.0.5, minimatch@npm:^9.0.1": +"minimatch@npm:9.0.5, minimatch@npm:^9.0.1, minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: @@ -7916,6 +7965,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 + languageName: node + linkType: hard + "minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -8338,6 +8394,13 @@ __metadata: languageName: node linkType: hard +"package-json-from-dist@npm:^1.0.0": + version: 1.0.1 + resolution: "package-json-from-dist@npm:1.0.1" + checksum: 10c0/62ba2785eb655fec084a257af34dbe24292ab74516d6aecef97ef72d4897310bc6898f6c85b5cd22770eaa1ce60d55a0230e150fb6a966e3ecd6c511e23d164b + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -8404,6 +8467,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^1.11.1": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" + dependencies: + lru-cache: "npm:^10.2.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: 10c0/32a13711a2a505616ae1cc1b5076801e453e7aae6ac40ab55b388bb91b9d0547a52f5aaceff710ea400205f18691120d4431e520afbe4266b836fadede15872d + languageName: node + linkType: hard + "path-to-regexp@npm:^6.2.1": version: 6.2.1 resolution: "path-to-regexp@npm:6.2.1" @@ -8446,7 +8519,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": +"picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be @@ -8478,17 +8551,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": - version: 29.7.0 - resolution: "pretty-format@npm:29.7.0" - dependencies: - "@jest/schemas": "npm:^29.6.3" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^18.0.0" - checksum: 10c0/edc5ff89f51916f036c62ed433506b55446ff739358de77207e63e88a28ca2894caac6e73dcb68166a606e51c8087d32d400473e6a9fdd2dbe743f46c9c0276f - languageName: node - linkType: hard - "proc-log@npm:^3.0.0": version: 3.0.0 resolution: "proc-log@npm:3.0.0" @@ -8980,13 +9042,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": - version: 18.2.0 - resolution: "react-is@npm:18.2.0" - checksum: 10c0/6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0 - languageName: node - linkType: hard - "read-pkg-up@npm:^7.0.1": version: 7.0.1 resolution: "read-pkg-up@npm:7.0.1" @@ -9364,6 +9419,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.3": + version: 7.7.2 + resolution: "semver@npm:7.7.2" + bin: + semver: bin/semver.js + checksum: 10c0/aca305edfbf2383c22571cb7714f48cadc7ac95371b4b52362fb8eeffdfbc0de0669368b82b2b15978f8848f01d7114da65697e56cd8c37b0dab8c58e543f9ea + languageName: node + linkType: hard + "setimmediate@npm:^1.0.5": version: 1.0.5 resolution: "setimmediate@npm:1.0.5" @@ -9519,7 +9583,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.1": +"source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -9666,15 +9730,6 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3": - version: 2.0.6 - resolution: "stack-utils@npm:2.0.6" - dependencies: - escape-string-regexp: "npm:^2.0.0" - checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a - languageName: node - linkType: hard - "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -9956,6 +10011,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.1 + resolution: "test-exclude@npm:7.0.1" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^9.0.4" + checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 + languageName: node + linkType: hard + "text-extensions@npm:^1.0.0": version: 1.9.0 resolution: "text-extensions@npm:1.9.0"