diff --git a/documentation/getting-started.md b/documentation/getting-started.md index 7356ce4d..b1a0c36a 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -84,6 +84,115 @@ It uses the CLI parameter `-a http://localhost:4000/` to inform the RS where it which internally sets the Components.js variable `urn:solid-server:uma:variable:AuthorizationServer` to the provided value. +## Authenticating as Resource Owner + +There are some APIs on the AS where a Resource Owner (RO) has to identify themself. +Specifically, the policy APIs, as described in the [policy management documentation](policy-management.md), +and the client credentials API described further below. +Two authentication methods are supported: OIDC tokens, both Solid and standard, and unsafe WebID strings. + +To use OIDC, the `Bearer` authorization scheme needs to be used, followed by the token. +For example, `Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI...`. + +To directly pass a WebID, the `WebID` scheme can be used together with a URL encoded WebID. +For example, `Authorization: WebID http%3A%2F%2Fexample.com%2Fprofile%2Fcard%23me`. +No validation is performed in this case, so this should only be used for development and debugging purposes. + +## Authenticating as Resource Server + +The RS has to send several requests to the AS, as described below. +Generally, these requests are done for a specific user, +e.g., registering a resource for its owner, +or requesting access on an owner's resource. +To identify both itself and the owner, +the RS has to send a Personal Access Token (PAT) in the authorization header +when making such a request. +As the UMA specification does not have strong requirements on how such a token should be generated, +the specific implementation of our AS is described here. + +The following steps need to be taken: +1. The owner requests client credentials from the AS for a specific RS, which is the client here, + as described in RFC 7591. +2. The AS returns an id/secret combination which uniquely identifies this owner/RS combination. +3. The owner provides this id/secret combination to the RS. +4. Before making a request, the RS uses this id/secret combination to request an access token from the AS + with scope `uma_protection`, as described in RFC 6749 and RFC 7617. + This token is the PAT. +5. The RS uses this bearer token for the request. + +### Requesting client credentials + +To register a RS, the owner should find the `registration_endpoint` API in the AS' UMA configuration. +They should then POST a request there with a body as follows: +```json +{ + "client_name": "descriptive name for the RS (optional)", + "client_uri": "http://localhost:3000" +} +``` + +The AS will then respond with client credentials such as +```json +{ + "client_uri": "http://localhost:3000", + "client_name": "descriptive name for the RS (optional)", + "client_id": "1be8b63f-29c2-4d2c-9932-8784a28de5cf", + "client_secret": "184984651984...", + "client_secret_expires_at": "0", + "grant_types": [ "client_credentials", "refresh_token" ], + "token_endpoint_auth_method": "client_secret_basic" +} +``` + +This response, or at least the `client_id` and `client_secret` should then be passed along to the RS. + +### Sending the credentials to the RS (CSS specific) + +This section is specific for our CSS implementation of the RS +and is irrelevant if you have your own custom RS. + +The implementation makes use of the +[CSS account API](https://communitysolidserver.github.io/CommunitySolidServer/latest/usage/account/json-api/). +A new `pat` entry has been added to the account controls after authenticating. +This API expects a POST request with the following body: +```json +{ + "id": "1be8b63f-29c2-4d2c-9932-8784a28de5cf", + "secret": "184984651984...", + "issuer": "http://localhost:4000/uma" +} +``` +Sending this request will update the stored credentials for the authenticated user. + +### Requesting a PAT as RS + +To request a PAT, the RS needs to find the `token_endpoint` API in the AS UMA config. +A PAT can then be requested by sending a POST request with a `application/x-www-form-urlencoded` body as follows: +``` +grant_type=client_credentials&scope=uma_protection +``` +A JSON body containing the same information would also work. + +The important thing is that the `Authorization` header needs to be set using the Basic id/secret combination +as described in RFC 7617. +Specifically, that means you generate a string `$MY_ID:$MY_secret` and generating the base 64 encoding of this result. +The Authorization header should then contain `Basic $ENCODED_RESULT`. + +The AS will then respond with a body containing the generated access token: +```json +{ + "access_token": "eyJhbGciOi...", + "refresh_token": "efe2dea0-9cb4-4ffd-9dbe-a581a249202b", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "uma_protection" +} +``` + +This access token then needs to be sent along in a Bearer Authorization header when making the necessary requests. +The current implementation of the AS allows the PAT to be reused until it is expired, +which can be useful when doing bulk resource registration. + ## Resource registration The Federated UMA specification requires that the RS registers every resource at the AS. diff --git a/documentation/policy-management.md b/documentation/policy-management.md index c37e6663..c9cacff7 100644 --- a/documentation/policy-management.md +++ b/documentation/policy-management.md @@ -16,21 +16,12 @@ The current implementation supports the following requests on the UMA server: These requests comply with some restrictions: - When the URL contains a policy ID, it must be URI encoded. -- Every request requires a valid Authorization header, which is detailed below. +- Every request requires a valid Authorization header. -### Authorization - -The policy API supports similar authentication tokens as the UMA API, -but expects them in the Authorization header, -as the body is already used for other purposes. -Two authorization methods are supported: OIDC tokens, both Solid and standard, and unsafe WebID strings. - -To use OIDC, the `Bearer` authorization scheme needs to be used, followed by the token. -For example, `Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI...`. +### Authentication -To directly pass a WebID, the `WebID` scheme can be used together with a URL encoded WebID. -For example, `Authorization: WebID http%3A%2F%2Fexample.com%2Fprofile%2Fcard%23me`. -No validation is performed in this case, so this should only be used for development and debugging purposes. +The client is expected to use the authentication method +described in the [getting started documentation](getting-started.md). ### Creating policies diff --git a/packages/css/config/init-pat.json b/packages/css/config/init-pat.json new file mode 100644 index 00000000..08d6f4bc --- /dev/null +++ b/packages/css/config/init-pat.json @@ -0,0 +1,33 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^8.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma-css/^0.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/asynchronous-handlers/^1.0.0/components/context.jsonld" + ], + "@graph": [ + { + "comment": "Automatically registers a PAT for every account on server start" + }, + { + "@id": "urn:solid-server:default:PatSeedRegistrar", + "@type": "PatSeedRegistrar", + "accountStorage": { "@id": "urn:solid-server:default:AccountStorage" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "umaClient": { "@id": "urn:solid-server:default:UmaClient" }, + "patUpdater": { "@id": "urn:solid-server:default:PatUpdater" } + }, + { + "comment": "The PatSeedRegistrar is added to the list of Initializers so Components.js finds and instantiate it.", + "@id": "urn:solid-server:default:PrimaryParallelInitializer", + "@type": "ParallelHandler", + "handlers": [{ "@id": "urn:solid-server:default:PatSeedRegistrar" }] + }, + { + "@id": "urn:solid-server:default:StatusDependantServerConfigurator", + "@type": "StatusDependantServerConfigurator", + "dependants": [ + { "@id": "urn:solid-server:default:PatSeedRegistrar" } + ] + } + ] +} diff --git a/packages/css/config/seed.json b/packages/css/config/seed.json index 6f203ac7..3cbc7d1d 100644 --- a/packages/css/config/seed.json +++ b/packages/css/config/seed.json @@ -4,7 +4,10 @@ "password": "abc123", "pods": [{ "name": "alice" - }] + }], + "authz": { + "server": "http://localhost:4000/uma" + } }, { "email": "bob@example.org", @@ -13,7 +16,10 @@ { "name": "bob" } - ] + ], + "authz": { + "server": "http://localhost:4000/uma" + } }, { "email": "demo@example.org", @@ -22,7 +28,10 @@ { "name": "demo" } - ] + ], + "authz": { + "server": "http://localhost:4000/uma" + } }, { "email": "resources@example.org", @@ -31,6 +40,9 @@ { "name": "resources" } - ] + ], + "authz": { + "server": "http://localhost:4000/uma" + } } ] diff --git a/packages/css/config/uma/default.json b/packages/css/config/uma/default.json index 94050854..acc32f45 100644 --- a/packages/css/config/uma/default.json +++ b/packages/css/config/uma/default.json @@ -12,10 +12,10 @@ "uma-css:config/uma/overrides/token-extractor.json", "uma-css:config/uma/overrides/www-auth.json", - "uma-css:config/uma/parts/cli.json", "uma-css:config/uma/parts/client.json", "uma-css:config/uma/parts/fetcher.json", "uma-css:config/uma/parts/owner-util.json", + "uma-css:config/uma/parts/pat.json", "uma-css:config/uma/parts/resource-registrar.json", "uma-css:config/uma/parts/server-configurator.json" ] diff --git a/packages/css/config/uma/parts/cli.json b/packages/css/config/uma/parts/cli.json deleted file mode 100644 index 4783d33c..00000000 --- a/packages/css/config/uma/parts/cli.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "@context": [ - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^8.0.0/components/context.jsonld", - "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma-css/^0.0.0/components/context.jsonld" - ], - "@graph": [ - { - "@id": "urn:solid-server-app-setup:default:CliExtractor", - "@type": "YargsCliExtractor", - "parameters": [ - { - "@type": "YargsParameter", - "name": "authServer", - "options": { - "alias": "a", - "requiresArg": true, - "type": "string", - "describe": "The URL of the UMA Authorization Server." - } - } - ] - }, - { - "comment": "Converts an input key/value object into an object mapping values to Components.js variables", - "@id": "urn:solid-server-app-setup:default:ShorthandResolver", - "@type": "CombinedShorthandResolver", - "resolvers": [ - { - "CombinedShorthandResolver:_resolvers_key": "urn:solid-server:uma:variable:AuthorizationServer", - "CombinedShorthandResolver:_resolvers_value": { - "@type": "KeyExtractor", - "key": "authServer", - "defaultValue": "http://localhost:4000" - } - } - ] - }, - { - "comment": "URL of the UMA Authorization Server.", - "@id": "urn:solid-server:uma:variable:AuthorizationServer", - "@type": "Variable" - } - ] -} diff --git a/packages/css/config/uma/parts/client.json b/packages/css/config/uma/parts/client.json index ec063af0..7e8e2469 100644 --- a/packages/css/config/uma/parts/client.json +++ b/packages/css/config/uma/parts/client.json @@ -16,7 +16,8 @@ "@id": "urn:solid-server:default:UmaFetcher" }, "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, - "resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" } + "resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }, + "baseUrl":{ "@id": "urn:solid-server:default:variable:baseUrl" } } ] } diff --git a/packages/css/config/uma/parts/fetcher.json b/packages/css/config/uma/parts/fetcher.json index 2e36441d..211eb6c3 100644 --- a/packages/css/config/uma/parts/fetcher.json +++ b/packages/css/config/uma/parts/fetcher.json @@ -10,16 +10,11 @@ "fetcher": { "@type": "RetryingFetcher", "fetcher": { - "@type": "SignedFetcher", - "fetcher": { - "@type": "BaseFetcher" - }, - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "keyGen": { "@id": "urn:solid-server:default:JwkGenerator" } + "@type": "BaseFetcher" }, "retries": 150, "exponent": 3, - "retryOn": [401, 500] + "retryOn": [500] } } ] diff --git a/packages/css/config/uma/parts/owner-util.json b/packages/css/config/uma/parts/owner-util.json index e8a555ad..fe2bab7f 100644 --- a/packages/css/config/uma/parts/owner-util.json +++ b/packages/css/config/uma/parts/owner-util.json @@ -8,14 +8,17 @@ "comment": "Provides utility for interacting with pod owner metadata", "@id": "urn:solid-server:default:OwnerUtil", "@type": "OwnerUtil", + "accountStorage": { + "@id": "urn:solid-server:default:AccountStorage" + }, + "accountStore": { + "@id": "urn:solid-server:default:AccountStore" + }, "podStore": { "@id": "urn:solid-server:default:PodStore" }, "storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" - }, - "umaServerURL": { - "@id": "urn:solid-server:uma:variable:AuthorizationServer" } } ] diff --git a/packages/css/config/uma/parts/pat.json b/packages/css/config/uma/parts/pat.json new file mode 100644 index 00000000..509084a9 --- /dev/null +++ b/packages/css/config/uma/parts/pat.json @@ -0,0 +1,48 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^8.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma-css/^0.0.0/components/context.jsonld" + ], + "@graph": [ + { + "@id": "urn:solid-server:default:AccountPatRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountPatRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:AccountIdRoute" }, + "relativePath": "pat/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:PatUpdateHandler", + "@type": "PatUpdateHandler", + "patUpdater": { + "@id": "urn:solid-server:default:PatUpdater", + "@type": "PatUpdater", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "podStore": { "@id": "urn:solid-server:default:PodStore" }, + "resourceStore": { "@id": "urn:solid-server:default:ResourceStore" }, + "umaClient": { "@id": "urn:solid-server:default:UmaClient" } + } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "StatusWaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountPatRouter" }] + }, + + { + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "pat", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountPatRoute" } + }] + } + ] +} diff --git a/packages/css/package.json b/packages/css/package.json index e9ae1307..ee82e30a 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -57,11 +57,11 @@ "build": "yarn build:ts && yarn build:components", "build:ts": "yarn run -T tsc", "build:components": "yarn run -T componentsjs-generator -r uma-css -s src/ -c dist/components -i .componentsignore --lenient", - "start:unseeded": "yarn run community-solid-server -m . -c ./config/default.json -a http://localhost:4000/", - "start": "yarn run community-solid-server -m . -c ./config/default.json --seedConfig ./config/seed.json -a http://localhost:4000/", + "start:unseeded": "yarn run community-solid-server -m . -c ./config/default.json", + "start": "yarn run community-solid-server -m . -c ./config/default.json ./config/init-pat.json --seedConfig ./config/seed.json", "demo": "yarn run demo:setup && yarn run demo:start", "demo:setup": "yarn run -T shx rm -rf ./tmp && yarn run -T shx cp -R ../../demo/data ./tmp", - "demo:start": "yarn run community-solid-server -m . -c ./config/demo.json -f ./tmp -a http://localhost:4000/" + "demo:start": "yarn run community-solid-server -m . -c ./config/demo.json ./config/init-pat.json -f ./tmp" }, "dependencies": { "@solid/community-server": "^8.0.0-alpha.1", diff --git a/packages/css/src/authentication/UmaTokenExtractor.ts b/packages/css/src/authentication/UmaTokenExtractor.ts index 23b4bc19..36ba0fee 100644 --- a/packages/css/src/authentication/UmaTokenExtractor.ts +++ b/packages/css/src/authentication/UmaTokenExtractor.ts @@ -54,7 +54,7 @@ export class UmaTokenExtractor extends CredentialsExtractor { try { 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 issuers = await Promise.all(owners.map(async o => (await this.ownerUtil.findUmaSettings(o)).issuer)) const validIssuers = issuers.filter((i): i is string => i !== undefined); if (this.introspect) { diff --git a/packages/css/src/authorization/UmaAuthorizer.ts b/packages/css/src/authorization/UmaAuthorizer.ts index 777a4341..3151ae5b 100644 --- a/packages/css/src/authorization/UmaAuthorizer.ts +++ b/packages/css/src/authorization/UmaAuthorizer.ts @@ -48,7 +48,7 @@ export class UmaAuthorizer extends Authorizer { await this.authorizer.handleSafe(input); } catch (error: unknown) { - // Unless 403/403 throw original error + // Unless 401/403 throw original error if (!UnauthorizedHttpError.isInstance(error) && !ForbiddenHttpError.isInstance(error)) throw error; // Request UMA ticket @@ -68,12 +68,12 @@ export class UmaAuthorizer extends Authorizer { protected async requestTicket(requestedModes: AccessMap): Promise { const owner = await this.ownerUtil.findCommonOwner(requestedModes.keys()); - const issuer = await this.ownerUtil.findIssuer(owner); + const { issuer, credentials } = await this.ownerUtil.findUmaSettings(owner); - if (!issuer) throw new InternalServerError(`No UMA authorization server found for ${owner}.`); + if (!issuer || !credentials) throw new InternalServerError(`Credentials and/or issuer are not set for ${owner}.`); try { - const ticket = await this.umaClient.fetchTicket(requestedModes, issuer); + const ticket = await this.umaClient.fetchTicket(requestedModes, issuer, credentials); return ticket ? `UMA realm="solid", as_uri="${issuer}", ticket="${ticket}"` : undefined; } catch (e) { this.logger.error(`Error while requesting UMA header: ${(e as Error).message}`); diff --git a/packages/css/src/identity/PatSeedRegistrar.ts b/packages/css/src/identity/PatSeedRegistrar.ts new file mode 100644 index 00000000..aee2c905 --- /dev/null +++ b/packages/css/src/identity/PatSeedRegistrar.ts @@ -0,0 +1,72 @@ +import { + AccountLoginStorage, + AccountStore, + WEBID_STORAGE_DESCRIPTION, + WEBID_STORAGE_TYPE +} from '@solid/community-server'; +import { StaticHandler } from 'asynchronous-handlers'; +import { getLoggerFor } from 'global-logger-factory'; +import { UmaClient } from '../uma/UmaClient'; +import type { StatusDependant } from '../util/fetch/StatusDependant'; +import { + ACCOUNT_SETTINGS_AS_TOKEN, + ACCOUNT_SETTINGS_AUTHZ_SERVER, + UMA_ACCOUNT_STORAGE_TYPE +} from './interaction/account/util/AccountSettings'; +import { PatUpdater } from './PatUpdater'; + +/** + * This class waits for the status to be set to true, + * and then registers a PAT client credentials for every account that has a WebID. + * + * The intended goal is for this is to ensure seeded accounts automatically get PAT client credentials. + * It needs to wait until the server is active and listening so the PausableFetcher can be used. + */ +export class PatSeedRegistrar extends StaticHandler implements StatusDependant { + protected readonly logger = getLoggerFor(this); + + private readonly accountStorage: AccountLoginStorage<{ [WEBID_STORAGE_TYPE]: typeof WEBID_STORAGE_DESCRIPTION }>; + + public constructor( + // Wrong typings to prevent Components.js typing issues + accountStorage: AccountLoginStorage>, + protected readonly accountStore: AccountStore, + protected readonly umaClient: UmaClient, + protected readonly patUpdater: PatUpdater, + ) { + super(); + this.accountStorage = accountStorage as unknown as typeof this.accountStorage; + } + + public async changeStatus(status: boolean): Promise { + if (status) { + await this.initialize(); + } + } + + protected async initialize(): Promise { + const accountMap: Record = {}; + this.logger.info('Registering PATs for seeded accounts'); + for await (const { webId, accountId } of this.accountStorage.entries(WEBID_STORAGE_TYPE)) { + // In case of multiple WebIDs just register the first one + if (accountMap[accountId]) { + this.logger.warn(`Multiple defined WebIDs for ${accountId}, only using ${accountMap[accountId]}`); + continue; + } + if (await this.accountStore.getSetting(accountId, ACCOUNT_SETTINGS_AS_TOKEN)) { + this.logger.debug(`Account ${accountId} with WebID ${webId} already has PAT client credentials`); + continue; + } + const issuer = await this.accountStore.getSetting(accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER); + if (!issuer) { + this.logger.warn(`No issuer defined for account ${accountId} with WebID ${webId}`); + continue; + } + accountMap[accountId] = webId; + const { id, secret } = await this.umaClient.generateClientCredentials(webId, issuer); + this.logger.info(`Generated client credentials for WebID ${webId}`); + + await this.patUpdater.updateSettings(accountId, id, secret, issuer); + } + } +} diff --git a/packages/css/src/identity/PatUpdater.ts b/packages/css/src/identity/PatUpdater.ts new file mode 100644 index 00000000..cb0da361 --- /dev/null +++ b/packages/css/src/identity/PatUpdater.ts @@ -0,0 +1,72 @@ +import { + AccountStore, + createErrorMessage, + isContainerIdentifier, + LDP, + PodStore, + ResourceStore +} from '@solid/community-server'; +import { getLoggerFor } from 'global-logger-factory'; +import { UmaClient } from '../uma/UmaClient'; +import { + ACCOUNT_SETTINGS_AS_TOKEN, + ACCOUNT_SETTINGS_AUTHZ_SERVER, + UMA_ACCOUNT_STORAGE_TYPE +} from './interaction/account/util/AccountSettings'; + +/** + * A utility class that wraps everything necessary to register all of an account's resources when the PAT is updated. + */ +export class PatUpdater { + protected readonly logger = getLoggerFor(this); + + public constructor( + protected readonly accountStore: AccountStore, + protected readonly podStore: PodStore, + protected readonly resourceStore: ResourceStore, + protected readonly umaClient: UmaClient, + ) {} + + public async updateSettings(accountId: string, id: string, secret: string, issuer: string): Promise { + const previousServer = await this.accountStore.getSetting(accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER); + const previousCredentials = await this.accountStore.getSetting(accountId, ACCOUNT_SETTINGS_AS_TOKEN); + + const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; + const credentials = `Basic ${Buffer.from(authString).toString('base64')}`; + await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_AS_TOKEN, credentials); + await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER, issuer); + + const pods = await this.podStore.findPods(accountId); + for (const { baseUrl: pod } of pods) { + // Don't await this as that would make the request very slow + this.updateRecursive( + pod, + issuer, + credentials, + (previousServer && previousCredentials) ? { issuer: previousServer, pat: previousCredentials } : undefined + ).catch(error => this.logger.error(`Unable to update resource registrations: ${createErrorMessage(error)}`)); + } + } + + protected async updateRecursive( + resource: string, + issuer: string, + credentials: string, + previous?: {issuer: string, pat: string }, + ): Promise { + const identifier = { path: resource }; + if (previous) { + // Removing the previous registration + await this.umaClient.deleteResource(identifier, previous.issuer, previous.pat); + } + await this.umaClient.registerResource(identifier, issuer, credentials); + if (isContainerIdentifier(identifier)) { + const representation = await this.resourceStore.getRepresentation(identifier, {}); + representation.data.destroy(); + const members = representation.metadata.getAll(LDP.terms.contains).map((term): string => term.value); + await Promise.all( + members.map((member): Promise => this.updateRecursive(member, issuer, credentials, previous)) + ); + } + } +} diff --git a/packages/css/src/identity/interaction/PatUpdateHandler.ts b/packages/css/src/identity/interaction/PatUpdateHandler.ts new file mode 100644 index 00000000..b8862b5f --- /dev/null +++ b/packages/css/src/identity/interaction/PatUpdateHandler.ts @@ -0,0 +1,46 @@ +import { + assertAccountId, + EmptyObject, + JsonInteractionHandler, + JsonInteractionHandlerInput, + JsonRepresentation, + JsonView, + parseSchema, + validateWithError +} from '@solid/community-server'; +import { getLoggerFor } from 'global-logger-factory'; +import { object, string } from 'yup'; +import { PatUpdater } from '../PatUpdater'; + +const inSchema = object({ + id: string().trim().required(), + secret: string().trim().required(), + issuer: string().trim().required(), +}); + +/** + * Updates the user's PAT credentials and corresponding issuer settings. + * All user's resources will be registered recursively at the issuer using these credentials. + */ +export class PatUpdateHandler extends JsonInteractionHandler implements JsonView { + protected readonly logger = getLoggerFor(this); + + public constructor( + protected readonly patUpdater: PatUpdater, + ) { + super(); + } + + public async getView(input: JsonInteractionHandlerInput): Promise { + return { json: parseSchema(inSchema)}; + } + + public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise> { + assertAccountId(accountId); + + const { id, secret, issuer } = await validateWithError(inSchema, json); + await this.patUpdater.updateSettings(accountId, id, secret, issuer); + + return { json: {}}; + } +} diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index ad30b5bc..1a67b1d6 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -9,6 +9,9 @@ export * from './http/output/metadata/UmaTicketMetadataWriter'; export * from './identity/interaction/account/util/AccountSettings'; export * from './identity/interaction/account/util/UmaAccountStore'; +export * from './identity/interaction/PatUpdateHandler'; +export * from './identity/PatSeedRegistrar'; +export * from './identity/PatUpdater'; export * from './init/EmptyContainerInitializer'; export * from './init/UmaSeededAccountInitializer'; diff --git a/packages/css/src/init/UmaSeededAccountInitializer.ts b/packages/css/src/init/UmaSeededAccountInitializer.ts index d8477084..3d6ba610 100644 --- a/packages/css/src/init/UmaSeededAccountInitializer.ts +++ b/packages/css/src/init/UmaSeededAccountInitializer.ts @@ -10,6 +10,7 @@ import { readJson } from 'fs-extra'; import { getLoggerFor } from 'global-logger-factory'; import { array, object, string } from 'yup'; import { + ACCOUNT_SETTINGS_AS_TOKEN, ACCOUNT_SETTINGS_AUTHZ_SERVER, ACCOUNT_SETTINGS_KEYS, UMA_ACCOUNT_STORAGE_TYPE @@ -19,8 +20,9 @@ const inSchema = array().of(object({ email: string().trim().email().lowercase().required(), password: string().trim().min(1).required(), authz: object({ - server: string() - }).optional(), + server: string().required(), + credentials: string(), + }).default(undefined), keys: array().of(string().required()).optional(), pods: array().of(object({ name: string().trim().min(1).required(), @@ -94,11 +96,14 @@ export class UmaSeededAccountInitializer extends Initializer { const accountId = await this.accountStore.create(); if (keys) { - await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_KEYS, keys) + await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_KEYS, keys); } - if (authz?.server) { - await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER, authz.server) + if (authz) { + await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER, authz.server); + if (authz.credentials) { + await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_AS_TOKEN, authz.credentials); + } } const id = await this.passwordStore.create(email, accountId, password); diff --git a/packages/css/src/uma/ResourceRegistrar.ts b/packages/css/src/uma/ResourceRegistrar.ts index cda44cb0..72800aca 100644 --- a/packages/css/src/uma/ResourceRegistrar.ts +++ b/packages/css/src/uma/ResourceRegistrar.ts @@ -1,4 +1,10 @@ -import { ActivityEmitter, AS, createErrorMessage, ResourceIdentifier } from '@solid/community-server'; +import { + ActivityEmitter, + AS, + createErrorMessage, + InternalServerError, + ResourceIdentifier +} from '@solid/community-server'; import { StaticHandler } from 'asynchronous-handlers'; import { getLoggerFor } from 'global-logger-factory'; import { OwnerUtil } from '../util/OwnerUtil'; @@ -18,29 +24,57 @@ export class ResourceRegistrar extends StaticHandler { super(); 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) => { + try { + const owner = await this.findOwner(resource); + if (!owner) + return; + const { issuer, credentials } = await this.findUmaSettings(owner); + this.umaClient.registerResource(resource, issuer, credentials).catch((err: Error) => { this.logger.error(`Unable to register resource ${resource.path}: ${createErrorMessage(err)}`); }); + } catch (err) { + this.logger.error(`Unable to find UMA settings: ${createErrorMessage(err)}`); } }); 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) => { + try { + const owner = await this.findOwner(resource); + if (!owner) + return; + const { issuer, credentials } = await this.findUmaSettings(owner); + this.umaClient.deleteResource(resource, issuer, credentials).catch((err: Error) => { this.logger.error(`Unable to remove resource registration ${resource.path}: ${createErrorMessage(err)}`); }); + } catch (err) { + this.logger.error(`Unable to find UMA settings: ${createErrorMessage(err)}`); } }); } - private async findOwners(resource: ResourceIdentifier): Promise { - return await this.ownerUtil.findOwners(resource).catch(() => []); + protected async findOwner(resource: ResourceIdentifier): Promise { + const webIds = await this.ownerUtil.findOwners(resource).catch((err) => { + this.logger.debug(`Defaulting to empty list of owners: ${createErrorMessage(err)}`); + return []; + }); + if (webIds.length === 0) { + // If there is no owner, we assume these are utility resources, such as in `.internal` + return; + } + // For multiple owners we would need several changes, including supporting multiple UMA IDs per resource + if (webIds.length > 1) { + throw new InternalServerError('Only resources with a single owner are supported.'); + } + return webIds[0]; } - private async findIssuer(owner: string): Promise { - const issuer = await this.ownerUtil.findIssuer(owner); - if (!issuer) throw new Error(`Could not find UMA AS for resource owner ${owner}`); - return issuer; + protected async findUmaSettings(owner: string): Promise<{ issuer: string, credentials: string }> { + const { credentials, issuer } = await this.ownerUtil.findUmaSettings(owner); + + if (!credentials || !issuer) { + throw new InternalServerError(`Credentials and/or issuer are not set for ${owner}`); + } + + return { credentials, issuer }; } } diff --git a/packages/css/src/uma/UmaClient.ts b/packages/css/src/uma/UmaClient.ts index 295105ab..f8feacbe 100644 --- a/packages/css/src/uma/UmaClient.ts +++ b/packages/css/src/uma/UmaClient.ts @@ -39,11 +39,19 @@ export type UmaClaims = JWTPayload & { export interface UmaConfig { jwks_uri: string; - // jwks: any; issuer: string; permission_endpoint: string; introspection_endpoint: string; resource_registration_endpoint: string; + token_endpoint: string, + registration_endpoint: string, +} + +interface TokenResponse { + access_token: string, + refresh_token: string, + token_type: string, + expires_in: number, } export type UmaVerificationOptions = Omit; @@ -72,11 +80,15 @@ export class UmaClient implements SingleThreaded { // Used to notify when registration finished for a resource. The event will be the identifier of the resource. protected readonly registerEmitter: EventEmitter = new EventEmitter(); + protected readonly configCache: NodeJS.Dict<{ config: UmaConfig, expiration: number }> = {}; + protected readonly patStorage: NodeJS.Dict<{ pat: string, expiration: number }> = {}; + /** * @param umaIdStore - Key/value store containing the resource path -> UMA ID bindings. * @param fetcher - Used to perform requests targeting the AS. * @param identifierStrategy - Utility functions based on the path configuration of the server. * @param resourceSet - Will be used to verify existence of resources. + * @param baseUrl - The base URL of this server. * @param options - JWT verification options. */ constructor( @@ -84,6 +96,7 @@ export class UmaClient implements SingleThreaded { protected readonly fetcher: Fetcher, protected readonly identifierStrategy: IdentifierStrategy, protected readonly resourceSet: ResourceSet, + protected readonly baseUrl: string, protected readonly options: UmaVerificationOptions = {}, ) { // This number can potentially get very big when seeding a bunch of pods. @@ -91,15 +104,61 @@ export class UmaClient implements SingleThreaded { this.registerEmitter.setMaxListeners(20); } + public async getPat(issuer: string, credentials: string): Promise { + const cached = this.patStorage[credentials]; + if (cached && cached.expiration > Date.now()) { + return cached.pat; + } + + const config = await this.fetchUmaConfig(issuer); + const response = await this.fetcher.fetch(config.token_endpoint, { + method: 'POST', + headers: { + authorization: credentials, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials&scope=uma_protection', + }); + if (response.status !== 201) { + throw new InternalServerError(`Unable to generate PAT: ${response.status} - ${await response.text()}`); + } + + const { access_token, token_type, expires_in } = await response.json() as TokenResponse; + this.logger.info(`Generated PAT ${access_token}`); + const pat = `${token_type} ${access_token}`; + const expiration = Date.now() + expires_in * 1000; + this.patStorage[credentials] = { pat, expiration }; + + return pat; + } + + public async generateClientCredentials(webId: string, issuer: string): Promise<{ id: string, secret: string }> { + const config = await this.fetchUmaConfig(issuer); + const response = await this.fetcher.fetch(config.registration_endpoint, { + method: 'POST', + headers: { + authorization: `WebID ${encodeURIComponent(webId)}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ client_uri: this.baseUrl }), + }); + if (response.status !== 201) { + throw new InternalServerError(`Something went wrong generating PAT credentials: ${response.status} - ${ + await response.text()}`); + } + const { client_id, client_secret } = await response.json() as { client_id: string, client_secret: string }; + return { id: client_id, secret: client_secret }; + } + /** * Method to fetch a ticket from the Permission Registration endpoint of the UMA Authorization Service. * * @param {AccessMap} permissions - the access targets and modes for which a ticket is requested - * @param {string} owner - the resource owner of the requested target resources * @param {string} issuer - the issuer from which to request the permission ticket + * @param {string} credentials - credentials the server should use to acquire a PAT * @return {Promise} - the permission ticket */ - public async fetchTicket(permissions: AccessMap, issuer: string): Promise { + public async fetchTicket(permissions: AccessMap, issuer: string, credentials: string): Promise { let endpoint: string; try { @@ -125,7 +184,7 @@ export class UmaClient implements SingleThreaded { // This can be a consequence of adding resources in the wrong way (e.g., copying files), // or other special resources, such as derived resources. if (await this.resourceSet.hasResource(target)) { - await this.registerResource(target, issuer); + await this.registerResource(target, issuer, credentials); umaId = await this.umaIdStore.get(target.path); } else { throw new NotFoundHttpError(); @@ -141,9 +200,11 @@ export class UmaClient implements SingleThreaded { }); } + const pat = await this.getPat(issuer, credentials); const response = await this.fetcher.fetch(endpoint, { method: 'POST', headers: { + 'Authorization': pat, 'Content-Type': 'application/json', 'Accept': 'application/json', }, @@ -247,6 +308,11 @@ export class UmaClient implements SingleThreaded { * @return {Promise} - UMA Configuration */ public async fetchUmaConfig(issuer: string): Promise { + const cached = this.configCache[issuer]; + if (cached && cached.expiration > Date.now()) { + return cached.config; + } + const configUrl = issuer + UMA_DISCOVERY; const res = await this.fetcher.fetch(configUrl); @@ -266,7 +332,10 @@ export class UmaClient implements SingleThreaded { `The Authorization Server Metadata of '${issuer}' should have string attributes ${noString.join(', ')}` ); - return configuration as unknown as UmaConfig; + const typedConfig = configuration as unknown as UmaConfig; + this.configCache[issuer] = { config: typedConfig, expiration: Date.now() + 5 * 60 * 1000 }; + + return typedConfig; } /** @@ -280,13 +349,13 @@ export class UmaClient implements SingleThreaded { * In that case the registration will be done immediately, * and updated with the relations once the parent registration is finished. */ - public async registerResource(resource: ResourceIdentifier, issuer: string): Promise { + public async registerResource(resource: ResourceIdentifier, issuer: string, credentials: string): Promise { if (this.inProgressResources.has(resource.path)) { // It is possible a resource is still being registered when an updated registration is already requested. // To prevent duplicate registrations of the same resource, // the next call will only happen when the first one is finished. await once(this.registerEmitter, resource.path); - return this.registerResource(resource, issuer); + return this.registerResource(resource, issuer, credentials); } this.inProgressResources.add(resource.path); let { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer); @@ -310,6 +379,8 @@ export class UmaClient implements SingleThreaded { description.resource_defaults = { 'http://www.w3.org/ns/ldp#contains': description.resource_scopes }; } + const pat = await this.getPat(issuer, credentials); + // This function can potentially cause multiple asynchronous calls to be required. // These will be stored in this array so they can be executed simultaneously. const promises: Promise[] = []; @@ -324,12 +395,12 @@ export class UmaClient implements SingleThreaded { promises.push( once(this.registerEmitter, parentIdentifier.path) - .then(() => this.registerResource(resource, issuer)), + .then(() => this.registerResource(resource, issuer, credentials)), ); // It is possible the parent is not yet being registered. // We need to force a registration in such a case, otherwise the above event will never be fired. if (!this.inProgressResources.has(parentIdentifier.path)) { - promises.push(this.registerResource(parentIdentifier, issuer)); + promises.push(this.registerResource(parentIdentifier, issuer, credentials)); } } } @@ -343,6 +414,7 @@ export class UmaClient implements SingleThreaded { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', + 'Authorization': pat, }, body: JSON.stringify(description), }; @@ -379,7 +451,7 @@ export class UmaClient implements SingleThreaded { /** * Deletes the UMA registration for the given resource from the given issuer. */ - public async deleteResource(resource: ResourceIdentifier, issuer: string): Promise { + public async deleteResource(resource: ResourceIdentifier, issuer: string, credentials: string): Promise { const { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer); const umaId = await this.umaIdStore.get(resource.path); @@ -390,7 +462,8 @@ export class UmaClient implements SingleThreaded { this.logger.info(`Deleting resource registration for <${resource.path}> at <${url}>`); - await this.fetcher.fetch(url, { method: 'DELETE' }); + const pat = await this.getPat(issuer, credentials); + await this.fetcher.fetch(url, { method: 'DELETE', headers: { Authorization: pat } }); } } diff --git a/packages/css/src/util/OwnerUtil.ts b/packages/css/src/util/OwnerUtil.ts index 199e99b4..7adc54db 100644 --- a/packages/css/src/util/OwnerUtil.ts +++ b/packages/css/src/util/OwnerUtil.ts @@ -1,12 +1,20 @@ import { + AccountLoginStorage, + AccountStore, InternalServerError, - joinUrl, PodStore, ResourceIdentifier, StorageLocationStrategy, + WEBID_STORAGE_DESCRIPTION, + WEBID_STORAGE_TYPE, WrappedSetMultiMap } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; +import { + ACCOUNT_SETTINGS_AS_TOKEN, + ACCOUNT_SETTINGS_AUTHZ_SERVER, + UMA_ACCOUNT_STORAGE_TYPE +} from '../identity/interaction/account/util/AccountSettings'; /** * ... @@ -14,11 +22,18 @@ import { getLoggerFor } from 'global-logger-factory'; export class OwnerUtil { protected readonly logger = getLoggerFor(this); + private readonly accountStorage: AccountLoginStorage<{ [WEBID_STORAGE_TYPE]: typeof WEBID_STORAGE_DESCRIPTION }>; + public constructor( - protected podStore: PodStore, - protected storageStrategy: StorageLocationStrategy, - protected umaServerURL: string, - ) {} + // TODO: CSS does not have utility functions to go from WebID -> AccountID + // Wrong typings to prevent Components.js typing issues + accountStorage: AccountLoginStorage>, + protected readonly accountStore: AccountStore, + protected readonly podStore: PodStore, + protected readonly storageStrategy: StorageLocationStrategy, + ) { + this.accountStorage = accountStorage as unknown as typeof this.accountStorage; + } /** * Finds the owners of the given resource. @@ -40,6 +55,9 @@ export class OwnerUtil { return owners.map((owner) => owner.webId); } + /** + * Finds the WebID that is owner for all the given resources. + */ public async findCommonOwner(resources: Iterable): Promise { const resourceSet = new Set(resources); const ownerMap = new WrappedSetMultiMap(); @@ -51,18 +69,40 @@ export class OwnerUtil { ownerMap.add(owner, target.path); } + const validOwners: string[] = []; for (const [owner, targets] of ownerMap.entrySets()) { if (targets.size === resourceSet.size) - return owner; + validOwners.push(owner); + } + if (validOwners.length === 0) { + throw new InternalServerError( + `No common owner found for resources: ${Array.from(resources).map(r => r.path).join(', ')}`, + ); + } + if (validOwners.length > 1) { + throw new InternalServerError( + `Multiple common owners 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(', ')}`, - ); + return validOwners[0]; } - public async findIssuer(webid: string): Promise { - this.logger.verbose(`Using UMA Authorization Server at ${this.umaServerURL} for WebID ${webid}.`); - return joinUrl(this.umaServerURL, 'uma'); + /** + * Finds the issuer and PAT registered to the account that owns this WebID. + * Errors if there are no or multiple accounts that link this WebID. + */ + public async findUmaSettings(webId: string): Promise<{ issuer?: string, credentials?: string }> { + const accounts = await this.accountStorage.find(WEBID_STORAGE_TYPE, { webId }); + if (accounts.length === 0) { + throw new InternalServerError(`Unable to find an account linked to WebID ${webId}`); + } + if (accounts.length > 1) { + throw new InternalServerError(`Found multiple accounts linked to WebID ${webId}`); + } + const accountId = accounts[0].accountId; + const issuer = await this.accountStore.getSetting(accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER); + const credentials = await this.accountStore.getSetting(accountId, ACCOUNT_SETTINGS_AS_TOKEN); + return { credentials, issuer }; } } diff --git a/packages/css/test/unit/authentication/UmaTokenExtractor.test.ts b/packages/css/test/unit/authentication/UmaTokenExtractor.test.ts index 2e933cac..402d4289 100644 --- a/packages/css/test/unit/authentication/UmaTokenExtractor.test.ts +++ b/packages/css/test/unit/authentication/UmaTokenExtractor.test.ts @@ -22,7 +22,7 @@ describe('UmaTokenExtractor', () => { ownerUtil = { findOwners: vi.fn().mockResolvedValue([ 'owner' ]), - findIssuer: vi.fn().mockResolvedValue('issuer'), + findUmaSettings: vi.fn().mockResolvedValue({ issuer: 'issuer' }), } satisfies Partial as any; extractor = new UmaTokenExtractor(client, targetExtractor, ownerUtil); diff --git a/packages/css/test/unit/authorization/UmaAuthorizer.test.ts b/packages/css/test/unit/authorization/UmaAuthorizer.test.ts index 774dba38..75a617ca 100644 --- a/packages/css/test/unit/authorization/UmaAuthorizer.test.ts +++ b/packages/css/test/unit/authorization/UmaAuthorizer.test.ts @@ -1,7 +1,8 @@ import { AccessMap, Authorizer, - ForbiddenHttpError, HttpError, + ForbiddenHttpError, + HttpError, IdentifierSetMultiMap, InternalServerError } from '@solid/community-server'; @@ -12,6 +13,8 @@ import { UmaClient } from '../../../src/uma/UmaClient'; import { OwnerUtil } from '../../../src/util/OwnerUtil'; describe('UmaAuthorizer', (): void => { + const issuer = 'issuer'; + const credentials = 'Basic 123'; let source: Mocked; let ownerUtil: Mocked; let client: Mocked; @@ -24,7 +27,7 @@ describe('UmaAuthorizer', (): void => { ownerUtil = { findCommonOwner: vi.fn(), - findIssuer: vi.fn(), + findUmaSettings: vi.fn().mockResolvedValue({ issuer, credentials }), } satisfies Partial as any; client = { @@ -41,7 +44,7 @@ describe('UmaAuthorizer', (): void => { expect(source.handleSafe).toHaveBeenCalledTimes(1); expect(source.handleSafe).toHaveBeenLastCalledWith(input); expect(ownerUtil.findCommonOwner).toHaveBeenCalledTimes(0); - expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(0); + expect(ownerUtil.findUmaSettings).toHaveBeenCalledTimes(0); expect(client.fetchTicket).toHaveBeenCalledTimes(0); }); @@ -51,26 +54,39 @@ describe('UmaAuthorizer', (): void => { await expect(authorizer.handle({ key: 'value' } as any)).rejects.toThrowError(error); expect(ownerUtil.findCommonOwner).toHaveBeenCalledTimes(0); - expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(0); + expect(ownerUtil.findUmaSettings).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'); + ownerUtil.findUmaSettings.mockResolvedValueOnce({ credentials }); const requestedModes: AccessMap = new IdentifierSetMultiMap([[ { path: 'id' }, PERMISSIONS.Read ]]); await expect(authorizer.handle({ requestedModes } as any)).rejects - .toThrowError(`No UMA authorization server found for owner.`); + .toThrowError(`Credentials and/or issuer are not set for owner.`); expect(ownerUtil.findCommonOwner).toHaveBeenCalledTimes(1); - expect(ownerUtil.findIssuer).toHaveBeenCalledTimes(1); + expect(ownerUtil.findUmaSettings).toHaveBeenCalledTimes(1); + expect(client.fetchTicket).toHaveBeenCalledTimes(0); + }); + + it('errors if no PAT could be found.', async(): Promise => { + source.handleSafe.mockRejectedValueOnce(new ForbiddenHttpError()); + ownerUtil.findCommonOwner.mockResolvedValueOnce('owner'); + ownerUtil.findUmaSettings.mockResolvedValueOnce({ issuer }); + const requestedModes: AccessMap = new IdentifierSetMultiMap([[ { path: 'id' }, PERMISSIONS.Read ]]); + + await expect(authorizer.handle({ requestedModes } as any)).rejects + .toThrowError(`Credentials and/or issuer are not set for owner.`); + expect(ownerUtil.findCommonOwner).toHaveBeenCalledTimes(1); + expect(ownerUtil.findUmaSettings).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' }, PERMISSIONS.Read ]]); @@ -87,7 +103,6 @@ describe('UmaAuthorizer', (): void => { 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' }, PERMISSIONS.Read ]]); await expect(authorizer.handle({ requestedModes } as any)).resolves.toBeUndefined(); @@ -96,7 +111,6 @@ describe('UmaAuthorizer', (): void => { 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' }, PERMISSIONS.Read ]]); diff --git a/packages/css/test/unit/identity/PatSeedRegistrar.test.ts b/packages/css/test/unit/identity/PatSeedRegistrar.test.ts new file mode 100644 index 00000000..f0604046 --- /dev/null +++ b/packages/css/test/unit/identity/PatSeedRegistrar.test.ts @@ -0,0 +1,108 @@ +import { + AccountLoginStorage, + AccountStore, + TypeObject, + WEBID_STORAGE_DESCRIPTION, + WEBID_STORAGE_TYPE +} from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { + ACCOUNT_SETTINGS_AS_TOKEN, + ACCOUNT_SETTINGS_AUTHZ_SERVER, + UMA_ACCOUNT_STORAGE_TYPE +} from '../../../src/identity/interaction/account/util/AccountSettings'; +import { PatSeedRegistrar } from '../../../src/identity/PatSeedRegistrar'; +import { PatUpdater } from '../../../src/identity/PatUpdater'; +import { UmaClient } from '../../../src/uma/UmaClient'; + +describe('PatSeedRegistrar', (): void => { + let entries: TypeObject[]; + let accountStorage: Mocked>; + let accountStore: Mocked>; + let umaClient: Mocked; + let patUpdater: Mocked; + let registrar: PatSeedRegistrar; + + beforeEach(async(): Promise => { + entries = [ + { id: 'id1', accountId: 'account1', webId: 'webId1'}, + { id: 'id2', accountId: 'account2', webId: 'webId2'}, + ]; + + accountStorage = { + entries: vi.fn(async function*() { + yield* entries; + }), + } as any; + + accountStore = { + getSetting: vi.fn(async (id: string, setting: string) => + setting === ACCOUNT_SETTINGS_AUTHZ_SERVER ? 'issuer' : undefined) as any, + updateSetting: vi.fn(), + create: vi.fn(), + }; + + umaClient = { + generateClientCredentials: vi.fn().mockResolvedValue({ id: 'id', secret: 'secret' }), + } satisfies Partial as any; + + patUpdater = { + updateSettings: vi.fn(), + } satisfies Partial as any; + + registrar = new PatSeedRegistrar(accountStorage as any, accountStore, umaClient, patUpdater); + }); + + it('initializes the PAT registrations once the status changes.', async(): Promise => { + await expect(registrar.changeStatus(true)).resolves.toBeUndefined(); + expect(accountStorage.entries).toHaveBeenCalledTimes(1); + expect(accountStorage.entries).toHaveBeenLastCalledWith(WEBID_STORAGE_TYPE); + expect(accountStore.getSetting).toHaveBeenCalledTimes(4); + expect(accountStore.getSetting).toHaveBeenNthCalledWith(1, 'account1', ACCOUNT_SETTINGS_AS_TOKEN); + expect(accountStore.getSetting).toHaveBeenNthCalledWith(2, 'account1', ACCOUNT_SETTINGS_AUTHZ_SERVER); + expect(accountStore.getSetting).toHaveBeenNthCalledWith(3, 'account2', ACCOUNT_SETTINGS_AS_TOKEN); + expect(accountStore.getSetting).toHaveBeenNthCalledWith(4, 'account2', ACCOUNT_SETTINGS_AUTHZ_SERVER); + expect(umaClient.generateClientCredentials).toHaveBeenCalledTimes(2); + expect(umaClient.generateClientCredentials).toHaveBeenNthCalledWith(1, 'webId1', 'issuer'); + expect(umaClient.generateClientCredentials).toHaveBeenNthCalledWith(2, 'webId2', 'issuer'); + expect(patUpdater.updateSettings).toHaveBeenCalledTimes(2); + expect(patUpdater.updateSettings).toHaveBeenNthCalledWith(1, 'account1', 'id', 'secret', 'issuer'); + expect(patUpdater.updateSettings).toHaveBeenNthCalledWith(2, 'account2', 'id', 'secret', 'issuer'); + }); + + it('only registers with the first WebID it finds', async(): Promise => { + entries = [ + ... entries, + { id: 'id1', accountId: 'account1', webId: 'webId3'}, + ]; + await expect(registrar.changeStatus(true)).resolves.toBeUndefined(); + expect(umaClient.generateClientCredentials).toHaveBeenCalledTimes(2); + expect(umaClient.generateClientCredentials).toHaveBeenNthCalledWith(1, 'webId1', 'issuer'); + expect(umaClient.generateClientCredentials).toHaveBeenNthCalledWith(2, 'webId2', 'issuer'); + }); + + it('does nothing if there already are credentials', async(): Promise => { + accountStore.getSetting.mockImplementation(async (id: string, setting: string) => { + if (setting === ACCOUNT_SETTINGS_AUTHZ_SERVER) { + return 'issuer'; + } + if (id === 'account1') { + return 'token1'; + } + }); + await expect(registrar.changeStatus(true)).resolves.toBeUndefined(); + expect(umaClient.generateClientCredentials).toHaveBeenCalledTimes(1); + expect(umaClient.generateClientCredentials).toHaveBeenNthCalledWith(1, 'webId2', 'issuer'); + }); + + it('does nothing if no issuer is defined.', async(): Promise => { + accountStore.getSetting.mockImplementation(async (id: string, setting: string) => { + if (setting === ACCOUNT_SETTINGS_AUTHZ_SERVER && id === 'account1') { + return 'issuer'; + } + }); + await expect(registrar.changeStatus(true)).resolves.toBeUndefined(); + expect(umaClient.generateClientCredentials).toHaveBeenCalledTimes(1); + expect(umaClient.generateClientCredentials).toHaveBeenNthCalledWith(1, 'webId1', 'issuer'); + }); +}); diff --git a/packages/css/test/unit/identity/PatUpdater.test.ts b/packages/css/test/unit/identity/PatUpdater.test.ts new file mode 100644 index 00000000..088a9cf4 --- /dev/null +++ b/packages/css/test/unit/identity/PatUpdater.test.ts @@ -0,0 +1,115 @@ +import { + AccountStore, + BasicRepresentation, + LDP, + PodStore, + Representation, + RepresentationMetadata, + ResourceIdentifier, + ResourceStore +} from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { flushPromises } from '../../../../../test/util/Util'; +import { + ACCOUNT_SETTINGS_AS_TOKEN, + ACCOUNT_SETTINGS_AUTHZ_SERVER, + UMA_ACCOUNT_STORAGE_TYPE +} from '../../../src/identity/interaction/account/util/AccountSettings'; +import { PatUpdater } from '../../../src/identity/PatUpdater'; +import { UmaClient } from '../../../src/uma/UmaClient'; + +function generateResource(id: ResourceIdentifier): Representation { + if (id.path === '/') { + return new BasicRepresentation('', new RepresentationMetadata({ + [LDP.contains]: [ '/foo/', '/bar' ], + })); + } + if (id.path === '/foo/') { + return new BasicRepresentation('', new RepresentationMetadata({ + [LDP.contains]: [ '/foo/baz' ], + })); + } + return new BasicRepresentation(); +} + +describe('PatUpdater', (): void => { + const accountId = 'accountId'; + const id = 'id'; + const secret = 'secret'; + const issuer = 'issuer'; + let accountStore: Mocked>; + let podStore: Mocked; + let resourceStore: Mocked; + let umaClient: Mocked; + let updater: PatUpdater; + + beforeEach(async(): Promise => { + accountStore = { + getSetting: vi.fn() as any, + updateSetting: vi.fn(), + create: vi.fn(), + }; + + podStore = { + findPods: vi.fn().mockResolvedValue([{ baseUrl: '/' }]), + } satisfies Partial as any; + + resourceStore = { + getRepresentation: vi.fn(generateResource as any), + } satisfies Partial as any; + + umaClient = { + registerResource: vi.fn(), + deleteResource: vi.fn(), + } satisfies Partial as any; + + updater = new PatUpdater(accountStore, podStore, resourceStore, umaClient); + }); + + it('registers all owned resources.', async(): Promise => { + await expect(updater.updateSettings(accountId, id, secret, issuer)).resolves.toBeUndefined(); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(2); + const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; + const credentials = `Basic ${Buffer.from(authString).toString('base64')}`; + expect(accountStore.updateSetting).toHaveBeenNthCalledWith(1, accountId, ACCOUNT_SETTINGS_AS_TOKEN, credentials); + expect(accountStore.updateSetting).toHaveBeenNthCalledWith(2, accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER, issuer); + + await flushPromises(); + expect(umaClient.registerResource).toHaveBeenCalledTimes(4); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/' }, issuer, credentials); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/foo/' }, issuer, credentials); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/foo/baz' }, issuer, credentials); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/bar' }, issuer, credentials); + expect(umaClient.deleteResource).toHaveBeenCalledTimes(0); + }); + + it('deletes registrations if they need to be replaced.', async(): Promise => { + accountStore.getSetting.mockImplementation(async (id: string, setting: string) => { + if (setting === ACCOUNT_SETTINGS_AUTHZ_SERVER) { + return 'oldIssuer'; + } + if (setting === ACCOUNT_SETTINGS_AS_TOKEN) { + return 'oldToken'; + } + }); + + await expect(updater.updateSettings(accountId, id, secret, issuer)).resolves.toBeUndefined(); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(2); + const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; + const credentials = `Basic ${Buffer.from(authString).toString('base64')}`; + expect(accountStore.updateSetting).toHaveBeenNthCalledWith(1, accountId, ACCOUNT_SETTINGS_AS_TOKEN, credentials); + expect(accountStore.updateSetting).toHaveBeenNthCalledWith(2, accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER, issuer); + + await flushPromises(); + expect(umaClient.registerResource).toHaveBeenCalledTimes(4); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/' }, issuer, credentials); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/foo/' }, issuer, credentials); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/foo/baz' }, issuer, credentials); + expect(umaClient.registerResource).toHaveBeenCalledWith({ path: '/bar' }, issuer, credentials); + expect(umaClient.deleteResource).toHaveBeenCalledTimes(4); + expect(umaClient.deleteResource).toHaveBeenCalledWith({ path: '/' }, 'oldIssuer', 'oldToken'); + expect(umaClient.deleteResource).toHaveBeenCalledWith({ path: '/foo/' }, 'oldIssuer', 'oldToken'); + expect(umaClient.deleteResource).toHaveBeenCalledWith({ path: '/foo/baz' }, 'oldIssuer', 'oldToken'); + expect(umaClient.deleteResource).toHaveBeenCalledWith({ path: '/bar' }, 'oldIssuer', 'oldToken'); + }); +}); diff --git a/packages/css/test/unit/identity/interaction/PatUpdateHandler.test.ts b/packages/css/test/unit/identity/interaction/PatUpdateHandler.test.ts new file mode 100644 index 00000000..652d3095 --- /dev/null +++ b/packages/css/test/unit/identity/interaction/PatUpdateHandler.test.ts @@ -0,0 +1,39 @@ +import { Mocked } from 'vitest'; +import { PatUpdateHandler } from '../../../../src/identity/interaction/PatUpdateHandler'; +import { PatUpdater } from '../../../../src/identity/PatUpdater'; + +describe('PatUpdateHandler', (): void => { + let updater: Mocked; + let handler: PatUpdateHandler; + + beforeEach(async(): Promise => { + updater = { + updateSettings: vi.fn(), + } satisfies Partial as any; + + handler = new PatUpdateHandler(updater); + }); + + it('can return the input view.', async(): Promise => { + await expect(handler.getView({} as any)).resolves.toEqual({ + json: { + fields: { + id: { required: true, type: 'string' }, + issuer: { required: true, type: 'string' }, + secret: { required: true, type: 'string' }, + } + } + }); + }); + + it('can call the updater with the new settings.', async(): Promise => { + const json = { + id: 'id', + issuer: 'issuer', + secret: 'secret', + }; + await expect(handler.handle({ accountId: 'accountId', json } as any)).resolves.toEqual({ json: {}}); + expect(updater.updateSettings).toHaveBeenCalledTimes(1); + expect(updater.updateSettings).toHaveBeenLastCalledWith('accountId', 'id', 'secret', 'issuer'); + }); +}); diff --git a/packages/css/test/unit/uma/ResourceRegistrar.test.ts b/packages/css/test/unit/uma/ResourceRegistrar.test.ts index 16e16fa0..ca677b53 100644 --- a/packages/css/test/unit/uma/ResourceRegistrar.test.ts +++ b/packages/css/test/unit/uma/ResourceRegistrar.test.ts @@ -6,8 +6,9 @@ import { OwnerUtil } from '../../../src/util/OwnerUtil'; describe('ResourceRegistrar', (): void => { const target = { path: 'http://example.com/foo' }; - const owners = [ 'owner1', 'owner2' ]; + const owners = [ 'owner1' ]; const issuer = 'issuer'; + const credentials = 'credentials'; let emitter: Mocked; let ownerUtil: Mocked; let umaClient: Mocked; @@ -20,7 +21,7 @@ describe('ResourceRegistrar', (): void => { ownerUtil = { findOwners: vi.fn().mockResolvedValue(owners), - findIssuer: vi.fn().mockResolvedValue(issuer), + findUmaSettings: vi.fn().mockResolvedValue({ issuer, credentials }), } satisfies Partial as any; umaClient = { @@ -37,12 +38,10 @@ describe('ResourceRegistrar', (): void => { 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'); + expect(ownerUtil.findUmaSettings).toHaveBeenCalledTimes(1); + expect(ownerUtil.findUmaSettings).toHaveBeenLastCalledWith('owner1'); + expect(umaClient.registerResource).toHaveBeenCalledTimes(1); + expect(umaClient.registerResource).toHaveBeenLastCalledWith(target, issuer, credentials); }); it('catches the error if something goes wrong registering.', async(): Promise => { @@ -58,12 +57,10 @@ describe('ResourceRegistrar', (): void => { 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'); + expect(ownerUtil.findUmaSettings).toHaveBeenCalledTimes(1); + expect(ownerUtil.findUmaSettings).toHaveBeenLastCalledWith('owner1'); + expect(umaClient.deleteResource).toHaveBeenCalledTimes(1); + expect(umaClient.deleteResource).toHaveBeenLastCalledWith(target, issuer, credentials); }); it('catches the error if something goes wrong deleting.', async(): Promise => { diff --git a/packages/css/test/unit/uma/UmaClient.test.ts b/packages/css/test/unit/uma/UmaClient.test.ts index 03eb31a1..45e5ba9c 100644 --- a/packages/css/test/unit/uma/UmaClient.test.ts +++ b/packages/css/test/unit/uma/UmaClient.test.ts @@ -1,7 +1,7 @@ import { AccessMap, IdentifierSetMultiMap, - IdentifierStrategy, + IdentifierStrategy, InternalServerError, KeyValueStorage, NotFoundHttpError, ResourceSet @@ -19,6 +19,8 @@ type Writeable = { -readonly [P in keyof T]: T[P] }; class PublicUmaClient extends UmaClient { public inProgressResources: Set = new Set(); public registerEmitter: EventEmitter = new EventEmitter(); + public configCache: NodeJS.Dict<{ config: UmaConfig, expiration: number }> = {}; + public patStorage: NodeJS.Dict<{ pat: string, expiration: number }> = {}; } vi.mock('jose', () => ({ @@ -28,13 +30,17 @@ vi.mock('jose', () => ({ })); describe('UmaClient', (): void => { + const baseUrl = 'http://example.org/'; const issuer = 'issuer'; + const credentials = 'credentials'; 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/', + token_endpoint: 'http://example.com/token_endpoint', + registration_endpoint: 'http://example.com/registration_endpoint', } let response: Mocked>; @@ -66,7 +72,7 @@ describe('UmaClient', (): void => { hasResource: vi.fn(), }; - client = new UmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + client = new UmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet, baseUrl); }); describe('.fetchUmaConfig', (): void => { @@ -104,6 +110,78 @@ describe('UmaClient', (): void => { }); }); + describe('.getPat', (): void => { + beforeEach(async(): Promise => { + // Config mock for first fetch call + fetcher.fetch.mockResolvedValueOnce(response); + }); + + it('returns the generated PAT.', async(): Promise => { + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 201, + json: vi.fn().mockResolvedValueOnce({ access_token: 'pat_token', token_type: 'Bearer', expires_in: 3600 }), + }); + + await expect(client.getPat(issuer, credentials)).resolves.toEqual('Bearer pat_token'); + + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).toHaveBeenLastCalledWith(umaConfig.token_endpoint, { + method: 'POST', + headers: { + authorization: credentials, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials&scope=uma_protection', + }); + }); + + it('throws an error if the response is not 201.', async(): Promise => { + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 400, + text: vi.fn().mockResolvedValueOnce('bad data'), + }); + await expect(client.getPat(issuer, credentials)).rejects.toThrow(InternalServerError); + }); + }); + + describe('.generateClientCredentials', (): void => { + beforeEach(async(): Promise => { + // Config mock for first fetch call + fetcher.fetch.mockResolvedValueOnce(response); + }); + + it('registers the credentials.', async(): Promise => { + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 201, + json: vi.fn().mockResolvedValueOnce({ client_id: 'id', client_secret: 'secret' }), + }); + + await expect(client.generateClientCredentials('web id', issuer)).resolves.toEqual({ id: 'id', secret: 'secret' }); + + expect(fetcher.fetch).toHaveBeenCalledTimes(2); + expect(fetcher.fetch).toHaveBeenLastCalledWith(umaConfig.registration_endpoint, { + method: 'POST', + headers: { + authorization: `WebID web%20id`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ client_uri: baseUrl }), + }); + }); + + it('throws an error if the response is not 201.', async(): Promise => { + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 400, + text: vi.fn().mockResolvedValueOnce('bad data'), + }); + await expect(client.generateClientCredentials('web id', issuer)).rejects.toThrow(InternalServerError); + }); + }); + describe('.fetchTicket', (): void => { let permissions: AccessMap; @@ -120,11 +198,18 @@ describe('UmaClient', (): void => { // Config mock for first fetch call fetcher.fetch.mockResolvedValueOnce(response); + // PAT mock for second fetch call + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 201, + json: vi.fn().mockResolvedValueOnce({ access_token: 'pat_token', token_type: 'Bearer', expires_in: 3600 }), + }); }); 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: " + + await expect(client.fetchTicket(permissions, issuer, credentials)) + .rejects.toThrow("Error while retrieving ticket: " + "Unable to retrieve UMA Configuration for Authorization Server 'issuer'" + " from 'issuer/.well-known/uma2-configuration'"); }); @@ -138,13 +223,14 @@ describe('UmaClient', (): void => { }); 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, { + await expect(client.fetchTicket(permissions, issuer, credentials)).resolves.toBe('ticket'); + expect(fetcher.fetch).toHaveBeenCalledTimes(3); + expect(fetcher.fetch).toHaveBeenNthCalledWith(3, umaConfig.permission_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', }, body: JSON.stringify([ { resource_id: 'uma1', resource_scopes: [`urn:example:css:modes:read`] }, @@ -161,7 +247,7 @@ describe('UmaClient', (): void => { }); umaIdStore.get.mockResolvedValueOnce('uma1'); umaIdStore.get.mockResolvedValueOnce('uma2'); - await expect(client.fetchTicket(permissions, issuer)).resolves.toBeUndefined(); + await expect(client.fetchTicket(permissions, issuer, credentials)).resolves.toBeUndefined(); }); it('waits for resource registration if it is in progress.', async(): Promise => { @@ -170,19 +256,20 @@ describe('UmaClient', (): void => { umaIdStore.get.mockResolvedValueOnce('uma1'); umaIdStore.get.mockResolvedValueOnce('uma2'); - const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet, baseUrl); publicClient.inProgressResources.add('target1'); - const prom = publicClient.fetchTicket(permissions, issuer); + const prom = publicClient.fetchTicket(permissions, issuer, credentials); await flushPromises(); vi.advanceTimersByTime(1000); publicClient.registerEmitter.emit('target1'); await expect(prom).resolves.toBeUndefined(); - expect(fetcher.fetch).toHaveBeenNthCalledWith(2, umaConfig.permission_endpoint, { + expect(fetcher.fetch).toHaveBeenNthCalledWith(3, umaConfig.permission_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', }, body: JSON.stringify([ { resource_id: 'uma1', resource_scopes: [`urn:example:css:modes:read`] }, @@ -199,9 +286,9 @@ describe('UmaClient', (): void => { umaIdStore.get.mockResolvedValueOnce('uma1'); umaIdStore.get.mockResolvedValueOnce('uma2'); - const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet, baseUrl); publicClient.inProgressResources.add('target1'); - const prom = publicClient.fetchTicket(permissions, issuer); + const prom = publicClient.fetchTicket(permissions, issuer, credentials); await flushPromises(); vi.advanceTimersByTime(3000); @@ -212,27 +299,29 @@ describe('UmaClient', (): void => { 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); + await expect(client.fetchTicket(permissions, issuer, credentials)).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); + const registerClient = new SimpleRegistrationUmaClient( + umaIdStore, fetcher, identifierStrategy, resourceSet, baseUrl); umaIdStore.get.mockResolvedValueOnce(undefined); resourceSet.hasResource.mockResolvedValueOnce(true); umaIdStore.get.mockResolvedValueOnce('uma1'); umaIdStore.get.mockResolvedValueOnce('uma2'); - await expect(registerClient.fetchTicket(permissions, issuer)).resolves.toBeUndefined(); + await expect(registerClient.fetchTicket(permissions, issuer, credentials)).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, { + expect(registerClient.registerResource).toHaveBeenLastCalledWith({ path: 'target1' }, issuer, credentials); + expect(fetcher.fetch).toHaveBeenCalledTimes(3); + expect(fetcher.fetch).toHaveBeenNthCalledWith(3, umaConfig.permission_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', }, body: JSON.stringify([ { resource_id: 'uma1', resource_scopes: [`urn:example:css:modes:read`] }, @@ -242,14 +331,15 @@ describe('UmaClient', (): void => { }); it('errors if there is still no UMA ID after registering the resource.', async(): Promise => { - const registerClient = new SimpleRegistrationUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet); + const registerClient = new SimpleRegistrationUmaClient( + umaIdStore, fetcher, identifierStrategy, resourceSet, baseUrl); umaIdStore.get.mockResolvedValue(undefined); resourceSet.hasResource.mockResolvedValueOnce(true); - await expect(registerClient.fetchTicket(permissions, issuer)).rejects + await expect(registerClient.fetchTicket(permissions, issuer, credentials)).rejects .toThrow(`Unable to request ticket: no UMA ID found for target1`); expect(registerClient.registerResource).toHaveBeenCalledTimes(1); - expect(registerClient.registerResource).toHaveBeenLastCalledWith({ path: 'target1' }, issuer); + expect(registerClient.registerResource).toHaveBeenLastCalledWith({ path: 'target1' }, issuer, credentials); }); }); @@ -402,34 +492,48 @@ describe('UmaClient', (): void => { beforeEach(async(): Promise => { fetcher.fetch.mockResolvedValueOnce(response); + // PAT mock for second fetch call + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 201, + json: vi.fn().mockResolvedValueOnce({ access_token: 'pat_token', token_type: 'Bearer', expires_in: 3600 }), + }); }); 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(); + await expect(client.registerResource({ path: '/' }, issuer, credentials)).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, { + expect(fetcher.fetch).toHaveBeenCalledTimes(3); + expect(fetcher.fetch).toHaveBeenNthCalledWith(3, umaConfig.resource_registration_endpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', + }, 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(); + await expect(client.registerResource({ path: '/' }, issuer, credentials)).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, { + expect(fetcher.fetch).toHaveBeenCalledTimes(3); + expect(fetcher.fetch).toHaveBeenNthCalledWith(3, umaConfig.resource_registration_endpoint + umaId, { method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', + }, body: JSON.stringify({ name: '/', resource_scopes, resource_defaults }), }); }); @@ -438,16 +542,20 @@ describe('UmaClient', (): void => { 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(); + await expect(client.registerResource({ path: '/foo' }, issuer, credentials)).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, { + expect(fetcher.fetch).toHaveBeenCalledTimes(3); + expect(fetcher.fetch).toHaveBeenNthCalledWith(3, umaConfig.resource_registration_endpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', + }, body: JSON.stringify({ name: '/foo', resource_scopes, resource_relations: { '@reverse': { 'http://www.w3.org/ns/ldp#contains': [ 'parentId' ] }}}), }); }); @@ -466,24 +574,36 @@ describe('UmaClient', (): void => { return response; }); - await expect(client.registerResource({ path: '/foo' }, issuer)).resolves.toBeUndefined(); + await expect(client.registerResource({ path: '/foo' }, issuer, credentials)).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).toHaveBeenCalledTimes(5); expect(fetcher.fetch).toHaveBeenCalledWith(umaConfig.resource_registration_endpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', + }, 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' }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', + }, 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' }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer pat_token', + }, body: JSON.stringify({ name: '/foo', resource_scopes, resource_relations: { '@reverse': { 'http://www.w3.org/ns/ldp#contains': [ 'parentId' ] }}}), }); }); @@ -492,19 +612,26 @@ describe('UmaClient', (): void => { describe('.deleteResource', (): void => { beforeEach(async(): Promise => { fetcher.fetch.mockResolvedValueOnce(response); + // PAT mock for second fetch call + fetcher.fetch.mockResolvedValueOnce({ + ...response, + status: 201, + json: vi.fn().mockResolvedValueOnce({ access_token: 'pat_token', token_type: 'Bearer', expires_in: 3600 }), + }); }); it('errors if there is no matching UMA identifier.', async(): Promise => { - await expect(client.deleteResource({ path: '/foo' }, issuer)).rejects + await expect(client.deleteResource({ path: '/foo' }, issuer, credentials)).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', { + await expect(client.deleteResource({ path: '/foo' }, issuer, credentials)).resolves.toBeUndefined(); + expect(fetcher.fetch).toHaveBeenCalledTimes(3); + expect(fetcher.fetch).nthCalledWith(3, umaConfig.resource_registration_endpoint + 'umaId', { method: 'DELETE', + headers: { 'Authorization': 'Bearer pat_token' }, }); }); }); diff --git a/packages/css/test/unit/util/OwnerUtil.test.ts b/packages/css/test/unit/util/OwnerUtil.test.ts index ceed754d..c73c8048 100644 --- a/packages/css/test/unit/util/OwnerUtil.test.ts +++ b/packages/css/test/unit/util/OwnerUtil.test.ts @@ -1,9 +1,25 @@ -import { PodStore, ResourceIdentifier, StorageLocationStrategy } from '@solid/community-server'; +import { + AccountLoginStorage, + AccountStore, + PodStore, + ResourceIdentifier, + StorageLocationStrategy, + WEBID_STORAGE_DESCRIPTION, + WEBID_STORAGE_TYPE +} from '@solid/community-server'; import { Mocked } from 'vitest'; +import { + ACCOUNT_SETTINGS_AS_TOKEN, + ACCOUNT_SETTINGS_AUTHZ_SERVER, + UMA_ACCOUNT_STORAGE_TYPE +} from '../../../src/identity/interaction/account/util/AccountSettings'; import { OwnerUtil } from '../../../src/util/OwnerUtil'; describe('OwnerUtil', (): void => { - const umaServerURL = 'http://example.com/'; + const webId = 'webId'; + const accountId = 'accountId'; + const issuer = 'issuer'; + const credentials = 'credentials'; const storage: ResourceIdentifier = { path: 'storage' }; const owners: { webId: string; visible: boolean }[] = [ { webId: 'owner1', visible: true }, @@ -11,11 +27,28 @@ describe('OwnerUtil', (): void => { ]; const basePod: { id: string, accountId: string } = { id: 'basePodId', accountId: 'accountId' }; const resource: ResourceIdentifier = { path: 'resource' }; + let accountStorage: Mocked>; + let accountStore: Mocked>; let podStore: Mocked; let storageStrategy: Mocked; let ownerUtil: OwnerUtil; beforeEach(async(): Promise => { + accountStorage = { + find: vi.fn().mockResolvedValue([{ accountId }]) + } satisfies Partial>> as any; + + accountStore = { + getSetting: vi.fn().mockImplementation((id, setting) => { + if (setting === ACCOUNT_SETTINGS_AUTHZ_SERVER) { + return issuer; + } + if (setting === ACCOUNT_SETTINGS_AS_TOKEN) { + return credentials; + } + }), + } satisfies Partial> as any; + podStore = { findByBaseUrl: vi.fn().mockResolvedValue(basePod), getOwners: vi.fn().mockResolvedValue(owners), @@ -25,7 +58,7 @@ describe('OwnerUtil', (): void => { getStorageIdentifier: vi.fn().mockResolvedValue(storage), }; - ownerUtil = new OwnerUtil(podStore, storageStrategy, umaServerURL); + ownerUtil = new OwnerUtil(accountStorage as any, accountStore, podStore, storageStrategy); }); it('can find the owners of a resource.', async(): Promise => { @@ -85,12 +118,30 @@ describe('OwnerUtil', (): void => { 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'); + it('returns the UMA settings.', async(): Promise => { + await expect (ownerUtil.findUmaSettings(webId)).resolves.toEqual({ issuer, credentials }); + expect(accountStorage.find).toHaveBeenCalledTimes(1); + expect(accountStorage.find).toHaveBeenLastCalledWith(WEBID_STORAGE_TYPE, { webId }); + expect(accountStore.getSetting).toHaveBeenCalledTimes(2); + expect(accountStore.getSetting).toHaveBeenNthCalledWith(1, accountId, ACCOUNT_SETTINGS_AUTHZ_SERVER); + expect(accountStore.getSetting).toHaveBeenNthCalledWith(2, accountId, ACCOUNT_SETTINGS_AS_TOKEN); + }); + + it('errors if no matching account is found for the WebID.', async(): Promise => { + accountStorage.find.mockResolvedValueOnce([]); + await expect(ownerUtil.findUmaSettings(webId)).rejects + .toThrow(`Unable to find an account linked to WebID ${webId}`); + expect(accountStorage.find).toHaveBeenCalledTimes(1); + expect(accountStorage.find).toHaveBeenLastCalledWith(WEBID_STORAGE_TYPE, { webId }); + expect(accountStore.getSetting).toHaveBeenCalledTimes(0); + }); + + it('errors if multiple accounts are found for the WebID.', async(): Promise => { + accountStorage.find.mockResolvedValueOnce([ { id: accountId }, { id: accountId } ]); + await expect(ownerUtil.findUmaSettings(webId)).rejects + .toThrow(`Found multiple accounts linked to WebID ${webId}`); + expect(accountStorage.find).toHaveBeenCalledTimes(1); + expect(accountStorage.find).toHaveBeenLastCalledWith(WEBID_STORAGE_TYPE, { webId }); + expect(accountStore.getSetting).toHaveBeenCalledTimes(0); }); }); diff --git a/packages/uma/config/credentials/validators/http-message.json b/packages/uma/config/credentials/validators/http-message.json new file mode 100644 index 00000000..da88fd28 --- /dev/null +++ b/packages/uma/config/credentials/validators/http-message.json @@ -0,0 +1,11 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld" + ], + "@graph": [ + { + "@id": "urn:uma:default:RequestValidator", + "@type": "HttpMessageValidator" + } + ] +} diff --git a/packages/uma/config/credentials/validators/pat.json b/packages/uma/config/credentials/validators/pat.json new file mode 100644 index 00000000..947fdc66 --- /dev/null +++ b/packages/uma/config/credentials/validators/pat.json @@ -0,0 +1,12 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld" + ], + "@graph": [ + { + "@id": "urn:uma:default:RequestValidator", + "@type": "PatRequestValidator", + "storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" } + } + ] +} diff --git a/packages/uma/config/default.json b/packages/uma/config/default.json index bb22250f..1dbdf4a9 100644 --- a/packages/uma/config/default.json +++ b/packages/uma/config/default.json @@ -6,12 +6,14 @@ ], "import": [ "sai-uma:config/credentials/parsers/default.json", + "sai-uma:config/credentials/validators/pat.json", "sai-uma:config/credentials/verifiers/default.json", "sai-uma:config/dialog/negotiators/default.json", "sai-uma:config/policies/authorizers/default.json", "sai-uma:config/resources/storage/default.json", "sai-uma:config/routes/discovery.json", "sai-uma:config/routes/introspection.json", + "sai-uma:config/routes/client-registration.json", "sai-uma:config/routes/keys.json", "sai-uma:config/routes/resources.json", "sai-uma:config/routes/tickets.json", @@ -126,7 +128,9 @@ { "@id": "urn:uma:default:PermissionRegistrationRoute" }, { "@id": "urn:uma:default:ResourceRegistrationRoute" }, { "@id": "urn:uma:default:ResourceRegistrationOpsRoute" }, - { "@id": "urn:uma:default:IntrospectionRoute" } + { "@id": "urn:uma:default:IntrospectionRoute" }, + { "@id": "urn:uma:default:ClientRegistrationRoute" }, + { "@id": "urn:uma:default:ClientRegistrationIdRoute" } ] } }, diff --git a/packages/uma/config/demo.json b/packages/uma/config/demo.json index 47de4ef2..6171a3e7 100644 --- a/packages/uma/config/demo.json +++ b/packages/uma/config/demo.json @@ -34,7 +34,7 @@ } } ], - "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" } + "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" } } }, { diff --git a/packages/uma/config/policies/authorizers/default.json b/packages/uma/config/policies/authorizers/default.json index 297b7bf9..652b1a41 100644 --- a/packages/uma/config/policies/authorizers/default.json +++ b/packages/uma/config/policies/authorizers/default.json @@ -57,7 +57,7 @@ "@type": "MemoryUCRulesStorage" } }, - "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" } + "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" } } ] } diff --git a/packages/uma/config/routes/client-registration.json b/packages/uma/config/routes/client-registration.json new file mode 100644 index 00000000..12106ad3 --- /dev/null +++ b/packages/uma/config/routes/client-registration.json @@ -0,0 +1,34 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^8.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld" + ], + "@graph": [ + { + "@id": "urn:uma:default:ClientRegistrationHandler", + "@type": "ClientRegistrationRequestHandler", + "credentialParser": { "@id": "urn:uma:default:CredentialParser" }, + "verifier": { "@id": "urn:uma:default:Verifier" }, + "storage": { + "@id": "urn:solid-server:default:ClientRegistrationStorage", + "@type": "WrappedIndexedStorage", + "valueStorage": { "@type": "MemoryMapStorage" }, + "indexStorage": { "@type": "MemoryMapStorage" } + } + }, + { + "@id": "urn:uma:default:ClientRegistrationRoute", + "@type": "HttpHandlerRoute", + "methods": [ "GET", "POST" ], + "handler": { "@id": "urn:uma:default:ClientRegistrationHandler" }, + "path": "/uma/reg/" + }, + { + "@id": "urn:uma:default:ClientRegistrationIdRoute", + "@type": "HttpHandlerRoute", + "methods": [ "DELETE" ], + "handler": { "@id": "urn:uma:default:ClientRegistrationHandler" }, + "path": "/uma/reg/{id}" + } + ] +} diff --git a/packages/uma/config/routes/introspection.json b/packages/uma/config/routes/introspection.json index 7189ab99..c8e21116 100644 --- a/packages/uma/config/routes/introspection.json +++ b/packages/uma/config/routes/introspection.json @@ -9,7 +9,8 @@ "methods": [ "POST" ], "handler": { "@type": "IntrospectionHandler", - "tokenFactory": { "@id": "urn:uma:default:TokenFactory" } + "tokenFactory": { "@id": "urn:uma:default:TokenFactory" }, + "validator": { "@id": "urn:uma:default:RequestValidator" } }, "path": "/uma/introspect" } diff --git a/packages/uma/config/routes/resources.json b/packages/uma/config/routes/resources.json index 5a8dd458..000c2684 100644 --- a/packages/uma/config/routes/resources.json +++ b/packages/uma/config/routes/resources.json @@ -6,8 +6,9 @@ { "@id": "urn:uma:default:ResourceRegistrationHandler", "@type": "ResourceRegistrationRequestHandler", - "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }, - "policies": { "@id": "urn:uma:default:RulesStorage" } + "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }, + "policies": { "@id": "urn:uma:default:RulesStorage" }, + "validator": { "@id": "urn:uma:default:RequestValidator" } }, { "@id": "urn:uma:default:ResourceRegistrationRoute", diff --git a/packages/uma/config/routes/tickets.json b/packages/uma/config/routes/tickets.json index d6ec2c9d..13f78064 100644 --- a/packages/uma/config/routes/tickets.json +++ b/packages/uma/config/routes/tickets.json @@ -11,7 +11,8 @@ "@type": "TicketRequestHandler", "ticketingStrategy": { "@id": "urn:uma:default:TicketingStrategy" }, "ticketStore": { "@id": "urn:uma:default:TicketStore" }, - "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" } + "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }, + "validator": { "@id": "urn:uma:default:RequestValidator" } }, "path": "/uma/ticket" } diff --git a/packages/uma/config/routes/tokens.json b/packages/uma/config/routes/tokens.json index 8d935fe2..0d33d1ac 100644 --- a/packages/uma/config/routes/tokens.json +++ b/packages/uma/config/routes/tokens.json @@ -9,7 +9,10 @@ "methods": [ "POST" ], "handler": { "@type": "TokenRequestHandler", - "negotiator": { "@id": "urn:uma:default:Negotiator" } + "negotiator": { "@id": "urn:uma:default:Negotiator" }, + "storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" }, + "keyGen": { "@id": "urn:uma:default:JwkGenerator" }, + "baseUrl": { "@id": "urn:uma:variables:baseUrl" } }, "path": "/uma/token" } diff --git a/packages/uma/package.json b/packages/uma/package.json index 2cfdf2bc..15298158 100644 --- a/packages/uma/package.json +++ b/packages/uma/package.json @@ -63,6 +63,7 @@ "@solid/access-token-verifier": "^1.2.0", "@solid/community-server": "^8.0.0-alpha.1", "@solidlab/ucp": "workspace:^", + "@types/ms": "^2.1.0", "@types/n3": "^1.16.4", "asynchronous-handlers": "^1.0.2", "componentsjs": "^6.3.0", @@ -71,6 +72,7 @@ "http-message-signatures": "^1.0.4", "jose": "^5.2.2", "logform": "^2.6.0", + "ms": "^2.1.3", "n3": "^1.17.2", "odrl-evaluator": "^0.5.0", "rdf-vocabulary": "^1.0.1", diff --git a/packages/uma/src/dialog/Input.ts b/packages/uma/src/dialog/Input.ts index b11c4e31..768405a5 100644 --- a/packages/uma/src/dialog/Input.ts +++ b/packages/uma/src/dialog/Input.ts @@ -15,6 +15,8 @@ export const DialogInput = ({ rpt: $(string), permissions: $(array(Permission)), // this deviates from UMA, which only has a 'scope' string-array permission: $(array(ODRLPermission)), // this deviates from UMA, which only has a 'scope' string-array + scope: $(string), + refresh_token: $(string), }); /** diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index 9f01f709..4a22dceb 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -48,6 +48,7 @@ export * from './routes/Log'; export * from './routes/VC'; export * from './routes/Contract'; export * from './routes/BaseHandler'; +export * from './routes/ClientRegistration'; // Tickets export * from './ticketing/Ticket'; @@ -74,10 +75,14 @@ export * from './util/http/server/JsonHttpErrorHandler'; export * from './util/http/server/JsonFormHttpHandler'; export * from './util/http/server/NodeHttpRequestResponseHandler'; export * from './util/http/server/RoutedHttpRequestHandler'; +export * from './util/http/validate/HttpMessageValidator'; +export * from './util/http/validate/PatRequestValidator'; +export * from './util/http/validate/RequestValidator'; // Util export * from './util/ConvertUtil'; export * from './util/HttpMessageSignatures'; +export * from './util/RegistrationStore'; export * from './util/Result'; export * from './util/ReType'; diff --git a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts index 3bfeb58b..65f841f9 100644 --- a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts @@ -1,9 +1,8 @@ -import { KeyValueStorage } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; import { ClaimSet } from '../../credentials/ClaimSet'; import { Requirements } from '../../credentials/Requirements'; +import { RegistrationStore } from '../../util/RegistrationStore'; import { Permission } from '../../views/Permission'; -import { ResourceDescription } from '../../views/ResourceDescription'; import { Authorizer } from './Authorizer'; const namespace = (resource: string) => new URL(resource).pathname.split('/')?.[2] ?? ''; @@ -20,12 +19,12 @@ export class NamespacedAuthorizer implements Authorizer { * @param authorizers - A key/value map with the key being the relevant namespace * and the value being the corresponding authorizer to use for that namespace. * @param fallback - Authorizer to use if there is no namespace match. - * @param resourceStore - The key/value store containing the resource registrations. + * @param registrationStore - The key/value store containing the resource registrations. */ constructor( protected authorizers: Record, protected fallback: Authorizer, - protected resourceStore: KeyValueStorage, + protected registrationStore: RegistrationStore, ) {} /** @inheritdoc */ @@ -85,13 +84,13 @@ export class NamespacedAuthorizer implements Authorizer { return; } - const description = await this.resourceStore.get(resourceId); - if (!description) { + const registration = await this.registrationStore.get(resourceId); + if (!registration) { this.logger.warn(`Cannot find a registered resource with id ${resourceId}`); return; } - const resourceIdentifier = description.name; + const resourceIdentifier = registration.description.name; if (!resourceIdentifier) { this.logger.warn(`Resource ${resourceId} has no registered name.`); return diff --git a/packages/uma/src/routes/ClientRegistration.ts b/packages/uma/src/routes/ClientRegistration.ts new file mode 100644 index 00000000..d073e8ab --- /dev/null +++ b/packages/uma/src/routes/ClientRegistration.ts @@ -0,0 +1,158 @@ +import { + BadRequestHttpError, + ConflictHttpError, + createErrorMessage, + IndexedStorage, + InternalServerError, + joinUrl, + MethodNotAllowedHttpError, + NotFoundHttpError, + UnauthorizedHttpError +} from '@solid/community-server'; +import { getLoggerFor } from 'global-logger-factory'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { WEBID } from '../credentials/Claims'; +import { CredentialParser } from '../credentials/CredentialParser'; +import { Verifier } from '../credentials/verify/Verifier'; +import { + HttpHandler, + HttpHandlerContext, + HttpHandlerRequest, + HttpHandlerResponse +} from '../util/http/models/HttpHandler'; +import { optional as $, reType, string, Type } from '../util/ReType'; + +export const ClientRegistrationInput = { + client_name: $(string), + client_uri: string, +}; + +export type ClientRegistrationInput = Type; + +export const CLIENT_REGISTRATION_STORAGE_TYPE = 'clientRegistration'; +export const CLIENT_REGISTRATION_STORAGE_DESCRIPTION = { + clientName: 'string?', + clientUri: 'string', + clientId: 'string', + clientSecret: 'string', + userId: 'string', +} as const; + +/** + * Allows the registration of clients. + * This is part of the PAT story. + * The idea is that a user registers their RS through this API, + * and then passes the resulting id and secret to the RS to be used for PAT generation. + */ +export class ClientRegistrationRequestHandler extends HttpHandler { + protected readonly logger = getLoggerFor(this); + private readonly storage: IndexedStorage<{ + [CLIENT_REGISTRATION_STORAGE_TYPE]: typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + }>; + + public constructor( + protected readonly credentialParser: CredentialParser, + protected readonly verifier: Verifier, + storage: IndexedStorage>, + ) { + super(); + this.storage = storage; + this.initializeStorage(); + } + + protected async initializeStorage(): Promise { + await this.storage.defineType(CLIENT_REGISTRATION_STORAGE_TYPE, CLIENT_REGISTRATION_STORAGE_DESCRIPTION); + await this.storage.createIndex(CLIENT_REGISTRATION_STORAGE_TYPE, 'userId'); + await this.storage.createIndex(CLIENT_REGISTRATION_STORAGE_TYPE, 'clientId'); + } + + public async handle({ request }: HttpHandlerContext): Promise { + const credential = await this.credentialParser.handleSafe(request); + const claims = await this.verifier.verify(credential); + const userId = claims[WEBID]; + + if (typeof userId !== 'string') { + throw new UnauthorizedHttpError(); + } + + switch (request.method) { + case 'GET': return this.getClients(request, userId); + case 'POST': return this.registerClient(request, userId); + case 'DELETE': return this.deleteClient(request, userId); + default: throw new MethodNotAllowedHttpError([ request.method ]); + } + } + + protected async getClients(request: HttpHandlerRequest, userId: string): Promise { + const results = await this.storage.find(CLIENT_REGISTRATION_STORAGE_TYPE, { userId }); + // Filter out secrets + const body = results.map((result) => ({ + name: result.clientName, + uri: result.clientUri, + id: result.clientId, + })); + return { + status: 200, + body, + }; + } + + protected async registerClient(request: HttpHandlerRequest, userId: string): Promise { + try { + reType(request.body, ClientRegistrationInput); + } catch (e) { + this.logger.warn(`Syntax error: ${createErrorMessage(e)}, ${request.body}`); + throw new BadRequestHttpError(`Request has bad syntax: ${createErrorMessage(e)}`); + } + + const match = await this.storage.findIds( + CLIENT_REGISTRATION_STORAGE_TYPE, { userId, clientUri: request.body.client_uri }); + if (match.length > 0) { + throw new ConflictHttpError(`${request.body.client_uri} is already registered for ${userId}`); + } + + const clientId = randomUUID(); + const clientSecret = randomBytes(64).toString('hex'); + await this.storage.create( + CLIENT_REGISTRATION_STORAGE_TYPE, + { + userId, + clientUri: request.body.client_uri, + clientName: request.body.client_name, + clientId, + clientSecret, + } + ); + + return { + status: 201, + headers: { location: `${joinUrl(request.url.href, encodeURIComponent(clientId))}` }, + body: { + client_uri: request.body.client_uri, + client_name: request.body.client_name, + client_id: clientId, + client_secret: clientSecret, + client_secret_expires_at: '0', + grant_types: [ 'client_credentials', 'refresh_token' ], + token_endpoint_auth_method: 'client_secret_basic', + } + } + } + + protected async deleteClient(request: HttpHandlerRequest, userId: string): Promise { + if (typeof request.parameters?.id !== 'string') { + throw new InternalServerError('URI for DELETE operation should include an id.'); + } + + const matches = await this.storage.findIds(CLIENT_REGISTRATION_STORAGE_TYPE, { clientId: request.parameters.id }); + if (matches.length === 0) { + throw new NotFoundHttpError(); + } + + await this.storage.delete(CLIENT_REGISTRATION_STORAGE_TYPE, matches[0]); + + return { status: 204 }; + } +} + +export default ClientRegistrationRequestHandler diff --git a/packages/uma/src/routes/Config.ts b/packages/uma/src/routes/Config.ts index 09c52e49..cb7ecb53 100644 --- a/packages/uma/src/routes/Config.ts +++ b/packages/uma/src/routes/Config.ts @@ -21,13 +21,14 @@ export type OAuthConfiguration = { dpop_signing_alg_values_supported?: string[], response_types_supported?: ResponseType[] scopes_supported?: string[] + registration_endpoint?: string, } export type UmaConfiguration = OAuthConfiguration & { uma_profiles_supported: string[], resource_registration_endpoint: string, permission_endpoint: string, - introspection_endpoint: string + introspection_endpoint: string, } /** @@ -71,6 +72,7 @@ export class ConfigRequestHandler extends HttpHandler { 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], + registration_endpoint: joinUrl(this.baseUrl, 'reg/'), }; } } diff --git a/packages/uma/src/routes/Introspection.ts b/packages/uma/src/routes/Introspection.ts index ff542892..5de73274 100644 --- a/packages/uma/src/routes/Introspection.ts +++ b/packages/uma/src/routes/Introspection.ts @@ -1,9 +1,8 @@ import { BadRequestHttpError, UnauthorizedHttpError } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; -import { ClaimSet } from '../credentials/ClaimSet'; import { TokenFactory } from '../tokens/TokenFactory'; import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler'; -import { verifyRequest } from '../util/HttpMessageSignatures'; +import { RequestValidator } from '../util/http/validate/RequestValidator'; type IntrospectionResponse = { active : boolean, @@ -16,7 +15,6 @@ type IntrospectionResponse = { nbf?: number, } - /** * An HTTP handler that provides introspection into opaque access tokens. */ @@ -27,15 +25,17 @@ export class IntrospectionHandler extends HttpHandler { * Creates an introspection handler for tokens in the given token store. * * @param tokenFactory - The factory with which tokens were produced. + * @param validator - Verifies the validity of the request. */ constructor( private readonly tokenFactory: TokenFactory, + private readonly validator: RequestValidator, ) { super(); } - async handle({request}: HttpHandlerContext): Promise> { - if (!await verifyRequest(request)) throw new UnauthorizedHttpError(); + public async handle({ request }: HttpHandlerContext): Promise> { + await this.validator.handleSafe({ request }); if (!request.body) { throw new BadRequestHttpError('Missing request body.'); diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts index 3a4f56bc..9dd3a4ca 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -2,16 +2,15 @@ import { BadRequestHttpError, ConflictHttpError, createErrorMessage, + ForbiddenHttpError, InternalServerError, joinUrl, - KeyValueStorage, MethodNotAllowedHttpError, NotFoundHttpError, - UnauthorizedHttpError, } from '@solid/community-server'; import { ODRL, ODRL_P, OWL, RDF, UCRulesStorage } from '@solidlab/ucp'; import { getLoggerFor } from 'global-logger-factory'; -import { DataFactory as DF, NamedNode, Quad, Quad_Object, Quad_Subject, Store } from 'n3'; +import { DataFactory as DF, NamedNode, Quad, Quad_Subject, Store } from 'n3'; import { randomUUID } from 'node:crypto'; import { HttpHandler, @@ -19,7 +18,8 @@ import { HttpHandlerRequest, HttpHandlerResponse } from '../util/http/models/HttpHandler'; -import { extractRequestSigner, verifyRequest } from '../util/HttpMessageSignatures'; +import { RequestValidator } from '../util/http/validate/RequestValidator'; +import { RegistrationStore } from '../util/RegistrationStore'; import { reType } from '../util/ReType'; import { ResourceDescription } from '../views/ResourceDescription'; @@ -38,34 +38,30 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { protected readonly logger = getLoggerFor(this); /** - * @param resourceStore - Key/value store containing the {@link ResourceDescription}s. + * @param registrationStore - Key/value store containing the {@link ResourceDescription}s. * @param policies - Policy store to contain the asset relation triples. + * @param validator - Validates that the request is valid. */ constructor( - private readonly resourceStore: KeyValueStorage, - private readonly policies: UCRulesStorage, + protected readonly registrationStore: RegistrationStore, + protected readonly policies: UCRulesStorage, + protected readonly validator: RequestValidator, ) { super(); } public async handle({ request }: HttpHandlerContext): Promise> { - const signer = await extractRequestSigner(request); - - // TODO: check if signer is actually the correct one - - if (!await verifyRequest(request, signer)) { - throw new UnauthorizedHttpError(`Failed to verify signature of <${signer}>`); - } + const { owner } = await this.validator.handleSafe({ request }); switch (request.method) { - case 'POST': return this.handlePost(request); - case 'PUT': return this.handlePut(request); - case 'DELETE': return this.handleDelete(request); + case 'POST': return this.handlePost(request, owner); + case 'PUT': return this.handlePut(request, owner); + case 'DELETE': return this.handleDelete(request, owner); default: throw new MethodNotAllowedHttpError([ request.method ]); } } - protected async handlePost(request: HttpHandlerRequest): Promise { + protected async handlePost(request: HttpHandlerRequest, owner: string): Promise { const { body } = request; try { @@ -79,7 +75,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { // Reason being that there is not yet a good way to determine what the identifier would be when writing policies. let resource = body.name; if (resource) { - if (await this.resourceStore.has(resource)) { + if (await this.registrationStore.has(resource)) { throw new ConflictHttpError( `A resource with name ${resource} is already registered. Use PUT to update existing registrations.`, ); @@ -90,7 +86,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { } // Set the resource metadata - await this.setResourceMetadata(resource, body); + await this.setResourceMetadata(resource, body, owner); return ({ status: 201, @@ -102,15 +98,20 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { }); } - protected async handlePut({ body, parameters }: HttpHandlerRequest): Promise { + protected async handlePut({ body, parameters }: HttpHandlerRequest, owner: string): Promise { if (typeof parameters?.id !== 'string') { throw new InternalServerError('URI for PUT operation should include an id.'); } - if (!await this.resourceStore.has(parameters.id)) { + const entry = await this.registrationStore.get(parameters.id); + if (!entry) { throw new NotFoundHttpError(); } + if (entry.owner !== owner) { + throw new ForbiddenHttpError(`${owner} is not the owner of this resource.`); + } + try { reType(body, ResourceDescription); } catch (e) { @@ -119,7 +120,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { } // Update the resource metadata - await this.setResourceMetadata(parameters.id, body); + await this.setResourceMetadata(parameters.id, body, owner); return ({ status: 200, @@ -130,16 +131,21 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { }); } - protected async handleDelete({ parameters }: HttpHandlerRequest): Promise { + protected async handleDelete({ parameters }: HttpHandlerRequest, owner: string): Promise { if (typeof parameters?.id !== 'string') { throw new InternalServerError('URI for DELETE operation should include an id.'); } - if (!await this.resourceStore.has(parameters.id)) { + const entry = await this.registrationStore.get(parameters.id); + if (!entry) { throw new NotFoundHttpError('Registration to be deleted does not exist (id unknown).'); } - await this.resourceStore.delete(parameters.id); + if (entry.owner !== owner) { + throw new ForbiddenHttpError(`${owner} is not the owner of this resource.`); + } + + await this.registrationStore.delete(parameters.id); this.logger.info(`Deleted resource ${parameters.id}.`); return ({ status: 204 }); @@ -149,8 +155,9 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { * Updates all asset collection and relation metadata for the given resource based on an updated description. * @param id - The identifier of the resource. * @param description - The new {@link ResourceDescription} for the resource. + * @param owner - The owner of the resource. */ - protected async setResourceMetadata(id: string, description: ResourceDescription): Promise { + protected async setResourceMetadata(id: string, description: ResourceDescription, owner: string): Promise { const policyStore = await this.policies.getStore(); const collectionQuads = await this.updateCollections(policyStore, id, description); const relationQuads = await this.updateRelations(policyStore, id, description); @@ -166,7 +173,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { // Store the new UMA ID (or update the contents of the existing one) // Note that we only do this after generating and updating the relation metadata, // as errors could be thrown there. - await this.resourceStore.set(id, description); + await this.registrationStore.set(id, { description, owner }); this.logger.info(`Updated registration for ${id}.`); } diff --git a/packages/uma/src/routes/Ticket.ts b/packages/uma/src/routes/Ticket.ts index f8bdb5f2..49b2a0cb 100644 --- a/packages/uma/src/routes/Ticket.ts +++ b/packages/uma/src/routes/Ticket.ts @@ -9,10 +9,10 @@ import { randomUUID } from 'node:crypto'; import { TicketingStrategy } from '../ticketing/strategy/TicketingStrategy'; import { Ticket } from '../ticketing/Ticket'; import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler'; -import { verifyRequest } from '../util/HttpMessageSignatures'; +import { RequestValidator } from '../util/http/validate/RequestValidator'; +import { RegistrationStore } from '../util/RegistrationStore'; import { array, reType } from '../util/ReType'; import { Permission } from '../views/Permission'; -import { ResourceDescription } from '../views/ResourceDescription'; /** * A TicketRequestHandler is tasked with implementing @@ -26,14 +26,15 @@ export class TicketRequestHandler extends HttpHandler { constructor( protected readonly ticketingStrategy: TicketingStrategy, protected readonly ticketStore: KeyValueStorage, - protected readonly resourceStore: KeyValueStorage, + protected readonly registrationStore: RegistrationStore, + protected readonly validator: RequestValidator, ) { super(); } async handle({request}: HttpHandlerContext): Promise> { this.logger.info(`Received permission registration request.`); - if (!await verifyRequest(request)) throw new UnauthorizedHttpError(); + await this.validator.handleSafe({ request }); try { reType(request.body, array(Permission)); @@ -44,7 +45,7 @@ export class TicketRequestHandler extends HttpHandler { for (const { resource_id } of request.body) { // https://docs.kantarainitiative.org/uma/wg/rec-oauth-uma-federated-authz-2.0.html#rfc.section.4.3 - if (!await this.resourceStore.has(resource_id)) { + if (!await this.registrationStore.has(resource_id)) { return { status: 400, body: { diff --git a/packages/uma/src/routes/Token.ts b/packages/uma/src/routes/Token.ts index fa1a6bea..6f8407fb 100644 --- a/packages/uma/src/routes/Token.ts +++ b/packages/uma/src/routes/Token.ts @@ -1,24 +1,67 @@ -import { BadRequestHttpError } from '@solid/community-server'; +import { + BadRequestHttpError, + ForbiddenHttpError, + IndexedStorage, + JwkGenerator, + matchesAuthorizationScheme, + TypeObject, + UnauthorizedHttpError +} from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; +import { importJWK, SignJWT } from 'jose'; +import ms, { StringValue } from 'ms'; +import { randomUUID } from 'node:crypto'; import { DialogInput } from '../dialog/Input'; import { Negotiator } from '../dialog/Negotiator'; import { NeedInfoError } from '../errors/NeedInfoError'; import { HttpHandler, HttpHandlerContext, HttpHandlerResponse } from '../util/http/models/HttpHandler'; import { reType } from '../util/ReType'; +import { CLIENT_REGISTRATION_STORAGE_DESCRIPTION, CLIENT_REGISTRATION_STORAGE_TYPE } from './ClientRegistration'; + +export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; +export const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'; +export const GRANT_TYPE_UMA_TICKET = 'urn:ietf:params:oauth:grant-type:uma-ticket'; + +export const PAT_STORAGE_TYPE = 'pat'; +export const PAT_STORAGE_DESCRIPTION = { + pat: 'string', + expiration: 'number', + refreshToken: 'string', + registration: `id:${CLIENT_REGISTRATION_STORAGE_TYPE}`, +} as const; /** * The TokenRequestHandler implements the interface of the UMA Token Endpoint. */ export class TokenRequestHandler extends HttpHandler { protected readonly logger = getLoggerFor(this); + protected readonly tokenExpiration: number; + private readonly storage: IndexedStorage<{ + [CLIENT_REGISTRATION_STORAGE_TYPE]: typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + [PAT_STORAGE_TYPE]: typeof PAT_STORAGE_DESCRIPTION, + }>; constructor( protected negotiator: Negotiator, + storage: IndexedStorage>, + protected readonly keyGen: JwkGenerator, + protected readonly baseUrl: string, + tokenExpiration: string = '30m', ) { super(); + this.tokenExpiration = Math.floor(ms(tokenExpiration as StringValue)/1000); + this.storage = storage; + this.initializeStorage(); + } + + protected async initializeStorage(): Promise { + await this.storage.defineType(PAT_STORAGE_TYPE, PAT_STORAGE_DESCRIPTION); + await this.storage.createIndex(PAT_STORAGE_TYPE, 'refreshToken'); + await this.storage.createIndex(PAT_STORAGE_TYPE, 'pat'); + await this.storage.createIndex(PAT_STORAGE_TYPE, 'registration'); } - async handle(input: HttpHandlerContext): Promise> { + public async handle(input: HttpHandlerContext): Promise> { this.logger.info(`Received token request.`); const params = input.request.body; @@ -28,10 +71,15 @@ export class TokenRequestHandler extends HttpHandler { throw new BadRequestHttpError(`Invalid token request body: ${e instanceof Error ? e.message : ''}`); } - 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'`); + switch (params.grant_type) { + case GRANT_TYPE_CLIENT_CREDENTIALS: return this.handlePatRequest(params, input.request.headers.authorization); + case GRANT_TYPE_REFRESH_TOKEN: return this.handleRefreshRequest(params, input.request.headers.authorization); + case GRANT_TYPE_UMA_TICKET: return this.handleUmaGrant(params); + default: throw new BadRequestHttpError(`Unsupported grant_type ${params.grant_type}`); } + } + protected async handleUmaGrant(params: DialogInput): Promise> { try { const tokenResponse = await this.negotiator.negotiate(params); @@ -50,4 +98,88 @@ export class TokenRequestHandler extends HttpHandler { throw e; // TODO: distinguish other errors } } + + protected async handlePatRequest(params: DialogInput, authorization?: string): Promise> { + const registration = await this.handlePreliminaryPatChecks(params, authorization); + // If there already is a stored token: reuse the ID + const matches = await this.storage.findIds(PAT_STORAGE_TYPE, { registration: registration.id }); + return this.generateToken(registration, matches.length > 0 ? matches[0] : undefined); + } + + protected async handleRefreshRequest(params: DialogInput, authorization?: string): Promise> { + if (!params.refresh_token) { + throw new BadRequestHttpError(`Missing refresh_token parameter`); + } + + const pats = await this.storage.find(PAT_STORAGE_TYPE, { refreshToken: params.refresh_token }); + if (pats.length === 0) { + throw new ForbiddenHttpError(`Unknown refresh token ${params.refresh_token}`); + } + const registration = await this.handlePreliminaryPatChecks(params, authorization); + if (registration.id !== pats[0].registration) { + throw new ForbiddenHttpError(`Wrong credentials for refresh token ${params.refresh_token}`); + } + + return this.generateToken(registration, pats[0].id); + } + + // Returns the UserId if there is a match, or throws an error + protected async handlePreliminaryPatChecks(params: DialogInput, authorization?: string): + Promise> { + if (typeof authorization !== 'string') { + throw new UnauthorizedHttpError(); + } + if (params.scope !== 'uma_protection') { + throw new BadRequestHttpError(`Expected scope 'uma_protection'`); + } + if (!matchesAuthorizationScheme('Basic', authorization)) { + throw new BadRequestHttpError(`Expected scheme 'Basic'`); + } + const decoded = Buffer.from(authorization.split(' ')[1], 'base64').toString('utf8'); + const [ id, secret ] = decoded.split(':'); + const match = await this.storage.find(CLIENT_REGISTRATION_STORAGE_TYPE, + { clientId: decodeURIComponent(id), clientSecret: decodeURIComponent(secret ?? '') }); + if (match.length === 0) { + throw new ForbiddenHttpError(); + } + return match[0]; + } + + protected async generateToken(registration: TypeObject, id?: string): + Promise> { + const refresh_token = randomUUID(); + const expiration = Date.now() + this.tokenExpiration * 1000; + const key = await this.keyGen.getPrivateKey(); + const jwk = await importJWK(key, key.alg); + const pat = await new SignJWT({ + scope: 'uma_protection', + azp: registration.clientId, + client_id: registration.clientId + }).setProtectedHeader({ alg: key.alg, kid: key.kid }) + .setIssuedAt() + .setSubject(registration.userId) + .setIssuer(this.baseUrl) + .setAudience(this.baseUrl) + .setExpirationTime(Math.floor(expiration/1000)) + .setJti(randomUUID()) + .sign(jwk); + + const body = { pat, refreshToken: refresh_token, expiration, registration: registration.id }; + if (id) { + await this.storage.set(PAT_STORAGE_TYPE, { id, ...body }); + } else { + await this.storage.create(PAT_STORAGE_TYPE, body); + } + + return { + status: 201, + body: { + access_token: pat, + refresh_token, + token_type: 'Bearer', + expires_in: this.tokenExpiration, + scope: 'uma_protection', + } + } + } } diff --git a/packages/uma/src/util/HttpMessageSignatures.ts b/packages/uma/src/util/HttpMessageSignatures.ts index 44b80142..c04aed9b 100644 --- a/packages/uma/src/util/HttpMessageSignatures.ts +++ b/packages/uma/src/util/HttpMessageSignatures.ts @@ -1,13 +1,7 @@ -import { type AlgJwk, BadRequestHttpError, UnauthorizedHttpError } from '@solid/community-server'; -import buildGetJwks from 'get-jwks'; +import { type AlgJwk } from '@solid/community-server'; import { httpbis, type Request as SignRequest, type SigningKey } 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 crypto, { webcrypto } from 'node:crypto'; import { BufferSource } from 'node:stream/web'; -import { HttpHandlerRequest } from './http/models/HttpHandler'; - -const authParserMod = import('@httpland/authorization-parser'); export async function signRequest( url: string, @@ -27,73 +21,9 @@ export async function signRequest( return await httpbis.signMessage({ key, fields: [ '@target-uri', '@method' ] }, { ...request, url }); } -export async function extractRequestSigner(request: HttpHandlerRequest): Promise { - const { authorization } = request.headers; - if (!authorization) { - throw new UnauthorizedHttpError('Missing authorization header in request.'); - } - - const { authScheme, params } = (await authParserMod).parseAuthorization(authorization); - if (authScheme !== 'HttpSig') { - throw new UnauthorizedHttpError(); - } - - if (!params || typeof params !== 'object' || !params.cred) { - throw new UnauthorizedHttpError(); - } - - return params.cred; -} - -export async function verifyRequest( - request: HttpHandlerRequest & SignRequest, - signer?: string, -): Promise { - signer = signer ?? await extractRequestSigner(request); - - if (signer.startsWith('"')) signer = signer.slice(1); - if (signer.endsWith('"')) signer = signer.slice(0,-1); - - const jwks = buildGetJwks(); - - const keyLookup: VerifierFinder = async (params: SignatureParameters) => { - const { alg, keyid } = params; - - try { - const jwk = await jwks.getJwk({ - domain: signer!, - alg: alg ?? '', - kid: keyid ?? '', - }); - - if (!alg) throw new BadRequestHttpError('Invalid HTTP message Signature parameters.'); - - const verifier: VerifyingKey = { - id: keyid, - algs: alg ? [ alg ] : [], - async verify(data: Buffer, signature: Buffer) { - try { - const params = algMap[alg]; - const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['verify']); - return await crypto.subtle.verify(params, key, signature, data); - } catch (err) { console.log(err); return null } - }, - }; - - return verifier; - - } catch (err) { - throw new Error(`Something went wrong during signature checking: ${err.message}`) - } - }; - - const verified = await verifyMessage({ keyLookup }, request); - return verified ?? false; -} - type AlgParams = webcrypto.RsaHashedImportParams | webcrypto.EcKeyImportParams | webcrypto.HmacImportParams -const algMap: Record = { +export const algMap: Record = { 'ES256': { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' }, 'ES384': { name: 'ECDSA', hash: 'SHA-384', namedCurve: 'P-384' }, 'ES512': { name: 'ECDSA', hash: 'SHA-512', namedCurve: 'P-512' }, diff --git a/packages/uma/src/util/RegistrationStore.ts b/packages/uma/src/util/RegistrationStore.ts new file mode 100644 index 00000000..e194f73d --- /dev/null +++ b/packages/uma/src/util/RegistrationStore.ts @@ -0,0 +1,9 @@ +import { KeyValueStorage } from '@solid/community-server'; +import { ResourceDescription } from '../views/ResourceDescription'; + +export type Registration = { + owner: string, + description: ResourceDescription, +} + +export type RegistrationStore = KeyValueStorage; diff --git a/packages/uma/src/util/http/validate/HttpMessageValidator.ts b/packages/uma/src/util/http/validate/HttpMessageValidator.ts new file mode 100644 index 00000000..cb421424 --- /dev/null +++ b/packages/uma/src/util/http/validate/HttpMessageValidator.ts @@ -0,0 +1,88 @@ +import { BadRequestHttpError, UnauthorizedHttpError } from '@solid/community-server'; +import buildGetJwks from 'get-jwks'; +import { verifyMessage } from 'http-message-signatures/lib/httpbis'; +import { SignatureParameters, VerifierFinder, VerifyingKey } from 'http-message-signatures/lib/types'; +import crypto from 'node:crypto'; +import { algMap } from '../../HttpMessageSignatures'; +import { HttpHandlerRequest } from '../models/HttpHandler'; +import { RequestValidator, RequestValidatorInput, RequestValidatorOutput } from './RequestValidator'; + +const authParserMod = import('@httpland/authorization-parser'); + +/** + * Validates requests using HTTP Message Signatures. + * This validator can not differentiate between individual owners + * and returns the server who signed the request as owner instead. + */ +export class HttpMessageValidator extends RequestValidator { + public async handle(input: RequestValidatorInput): Promise { + const signer = await this.extractRequestSigner(input.request); + if (!await this.verifyRequest(input.request, signer)) { + throw new UnauthorizedHttpError('Failed to verify signature'); + } + return { owner: signer }; + } + + protected async extractRequestSigner(request: HttpHandlerRequest): Promise { + const { authorization } = request.headers; + if (!authorization) { + throw new UnauthorizedHttpError('Missing authorization header in request.'); + } + + const { authScheme, params } = (await authParserMod).parseAuthorization(authorization); + if (authScheme !== 'HttpSig') { + throw new UnauthorizedHttpError(); + } + + if (!params || typeof params !== 'object' || !params.cred) { + throw new UnauthorizedHttpError(); + } + + let signer = params.cred; + if (signer.startsWith('"')) signer = signer.slice(1); + if (signer.endsWith('"')) signer = signer.slice(0,-1); + + return signer; + } + + protected async verifyRequest( + request: HttpHandlerRequest, + signer: string, + ): Promise { + const jwks = buildGetJwks(); + + const keyLookup: VerifierFinder = async (params: SignatureParameters) => { + const { alg, keyid } = params; + + try { + const jwk = await jwks.getJwk({ + domain: signer!, + alg: alg ?? '', + kid: keyid ?? '', + }); + + if (!alg) throw new BadRequestHttpError('Invalid HTTP message Signature parameters.'); + + const verifier: VerifyingKey = { + id: keyid, + algs: alg ? [ alg ] : [], + async verify(data: Buffer, signature: Buffer) { + try { + const params = algMap[alg]; + const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['verify']); + return await crypto.subtle.verify(params, key, signature, data); + } catch (err) { console.log(err); return null } + }, + }; + + return verifier; + + } catch (err) { + throw new Error(`Something went wrong during signature checking: ${err.message}`) + } + }; + + const verified = await verifyMessage({ keyLookup }, request); + return verified ?? false; + } +} diff --git a/packages/uma/src/util/http/validate/PatRequestValidator.ts b/packages/uma/src/util/http/validate/PatRequestValidator.ts new file mode 100644 index 00000000..58a8700a --- /dev/null +++ b/packages/uma/src/util/http/validate/PatRequestValidator.ts @@ -0,0 +1,51 @@ +import { + ForbiddenHttpError, + IndexedStorage, + InternalServerError, + UnauthorizedHttpError +} from '@solid/community-server'; +import { + CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + CLIENT_REGISTRATION_STORAGE_TYPE +} from '../../../routes/ClientRegistration'; +import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../routes/Token'; +import { RequestValidator, RequestValidatorInput, RequestValidatorOutput } from './RequestValidator'; + +/** + * Validates requests by verifying if the PAT is registered. + */ +export class PatRequestValidator extends RequestValidator { + private readonly storage: IndexedStorage<{ + [CLIENT_REGISTRATION_STORAGE_TYPE]: typeof CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + [PAT_STORAGE_TYPE]: typeof PAT_STORAGE_DESCRIPTION, + }>; + + public constructor( + storage: IndexedStorage>, + ) { + super(); + this.storage = storage; + } + + public async handle({ request }: RequestValidatorInput): Promise { + const { authorization } = request.headers; + if (!authorization || !/^Bearer /ui.test(authorization)) { + throw new UnauthorizedHttpError('No Bearer Authorization header specified.'); + } + + const token = authorization?.replace(/^Bearer/, '')?.trimStart(); + const patEntries = await this.storage.find(PAT_STORAGE_TYPE, { pat: token }); + if (patEntries.length === 0) { + throw new ForbiddenHttpError('Unknown PAT.'); + } + if (patEntries[0].expiration < Date.now()) { + throw new ForbiddenHttpError('Expired PAT.'); + } + const registration = await this.storage.get(CLIENT_REGISTRATION_STORAGE_TYPE, patEntries[0].registration); + if (!registration) { + throw new InternalServerError('Unable to find matching client for PAT.'); + } + + return { owner: registration.userId }; + } +} diff --git a/packages/uma/src/util/http/validate/RequestValidator.ts b/packages/uma/src/util/http/validate/RequestValidator.ts new file mode 100644 index 00000000..4b2545d9 --- /dev/null +++ b/packages/uma/src/util/http/validate/RequestValidator.ts @@ -0,0 +1,16 @@ +import { AsyncHandler } from 'asynchronous-handlers'; +import { HttpHandlerRequest } from '../models/HttpHandler'; + +export interface RequestValidatorInput { + request: HttpHandlerRequest, +} + +export interface RequestValidatorOutput { + owner: string; +} + +/** + * Validates if a request is valid. + * Returns the associated owner that performed this request. + */ +export abstract class RequestValidator extends AsyncHandler {} diff --git a/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts index 270e0502..ef5e0d22 100644 --- a/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts +++ b/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts @@ -1,16 +1,15 @@ -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'; +import { Registration, RegistrationStore } from '../../../../src/util/RegistrationStore'; describe('NamespacedAuthorizer', (): void => { const claims: ClaimSet = { claim: 'set' }; let authorizers: Record>; let fallback: Mocked; - let resourceStore: Mocked>; + let registrationStore: Mocked; let authorizer: NamespacedAuthorizer; beforeEach(async(): Promise => { @@ -21,16 +20,16 @@ describe('NamespacedAuthorizer', (): void => { 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' }, + const descriptions: Record = { + res1: { description: { name: 'http://example.com/foo/ns1/res', resource_scopes: [] }, owner: 'owner1' }, + res2: { description: { name: 'http://example.com/foo/ns2/res', resource_scopes: [] }, owner: 'owner2' }, + res3: { description: { name: 'http://example.com/foo/ns3/res', resource_scopes: [] }, owner: 'owner3' }, } - resourceStore = { + registrationStore = { get: vi.fn((id: string): any => descriptions[id]), - } satisfies Partial> as any; + } satisfies Partial as any; - authorizer = new NamespacedAuthorizer(authorizers, fallback, resourceStore); + authorizer = new NamespacedAuthorizer(authorizers, fallback, registrationStore); }); describe('.permissions', (): void => { diff --git a/packages/uma/test/unit/routes/ClientRegistration.test.ts b/packages/uma/test/unit/routes/ClientRegistration.test.ts new file mode 100644 index 00000000..bb9dd543 --- /dev/null +++ b/packages/uma/test/unit/routes/ClientRegistration.test.ts @@ -0,0 +1,163 @@ +import { + BadRequestHttpError, + ConflictHttpError, + IndexedStorage, InternalServerError, + joinUrl, NotFoundHttpError, + UnauthorizedHttpError +} from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { WEBID } from '../../../src/credentials/Claims'; +import { CredentialParser } from '../../../src/credentials/CredentialParser'; +import { Verifier } from '../../../src/credentials/verify/Verifier'; +import ClientRegistrationRequestHandler, { + CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + CLIENT_REGISTRATION_STORAGE_TYPE +} from '../../../src/routes/ClientRegistration'; +import { HttpHandlerRequest } from '../../../src/util/http/models/HttpHandler'; +import * as crypto from 'node:crypto'; + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(), + randomBytes: vi.fn(), +})); + +describe('ClientRegistration', (): void => { + const token = 'token'; + const format = 'format'; + const webId = 'webId'; + let request: HttpHandlerRequest; + let credentialParser: Mocked; + let verifier: Mocked; + let storage: Mocked>; + let handler: ClientRegistrationRequestHandler; + + beforeEach(async(): Promise => { + request = { + method: 'GET', + } satisfies Partial as any; + + credentialParser = { + handleSafe: vi.fn().mockResolvedValue({ token, format }), + } satisfies Partial as any; + + verifier = { + verify: vi.fn().mockResolvedValue({ [WEBID]: webId }), + }; + + storage = { + defineType: vi.fn(), + createIndex: vi.fn(), + find: vi.fn(), + findIds: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + } as any; + + handler = new ClientRegistrationRequestHandler(credentialParser, verifier, storage as any); + }); + + it('errors if no valid credentials are provided.', async(): Promise => { + verifier.verify.mockResolvedValueOnce({}); + await expect(handler.handle({ request })).rejects.toThrow(UnauthorizedHttpError); + }); + + it('returns all registered clients on GET requests.', async(): Promise => { + storage.find.mockResolvedValueOnce([ + { clientName: 'client1', clientUri: 'uri1', clientId: 'id1', clientSecret: 'secret1', userId: webId }, + { clientUri: 'uri2', clientId: 'id2', clientSecret: 'secret2', userId: webId }, + ]); + await expect(handler.handle({ request })).resolves.toEqual({ + status: 200, + body: [ + { name: 'client1', uri: 'uri1', id: 'id1' }, + { uri: 'uri2', id: 'id2' }, + ] + }); + }); + + it('registers a client on POST requests.', async(): Promise => { + request.method = 'POST'; + request.body = { + client_name: 'name', + client_uri: 'uri', + }; + request.url = new URL('http://example.com/'); + + storage.findIds.mockResolvedValueOnce([]); + vi.spyOn(crypto, 'randomUUID').mockReturnValueOnce('0000-1111-2222-3333-4444'); + vi.spyOn(crypto, 'randomBytes').mockReturnValueOnce(Buffer.from('abc') as any); + + await expect(handler.handle({ request })).resolves.toEqual({ + status: 201, + headers: { location: `http://example.com/0000-1111-2222-3333-4444` }, + body: { + client_uri: 'uri', + client_name: 'name', + client_id: '0000-1111-2222-3333-4444', + client_secret: '616263', + client_secret_expires_at: '0', + grant_types: [ 'client_credentials', 'refresh_token' ], + token_endpoint_auth_method: 'client_secret_basic', + } + }); + expect(storage.findIds).toHaveBeenCalledTimes(1); + expect(storage.findIds).toHaveBeenLastCalledWith( + CLIENT_REGISTRATION_STORAGE_TYPE, { userId: webId, clientUri: 'uri' }); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, { + userId: webId, + clientUri: 'uri', + clientName: 'name', + clientId: '0000-1111-2222-3333-4444', + clientSecret: '616263', + }); + }); + + it('requires valid input when registering.', async(): Promise => { + request.method = 'POST'; + request.body = {}; + await expect(handler.handle({ request })).rejects.toThrow(BadRequestHttpError); + expect(storage.create).toHaveBeenCalledTimes(0); + }); + + it('errors if a client is already registered.', async(): Promise => { + request.method = 'POST'; + request.body = { + client_name: 'name', + client_uri: 'uri', + }; + storage.findIds.mockResolvedValueOnce([ 'match' ]); + await expect(handler.handle({ request })).rejects.toThrow(ConflictHttpError); + expect(storage.create).toHaveBeenCalledTimes(0); + }); + + it('can remove a registration on DELETE requests.', async(): Promise => { + request.method = 'DELETE'; + request.parameters = { id: 'id' }; + storage.findIds.mockResolvedValueOnce([ 'match' ]); + + await expect(handler.handle({ request })).resolves.toEqual({ status: 204 }); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, 'match'); + }); + + it('errors if there is no ID when deleting.', async(): Promise => { + request.method = 'DELETE'; + request.parameters = {}; + storage.findIds.mockResolvedValueOnce([ 'match' ]); + + await expect(handler.handle({ request })).rejects.toThrow(InternalServerError); + expect(storage.delete).toHaveBeenCalledTimes(0); + }); + + it('errors if the ID is unknown when deleting.', async(): Promise => { + request.method = 'DELETE'; + request.parameters = { id: 'id' }; + storage.findIds.mockResolvedValueOnce([]); + + await expect(handler.handle({ request })).rejects.toThrow(NotFoundHttpError); + expect(storage.delete).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/uma/test/unit/routes/Config.test.ts b/packages/uma/test/unit/routes/Config.test.ts index 64d48b99..998f3c99 100644 --- a/packages/uma/test/unit/routes/Config.test.ts +++ b/packages/uma/test/unit/routes/Config.test.ts @@ -20,6 +20,7 @@ describe('Config', (): void => { dpop_signing_alg_values_supported: expect.arrayContaining(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512']), response_types_supported: ['token'], + registration_endpoint: 'http://example.com/uma/reg/', } }); }); diff --git a/packages/uma/test/unit/routes/Introspection.test.ts b/packages/uma/test/unit/routes/Introspection.test.ts index 6fc02531..8d4d3d35 100644 --- a/packages/uma/test/unit/routes/Introspection.test.ts +++ b/packages/uma/test/unit/routes/Introspection.test.ts @@ -1,41 +1,33 @@ -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'; +import { RequestValidator } from '../../../src/util/http/validate/RequestValidator'; 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 validator: Mocked; let handler: IntrospectionHandler; beforeEach(async(): Promise => { - vi.clearAllMocks(); - verifyRequest.mockResolvedValue(true); + validator = { + handleSafe: vi.fn().mockResolvedValue({ owner: 'owner' }), + } satisfies Partial as any; 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); + handler = new IntrospectionHandler(factory, validator); }); 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({}); + expect(validator.handleSafe).toHaveBeenCalledTimes(1); + expect(validator.handleSafe).toHaveBeenLastCalledWith({ request: {}}); }); it('returns the token.', async(): Promise => { @@ -43,7 +35,7 @@ describe('Introspection', (): void => { status: 200, body: { ...token, active: true }, }); - expect(verifyRequest).toHaveBeenCalledTimes(1); + expect(validator.handleSafe).toHaveBeenCalledTimes(1); expect(factory.deserialize).toHaveBeenCalledTimes(1); expect(factory.deserialize).toHaveBeenLastCalledWith('token'); }); @@ -51,7 +43,7 @@ describe('Introspection', (): void => { 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(validator.handleSafe).toHaveBeenCalledTimes(1); expect(factory.deserialize).toHaveBeenCalledTimes(1); expect(factory.deserialize).toHaveBeenLastCalledWith('token'); }); diff --git a/packages/uma/test/unit/routes/ResourceRegistration.test.ts b/packages/uma/test/unit/routes/ResourceRegistration.test.ts index 8fa41951..d43b313d 100644 --- a/packages/uma/test/unit/routes/ResourceRegistration.test.ts +++ b/packages/uma/test/unit/routes/ResourceRegistration.test.ts @@ -1,40 +1,36 @@ import 'jest-rdf'; import { - joinUrl, + ForbiddenHttpError, KeyValueStorage, MethodNotAllowedHttpError, NotFoundHttpError, - RDF, - UnauthorizedHttpError + RDF } 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 { RequestValidator } from '../../../src/util/http/validate/RequestValidator'; +import { RegistrationStore } from '../../../src/util/RegistrationStore'; 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 => { + const owner = 'owner'; let input: HttpHandlerContext; let policyStore: Store; - let resourceStore: Mocked>; + let registrationStore: Mocked; let policies: Mocked; + let validator: Mocked; let handler: ResourceRegistrationRequestHandler; beforeEach(async(): Promise => { - vi.clearAllMocks(); input = { request: { url: new URL('http://example.com/foo'), @@ -48,8 +44,9 @@ describe('ResourceRegistration', (): void => { policyStore = new Store(); - resourceStore = { + registrationStore = { has: vi.fn().mockResolvedValue(false), + get: vi.fn().mockResolvedValue({ owner, description: input.request.body }), set: vi.fn(), delete: vi.fn(), } satisfies Partial> as any; @@ -60,15 +57,11 @@ describe('ResourceRegistration', (): void => { removeData: vi.fn(), } satisfies Partial as any; - handler = new ResourceRegistrationRequestHandler(resourceStore, policies); - }); + validator = { + handleSafe: vi.fn().mockResolvedValue({ owner }) + } satisfies Partial as any; - 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'); + handler = new ResourceRegistrationRequestHandler(registrationStore, policies, validator); }); it('throws an error if the method is not allowed.', async(): Promise => { @@ -86,12 +79,12 @@ describe('ResourceRegistration', (): void => { }); it('throws an error when trying to register a resource with a known name.', async(): Promise => { - resourceStore.has.mockResolvedValueOnce(true); + registrationStore.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); + expect(registrationStore.has).toHaveBeenCalledTimes(1); + expect(registrationStore.has).toHaveBeenLastCalledWith('name'); + expect(registrationStore.set).toHaveBeenCalledTimes(0); }); it('registers the resource using the name as identifier.', async(): Promise => { @@ -100,8 +93,8 @@ describe('ResourceRegistration', (): void => { headers: { location: `http://example.com/foo/name` }, body: { _id: 'name', user_access_policy_uri: 'TODO: implement policy UI' }, }); - expect(resourceStore.set).toHaveBeenCalledTimes(1); - expect(resourceStore.set).lastCalledWith('name', input.request.body); + expect(registrationStore.set).toHaveBeenCalledTimes(1); + expect(registrationStore.set).lastCalledWith('name', { owner, description: input.request.body }); }); it('stores newly created asset collections.', async(): Promise => { @@ -165,7 +158,7 @@ describe('ResourceRegistration', (): void => { input.request.method = 'PUT'; input.request.parameters = { id: 'name' }; - resourceStore.has.mockResolvedValue(true); + registrationStore.has.mockResolvedValue(true); }); it('errors if no id parameter is provided.', async(): Promise => { @@ -174,7 +167,7 @@ describe('ResourceRegistration', (): void => { }); it('errors if the resource is not known.', async(): Promise => { - resourceStore.has.mockResolvedValueOnce(false); + registrationStore.get.mockResolvedValueOnce(undefined); await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError); }); @@ -183,13 +176,18 @@ describe('ResourceRegistration', (): void => { await expect(handler.handle(input)).rejects.toThrow('Request has bad syntax: value is not an array'); }); + it('only allows owners to update their own resources.', async(): Promise => { + registrationStore.get.mockResolvedValueOnce({ owner: 'someone-else', description: input.request.body } as any); + await expect(handler.handle(input)).rejects.toThrow(ForbiddenHttpError); + }); + 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); + expect(registrationStore.set).toHaveBeenCalledTimes(1); + expect(registrationStore.set).lastCalledWith('name', { owner, description: input.request.body }); }); it('stores newly created asset collections.', async(): Promise => { @@ -250,25 +248,30 @@ describe('ResourceRegistration', (): void => { input.request.method = 'DELETE'; input.request.parameters = { id: 'name' }; - resourceStore.has.mockResolvedValue(true); + registrationStore.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); + expect(registrationStore.delete).toHaveBeenCalledTimes(0); }); it('errors if the resource is not known.', async(): Promise => { - resourceStore.has.mockResolvedValueOnce(false); + registrationStore.get.mockResolvedValueOnce(undefined); await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError); - expect(resourceStore.delete).toHaveBeenCalledTimes(0); + expect(registrationStore.delete).toHaveBeenCalledTimes(0); + }); + + it('only allows owners to delete their resources.', async(): Promise => { + registrationStore.get.mockResolvedValueOnce({ owner: 'someone-else', description: input.request.body } as any); + await expect(handler.handle(input)).rejects.toThrow(ForbiddenHttpError); }); 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'); + expect(registrationStore.delete).toHaveBeenCalledTimes(1); + expect(registrationStore.delete).toHaveBeenLastCalledWith('name'); }); }); }); diff --git a/packages/uma/test/unit/routes/Ticket.test.ts b/packages/uma/test/unit/routes/Ticket.test.ts index feb524da..a58c0586 100644 --- a/packages/uma/test/unit/routes/Ticket.test.ts +++ b/packages/uma/test/unit/routes/Ticket.test.ts @@ -4,7 +4,9 @@ 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 { RequestValidator } from '../../../src/util/http/validate/RequestValidator'; import * as signatures from '../../../src/util/HttpMessageSignatures'; +import { RegistrationStore } from '../../../src/util/RegistrationStore'; import { ResourceDescription } from '../../../src/views/ResourceDescription'; vi.mock('node:crypto', () => ({ @@ -12,17 +14,17 @@ vi.mock('node:crypto', () => ({ })); describe('Ticket', (): void => { + const owner = 'owner'; let request: HttpHandlerContext; - const verifyRequest = vi.spyOn(signatures, 'verifyRequest'); let ticketingStrategy: Mocked; let ticketStore: Mocked>; - let resourceStore: Mocked>; + let registrationStore: Mocked; + let validator: Mocked; let handler: TicketRequestHandler; beforeEach(async(): Promise => { vi.clearAllMocks(); - verifyRequest.mockResolvedValue(true); request = { request: { body: [{ resource_id: 'id', @@ -35,7 +37,7 @@ describe('Ticket', (): void => { validateClaims: vi.fn(), }; - resourceStore = { + registrationStore = { has: vi.fn().mockResolvedValue(true), } satisfies Partial> as any; @@ -43,14 +45,11 @@ describe('Ticket', (): void => { set: vi.fn(), } satisfies Partial> as any; - handler = new TicketRequestHandler(ticketingStrategy, ticketStore, resourceStore); - }); + validator = { + handleSafe: vi.fn().mockResolvedValue({ owner }), + } satisfies Partial as any; - 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); + handler = new TicketRequestHandler(ticketingStrategy, ticketStore, registrationStore, validator); }); it('throws an error if the body is invalid.', async(): Promise => { @@ -76,8 +75,8 @@ describe('Ticket', (): void => { { resource_id: 'id1', resource_scopes: [ 'scope1' ]}, { resource_id: 'id2', resource_scopes: [ 'scope2' ]}, ]; - resourceStore.has.mockResolvedValueOnce(true); - resourceStore.has.mockResolvedValueOnce(false); + registrationStore.has.mockResolvedValueOnce(true); + registrationStore.has.mockResolvedValueOnce(false); await expect(handler.handle(request)).resolves .toEqual({ status: 400, body: { error: 'invalid_resource_id', error_description: 'Unknown UMA ID id2' }}); expect(ticketStore.set).toHaveBeenCalledTimes(0); diff --git a/packages/uma/test/unit/routes/Token.test.ts b/packages/uma/test/unit/routes/Token.test.ts index 747a3674..51d861be 100644 --- a/packages/uma/test/unit/routes/Token.test.ts +++ b/packages/uma/test/unit/routes/Token.test.ts @@ -1,57 +1,321 @@ -import { Mocked } from 'vitest'; +import { + AlgJwk, + ForbiddenHttpError, + IndexedStorage, + JwkGenerator, + UnauthorizedHttpError +} from '@solid/community-server'; +import { decodeJwt, exportJWK, generateKeyPair, GenerateKeyPairResult, importJWK, jwtVerify, KeyLike } from 'jose'; +import { beforeAll, 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'; +import { + CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + CLIENT_REGISTRATION_STORAGE_TYPE +} from '../../../src/routes/ClientRegistration'; +import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE, TokenRequestHandler } from '../../../src/routes/Token'; +import { HttpHandlerRequest } from '../../../src/util/http/models/HttpHandler'; + +vi.useFakeTimers(); describe('Token', (): void => { - let request: HttpHandlerContext; + const now = Date.now(); + const clientUri = 'http://example.org'; + const baseUrl = 'http://example.com'; + const userId = 'userId'; + const registrationId = 'registrationId'; + const clientId = 'clientId'; + const clientSecret = 'sec ret'; + const encoded = Buffer.from('clientId:sec%20ret', 'utf8').toString('base64'); + const alg = 'ES256'; + let keys: GenerateKeyPairResult; + let publicKey: AlgJwk; + let privateKey: AlgJwk; + let request: HttpHandlerRequest; let negotiator: Mocked; + let storage: Mocked>; + let keyGen: Mocked; let handler: TokenRequestHandler; + beforeAll(async(): Promise => { + keys = await generateKeyPair(alg); + publicKey = { ...await exportJWK(keys.publicKey), alg }; + privateKey = { ...await exportJWK(keys.privateKey), alg }; + }); + beforeEach(async(): Promise => { - request = { request: { body: { - ticket: 'ticket', - grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket' - }}} as any; + request = { + url: new URL('http://example.com/token'), + parameters: {}, + method: 'POST', + headers: {}, + body: {}, + }; negotiator = { negotiate: vi.fn().mockResolvedValue('response'), }; - handler = new TokenRequestHandler(negotiator); + storage = { + defineType: vi.fn(), + createIndex: vi.fn(), + find: vi.fn().mockResolvedValue([{ id: registrationId, clientId, clientSecret, clientUri, userId }]), + findIds: vi.fn().mockResolvedValue([]), + set: vi.fn(), + create: vi.fn(), + } as any; + + keyGen = { + alg: alg, + getPublicKey: vi.fn().mockResolvedValue(publicKey), + getPrivateKey: vi.fn().mockResolvedValue(privateKey), + }; + + handler = new TokenRequestHandler(negotiator, storage as any, keyGen, baseUrl); }); it('throws an error if the body is invalid.', async(): Promise => { - request.request.body = { ticket: 5 }; - await expect(handler.handle(request)).rejects + 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'") + request.body = { grant_type: 'not supported' }; + await expect(handler.handle({ request })).rejects + .toThrow('Unsupported grant_type not supported') }); - 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); + describe('generating an UMA token', (): void => { + beforeEach(async(): Promise => { + request.body = { + ticket: 'ticket', + grant_type: '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.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'); + }); }); - 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' ]] }, - }}); + describe('using client credentials', (): void => { + beforeEach(async(): Promise => { + request.headers = { + authorization: `Basic ${encoded}`, + }; + request.body = { + grant_type: 'client_credentials', + scope: 'uma_protection', + }; + }); + + it('errors if the authorization header is missing.', async(): Promise => { + delete request.headers.authorization; + await expect(handler.handle({ request })).rejects.toThrow(UnauthorizedHttpError); + }); + + it('errors if the scope is wrong.', async(): Promise => { + request.body = { grant_type: 'client_credentials' }; + await expect(handler.handle({ request })).rejects.toThrow(`Expected scope 'uma_protection'`); + }); + + it('errors for non-Basic authorization schemes.', async(): Promise => { + request.headers.authorization = `Bearer ${encoded}`; + await expect(handler.handle({ request })).rejects.toThrow(`Expected scheme 'Basic'`); + }); + + it('errors if the credentials are not known.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(handler.handle({ request })).rejects.toThrow(ForbiddenHttpError); + }); + + it('generates a token response.', async(): Promise => { + const response = await handler.handle({ request }); + expect(response).toEqual({ + status: 201, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + token_type: 'Bearer', + expires_in: 1800, + scope: 'uma_protection', + } + }); + + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, { + clientId: clientId, + clientSecret: clientSecret, + }); + expect(storage.findIds).toHaveBeenCalledTimes(1); + expect(storage.findIds).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { registration: registrationId }); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { + pat: response.body.access_token, + refreshToken: response.body.refresh_token, + expiration: now + 1800 * 1000, + registration: registrationId, + }); + + const jwk = await importJWK(publicKey, publicKey.alg); + const decodedToken = await jwtVerify(response.body.access_token, jwk); + expect(decodedToken.payload).toEqual({ + scope: 'uma_protection', + azp: clientId, + client_id: clientId, + iat: Math.floor(now/1000), + sub: userId, + iss: baseUrl, + aud: baseUrl, + exp: Math.floor(now/1000) + 1800, + jti: expect.any(String), + }) + }); + + it('replaces the token for the given credentials if there is one.', async(): Promise => { + storage.findIds.mockResolvedValueOnce(['patId']); + const response = await handler.handle({ request }); + expect(response).toEqual({ + status: 201, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + token_type: 'Bearer', + expires_in: 1800, + scope: 'uma_protection', + } + }); + expect(storage.create).toHaveBeenCalledTimes(0); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { + id: 'patId', + pat: response.body.access_token, + refreshToken: response.body.refresh_token, + expiration: now + 1800 * 1000, + registration: registrationId, + }); + }); }); - 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'); + describe('using a refresh token', (): void => { + const refreshToken = 'refreshToken'; + const patId = 'patId'; + + beforeEach(async(): Promise => { + request.headers = { + authorization: `Basic ${encoded}`, + }; + request.body = { + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: 'uma_protection', + }; + + storage.find.mockImplementation((type): any => { + if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { + return [{ id: registrationId, clientId, clientSecret, clientUri, userId }]; + } + return [{ id: patId, registration: registrationId, refreshToken: refreshToken }]; + }); + }); + + it('errors if no refresh token is provided.', async(): Promise => { + request.body = { + grant_type: 'refresh_token', + scope: 'uma_protection', + }; + await expect(handler.handle({ request })).rejects.toThrow('Missing refresh_token parameter'); + }); + + it('errors if no matching refresh token could be found.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(handler.handle({ request })).rejects.toThrow(`Unknown refresh token ${refreshToken}`); + }); + + it('errors if the authorization header is missing.', async(): Promise => { + delete request.headers.authorization; + await expect(handler.handle({ request })).rejects.toThrow(UnauthorizedHttpError); + }); + + it('errors if the scope is wrong.', async(): Promise => { + request.body = { grant_type: 'client_credentials' }; + await expect(handler.handle({ request })).rejects.toThrow(`Expected scope 'uma_protection'`); + }); + + it('errors for non-Basic authorization schemes.', async(): Promise => { + request.headers.authorization = `Bearer ${encoded}`; + await expect(handler.handle({ request })).rejects.toThrow(`Expected scheme 'Basic'`); + }); + + it('errors if the credentials are not known.', async(): Promise => { + storage.find.mockImplementation((type): any => { + if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { + return []; + } + return [{ id: patId, registration: registrationId, refreshToken: refreshToken }]; + }); + await expect(handler.handle({ request })).rejects.toThrow(ForbiddenHttpError); + }); + + it('errors if the refresh token is not associated with these credentials.', async(): Promise => { + storage.find.mockImplementation((type): any => { + if (type === CLIENT_REGISTRATION_STORAGE_TYPE) { + return [{ id: 'wrongId', clientId, clientSecret, clientUri, userId }]; + } + return [{ id: patId, registration: registrationId, refreshToken: refreshToken }]; + }); + await expect(handler.handle({ request })).rejects.toThrow(`Wrong credentials for refresh token ${refreshToken}`); + }); + + it('generates a token response.', async(): Promise => { + const response = await handler.handle({ request }); + expect(response).toEqual({ + status: 201, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + token_type: 'Bearer', + expires_in: 1800, + scope: 'uma_protection', + } + }); + + expect(storage.find).toHaveBeenCalledTimes(2); + expect(storage.find).nthCalledWith(1, PAT_STORAGE_TYPE, { refreshToken }); + expect(storage.find).nthCalledWith(2, CLIENT_REGISTRATION_STORAGE_TYPE, { + clientId: clientId, + clientSecret: clientSecret, + }); + expect(storage.create).toHaveBeenCalledTimes(0); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { + id: 'patId', + pat: response.body.access_token, + refreshToken: response.body.refresh_token, + expiration: now + 1800 * 1000, + registration: registrationId, + }); + }); }); }); diff --git a/packages/uma/test/unit/util/HttpMessageSignatures.test.ts b/packages/uma/test/unit/util/HttpMessageSignatures.test.ts index cc1557c3..6809e1ec 100644 --- a/packages/uma/test/unit/util/HttpMessageSignatures.test.ts +++ b/packages/uma/test/unit/util/HttpMessageSignatures.test.ts @@ -1,12 +1,9 @@ -import { AlgJwk, UnauthorizedHttpError } from '@solid/community-server'; +import { AlgJwk } 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'); +import { beforeAll } from 'vitest'; +import { signRequest } from '../../../src/util/HttpMessageSignatures'; describe('HttpMessageSignatures', (): void => { const url = 'https://example.com/foo'; @@ -40,59 +37,4 @@ describe('HttpMessageSignatures', (): void => { 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/validate/HttpMessageValidator.test.ts b/packages/uma/test/unit/util/http/validate/HttpMessageValidator.test.ts new file mode 100644 index 00000000..1a998b7b --- /dev/null +++ b/packages/uma/test/unit/util/http/validate/HttpMessageValidator.test.ts @@ -0,0 +1,86 @@ +import { AlgJwk, UnauthorizedHttpError } from '@solid/community-server'; +import { exportJWK, generateKeyPair, GenerateKeyPairResult } from 'jose'; +import { beforeAll, Mock } from 'vitest'; +import { HttpHandlerRequest } from '../../../../../src/util/http/models/HttpHandler'; +import { HttpMessageValidator } from '../../../../../src/util/http/validate/HttpMessageValidator'; +import { signRequest } from '../../../../../src/util/HttpMessageSignatures'; + +vi.mock('get-jwks'); + +describe('HttpMessageValidator', (): void => { + const url = 'https://example.com/foo'; + const alg = 'ES256'; + let keys: GenerateKeyPairResult; + let publicKey: AlgJwk; + let privateKey: AlgJwk; + let getJwk: Mock<() => unknown>; + let request: HttpHandlerRequest; + + const validator = new HttpMessageValidator(); + + beforeAll(async(): Promise => { + keys = await generateKeyPair(alg); + publicKey = { ...await exportJWK(keys.publicKey), alg, kid: 'public' }; + privateKey = { ...await exportJWK(keys.privateKey), alg, kid: 'private' }; + + const getJwks = await import('get-jwks'); + getJwk = vi.fn().mockResolvedValue(publicKey); + (getJwks.default as unknown as Mock).mockReturnValue({ getJwk } as any); + + const baseRequest = { + method: 'POST', + headers: { authorization: 'HttpSig cred="https://example.com/bar"' }, + body: 'text' + }; + const signedRequest = await signRequest(url, baseRequest, privateKey); + request = { + url: new URL(url), + method: signedRequest.method, + headers: signedRequest.headers as any, + parameters: {}, + } + }); + + it('returns the signer as the owner.', async(): Promise => { + await expect(validator.handle({ request })).resolves.toEqual({ owner: 'https://example.com/bar' }); + expect(getJwk).toHaveBeenCalledTimes(1); + expect(getJwk).toHaveBeenLastCalledWith({ + domain: 'https://example.com/bar', + alg, + kid: 'private', + }); + }); + + it('errors if the authorization header is missing.', async(): Promise => { + request.headers = {}; + await expect(validator.handle({ request })).rejects.toThrow('Missing authorization header in request.'); + }); + + it('errors if the authorization scheme is not HttpSig.', async(): Promise => { + request.headers.authorization = 'Basic 123'; + await expect(validator.handle({ request })).rejects.toThrow(UnauthorizedHttpError); + }); + + it('errors if no `cred` parameter could be extracted from the header.', async(): Promise => { + request.headers.authorization = 'HttpSig pear'; + await expect(validator.handle({ request })).rejects.toThrow(UnauthorizedHttpError); + }); + + it('errors if the signature used a wrong key.', async(): Promise => { + keys = await generateKeyPair(alg); + const otherKey = { ...await exportJWK(keys.privateKey), alg, kid: 'private' }; + const baseRequest = { + method: 'POST', + headers: { authorization: 'HttpSig cred="https://example.com/bar"' }, + body: 'text' + }; + const signedRequest = await signRequest(url, baseRequest, otherKey as AlgJwk); + request = { + url: new URL(url), + method: signedRequest.method, + headers: signedRequest.headers as any, + parameters: {}, + } + await expect(validator.handle({ request })).rejects.toThrow('Failed to verify signature'); + }); +}); diff --git a/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts b/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts new file mode 100644 index 00000000..77562f3c --- /dev/null +++ b/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts @@ -0,0 +1,64 @@ +import { IndexedStorage } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { + CLIENT_REGISTRATION_STORAGE_DESCRIPTION, + CLIENT_REGISTRATION_STORAGE_TYPE +} from '../../../../../src/routes/ClientRegistration'; +import { PAT_STORAGE_DESCRIPTION, PAT_STORAGE_TYPE } from '../../../../../src/routes/Token'; +import { HttpHandlerRequest } from '../../../../../src/util/http/models/HttpHandler'; +import { PatRequestValidator } from '../../../../../src/util/http/validate/PatRequestValidator'; + +describe('PatRequestValidator', (): void => { + const registrationId = 'registrationId'; + const userId = 'userId'; + const pat = 'pat'; + let request: HttpHandlerRequest; + + let storage: Mocked>; + let validator: PatRequestValidator; + + beforeEach(async(): Promise => { + request = { + url: new URL('http://example.com/foo'), + parameters: {}, + headers: { authorization: `Bearer ${pat}` }, + method: 'GET' + } + + storage = { + find: vi.fn().mockResolvedValue([{ expiration: Date.now() + 5000, registration: registrationId }]), + get: vi.fn().mockResolvedValue({ userId }), + } as any; + + validator = new PatRequestValidator(storage as any); + }); + + it('returns the stored user as owner.', async(): Promise => { + await expect(validator.handle({ request })).resolves.toEqual({ owner: userId }); + expect(storage.find).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { pat }); + expect(storage.get).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, registrationId); + }); + + it('errors on non-Bearer tokens.', async(): Promise => { + request.headers.authorization = 'Basic 1234'; + await expect(validator.handle({ request })).rejects.toThrow('No Bearer Authorization header specified.'); + }); + + it('errors on non-Bearer tokens.', async(): Promise => { + request.headers.authorization = 'Basic 1234'; + await expect(validator.handle({ request })).rejects.toThrow('No Bearer Authorization header specified.'); + }); + + it('errors if no matched token was found.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(validator.handle({ request })).rejects.toThrow('Unknown PAT.'); + }); + + it('errors if the PAT is expired.', async(): Promise => { + storage.find.mockResolvedValueOnce([{ expiration: Date.now() - 5000, registration: registrationId }] as any); + await expect(validator.handle({ request })).rejects.toThrow('Expired PAT.'); + }); +}); diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts index b259a2d9..042c8cd5 100644 --- a/test/integration/Base.test.ts +++ b/test/integration/Base.test.ts @@ -3,7 +3,9 @@ import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-fact import { Parser, Writer } from 'n3'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; +import { promises } from 'node:timers'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; +import { generateCredentials } from '../util/UmaUtil'; const [ cssPort, umaPort ] = getPorts('Base'); @@ -41,7 +43,7 @@ describe('A server setup', (): void => { await Promise.all([ umaApp.stop(), cssApp.stop() ]); }); - describe('initializing policies', (): void => { + describe('initializing the servers', (): void => { it('can set up all the necessary policies.', async(): Promise => { const owner = 'https://pod.woutslabbinck.com/profile/card#me'; const url = `http://localhost:${umaPort}/uma/policies`; @@ -59,10 +61,21 @@ describe('A server setup', (): void => { }); expect(response.status).toBe(201); }); + + it('can register client credentials for the user/RS combination.', async(): Promise => { + await generateCredentials({ + webId: `http://localhost:${cssPort}/alice/profile/card#me`, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'alice@example.org', + password: 'abc123' + }); + }); }); describe('using public namespace authorization', (): void => { it('RS: provides immediate read access.', async(): Promise => { + await promises.setTimeout(1000); const publicResource = `http://localhost:${cssPort}/alice/profile/card`; const publicResponse = await fetch(publicResource); diff --git a/test/integration/Demo.test.ts b/test/integration/Demo.test.ts index 791b6783..f6250424 100644 --- a/test/integration/Demo.test.ts +++ b/test/integration/Demo.test.ts @@ -3,7 +3,7 @@ import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-fact import { Parser, Store } from 'n3'; import * as path from 'node:path'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; -import { findTokenEndpoint, getToken, noTokenFetch, tokenFetch, umaFetch } from '../util/UmaUtil'; +import { findTokenEndpoint, getToken, noTokenFetch, generateCredentials, tokenFetch, umaFetch } from '../util/UmaUtil'; const [ cssPort, umaPort ] = getPorts('Demo'); @@ -60,6 +60,16 @@ describe('A demo server setup', (): void => { await Promise.all([ umaApp.stop(), cssApp.stop() ]); }); + it('can register a PAT for the user.', async(): Promise => { + await generateCredentials({ + webId: `http://localhost:${cssPort}/ruben/profile/card#me`, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'ruben@example.org', + password: 'abc123' + }); + }); + it('sets up the initial data.', async(): Promise => { // Policy that allows the creation of all the initial resources const policy = ` diff --git a/test/integration/Odrl.test.ts b/test/integration/Odrl.test.ts index c2743b5d..4a02633b 100644 --- a/test/integration/Odrl.test.ts +++ b/test/integration/Odrl.test.ts @@ -4,6 +4,7 @@ import { Parser, Writer } from 'n3'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; +import { generateCredentials } from '../util/UmaUtil'; const [ cssPort, umaPort ] = getPorts('ODRL'); @@ -39,7 +40,7 @@ describe('An ODRL server setup', (): void => { await Promise.all([umaApp.start(), cssApp.start()]); }); - describe('initializing policies', (): void => { + describe('initializing the servers', (): void => { it('can set up all the necessary policies.', async(): Promise => { const owner = 'https://pod.woutslabbinck.com/profile/card#me'; const url = `http://localhost:${umaPort}/uma/policies`; @@ -57,6 +58,16 @@ describe('An ODRL server setup', (): void => { }); expect(response.status).toBe(201); }); + + it('can register a PAT for the user.', async(): Promise => { + await generateCredentials({ + webId: `http://localhost:${cssPort}/alice/profile/card#me`, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'alice@example.org', + password: 'abc123' + }); + }); }); describe('creating a resource', (): void => { diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts index faadc732..863f8508 100644 --- a/test/integration/Oidc.test.ts +++ b/test/integration/Oidc.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'; import { createServer, Server } from 'node:http'; import path from 'node:path'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; -import { findTokenEndpoint, noTokenFetch } from '../util/UmaUtil'; +import { findTokenEndpoint, noTokenFetch, generateCredentials } from '../util/UmaUtil'; const [ cssPort, umaPort ] = getPorts('OIDC'); const idpPort = umaPort + 100; @@ -81,6 +81,24 @@ describe('A server supporting OIDC tokens', (): void => { await Promise.all([umaApp.start(), cssApp.start()]); }); + it('can register credentials for the RS/user combinations.', async(): Promise => { + await generateCredentials({ + webId: `http://localhost:${cssPort}/alice/profile/card#me`, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'alice@example.org', + password: 'abc123' + }); + + await generateCredentials({ + webId: `http://localhost:${cssPort}/bob/profile/card#me`, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'bob@example.org', + password: 'abc123' + }); + }); + describe('accessing a resource using a standard OIDC token.', (): void => { const resource = `http://localhost:${cssPort}/alice/standard`; const sub = '123456'; diff --git a/test/integration/Policies.test.ts b/test/integration/Policies.test.ts index 2126e42f..2f639120 100644 --- a/test/integration/Policies.test.ts +++ b/test/integration/Policies.test.ts @@ -13,7 +13,7 @@ import { putPolicyB } from '../../scripts/util/policyExamples'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; -import { findTokenEndpoint, noTokenFetch } from '../util/UmaUtil'; +import { findTokenEndpoint, noTokenFetch, generateCredentials } from '../util/UmaUtil'; const [ cssPort, umaPort ] = getPorts('Policies'); @@ -110,6 +110,14 @@ describe('A policy server setup', (): void => { }); it('can not create a resource without access.', async(): Promise => { + await generateCredentials({ + webId: `http://localhost:${cssPort}/alice/profile/card#me`, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'alice@example.org', + password: 'abc123' + }); + await expect(attemptRequest(target, { method: 'PUT', headers: { 'content-type': 'text/plain' }, body: 'test' })) .resolves.toBe(false); }); diff --git a/test/util/UmaUtil.ts b/test/util/UmaUtil.ts index 4c70c8c4..139dce2c 100644 --- a/test/util/UmaUtil.ts +++ b/test/util/UmaUtil.ts @@ -1,4 +1,5 @@ import { DialogOutput } from '@solidlab/uma'; +import {joinUrl} from '@solid/community-server'; /** * The initial request to a RS without a token. @@ -103,3 +104,57 @@ export async function umaFetch(input: string | URL | globalThis.Request, init?: // Perform new call with token return tokenFetch(token, input, init); } + +/** + * Generates credentials for the RS so it can request PATs. + */ +export async function generateCredentials(args: { + webId: string, + authorizationServer: string, + resourceServer: string, + email: string, + password: string, + }): Promise { + const configurationUrl = joinUrl(args.authorizationServer, '/.well-known/uma2-configuration'); + const configResponse = await fetch(configurationUrl); + expect(configResponse.status).toBe(200); + const configuration = await configResponse.json() as { registration_endpoint: string }; + expect(configuration.registration_endpoint).toBeDefined(); + + let response = await fetch(configuration.registration_endpoint, { + method: 'POST', + headers: { + authorization: `WebID ${encodeURIComponent(args.webId)}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ client_uri: args.resourceServer }), + }); + expect(response.status).toBe(201); + const { client_id, client_secret } = await response.json() as { client_id: string, client_secret: string }; + + let indexResponse = await fetch(joinUrl(args.resourceServer, '.account/')); + let { controls } = await indexResponse.json() as Record; + expect(controls?.password?.login).toBeDefined(); + + response = await fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: args.email, password: args.password }), + }); + expect(response.status).toBe(200); + const { authorization } = await response.json() as { authorization: string }; + expect(authorization).toBeDefined(); + + indexResponse = await fetch(joinUrl(args.resourceServer, '.account/'), { + headers: { authorization: `CSS-Account-Token ${authorization}` } + }); + ({ controls } = await indexResponse.json() as Record); + expect(controls?.account?.pat).toBeDefined(); + + response = await fetch(controls.account.pat, { + method: 'POST', + headers: { authorization: `CSS-Account-Token ${authorization}`, 'content-type': 'application/json' }, + body: JSON.stringify({ id: client_id, secret: client_secret, issuer: args.authorizationServer }), + }); + expect(response.status).toBe(200); +} diff --git a/yarn.lock b/yarn.lock index a6c65d61..a7d82c3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5655,6 +5655,7 @@ __metadata: "@solid/access-token-verifier": "npm:^1.2.0" "@solid/community-server": "npm:^8.0.0-alpha.1" "@solidlab/ucp": "workspace:^" + "@types/ms": "npm:^2.1.0" "@types/n3": "npm:^1.16.4" asynchronous-handlers: "npm:^1.0.2" componentsjs: "npm:^6.3.0" @@ -5663,6 +5664,7 @@ __metadata: http-message-signatures: "npm:^1.0.4" jose: "npm:^5.2.2" logform: "npm:^2.6.0" + ms: "npm:^2.1.3" n3: "npm:^1.17.2" odrl-evaluator: "npm:^0.5.0" rdf-vocabulary: "npm:^1.0.1" @@ -6038,6 +6040,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:^2.1.0": + version: 2.1.0 + resolution: "@types/ms@npm:2.1.0" + checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225 + languageName: node + linkType: hard + "@types/n3@npm:^1.0.0, @types/n3@npm:^1.10.4, @types/n3@npm:^1.16.3, @types/n3@npm:^1.16.4, @types/n3@npm:^1.21.0, @types/n3@npm:^1.21.1": version: 1.26.0 resolution: "@types/n3@npm:1.26.0"