diff --git a/documentation/getting-started.md b/documentation/getting-started.md
index b0f31b98..7fcf7a41 100644
--- a/documentation/getting-started.md
+++ b/documentation/getting-started.md
@@ -48,6 +48,7 @@ so some information might change depending on which version and branch you're us
+ [Client application identification](#client-application-identification)
* [Adding or changing policies](#adding-or-changing-policies)
* [Policy backups](#policy-backups)
+ * [Data aggregation](#data-aggregation)
Table of contents generated with markdown-toc
@@ -460,3 +461,7 @@ You want to change the line that defines the `urn:uma:variables:backupFilePath`
and set the string value to the path where you want the backup file to be stored,
e.g., `backup.ttl`.
When restarting the server, the contents of that file will be read to initialize policies on the server.
+
+## Data aggregation
+
+The UMA server implements the [Aggregator Specification](https://spec.knows.idlab.ugent.be/aggregator-protocol/latest/).
diff --git a/packages/uma/.componentsignore b/packages/uma/.componentsignore
index 7303a118..2dc82180 100644
--- a/packages/uma/.componentsignore
+++ b/packages/uma/.componentsignore
@@ -6,6 +6,7 @@
"Buffer",
"Error",
"EventEmitter",
+ "ForbiddenHttpError",
"Map",
"NodeJS.Dict",
"Permission",
diff --git a/packages/uma/bin/main.js b/packages/uma/bin/main.js
index 503552dc..f6b25988 100644
--- a/packages/uma/bin/main.js
+++ b/packages/uma/bin/main.js
@@ -18,7 +18,7 @@ const launch = async () => {
variables['urn:uma:variables:policyBaseIRI'] = 'http://localhost:3000/';
variables['urn:uma:variables:policyDir'] = path.join(rootDir, './config/rules/policy');
variables['urn:uma:variables:eyePath'] = 'eye';
- variables['urn:uma:variables:backupFilePath'] = 'backup.ttl';
+ variables['urn:uma:variables:backupFilePath'] = '';
const configPath = path.join(rootDir, './config/default.json');
diff --git a/packages/uma/config/credentials/verifiers/default.json b/packages/uma/config/credentials/verifiers/default.json
index 7862b724..66a3c16b 100644
--- a/packages/uma/config/credentials/verifiers/default.json
+++ b/packages/uma/config/credentials/verifiers/default.json
@@ -25,9 +25,14 @@
"TypedVerifier:_verifiers_value": {
"@id": "urn:uma:default:OidcVerifier",
"@type": "OidcVerifier",
- "baseUrl": { "@id": "urn:uma:variables:baseUrl" }
+ "baseUrl": { "@id": "urn:uma:variables:baseUrl" },
+ "derivationStore": { "@id": "urn:uma:default:DerivationStore" }
}
},
+ {
+ "TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:access_token",
+ "TypedVerifier:_verifiers_value": { "@id": "urn:uma:default:OidcVerifier" }
+ },
{
"TypedVerifier:_verifiers_key": "urn:solidlab:uma:claims:formats:jwt",
"TypedVerifier:_verifiers_value": {
diff --git a/packages/uma/config/default.json b/packages/uma/config/default.json
index 88618c11..e0a0491f 100644
--- a/packages/uma/config/default.json
+++ b/packages/uma/config/default.json
@@ -134,6 +134,7 @@
"@type": "RoutedHttpRequestHandler",
"routes": [
{ "@id": "urn:uma:default:UmaConfigRoute" },
+ { "@id": "urn:uma:default:OidcConfigRoute" },
{ "@id": "urn:uma:default:JwksRoute" },
{ "@id": "urn:uma:default:TokenRoute" },
{ "@id": "urn:uma:default:PermissionRegistrationRoute" },
diff --git a/packages/uma/config/dialog/negotiators/default.json b/packages/uma/config/dialog/negotiators/default.json
index 7f590a62..0f6066b1 100644
--- a/packages/uma/config/dialog/negotiators/default.json
+++ b/packages/uma/config/dialog/negotiators/default.json
@@ -5,11 +5,16 @@
"@graph": [
{
"@id": "urn:uma:default:Negotiator",
- "@type": "ContractNegotiator",
- "verifier": { "@id": "urn:uma:default:Verifier" },
+ "@type": "AggregatorNegotiator",
"ticketStore": { "@id": "urn:uma:default:TicketStore" },
- "ticketingStrategy": { "@id": "urn:uma:default:TicketingStrategy" },
- "tokenFactory": { "@id": "urn:uma:default:TokenFactory" }
+ "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
+ "negotiator": {
+ "@type": "BaseNegotiator",
+ "verifier": { "@id": "urn:uma:default:Verifier" },
+ "ticketStore": { "@id": "urn:uma:default:TicketStore" },
+ "ticketingStrategy": { "@id": "urn:uma:default:TicketingStrategy" },
+ "tokenFactory": { "@id": "urn:uma:default:TokenFactory" }
+ }
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/uma/config/resources/storage/default.json b/packages/uma/config/resources/storage/default.json
index 4b1a6609..1b0ed151 100644
--- a/packages/uma/config/resources/storage/default.json
+++ b/packages/uma/config/resources/storage/default.json
@@ -6,6 +6,10 @@
{
"@id": "urn:uma:default:ResourceRegistrationStore",
"@type": "MemoryMapStorage"
+ },
+ {
+ "@id": "urn:uma:default:DerivationStore",
+ "@type": "MemoryMapStorage"
}
]
}
diff --git a/packages/uma/config/routes/discovery.json b/packages/uma/config/routes/discovery.json
index c857f518..4cfbd555 100644
--- a/packages/uma/config/routes/discovery.json
+++ b/packages/uma/config/routes/discovery.json
@@ -8,10 +8,19 @@
"@type": "HttpHandlerRoute",
"methods": [ "GET" ],
"handler": {
+ "@id": "urn:uma:default:ConfigRequestHandler",
"@type": "ConfigRequestHandler",
"baseUrl": { "@id": "urn:uma:variables:baseUrl" }
},
"path": "/uma/.well-known/uma2-configuration"
+ },
+ {
+ "comment": "Default OIDC decoding algorithms will look here for the JWKS, so can be useful to have it here as well",
+ "@id": "urn:uma:default:OidcConfigRoute",
+ "@type": "HttpHandlerRoute",
+ "methods": [ "GET" ],
+ "handler": { "@id": "urn:uma:default:ConfigRequestHandler" },
+ "path": "/uma/.well-known/openid-configuration"
}
]
}
diff --git a/packages/uma/config/routes/resources.json b/packages/uma/config/routes/resources.json
index 000c2684..29044cf0 100644
--- a/packages/uma/config/routes/resources.json
+++ b/packages/uma/config/routes/resources.json
@@ -6,6 +6,7 @@
{
"@id": "urn:uma:default:ResourceRegistrationHandler",
"@type": "ResourceRegistrationRequestHandler",
+ "derivationStore": { "@id": "urn:uma:default:DerivationStore" },
"registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
"policies": { "@id": "urn:uma:default:RulesStorage" },
"validator": { "@id": "urn:uma:default:RequestValidator" }
diff --git a/packages/uma/config/tickets/strategy/claim-elimination.json b/packages/uma/config/tickets/strategy/claim-elimination.json
deleted file mode 100644
index 047cb585..00000000
--- a/packages/uma/config/tickets/strategy/claim-elimination.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "@context": [
- "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld"
- ],
- "@graph": [
- {
- "@id": "urn:uma:default:TicketingStrategy",
- "@type": "ClaimEliminationStrategy",
- "authorizer": { "@id": "urn:uma:default:Authorizer" }
- }
- ]
-}
\ No newline at end of file
diff --git a/packages/uma/config/tickets/strategy/immediate-authorizer.json b/packages/uma/config/tickets/strategy/immediate-authorizer.json
index ae0f65db..cede6776 100644
--- a/packages/uma/config/tickets/strategy/immediate-authorizer.json
+++ b/packages/uma/config/tickets/strategy/immediate-authorizer.json
@@ -5,8 +5,13 @@
"@graph": [
{
"@id": "urn:uma:default:TicketingStrategy",
- "@type": "ImmediateAuthorizerStrategy",
- "authorizer": { "@id": "urn:uma:default:Authorizer" }
+ "@type": "AggregatorStrategy",
+ "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
+ "derivationStore": { "@id": "urn:uma:default:DerivationStore" },
+ "strategy": {
+ "@type": "ImmediateAuthorizerStrategy",
+ "authorizer": { "@id": "urn:uma:default:Authorizer" }
+ }
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/uma/src/credentials/Claims.ts b/packages/uma/src/credentials/Claims.ts
index 9806156f..dfd911e8 100644
--- a/packages/uma/src/credentials/Claims.ts
+++ b/packages/uma/src/credentials/Claims.ts
@@ -3,3 +3,4 @@ export const WEBID = 'urn:solidlab:uma:claims:types:webid';
export const CLIENTID = 'urn:solidlab:uma:claims:types:clientid';
export const PURPOSE = 'http://www.w3.org/ns/odrl/2/purpose';
export const LEGAL_BASIS = 'https://w3id.org/oac#LegalBasis';
+export const ACCESS = 'urn:solidlab:uma:claims:types:access';
diff --git a/packages/uma/src/credentials/Formats.ts b/packages/uma/src/credentials/Formats.ts
index 6eb70d33..c2476791 100644
--- a/packages/uma/src/credentials/Formats.ts
+++ b/packages/uma/src/credentials/Formats.ts
@@ -2,3 +2,4 @@
export const JWT = 'urn:solidlab:uma:claims:formats:jwt';
export const UNSECURE = 'urn:solidlab:uma:claims:formats:webid';
export const OIDC = 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken';
+export const ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token';
diff --git a/packages/uma/src/credentials/Requirements.ts b/packages/uma/src/credentials/Requirements.ts
deleted file mode 100644
index 4be8d8e4..00000000
--- a/packages/uma/src/credentials/Requirements.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-
-export type ClaimVerifier = (...args: unknown[]) => Promise;
-
-export type Requirements = NodeJS.Dict;
diff --git a/packages/uma/src/credentials/verify/OidcVerifier.ts b/packages/uma/src/credentials/verify/OidcVerifier.ts
index fb580ea5..8e5f4c6d 100644
--- a/packages/uma/src/credentials/verify/OidcVerifier.ts
+++ b/packages/uma/src/credentials/verify/OidcVerifier.ts
@@ -1,15 +1,25 @@
import { createSolidTokenVerifier } from '@solid/access-token-verifier';
-import { BadRequestHttpError, joinUrl } from '@solid/community-server';
+import {
+ BadRequestHttpError,
+ ForbiddenHttpError,
+ InternalServerError,
+ joinUrl,
+ KeyValueStorage
+} from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
-import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify, JWTVerifyOptions } from 'jose';
-import { CLIENTID, WEBID } from '../Claims';
+import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify } from 'jose';
+import { AccessToken } from '../../tokens/AccessToken';
+import { UMA_SCOPES } from '../../ucp/util/Vocabularies';
+import { reType } from '../../util/ReType';
+import { Permission } from '../../views/Permission';
+import { ACCESS, CLIENTID, WEBID } from '../Claims';
import { ClaimSet } from '../ClaimSet';
import { Credential } from '../Credential';
-import { OIDC } from '../Formats';
+import { ACCESS_TOKEN, OIDC } from '../Formats';
import { Verifier } from './Verifier';
/**
- * A Verifier for OIDC ID Tokens.
+ * A Verifier for OIDC Tokens.
*
* The `allowedIssuers` list can be used to only allow tokens from these issuers.
* Default is an empty list, which allows all issuers.
@@ -21,6 +31,7 @@ export class OidcVerifier implements Verifier {
public constructor(
protected readonly baseUrl: string,
+ protected readonly derivationStore: KeyValueStorage,
protected readonly allowedIssuers: string[] = [],
protected readonly verifyOptions: Record = {},
) {}
@@ -28,24 +39,25 @@ export class OidcVerifier implements Verifier {
/** @inheritdoc */
public async verify(credential: Credential): Promise {
this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`);
- if (credential.format !== OIDC) {
+ if (credential.format !== OIDC && credential.format !== ACCESS_TOKEN) {
throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`);
}
// We first need to determine if this is a Solid OIDC token or a standard one
const unsafeDecoded = decodeJwt(credential.token);
- const isSolidToken = unsafeDecoded.aud === 'solid' ||
- (Array.isArray(unsafeDecoded.aud) && unsafeDecoded.aud.includes('solid'));
+ const isSolidToken = (unsafeDecoded.aud === 'solid' ||
+ (Array.isArray(unsafeDecoded.aud) && unsafeDecoded.aud.includes('solid')))
+ && typeof unsafeDecoded.webid === 'string';
try {
this.validateToken(unsafeDecoded);
if (isSolidToken) {
return await this.verifySolidToken(credential.token);
} else {
- return await this.verifyStandardToken(credential.token, unsafeDecoded.iss!);
+ return await this.verifyStandardToken(credential.token, credential.format, unsafeDecoded.iss!);
}
} catch (error: unknown) {
- const message = `Error verifying OIDC ID Token: ${(error as Error).message}`;
+ const message = `Error verifying OIDC Token: ${(error as Error).message}`;
this.logger.debug(message);
throw new BadRequestHttpError(message);
@@ -77,8 +89,8 @@ export class OidcVerifier implements Verifier {
});
}
- protected async verifyStandardToken(token: string, issuer: string):
- Promise<{ [WEBID]: string, [CLIENTID]?: string }> {
+ protected async verifyStandardToken(token: string, format: string, issuer: string):
+ Promise<{ [WEBID]?: string, [CLIENTID]?: string, [ACCESS]?: Permission[] }> {
const configUrl = joinUrl(issuer, '/.well-known/openid-configuration');
const configResponse = await fetch(configUrl);
if (configResponse.status !== 200) {
@@ -90,13 +102,43 @@ export class OidcVerifier implements Verifier {
}
const jwkSet = createRemoteJWKSet(new URL(config.jwks_uri));
const decoded = await jwtVerify(token, jwkSet, this.verifyOptions);
- if (!decoded.payload.sub) {
- throw new BadRequestHttpError('Invalid OIDC token: missing `sub` claim');
+
+ if (format === OIDC) {
+ if (!decoded.payload.sub) {
+ throw new BadRequestHttpError('Invalid OIDC ID token: missing `sub` claim');
+ }
+ const client = decoded.payload.azp as string | undefined;
+ return {
+ [WEBID]: decoded.payload.sub,
+ ...client && { [CLIENTID]: client }
+ };
+ } else if (format === ACCESS_TOKEN) {
+ const iss = decoded.payload.iss;
+ // TODO: generalize this so the derivation-read specifics are not in this class
+ reType(decoded.payload, AccessToken);
+ const permissions: Permission[] = [];
+ for (const { resource_id: id, resource_scopes: scopes } of decoded.payload.permissions) {
+ // Need to make sure the token was issued by the corresponding issuer
+ if (scopes.includes(UMA_SCOPES['derivation-read'])) {
+ const issuer = await this.derivationStore.get(id);
+ if (!issuer) {
+ this.logger.warn(`Received access token for unknown aggregated id ${id}, ignoring permissions.`);
+ }
+ if (issuer !== iss) {
+ this.logger.warn(`Received access token for aggregated id ${id} with wrong issuer: ${iss
+ } instead of ${issuer}, rejection request.`);
+ throw new ForbiddenHttpError(`Invalid issuer for ${id}, expected ${issuer} but got ${iss}`);
+ }
+ permissions.push({ resource_id: id, resource_scopes: [ UMA_SCOPES['derivation-read']] });
+ } else {
+ // TODO: we could just accept the access permissions here, but this could potentially be unsafe
+ this.logger.warn(`Received unexpected permissions in access token: ${scopes}`);
+ }
+ }
+ return {
+ [ACCESS]: permissions,
+ }
}
- const client = decoded.payload.azp as string | undefined;
- return ({
- [WEBID]: decoded.payload.sub,
- ...client && { [CLIENTID]: client }
- });
+ throw new InternalServerError(`Unsupported claim format ${format}`);
}
}
diff --git a/packages/uma/src/dialog/AggregatorNegotiator.ts b/packages/uma/src/dialog/AggregatorNegotiator.ts
new file mode 100644
index 00000000..036df752
--- /dev/null
+++ b/packages/uma/src/dialog/AggregatorNegotiator.ts
@@ -0,0 +1,61 @@
+import { BadRequestHttpError, KeyValueStorage } from '@solid/community-server';
+import { Ticket } from '../ticketing/Ticket';
+import { UMA_SCOPES } from '../ucp/util/Vocabularies';
+import { encodeAggregateId } from '../util/AggregatorUtil';
+import { RegistrationStore } from '../util/RegistrationStore';
+import { DialogInput } from './Input';
+import { Negotiator } from './Negotiator';
+import { DialogOutput } from './Output';
+
+/**
+ * Ensures the `derivation_resource_id` is present in Token responses when required for aggregators.
+ */
+export class AggregatorNegotiator implements Negotiator {
+ public constructor(
+ protected readonly negotiator: Negotiator,
+ protected readonly ticketStore: KeyValueStorage,
+ protected readonly registrationStore: RegistrationStore,
+ ) {}
+
+ public async negotiate(input: DialogInput): Promise {
+ const scopes = input.scope?.split(' ') ?? [];
+ // This class is only relevant for derivation-creation requests
+ if (!scopes.includes(UMA_SCOPES['derivation-creation'])) {
+ return this.negotiator.negotiate(input);
+ }
+
+ return this.negotiateDerivationCreation(input);
+ }
+
+ protected async negotiateDerivationCreation(input: DialogInput): Promise {
+ // This needs to happen first as the source negotiator might already delete the stored ticket
+ const ticket = await this.getTicket(input);
+ if (ticket.permissions.length !== 1) {
+ throw new BadRequestHttpError('Aggregate token requests require exactly 1 target resource identifier');
+ }
+ const resourceId = ticket.permissions[0].resource_id;
+
+ // Add the new derivation-creation scope to the token
+ ticket.permissions[0].resource_scopes.push(UMA_SCOPES['derivation-creation']);
+ await this.ticketStore.set(input.ticket!, ticket);
+
+ const result = await this.negotiator.negotiate(input);
+ const derivationId = await encodeAggregateId(resourceId);
+ return {
+ ...result,
+ derivation_resource_id: derivationId,
+ }
+ }
+
+ protected async getTicket(input: DialogInput): Promise {
+ if (input.ticket) {
+ const ticket = await this.ticketStore.get(input.ticket);
+ if (!ticket) {
+ throw new BadRequestHttpError('Unknown ticket ID');
+ }
+ return ticket;
+ } else {
+ throw new BadRequestHttpError('Aggregators are only supported when using tickets.');
+ }
+ }
+}
diff --git a/packages/uma/src/dialog/BaseNegotiator.ts b/packages/uma/src/dialog/BaseNegotiator.ts
index 123a7ccf..f61f5304 100644
--- a/packages/uma/src/dialog/BaseNegotiator.ts
+++ b/packages/uma/src/dialog/BaseNegotiator.ts
@@ -1,9 +1,8 @@
import { BadRequestHttpError, ForbiddenHttpError, HttpErrorClass, KeyValueStorage } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { randomUUID } from 'node:crypto';
-import { ClaimSet } from '../credentials/ClaimSet';
import { Verifier } from '../credentials/verify/Verifier';
-import { NeedInfoError } from '../errors/NeedInfoError';
+import { NeedInfoError, RequiredClaim } from '../errors/NeedInfoError';
import { getOperationLogger } from '../logging/OperationLogger';
import { serializePolicyInstantiation } from '../logging/OperationSerializer';
import { TicketingStrategy } from '../ticketing/strategy/TicketingStrategy';
@@ -73,21 +72,18 @@ export class BaseNegotiator implements Negotiator {
}
// ... on failure, deny if no solvable requirements
- this.denyRequest(ticket);
+ this.denyRequest(ticket, resolved.value);
}
// TODO:
- protected denyRequest(ticket: Ticket): never {
- const requiredClaims = ticket.required.map(req => Object.keys(req));
- if (requiredClaims.length === 0) throw new ForbiddenHttpError();
+ protected denyRequest(ticket: Ticket, requirements: RequiredClaim[]): never {
+ if (requirements.length === 0) throw new ForbiddenHttpError('Request denied');
// ... require more info otherwise
const id = randomUUID();
this.ticketStore.set(id, ticket);
throw new NeedInfoError('Need more info to authorize request ...', id, {
- required_claims: {
- claim_token_format: requiredClaims,
- },
+ required_claims: requirements,
});
}
@@ -127,15 +123,20 @@ export class BaseNegotiator implements Negotiator {
* @returns An updated Ticket in which the Credentials have been validated.
*/
protected async processCredentials(input: DialogInput, ticket: Ticket): Promise {
- const { claim_token: token, claim_token_format: format } = input;
-
- if (token || format) {
- if (!token) this.error(BadRequestHttpError, 'Request with a "claim_token_format" must contain a "claim_token".');
- if (!format) this.error(BadRequestHttpError, 'Request with a "claim_token" must contain a "claim_token_format".');
+ const tokens: { claim_token?: string, claim_token_format?: string}[] = [];
+ if (Array.isArray(input.claim_token)) {
+ tokens.push(...input.claim_token);
+ } else if (input.claim_token || input.claim_token_format) {
+ tokens.push({ claim_token: input.claim_token, claim_token_format: input.claim_token_format });
+ }
+ for (const { claim_token: token, claim_token_format: format } of tokens) {
+ if (!token || !format) {
+ this.error(BadRequestHttpError, `Every claim requires both a token and format, received { claim_token: ${
+ token}, claim_token_format: ${format} }`);
+ }
const claims = await this.verifier.verify({ token, format });
-
- return await this.ticketingStrategy.validateClaims(ticket, claims);
+ ticket = await this.ticketingStrategy.validateClaims(ticket, claims);
}
return ticket;
diff --git a/packages/uma/src/dialog/ContractNegotiator.ts b/packages/uma/src/dialog/ContractNegotiator.ts
index dafc0e3c..4ba3cbb6 100644
--- a/packages/uma/src/dialog/ContractNegotiator.ts
+++ b/packages/uma/src/dialog/ContractNegotiator.ts
@@ -1,7 +1,7 @@
import { createErrorMessage, KeyValueStorage } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
-import { Requirements } from '../credentials/Requirements';
import { Verifier } from '../credentials/verify/Verifier';
+import { RequiredClaim } from '../errors/NeedInfoError';
import { ContractManager } from '../policies/contracts/ContractManager';
import { TicketingStrategy } from '../ticketing/strategy/TicketingStrategy';
import { Ticket } from '../ticketing/Ticket';
@@ -70,7 +70,7 @@ export class ContractNegotiator extends BaseNegotiator {
}
// ... on failure, deny if no solvable requirements
- this.denyRequest(ticket);
+ this.denyRequest(ticket, result.value);
}
/**
@@ -81,8 +81,8 @@ export class ContractNegotiator extends BaseNegotiator {
* In case the ticket is not resolved,
* the needed requirements will be returned as Failure.
*/
- protected async toContract(ticket: Ticket): Promise> {
- let result : Result;
+ protected async toContract(ticket: Ticket): Promise> {
+ let result : Result;
let contract: ODRLContract | undefined;
// Check contract availability
diff --git a/packages/uma/src/dialog/Input.ts b/packages/uma/src/dialog/Input.ts
index 768405a5..15dbc31a 100644
--- a/packages/uma/src/dialog/Input.ts
+++ b/packages/uma/src/dialog/Input.ts
@@ -1,6 +1,6 @@
-import { Type, string, array, optional as $, unknown } from "../util/ReType";
-import { ODRLPermission } from "../views/Contract";
-import { Permission } from "../views/Permission";
+import { array, optional as $, string, Type, union } from '../util/ReType';
+import { ODRLPermission } from '../views/Contract';
+import { Permission } from '../views/Permission';
/**
* A ReType constant for {@link DialogInput:type}.
@@ -9,8 +9,9 @@ export const DialogInput = ({
"@context": $(string),
grant_type: $(string),
ticket: $(string),
- claim_token: $(string),
- claim_token_format: $(string), // TODO: switch to array of claims objects with unknown structure
+ // this deviates from UMA, which only has the singular token/format entry
+ claim_token: $(union(string, array({ claim_token: $(string), claim_token_format: $(string) }))),
+ claim_token_format: $(string),
pct: $(string),
rpt: $(string),
permissions: $(array(Permission)), // this deviates from UMA, which only has a 'scope' string-array
diff --git a/packages/uma/src/dialog/Output.ts b/packages/uma/src/dialog/Output.ts
index 653f3df2..36d55b62 100644
--- a/packages/uma/src/dialog/Output.ts
+++ b/packages/uma/src/dialog/Output.ts
@@ -9,9 +9,10 @@ export const DialogOutput = ({
token_type: string,
expires_in: $(number),
upgraded: $(boolean),
+ derivation_resource_id: $(string),
});
/**
* The output for a dialog.
*/
-export type DialogOutput = Type;
\ No newline at end of file
+export type DialogOutput = Type;
diff --git a/packages/uma/src/errors/NeedInfoError.ts b/packages/uma/src/errors/NeedInfoError.ts
index 7f3d5a84..47142190 100644
--- a/packages/uma/src/errors/NeedInfoError.ts
+++ b/packages/uma/src/errors/NeedInfoError.ts
@@ -3,8 +3,19 @@ import { ForbiddenHttpError } from '@solid/community-server';
export type RedirectUserInfo = {
redirect_user: string
}
+
+export type RequiredClaim = {
+ claim_token_format?: string,
+ claim_type?: string,
+ friendly_name?: string,
+ issuer?: string,
+ name?: string,
+ derivation_resource_id?: string,
+ resource_scopes?: string[],
+}
+
export type RequiredClaimsInfo = {
- required_claims: { claim_token_format: string[][] }
+ required_claims: RequiredClaim[]
}
/**
diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts
index 46367eeb..090b23af 100644
--- a/packages/uma/src/index.ts
+++ b/packages/uma/src/index.ts
@@ -1,7 +1,6 @@
// Credentials
export * from './credentials/ClaimSet';
-export * from './credentials/Requirements';
export * from './credentials/Credential';
export * from './credentials/CredentialParser';
export * from './credentials/Formats';
@@ -18,12 +17,16 @@ export * from './credentials/verify/JwtVerifier';
export * from './credentials/verify/IriVerifier';
// Dialog
+export * from './dialog/AggregatorNegotiator';
export * from './dialog/Input';
export * from './dialog/Output';
export * from './dialog/Negotiator';
export * from './dialog/BaseNegotiator';
export * from './dialog/ContractNegotiator';
+// Errors
+export * from './errors/NeedInfoError';
+
// Authorizers
export * from './policies/authorizers/Authorizer';
export * from './policies/authorizers/AllAuthorizer';
@@ -52,13 +55,14 @@ export * from './routes/ClientRegistration';
// Tickets
export * from './ticketing/Ticket';
+export * from './ticketing/strategy/AggregatorStrategy';
export * from './ticketing/strategy/TicketingStrategy';
-export * from './ticketing/strategy/ClaimEliminationStrategy';
export * from './ticketing/strategy/ImmediateAuthorizerStrategy';
// Tokens
export * from './tokens/AccessToken';
export * from './tokens/JwtTokenFactory';
+export * from './tokens/OpaqueTokenFactory';
export * from './tokens/TokenFactory';
// Views
@@ -91,6 +95,7 @@ export * from './ucp/util/Util';
export * from './ucp/util/Vocabularies';
// Util
+export * from './util/AggregatorUtil';
export * from './util/ConvertUtil';
export * from './util/HttpMessageSignatures';
export * from './util/RegistrationStore';
diff --git a/packages/uma/src/policies/authorizers/AllAuthorizer.ts b/packages/uma/src/policies/authorizers/AllAuthorizer.ts
index 4c8849c0..3efabfe2 100644
--- a/packages/uma/src/policies/authorizers/AllAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/AllAuthorizer.ts
@@ -1,6 +1,5 @@
import { getLoggerFor } from 'global-logger-factory';
import { ClaimSet } from '../../credentials/ClaimSet';
-import { Requirements } from '../../credentials/Requirements';
import { Permission } from '../../views/Permission';
import { ANY_RESOURCE, ANY_SCOPE, Authorizer } from './Authorizer';
@@ -28,10 +27,4 @@ export class AllAuthorizer implements Authorizer {
return [{ resource_id: ANY_RESOURCE, resource_scopes: [ ANY_SCOPE ] }];
}
-
- /** @inheritdoc */
- public async credentials(permissions: Permission[]): Promise {
- this.logger.info(`Skipping credentials. ${JSON.stringify(permissions)}`);
- return [{}];
- }
}
diff --git a/packages/uma/src/policies/authorizers/Authorizer.ts b/packages/uma/src/policies/authorizers/Authorizer.ts
index 393c6708..b616fb2f 100644
--- a/packages/uma/src/policies/authorizers/Authorizer.ts
+++ b/packages/uma/src/policies/authorizers/Authorizer.ts
@@ -1,4 +1,3 @@
-import { Requirements } from '../../credentials/Requirements';
import { ClaimSet } from '../../credentials/ClaimSet';
import { Permission } from '../../views/Permission';
@@ -17,18 +16,4 @@ export abstract class Authorizer {
* @return {Promise} - An Array of available Permissions.
*/
public abstract permissions(claims: ClaimSet, query?: Partial[]): Promise;
-
- /**
- * Calculates the required Credentials to achieve a set of given Permissions.
- *
- * @param {Permissions[]} permissions - The requested Permissions.
- * @param {Requirements} query - An optional query to constrain the calculated Requirements.
- *
- * @return {Promise} An object containing ClaimDescriptions.
- */
- public abstract credentials(permissions: Permission[], query?: Requirements): Promise;
- // TODO:
- // * @throws {ForbiddenHttpError} When no Credentials can be found (within the query limits)
- // * that would grant the requested Permissions.
-
}
diff --git a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts
index 65f841f9..96102d24 100644
--- a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts
@@ -1,6 +1,5 @@
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 { Authorizer } from './Authorizer';
@@ -52,30 +51,6 @@ export class NamespacedAuthorizer implements Authorizer {
return authorizer.permissions(claims, query);
}
- /** @inheritdoc */
- public async credentials(permissions: Permission[], query?: Requirements): Promise {
- this.logger.info(`Calculating credentials. ${JSON.stringify({ permissions, query })}`);
-
- // No requirements if no requested permissions
- if (!permissions || permissions.length === 0) return [];
-
- // Base namespace on first resource
- const ns = await this.findNamespace(permissions[0].resource_id);
-
- // Check namespaces of other resources
- for (let i = 1; i < permissions.length; ++i) {
- if (await this.findNamespace(permissions[i].resource_id) !== ns) {
- this.logger.warn(`Cannot calculate credentials over multiple namespaces at once.`);
- return [];
- }
- }
-
- // Find applicable authorizer
- const authorizer = (typeof ns === 'string' && this.authorizers[ns]) || this.fallback;
-
- return authorizer.credentials(permissions, query);
- }
-
/**
* Finds the applicable authorizer to use based on the input query.
*/
diff --git a/packages/uma/src/policies/authorizers/NoneAuthorizer.ts b/packages/uma/src/policies/authorizers/NoneAuthorizer.ts
index db5656e8..99c0e4b4 100644
--- a/packages/uma/src/policies/authorizers/NoneAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/NoneAuthorizer.ts
@@ -1,6 +1,5 @@
import { getLoggerFor } from 'global-logger-factory';
import { ClaimSet } from '../../credentials/ClaimSet';
-import { Requirements } from '../../credentials/Requirements';
import { Permission } from '../../views/Permission';
import { Authorizer } from './Authorizer';
@@ -14,11 +13,4 @@ export class NoneAuthorizer implements Authorizer {
public async permissions(claims: ClaimSet, query?: Partial[]): Promise {
return [];
}
-
- /** @inheritdoc */
- public async credentials(permissions: Permission[], query?: Requirements): Promise {
- this.logger.info(`Skipping credentials. ${JSON.stringify({ permissions, query })}`);
- // throw new ForbiddenHttpError(); // TODO: indicating impossibility to RS would save roundtrip
- return [];
- }
}
diff --git a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
index 7c750afb..dab44498 100644
--- a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
@@ -1,11 +1,10 @@
-import { BadRequestHttpError, DC, NotImplementedHttpError, RDF } from '@solid/community-server';
+import { DC, RDF } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
-import { DataFactory, Literal, NamedNode, Quad, Quad_Subject, Store } from 'n3';
+import { DataFactory, Literal, NamedNode, Quad, Quad_Subject, Store, Writer } from 'n3';
import { EyeReasoner, ODRLEngineMultipleSteps, ODRLEvaluator } from 'odrl-evaluator'
import { createVocabulary } from 'rdf-vocabulary';
import { CLIENTID, WEBID } from '../../credentials/Claims';
import { ClaimSet } from '../../credentials/ClaimSet';
-import { Requirements } from '../../credentials/Requirements';
import { basicPolicy } from '../../ucp/policy/ODRL';
import { UCPPolicy } from '../../ucp/policy/UsageControlPolicy';
import { UCRulesStorage } from '../../ucp/storage/UCRulesStorage';
@@ -94,8 +93,11 @@ export class OdrlAuthorizer implements Authorizer {
for (const {resource_id, resource_scopes} of query) {
grantedPermissions[resource_id] = [];
- const actions = transformActionsCssToOdrl(resource_scopes);
- for (const action of actions) {
+ for (const scope of resource_scopes) {
+ // TODO: why is this transformation happening (here)?
+ // IMO this should either happen on the RS,
+ // or the policies should just use the "CSS" modes (not really though)
+ const action = scopeCssToOdrl.get(scope) ?? scope;
this.logger.info(`Evaluating Request [S R AR]: [${subject} ${resource_id} ${action}]`);
const requestPolicy: UCPPolicy = {
type: ODRL.Request,
@@ -135,7 +137,7 @@ export class OdrlAuthorizer implements Authorizer {
const activeReports = policyReport.ruleReport.filter(
(report) => report.activationState === ActivationState.Active);
if (activeReports.length > 0 && activeReports[0].type === RuleReportType.PermissionReport) {
- grantedPermissions[resource_id].push(action);
+ grantedPermissions[resource_id].push(scope);
}
}
}
@@ -144,15 +146,10 @@ export class OdrlAuthorizer implements Authorizer {
Object.keys(grantedPermissions).forEach(
resource_id => permissions.push({
resource_id,
- resource_scopes: transformActionsOdrlToCss(grantedPermissions[resource_id])
+ resource_scopes: grantedPermissions[resource_id],
}) );
return permissions;
}
-
- public async credentials(permissions: Permission[], query?: Requirements | undefined): Promise {
- throw new NotImplementedHttpError('Method not implemented.');
- }
-
}
const scopeCssToOdrl: Map = new Map();
scopeCssToOdrl.set('urn:example:css:modes:read','http://www.w3.org/ns/odrl/2/read');
@@ -163,38 +160,6 @@ scopeCssToOdrl.set('urn:example:css:modes:write','http://www.w3.org/ns/odrl/2/wr
const scopeOdrlToCss : Map = new Map(Array.from(scopeCssToOdrl, entry => [entry[1], entry[0]]));
-/**
- * Transform the Actions enforced by the Community Solid Server to equivalent ODRL Actions
- * @param actions
- */
-function transformActionsCssToOdrl(actions: string[]): string[] {
- // scopes come from UmaClient.ts -> see CSS package
-
- // in UMAPermissionReader, only the last part of the URN will be used, divided by a colon
- // again, see CSS package
- return actions.map(action => {
- const result = scopeCssToOdrl.get(action);
- if (!result) {
- throw new BadRequestHttpError(`Unsupported action ${action}`);
- }
- return result;
- });
-}
-/**
- * Transform ODRL Actions to equivalent Actions enforced by the Community Solid Server
- * @param actions
- */
-function transformActionsOdrlToCss(actions: string[]): string[] {
- const cssActions = []
- for (const action of actions) {
- if (action === 'http://www.w3.org/ns/odrl/2/use'){
- return Array.from(scopeCssToOdrl.keys());
- }
- cssActions.push(scopeOdrlToCss.get(action)!);
- }
- return cssActions;
-}
-
type PolicyReport = {
id: NamedNode;
created: Literal;
diff --git a/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts b/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts
index 1e882252..e0e174ae 100644
--- a/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts
@@ -1,6 +1,5 @@
import { ANY_RESOURCE, ANY_SCOPE, Authorizer } from './Authorizer';
import { Permission } from '../../views/Permission';
-import { Requirements } from '../../credentials/Requirements';
import { ClaimSet } from '../../credentials/ClaimSet';
import { WEBID } from '../../credentials/Claims';
import { getLoggerFor } from 'global-logger-factory';
@@ -35,15 +34,4 @@ export class WebIdAuthorizer implements Authorizer {
})
);
}
-
- /** @inheritdoc */
- public async credentials(permissions: Permission[], query?: Requirements): Promise {
- this.logger.info(`Calculating credentials. ${JSON.stringify({ permissions, query })}`);
-
- if (query && !Object.keys(query).includes(WEBID)) return [];
-
- return [{
- [WEBID]: async (webid) => typeof webid === 'string' && this.webids.includes(webid),
- }];
- }
}
diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts
index 63d666f2..47f1bf5a 100644
--- a/packages/uma/src/routes/ResourceRegistration.ts
+++ b/packages/uma/src/routes/ResourceRegistration.ts
@@ -5,8 +5,10 @@ import {
ForbiddenHttpError,
InternalServerError,
joinUrl,
+ KeyValueStorage,
MethodNotAllowedHttpError,
- NotFoundHttpError, RDF,
+ NotFoundHttpError,
+ RDF,
} from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { DataFactory as DF, NamedNode, Quad, Quad_Subject, Store } from 'n3';
@@ -39,11 +41,13 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
protected readonly logger = getLoggerFor(this);
/**
+ * @param derivationStore - Key/value store linking derivation_resource_ids to their issuer.
* @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(
+ protected readonly derivationStore: KeyValueStorage,
protected readonly registrationStore: RegistrationStore,
protected readonly policies: UCRulesStorage,
protected readonly validator: RequestValidator,
@@ -121,7 +125,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
}
// Update the resource metadata
- await this.setResourceMetadata(parameters.id, body, owner);
+ await this.setResourceMetadata(parameters.id, body, owner, entry.description);
return ({
status: 200,
@@ -157,11 +161,13 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
* @param id - The identifier of the resource.
* @param description - The new {@link ResourceDescription} for the resource.
* @param owner - The owner of the resource.
+ * @param previous - The previously stored {@link ResourceDescription}, if there is one.
*/
- protected async setResourceMetadata(id: string, description: ResourceDescription, owner: string): Promise {
+ protected async setResourceMetadata(id: string, description: ResourceDescription, owner: string,
+ previous?: ResourceDescription): Promise {
const policyStore = await this.policies.getStore();
- const collectionQuads = await this.updateCollections(policyStore, id, description);
- const relationQuads = await this.updateRelations(policyStore, id, description);
+ const collectionQuads = await this.updateCollections(policyStore, id, description, previous);
+ const relationQuads = await this.updateRelations(policyStore, id, description, previous);
const addQuads = [ ...collectionQuads.add, ...relationQuads.add ];
if (addQuads.length > 0) {
await this.policies.addRule(new Store([...collectionQuads.add, ...relationQuads.add]));
@@ -171,6 +177,17 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
await this.policies.removeData(new Store([...collectionQuads.remove, ...relationQuads.remove]));
}
+ // Update the stored derivation IDs accordingly
+ const derivedEntries = description.derived_from ?? [];
+ const removedDerivedIds = new Set((previous?.derived_from ?? []).map((entry) => entry.derivation_resource_id));
+ for (const entry of derivedEntries) {
+ await this.derivationStore.set(entry.derivation_resource_id, entry.issuer);
+ removedDerivedIds.delete(entry.derivation_resource_id);
+ }
+ for (const id of removedDerivedIds) {
+ await this.derivationStore.delete(id);
+ }
+
// 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.
diff --git a/packages/uma/src/ticketing/Ticket.ts b/packages/uma/src/ticketing/Ticket.ts
index d309d183..632be23f 100644
--- a/packages/uma/src/ticketing/Ticket.ts
+++ b/packages/uma/src/ticketing/Ticket.ts
@@ -1,9 +1,10 @@
import { ClaimSet } from '../credentials/ClaimSet';
-import { Requirements } from '../credentials/Requirements';
import { Permission } from '../views/Permission';
export interface Ticket {
+ // These are identifiers that were not originally part of the request
+ // but got included because other identifiers require them.
+ derivedIds?: string[],
permissions: Permission[],
- required: Requirements[],
provided: ClaimSet,
}
diff --git a/packages/uma/src/ticketing/strategy/AggregatorStrategy.ts b/packages/uma/src/ticketing/strategy/AggregatorStrategy.ts
new file mode 100644
index 00000000..e68cc1a2
--- /dev/null
+++ b/packages/uma/src/ticketing/strategy/AggregatorStrategy.ts
@@ -0,0 +1,179 @@
+import { BadRequestHttpError, InternalServerError, KeyValueStorage } from '@solid/community-server';
+import { getLoggerFor } from 'global-logger-factory';
+import { ACCESS } from '../../credentials/Claims';
+import { ClaimSet } from '../../credentials/ClaimSet';
+import { RequiredClaim } from '../../errors/NeedInfoError';
+import { ODRL, UMA_SCOPES } from '../../ucp/util/Vocabularies';
+import { decodeAggregateId } from '../../util/AggregatorUtil';
+import { RegistrationStore } from '../../util/RegistrationStore';
+import { Failure, Result } from '../../util/Result';
+import { Permission } from '../../views/Permission';
+import { Ticket } from '../Ticket';
+import { TicketingStrategy } from './TicketingStrategy';
+
+/**
+ * TicketingStrategy that handles urn:knows:uma:scopes:read requests.
+ */
+export class AggregatorStrategy implements TicketingStrategy {
+ protected readonly logger = getLoggerFor(this);
+
+ public constructor(
+ protected readonly strategy: TicketingStrategy,
+ protected readonly registrationStore: RegistrationStore,
+ protected readonly derivationStore: KeyValueStorage,
+ ) {}
+
+ public async initializeTicket(permissions: Permission[]): Promise {
+ const additionalPermissions: Permission[] = [];
+ const derivedIds: string[] = [];
+ // When on aggregator AS side: add derivation-read permission requirements for all derived_from resources of target
+ for (const { resource_id: id, resource_scopes: scopes } of permissions) {
+ const registration = await this.registrationStore.get(id);
+ const derivedFrom = registration?.description.derived_from ?? [];
+ if (derivedFrom.length > 0) {
+ if (scopes.length > 1 || scopes[0] !== ODRL.read) {
+ throw new BadRequestHttpError(
+ `Derived resources are only supported with http://www.w3.org/ns/odrl/2/read permissions, received ${scopes}`);
+ }
+ }
+ for (const derived of derivedFrom) {
+ derivedIds.push(derived.derivation_resource_id);
+ additionalPermissions.push({
+ resource_id: derived.derivation_resource_id,
+ resource_scopes: [ UMA_SCOPES['derivation-read']]
+ });
+ }
+ }
+
+ const ticket = await this.strategy.initializeTicket([ ...permissions, ...additionalPermissions ]);
+ if (additionalPermissions.length > 0) {
+ this.logger.info(`Adding additional derivation permission requirements: ${
+ JSON.stringify(additionalPermissions)}`);
+ }
+
+ return {
+ ...ticket,
+ derivedIds: [...derivedIds, ...ticket.derivedIds ?? []],
+ }
+ }
+
+ public async validateClaims(ticket: Ticket, claims: ClaimSet): Promise {
+ // These IDs will be present on aggregator AS side, if the correct access tokens were provided
+ const derivedReadIds = new Set();
+ if (Array.isArray(claims[ACCESS])) {
+ for (const { resource_id: id, resource_scopes: scopes } of claims[ACCESS]) {
+ if (scopes.includes(UMA_SCOPES['derivation-read'])) {
+ derivedReadIds.add(id);
+ }
+ }
+ }
+
+ // On aggregator AS: see if we got the correct access tokens for the derivation-read requests
+ const updatedPermissions: Permission[] = [];
+ for (const permission of ticket.permissions) {
+ const { resource_id: id, resource_scopes: scopes } = permission;
+ if (ticket.derivedIds?.includes(id) && derivedReadIds.has(id) && scopes.includes(UMA_SCOPES['derivation-read'])) {
+ const remainingScopes = scopes.filter((scope) => scope !== UMA_SCOPES['derivation-read']);
+ if (remainingScopes.length > 0) {
+ updatedPermissions.push({ resource_id: id, resource_scopes: remainingScopes });
+ }
+ } else {
+ // Non-derivation permissions
+ updatedPermissions.push(permission);
+ }
+ }
+
+ const { ticket: newTicket, decodedIds } = await this.decodeIds({ ...ticket, permissions: updatedPermissions });
+
+ // Send ticket with updated identifiers to source strategy
+ const sourceTicket = await this.strategy.validateClaims(newTicket, claims);
+
+ return this.encodeIds(sourceTicket, decodedIds);
+ }
+
+ public async resolveTicket(ticket: Ticket): Promise> {
+ const requiredClaims: RequiredClaim[] = [];
+
+ // If there are still derivation-read permissions left, and this is an aggregator AS, there were insufficient claims
+ for (const permission of ticket.permissions) {
+ const { resource_id: id, resource_scopes: scopes } = permission;
+ if (ticket.derivedIds?.includes(id) && scopes.includes(UMA_SCOPES['derivation-read'])) {
+ const issuer = await this.derivationStore.get(id);
+ if (!issuer) {
+ throw new InternalServerError(`Missing issuer for derivation identifier ${id}`);
+ }
+ requiredClaims.push({
+ claim_token_format: 'urn:ietf:params:oauth:token-type:access_token',
+ issuer,
+ derivation_resource_id: id,
+ resource_scopes: [UMA_SCOPES['derivation-read']],
+ });
+ }
+ }
+
+ const { ticket: decodedTicket, decodedIds } = await this.decodeIds(ticket);
+ const result = await this.strategy.resolveTicket(decodedTicket);
+
+ if (result.success) {
+ if (requiredClaims.length > 0) {
+ return Failure(requiredClaims);
+ }
+ // Encode success values again to not leak actual resource IDs
+ return {
+ success: true,
+ value: result.value.map((perm) => {
+ const encoded = decodedIds[perm.resource_id];
+ if (encoded) {
+ return { resource_id: encoded, resource_scopes: perm.resource_scopes }
+ }
+ return perm;
+ })
+ }
+ }
+ // TODO: Failure result could potentially contain decoded IDs somewhere, but since the format is not set there yet, this could be anywhere
+ return Failure([...result.value, ...requiredClaims]);
+ }
+
+ protected async decodeIds(ticket: Ticket): Promise<{ ticket: Ticket, decodedIds: Record }> {
+ const decodedIds: Record = {};
+ const updatedPermissions: Permission[] = [];
+ for (const permission of ticket.permissions) {
+ const { resource_id: id, resource_scopes: scopes } = permission;
+
+ // Ignore derived IDs as the client is not directly trying to access those
+ if (!ticket.derivedIds?.includes(id) && scopes.includes(UMA_SCOPES['derivation-read'])) {
+ // On source AS: client is requesting the access token to get derivation-read permission.
+ // These need to be converted to the actual internal identifiers.
+ try {
+ const decodedId = await decodeAggregateId(id);
+ decodedIds[decodedId] = id;
+ updatedPermissions.push({ resource_id: decodedId, resource_scopes: scopes });
+ } catch {
+ // Handle non-encoded IDs
+ updatedPermissions.push(permission);
+ }
+ } else {
+ updatedPermissions.push(permission);
+ }
+ }
+ return {
+ ticket: { ...ticket, permissions: updatedPermissions, provided: ticket.provided },
+ decodedIds,
+ }
+ }
+
+ protected encodeIds(ticket: Ticket, decodedIds: Record): Ticket {
+ if (Object.keys(decodedIds).length === 0) {
+ return ticket;
+ }
+
+ const encodedPermissions = ticket.permissions.map((perm): Permission => {
+ return { resource_id: decodedIds[perm.resource_id] ?? perm.resource_id, resource_scopes: perm.resource_scopes };
+ })
+ return {
+ ...ticket,
+ permissions: encodedPermissions,
+ provided: ticket.provided,
+ }
+ }
+}
diff --git a/packages/uma/src/ticketing/strategy/ClaimEliminationStrategy.ts b/packages/uma/src/ticketing/strategy/ClaimEliminationStrategy.ts
deleted file mode 100644
index 2ad2378d..00000000
--- a/packages/uma/src/ticketing/strategy/ClaimEliminationStrategy.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { getLoggerFor } from 'global-logger-factory';
-import { ClaimSet } from '../../credentials/ClaimSet';
-import { Requirements } from '../../credentials/Requirements';
-import { Authorizer } from '../../policies/authorizers/Authorizer';
-import { Failure, Result, Success } from '../../util/Result';
-import { Permission } from '../../views/Permission';
-import { Ticket } from '../Ticket';
-import { TicketingStrategy } from './TicketingStrategy';
-
-/**
- * A TicketingStrategy that calculates all necessary Claims for a given Permissions
- * upon initialization if a Ticket, and eliminates those Claims upon validation.
- * When all necessary Claims are eliminated, the Ticket resolves to the initial
- * requested Permissions.
- */
-export class ClaimEliminationStrategy implements TicketingStrategy {
- protected readonly logger = getLoggerFor(this);
-
- constructor(
- protected authorizer: Authorizer,
- ) {}
-
- /** @inheritdoc */
- public async initializeTicket(permissions: Permission[]): Promise {
- this.logger.info(`Initializing ticket. ${JSON.stringify(permissions)}`)
-
- return ({
- permissions,
- required: await this.calculateRequiredClaims(permissions),
- provided: {}
- });
- }
-
- protected async calculateRequiredClaims(permissions: Permission[]): Promise {
- return this.authorizer.credentials(permissions);
- }
-
- /** @inheritdoc */
- public async validateClaims(ticket: Ticket, claims: ClaimSet): Promise {
- this.logger.debug(`Validating claims. ${JSON.stringify({ ticket, claims })}`);
-
- for (const key of Object.keys(claims)) {
- ticket.provided[key] = claims[key];
-
- for (const requirements of ticket.required) {
- const requirement = requirements[key];
-
- if (requirement && await requirement(claims[key])) {
- delete requirements[key];
- }
- }
- }
-
- return ticket;
- }
-
- /** @inheritdoc {@link TicketingStrategy.resolveTicket} */
- public async resolveTicket(ticket: Ticket): Promise> {
- this.logger.debug(`Resolving ticket. ${JSON.stringify(ticket)}`);
-
- return ticket.required.some(req => Object.keys(req).length === 0)
- ? Success(ticket.permissions)
- : Failure(ticket.required);
- }
-}
diff --git a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts
index 9e8042bd..664da7ac 100644
--- a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts
+++ b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts
@@ -1,11 +1,11 @@
import { ClaimSet } from "../../credentials/ClaimSet";
+import { RequiredClaim } from '../../errors/NeedInfoError';
import { Ticket } from "../Ticket";
import { getLoggerFor } from 'global-logger-factory';
import { Permission } from "../../views/Permission";
import { Failure, Result, Success } from "../../util/Result";
import { TicketingStrategy } from "./TicketingStrategy";
import { Authorizer } from "../../policies/authorizers/Authorizer";
-import type { Requirements } from "../../credentials/Requirements";
/**
* A TicketingStrategy that simply stores provided Claims, and calculates all
@@ -22,11 +22,10 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy {
public async initializeTicket(permissions: Permission[]): Promise {
this.logger.info(`Initializing ticket. ${JSON.stringify(permissions)}`)
- return ({
+ return {
permissions,
- required: [{}],
provided: {}
- });
+ };
}
/** @inheritdoc */
@@ -41,13 +40,40 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy {
}
/** @inheritdoc */
- public async resolveTicket(ticket: Ticket): Promise> {
+ public async resolveTicket(ticket: Ticket): Promise> {
this.logger.info(`Resolving ticket. ${JSON.stringify(ticket)}`);
const permissions = await this.calculatePermissions(ticket);
if (permissions.length === 0) return Failure([]);
+ // TODO: if, in the future, we want to allow partial results, this will need to change
+ // Verify all required scopes have been granted
+ const unmatchedPermissions: Permission[] = [];
+ for (const required of ticket.permissions) {
+ const scopeMatch = Object.fromEntries(required.resource_scopes.map((scope) => [ scope, false ]));
+ for (const result of permissions) {
+ if (required.resource_id !== result.resource_id) {
+ continue;
+ }
+ for (const scope of result.resource_scopes) {
+ scopeMatch[scope] = true;
+ }
+ }
+ const unmatchedScopes = Object.keys(scopeMatch).filter((scope) => !scopeMatch[scope]);
+ if (unmatchedScopes.length > 0) {
+ unmatchedPermissions.push({ resource_id: required.resource_id, resource_scopes: unmatchedScopes });
+ }
+ }
+
+ if (unmatchedPermissions.length > 0) {
+ // TODO: due to the current format, scopes are not linked to resources,
+ // so this will be weird for requests with multiple target resources.
+ return Failure([{
+ resource_scopes: unmatchedPermissions.flatMap((perm) => perm.resource_scopes),
+ }]);
+ }
+
return Success(permissions);
}
diff --git a/packages/uma/src/ticketing/strategy/TicketingStrategy.ts b/packages/uma/src/ticketing/strategy/TicketingStrategy.ts
index 0b936ae4..5be7fd2e 100644
--- a/packages/uma/src/ticketing/strategy/TicketingStrategy.ts
+++ b/packages/uma/src/ticketing/strategy/TicketingStrategy.ts
@@ -1,8 +1,8 @@
import { ClaimSet } from "../../credentials/ClaimSet";
+import { RequiredClaim } from '../../errors/NeedInfoError';
import { Ticket } from "../Ticket";
import { Permission } from "../../views/Permission";
import { Result } from "../../util/Result";
-import type { Requirements } from "../../credentials/Requirements";
/**
* A strategy interface for different actions on Tickets.
@@ -11,40 +11,40 @@ export interface TicketingStrategy {
/**
* Initializes a Ticket based on requested Permissions.
- *
+ *
* Tickets should always be created using this function, as it enables initial
* preprocessing of a new Ticket before starting its life cycle.
- *
+ *
* @param permissions - An Array of requested Permissions.
- *
+ *
* @returns A Ticket based on the requested Permissions.
*/
initializeTicket(permissions: Permission[]): Promise;
/**
* Validates Claims in the context of a Ticket.
- *
+ *
* This function should be called whenever (new) Claims are presented for
- * resolving a Ticket.
- *
+ * resolving a Ticket.
+ *
* @param ticket - The Ticket for which to validate the Claims.
* @param claims - The set of Claims to validate.
- *
+ *
* @returns An upgraded Ticket in which the Claims have been validated.
*/
validateClaims(ticket: Ticket, claims: ClaimSet): Promise;
/**
* Resolves a Ticket.
- *
+ *
* This function can be used to check whether a Ticket can be resolved. If
* so, it should return a list of Permissions; otherwise, it should return
* the list of Claims that still have to be presented.
- *
+ *
* @param ticket - The Ticket to resolve.
- *
- * @returns A Result with an Array of Permissions as Success value, or an
- * Dict of Claim descriptions as Failure value.
+ *
+ * @returns A Result with an Array of Permissions as Success value, or a
+ * Dict of required claims as Failure value.
*/
- resolveTicket(ticket: Ticket): Promise>;
+ resolveTicket(ticket: Ticket): Promise>;
}
diff --git a/packages/uma/src/ucp/util/Vocabularies.ts b/packages/uma/src/ucp/util/Vocabularies.ts
index 4f109d5e..c723a5eb 100644
--- a/packages/uma/src/ucp/util/Vocabularies.ts
+++ b/packages/uma/src/ucp/util/Vocabularies.ts
@@ -133,6 +133,7 @@ export const ODRL = createVocabulary(
'lt',
'eq',
'uid',
+ 'read',
);
export const ODRL_P = createVocabulary(
@@ -144,3 +145,9 @@ export const OWL = createVocabulary(
'http://www.w3.org/2002/07/owl#',
'inverseOf',
);
+
+export const UMA_SCOPES = createVocabulary(
+ 'urn:knows:uma:scopes:',
+ 'derivation-creation',
+ 'derivation-read',
+);
diff --git a/packages/uma/src/util/AggregatorUtil.ts b/packages/uma/src/util/AggregatorUtil.ts
new file mode 100644
index 00000000..ce4b418b
--- /dev/null
+++ b/packages/uma/src/util/AggregatorUtil.ts
@@ -0,0 +1,21 @@
+import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
+
+// Used to convert stored identifiers into aggregate identifiers.
+// This way we don't need to store mappings between derived and actual identifiers.
+const key = randomBytes(32);
+const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
+
+export async function encodeAggregateId(id: string): Promise {
+ const iv = randomBytes(12);
+ const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
+ let encrypted = cipher.update(id, 'utf8', 'hex') + cipher.final('hex');
+ const authTag = cipher.getAuthTag();
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
+}
+
+export async function decodeAggregateId(payload: string): Promise {
+ const [ ivHex, authTagHex, encrypted ] = payload.split(':');
+ const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, Buffer.from(ivHex, 'hex'));
+ decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
+ return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8');
+}
diff --git a/packages/uma/src/views/ResourceDescription.ts b/packages/uma/src/views/ResourceDescription.ts
index 713a91a5..07c42789 100644
--- a/packages/uma/src/views/ResourceDescription.ts
+++ b/packages/uma/src/views/ResourceDescription.ts
@@ -1,9 +1,10 @@
-import { Type, array, optional as $, string, dict, union } from '../util/ReType';
+import { array, dict, intersection, optional as $, string, Type, union } from '../util/ReType';
export const ResourceDescription = {
resource_scopes: array(string),
resource_defaults: $(union({ '@reverse': dict(array(string)) }, dict(array(string)))),
resource_relations: $(union({ '@reverse': dict(array(string)) }, dict(array(string)))),
+ derived_from: $(array({ issuer: string, derivation_resource_id: string })),
type: $(string),
name: $(string),
icon_uri: $(string),
diff --git a/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts
index 3fccc757..735084e0 100644
--- a/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts
+++ b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts
@@ -1,8 +1,11 @@
import * as accessTokenVerifier from '@solid/access-token-verifier';
+import { KeyValueStorage } from '@solid/community-server';
import { JWTPayload } from 'jose';
import * as jose from 'jose';
-import { MockInstance } from 'vitest';
+import { Mocked, MockInstance } from 'vitest';
+import { ACCESS } from '../../../../src/credentials/Claims';
import { Credential } from '../../../../src/credentials/Credential';
+import { ACCESS_TOKEN } from '../../../../src/credentials/Formats';
import { OidcVerifier } from '../../../../src/credentials/verify/OidcVerifier';
vi.mock('jose', () => ({
@@ -14,16 +17,9 @@ vi.mock('jose', () => ({
describe('OidcVerifier', (): void => {
const issuer = 'http://example.org/issuer';
const baseUrl = 'http://example.com/uma';
- const credential: Credential = {
- format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken',
- token: 'token',
- };
-
- const decodedToken: JWTPayload = {
- sub: 'sub',
- iss: issuer,
- aud: baseUrl,
- };
+ let credential: Credential;
+
+ let decodedToken: JWTPayload;
const remoteKeySet = 'remoteKeySet';
const decodeJwt = vi.spyOn(jose, 'decodeJwt');
const jwtVerify = vi.spyOn(jose, 'jwtVerify');
@@ -31,9 +27,21 @@ describe('OidcVerifier', (): void => {
const fetchMock = vi.spyOn(global, 'fetch');
const verifierMock = vi.fn();
vi.spyOn(accessTokenVerifier, 'createSolidTokenVerifier').mockReturnValue(verifierMock);
+ let derivationStore: Mocked>
let verifier: OidcVerifier;
beforeEach(async(): Promise => {
+ credential = {
+ format: 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken',
+ token: 'token',
+ };
+
+ decodedToken = {
+ sub: 'sub',
+ iss: issuer,
+ aud: baseUrl,
+ };
+
vi.clearAllMocks();
fetchMock.mockResolvedValue({
status: 200,
@@ -48,7 +56,11 @@ describe('OidcVerifier', (): void => {
client_id: 'clientId'
});
- verifier = new OidcVerifier(baseUrl)
+ derivationStore = {
+ get: vi.fn(),
+ } satisfies Partial> as any;
+
+ verifier = new OidcVerifier(baseUrl, derivationStore);
});
it('errors on non-OIDC credentials.', async(): Promise => {
@@ -57,10 +69,10 @@ describe('OidcVerifier', (): void => {
});
it('errors if the issuer is not allowed.', async(): Promise => {
- verifier = new OidcVerifier(baseUrl, [ 'otherIssuer' ]);
+ verifier = new OidcVerifier(baseUrl, derivationStore, [ 'otherIssuer' ]);
await expect(verifier.verify(credential)).rejects.toThrow('Unsupported issuer');
- verifier = new OidcVerifier(baseUrl, [ issuer ]);
+ verifier = new OidcVerifier(baseUrl, derivationStore, [ issuer ]);
await expect(verifier.verify(credential)).resolves.toEqual({
['urn:solidlab:uma:claims:types:webid']: 'sub',
});
@@ -68,7 +80,7 @@ describe('OidcVerifier', (): void => {
describe('parsing a Solid OIDC token', (): void => {
beforeEach(async(): Promise => {
- decodeJwt.mockReturnValue({ ...decodedToken, aud: [ baseUrl, 'solid' ] });
+ decodeJwt.mockReturnValue({ ...decodedToken, aud: [ baseUrl, 'solid' ], webid: 'webId' });
});
it('returns the extracted WebID.', async(): Promise => {
@@ -80,14 +92,14 @@ describe('OidcVerifier', (): void => {
it('throws an error if the token could not be verified.', async(): Promise => {
verifierMock.mockRejectedValueOnce(new Error('bad data'));
- await expect(verifier.verify(credential)).rejects.toThrow('Error verifying OIDC ID Token: bad data');
+ await expect(verifier.verify(credential)).rejects.toThrow('Error verifying OIDC Token: bad data');
});
});
describe('parsing a standard OIDC token', (): void => {
it('errors if the sub claim is missing', async(): Promise => {
jwtVerify.mockResolvedValue({ payload: { ...decodedToken, sub: undefined } } as any);
- await expect(verifier.verify(credential)).rejects.toThrow('Invalid OIDC token: missing `sub` claim');
+ await expect(verifier.verify(credential)).rejects.toThrow('Invalid OIDC ID token: missing `sub` claim');
});
it('returns the extracted identity.', async(): Promise => {
@@ -105,4 +117,36 @@ describe('OidcVerifier', (): void => {
});
});
});
+
+ describe('parsing access tokens', (): void => {
+ beforeEach(async(): Promise => {
+ credential.format = 'urn:ietf:params:oauth:token-type:access_token';
+ });
+
+ it('returns the matching derivation-read access claims.', async(): Promise => {
+ decodedToken.permissions = [
+ { resource_id: 'id1', resource_scopes: [ 'scope1', 'urn:knows:uma:scopes:derivation-read' ] },
+ { resource_id: 'id2', resource_scopes: [ 'scope2', 'urn:knows:uma:scopes:derivation-read' ] },
+ { resource_id: 'id3', resource_scopes: [ 'scope3' ] },
+ ];
+ decodedToken.iss = 'issuer';
+ derivationStore.get.mockImplementation(async (id) => 'issuer');
+ await expect(verifier.verify(credential)).resolves.toEqual({
+ ['urn:solidlab:uma:claims:types:access']: [
+ { resource_id: 'id1', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ] },
+ { resource_id: 'id2', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ] },
+ ],
+ });
+ });
+
+ it('errors on issuer mismatch.', async(): Promise => {
+ decodedToken.permissions = [
+ { resource_id: 'id1', resource_scopes: [ 'scope1', 'urn:knows:uma:scopes:derivation-read' ] },
+ ];
+ decodedToken.iss = 'wrong-issuer';
+ derivationStore.get.mockImplementation(async (id) => 'issuer');
+ await expect(verifier.verify(credential)).rejects
+ .toThrow('Invalid issuer for id1, expected issuer but got wrong-issuer');
+ });
+ });
});
diff --git a/packages/uma/test/unit/dialog/AggregatorNegotiator.test.ts b/packages/uma/test/unit/dialog/AggregatorNegotiator.test.ts
new file mode 100644
index 00000000..11783223
--- /dev/null
+++ b/packages/uma/test/unit/dialog/AggregatorNegotiator.test.ts
@@ -0,0 +1,95 @@
+import { KeyValueStorage } from '@solid/community-server';
+import { Mocked } from 'vitest';
+import { AggregatorNegotiator } from '../../../src/dialog/AggregatorNegotiator';
+import { DialogInput } from '../../../src/dialog/Input';
+import { Negotiator } from '../../../src/dialog/Negotiator';
+import { DialogOutput } from '../../../src/dialog/Output';
+import { Ticket } from '../../../src/ticketing/Ticket';
+import { decodeAggregateId, encodeAggregateId } from '../../../src/util/AggregatorUtil';
+import { RegistrationStore } from '../../../src/util/RegistrationStore';
+
+describe('AggregatorNegotiator', (): void => {
+ let input: DialogInput;
+ let output: DialogOutput;
+ let ticket: Ticket;
+ let source: Mocked;
+ let ticketStore: Mocked>;
+ let registrationStore: Mocked;
+ let negotiator: AggregatorNegotiator;
+
+ beforeEach(async(): Promise => {
+ input = {
+ grant_type: 'grant',
+ ticket: 'ticket',
+ scope: 'urn:knows:uma:scopes:derivation-creation',
+ };
+
+ output = {
+ access_token: 'access_token',
+ token_type: 'Bearer',
+ };
+
+ ticket = {
+ permissions: [{ resource_id: 'id', resource_scopes: [ 'scope' ] }],
+ provided: {},
+ };
+
+ source = {
+ negotiate: vi.fn().mockResolvedValue(output),
+ } satisfies Partial as any;
+
+ ticketStore = {
+ get: vi.fn().mockResolvedValue(ticket),
+ set: vi.fn(),
+ } satisfies Partial> as any;
+
+ registrationStore = {} satisfies Partial as any;
+
+ negotiator = new AggregatorNegotiator(source, ticketStore, registrationStore);
+ });
+
+ it('returns the source result if the scope has not been set.', async(): Promise => {
+ input = {};
+ await expect(negotiator.negotiate(input)).resolves.toEqual(output);
+ expect(source.negotiate).toHaveBeenCalledExactlyOnceWith(input);
+ });
+
+ it('errors if there are multiple target resources in the ticket.', async(): Promise => {
+ ticket.permissions = [
+ { resource_id: 'id1', resource_scopes: [ 'scope1' ] },
+ { resource_id: 'id1', resource_scopes: [ 'scope2' ] },
+ ];
+ await expect(negotiator.negotiate(input)).rejects
+ .toThrow('Aggregate token requests require exactly 1 target resource identifier');
+ expect(source.negotiate).toHaveBeenCalledTimes(0);
+ expect(ticketStore.get).toHaveBeenCalledExactlyOnceWith('ticket');
+ });
+
+ it('errors if no ticket was found.', async(): Promise => {
+ ticketStore.get.mockResolvedValueOnce(undefined);
+ await expect(negotiator.negotiate(input)).rejects.toThrow('Unknown ticket ID');
+ expect(source.negotiate).toHaveBeenCalledTimes(0);
+ expect(ticketStore.get).toHaveBeenCalledExactlyOnceWith('ticket');
+ });
+
+ it('errors if no ticket was provided.', async(): Promise => {
+ delete input.ticket;
+ await expect(negotiator.negotiate(input)).rejects.toThrow('Aggregators are only supported when using tickets.');
+ expect(source.negotiate).toHaveBeenCalledTimes(0);
+ });
+
+ it('adds a derivation ID if negotiation was successful.', async(): Promise => {
+ const result = await negotiator.negotiate(input);
+ expect(result.derivation_resource_id).toBeDefined();
+ expect(result).toEqual({
+ ...output,
+ derivation_resource_id: result.derivation_resource_id,
+ });
+ await expect(decodeAggregateId(result.derivation_resource_id!)).resolves.toBe('id');
+ expect(ticketStore.set).toHaveBeenCalledExactlyOnceWith('ticket', {
+ permissions: [{ resource_id: 'id', resource_scopes: [ 'scope', 'urn:knows:uma:scopes:derivation-creation' ] }],
+ provided: {},
+ });
+ expect(ticketStore.set).toHaveBeenCalledBefore(source.negotiate);
+ });
+});
diff --git a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts
index b88a3bdc..e2ba3b18 100644
--- a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts
+++ b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts
@@ -1,10 +1,10 @@
-import { BadRequestHttpError, ForbiddenHttpError, KeyValueStorage } from '@solid/community-server';
+import { ForbiddenHttpError, KeyValueStorage } from '@solid/community-server';
import { Mocked } from 'vitest';
import { ClaimSet } from '../../../src/credentials/ClaimSet';
import { Verifier } from '../../../src/credentials/verify/Verifier';
import { BaseNegotiator } from '../../../src/dialog/BaseNegotiator';
import { DialogInput } from '../../../src/dialog/Input';
-import { NeedInfoError, RequiredClaimsInfo } from '../../../src/errors/NeedInfoError';
+import { NeedInfoError } from '../../../src/errors/NeedInfoError';
import { TicketingStrategy } from '../../../src/ticketing/strategy/TicketingStrategy';
import { Ticket } from '../../../src/ticketing/Ticket';
import { SerializedToken, TokenFactory } from '../../../src/tokens/TokenFactory';
@@ -19,7 +19,6 @@ describe('BaseNegotiator', (): void => {
const claims: ClaimSet = { claim1: 'value1', claim2: 'value2' };
const ticket: Ticket = {
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ],
- required: [],
provided: { claim: 'value' },
};
const token: SerializedToken = { token: 'token', tokenType: 'type' };
@@ -95,15 +94,14 @@ describe('BaseNegotiator', (): void => {
ticketingStrategy.initializeTicket.mockResolvedValueOnce({
permissions: [],
provided: {},
- required: [{ fn: async() => false }],
})
- ticketingStrategy.resolveTicket.mockResolvedValueOnce({ success: false, value: [] });
+ ticketingStrategy.resolveTicket.mockResolvedValueOnce({ success: false, value: [{ name: 'missing' }] });
try {
await negotiator.negotiate(input);
} catch (error) {
expect(error).toBeInstanceOf(NeedInfoError);
expect((error as NeedInfoError).additionalParams).toEqual({
- required_claims: { claim_token_format: [['fn']] },
+ required_claims: [{ name: 'missing' }],
});
}
expect(ticketStore.set).toHaveBeenCalledTimes(1);
@@ -134,10 +132,10 @@ describe('BaseNegotiator', (): void => {
});
it('errors if invalid credentials are provided.', async(): Promise => {
- await expect(negotiator.negotiate({ ...input, claim_token: 'token' }))
- .rejects.toThrow('Request with a "claim_token" must contain a "claim_token_format".');
- await expect(negotiator.negotiate({ ...input, claim_token_format: 'format' }))
- .rejects.toThrow('Request with a "claim_token_format" must contain a "claim_token".');
+ await expect(negotiator.negotiate({ ...input, claim_token: 'token' })).rejects.toThrow(
+ 'Every claim requires both a token and format, received { claim_token: token, claim_token_format: undefined }');
+ await expect(negotiator.negotiate({ ...input, claim_token_format: 'format' })).rejects.toThrow(
+ 'Every claim requires both a token and format, received { claim_token: undefined, claim_token_format: format }');
});
it('processes the credentials if they are provided.', async(): Promise => {
@@ -156,4 +154,21 @@ describe('BaseNegotiator', (): void => {
expect(tokenFactory.serialize).toHaveBeenLastCalledWith(
{ permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } });
});
+
+ it('supports multiple claim tokens.', async(): Promise => {
+ await expect(negotiator.negotiate({ ...input,
+ claim_token: [
+ { claim_token: 'token', claim_token_format: 'format' },
+ { claim_token: 'token2', claim_token_format: 'format2' },
+ ],
+ })).resolves
+ .toEqual({ access_token: 'token', token_type: 'type' });
+ expect(ticketingStrategy.initializeTicket).toHaveBeenCalledTimes(1);
+ expect(ticketingStrategy.initializeTicket).toHaveBeenLastCalledWith(input.permissions);
+ expect(verifier.verify).toHaveBeenCalledTimes(2);
+ expect(verifier.verify).toHaveBeenCalledWith({ token: 'token', format: 'format' });
+ expect(verifier.verify).toHaveBeenCalledWith({ token: 'token2', format: 'format2' });
+ expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(2);
+ expect(ticketingStrategy.validateClaims).toHaveBeenCalledWith(ticket, claims);
+ });
});
diff --git a/packages/uma/test/unit/policies/authorizers/AllAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/AllAuthorizer.test.ts
index 8fddb2b1..90fdd57d 100644
--- a/packages/uma/test/unit/policies/authorizers/AllAuthorizer.test.ts
+++ b/packages/uma/test/unit/policies/authorizers/AllAuthorizer.test.ts
@@ -21,8 +21,4 @@ describe('AllAuthorizer', (): void => {
{ resource_id: 'urn:solidlab:uma:resources:any', resource_scopes: [ 'scope3' ]},
]);
});
-
- it('has no requirements.', async(): Promise => {
- await expect(authorizer.credentials([])).resolves.toEqual([{}]);
- });
});
diff --git a/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts
index ef5e0d22..3577a127 100644
--- a/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts
+++ b/packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts
@@ -14,11 +14,11 @@ describe('NamespacedAuthorizer', (): void => {
beforeEach(async(): Promise => {
authorizers = {
- ns1: { permissions: vi.fn().mockResolvedValue('perm1'), credentials: vi.fn().mockResolvedValue('cred1'), },
- ns2: { permissions: vi.fn().mockResolvedValue('perm2'), credentials: vi.fn().mockResolvedValue('cred2'), },
+ ns1: { permissions: vi.fn().mockResolvedValue('perm1'), },
+ ns2: { permissions: vi.fn().mockResolvedValue('perm2'), },
};
- fallback = { permissions: vi.fn().mockResolvedValue('perm'), credentials: vi.fn().mockResolvedValue('cred'), };
+ fallback = { permissions: vi.fn().mockResolvedValue('perm'), };
const descriptions: Record = {
res1: { description: { name: 'http://example.com/foo/ns1/res', resource_scopes: [] }, owner: 'owner1' },
@@ -64,38 +64,4 @@ describe('NamespacedAuthorizer', (): void => {
expect(fallback.permissions).toHaveBeenCalledWith(claims, query2);
});
});
-
- describe('.credentials', (): void => {
- const query = { key: vi.fn() };
-
- it('returns an empty list if there are no permissions or multiple identifiers.', async(): Promise => {
- await expect(authorizer.credentials([])).resolves.toEqual([]);
- const permissions = [{ resource_id: 'res1', resource_scopes: [] }, { resource_id: 'res2', resource_scopes: [] }];
- await expect(authorizer.credentials(permissions, query)).resolves.toEqual([]);
- expect(authorizers.ns1.credentials).toHaveBeenCalledTimes(0);
- expect(authorizers.ns2.credentials).toHaveBeenCalledTimes(0);
- expect(fallback.credentials).toHaveBeenCalledTimes(0);
- });
-
- it('calls the matching authorizer.', async(): Promise => {
- const permissions = [{ resource_id: 'res2', resource_scopes: [ 'scope1' ] }];
- await expect(authorizer.credentials(permissions, query)).resolves.toEqual('cred2');
- expect(authorizers.ns1.credentials).toHaveBeenCalledTimes(0);
- expect(authorizers.ns2.credentials).toHaveBeenCalledTimes(1);
- expect(authorizers.ns2.credentials).toHaveBeenLastCalledWith(permissions, query);
- expect(fallback.credentials).toHaveBeenCalledTimes(0);
- });
-
- it('calls the fallback authorizer if there is no match.', async(): Promise => {
- const perms1 = [{ resource_id: 'res3', resource_scopes: [ 'scope' ] }];
- const perms2 = [{ resource_id: 'unknown', resource_scopes: [ 'scope' ] }];
- await expect(authorizer.credentials(perms1, query)).resolves.toEqual('cred');
- await expect(authorizer.credentials(perms2, query)).resolves.toEqual('cred');
- expect(authorizers.ns1.credentials).toHaveBeenCalledTimes(0);
- expect(authorizers.ns2.credentials).toHaveBeenCalledTimes(0);
- expect(fallback.credentials).toHaveBeenCalledTimes(2);
- expect(fallback.credentials).toHaveBeenCalledWith(perms1, query);
- expect(fallback.credentials).toHaveBeenCalledWith(perms2, query);
- });
- });
});
diff --git a/packages/uma/test/unit/policies/authorizers/NoneAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/NoneAuthorizer.test.ts
index 15487ed3..ff2458c4 100644
--- a/packages/uma/test/unit/policies/authorizers/NoneAuthorizer.test.ts
+++ b/packages/uma/test/unit/policies/authorizers/NoneAuthorizer.test.ts
@@ -6,8 +6,4 @@ describe('NoneAuthorizer', (): void => {
it('returns an empty list of permissions.', async(): Promise => {
await expect(authorizer.permissions({})).resolves.toEqual([]);
});
-
- it('returns an empty list of requirements.', async(): Promise => {
- await expect(authorizer.credentials([])).resolves.toEqual([]);
- });
});
diff --git a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts
index 73624033..9e614cb0 100644
--- a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts
+++ b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts
@@ -33,7 +33,7 @@ describe('OdrlAuthorizer', (): void => {
vi.clearAllMocks();
evaluate.mockResolvedValue([]);
- vi.mocked(basicPolicy).mockReturnValueOnce({
+ vi.mocked(basicPolicy).mockReturnValue({
ruleIRIs:[],
policyIRI: '',
representation: new Store(requestQuads),
@@ -47,10 +47,6 @@ describe('OdrlAuthorizer', (): void => {
authorizer = new OdrlAuthorizer(policies);
});
- it('does not support credentials requests.', async(): Promise => {
- await expect(authorizer.credentials([])).rejects.toThrow(NotImplementedHttpError);
- });
-
it('returns an empty result if there is no query.', async(): Promise => {
await expect(authorizer.permissions({})).resolves.toEqual([]);
expect(evaluate).toHaveBeenCalledTimes(0);
diff --git a/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts
index 1fe70549..7e8cb395 100644
--- a/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts
+++ b/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts
@@ -27,17 +27,4 @@ describe('WebIdAuthorizer', (): void => {
{ resource_id: 'urn:solidlab:uma:resources:any', resource_scopes: [ 'scope3' ]},
]);
});
-
- it('returns an empty requirements result if there a query without WebID', async(): Promise => {
- await expect(authorizer.credentials([], {})).resolves.toEqual([]);
- });
-
- it('requires a WebID match.', async(): Promise => {
- const result = await authorizer.credentials([], { [WEBID]: vi.fn() });
- expect(result).toHaveLength(1);
- expect(result[0][WEBID]).toBeDefined();
- await expect(result[0][WEBID]!(webIds[0])).resolves.toBe(true);
- await expect(result[0][WEBID]!(webIds[1])).resolves.toBe(true);
- await expect(result[0][WEBID]!('wrong')).resolves.toBe(false);
- });
});
diff --git a/packages/uma/test/unit/routes/ResourceRegistration.test.ts b/packages/uma/test/unit/routes/ResourceRegistration.test.ts
index acd2bdfc..74eac557 100644
--- a/packages/uma/test/unit/routes/ResourceRegistration.test.ts
+++ b/packages/uma/test/unit/routes/ResourceRegistration.test.ts
@@ -26,6 +26,7 @@ describe('ResourceRegistration', (): void => {
let input: HttpHandlerContext;
let policyStore: Store;
+ let derivationStore: Mocked>;
let registrationStore: Mocked;
let policies: Mocked;
let validator: Mocked;
@@ -46,9 +47,14 @@ describe('ResourceRegistration', (): void => {
policyStore = new Store();
+ derivationStore = {
+ set: vi.fn(),
+ delete: vi.fn(),
+ } satisfies Partial> as any;
+
registrationStore = {
has: vi.fn().mockResolvedValue(false),
- get: vi.fn().mockResolvedValue({ owner, description: input.request.body }),
+ get: vi.fn().mockResolvedValue({ owner, description: { ...input.request.body }}),
set: vi.fn(),
delete: vi.fn(),
} satisfies Partial> as any;
@@ -60,10 +66,10 @@ describe('ResourceRegistration', (): void => {
} satisfies Partial as any;
validator = {
- handleSafe: vi.fn().mockResolvedValue({ owner })
+ handleSafe: vi.fn().mockResolvedValue({ owner }),
} satisfies Partial as any;
- handler = new ResourceRegistrationRequestHandler(registrationStore, policies, validator);
+ handler = new ResourceRegistrationRequestHandler(derivationStore, registrationStore, policies, validator);
});
it('throws an error if the method is not allowed.', async(): Promise => {
@@ -152,6 +158,21 @@ describe('ResourceRegistration', (): void => {
DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:2')),
]);
});
+
+ it('stores derivation IDs.', async(): Promise => {
+ input.request.body!.derived_from = [
+ { derivation_resource_id: 'd1', issuer: 'issuer1' },
+ { derivation_resource_id: 'd2', issuer: 'issuer2' },
+ ];
+ await expect(handler.handle(input)).resolves.toEqual({
+ status: 201,
+ headers: { location: `http://example.com/foo/name` },
+ body: { _id: 'name', user_access_policy_uri: 'TODO: implement policy UI' },
+ });
+ expect(derivationStore.set).toHaveBeenCalledTimes(2);
+ expect(derivationStore.set).toHaveBeenCalledWith('d1', 'issuer1');
+ expect(derivationStore.set).toHaveBeenCalledWith('d2', 'issuer2');
+ });
});
@@ -243,6 +264,71 @@ describe('ResourceRegistration', (): void => {
DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:2')),
]);
});
+
+ it('removes outdated relation triples.', async(): Promise => {
+ policyStore.addQuads([
+ DF.quad(DF.namedNode('collection:1'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:1'), ODRL.terms.source, DF.namedNode('name')),
+ DF.quad(DF.namedNode('collection:1'), ODRL_P.terms.relation, DF.namedNode('pred')),
+ DF.quad(DF.namedNode('collection:2'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:2'), ODRL.terms.source, DF.namedNode('name')),
+ DF.quad(DF.namedNode('collection:2'), ODRL_P.terms.relation, DF.blankNode('n3-0')),
+ DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')),
+ DF.quad(DF.namedNode('collection:3'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:3'), ODRL.terms.source, DF.namedNode('name2')),
+ DF.quad(DF.namedNode('collection:3'), ODRL_P.terms.relation, DF.namedNode('pred2')),
+ DF.quad(DF.namedNode('collection:4'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:4'), ODRL.terms.source, DF.namedNode('name2')),
+ DF.quad(DF.namedNode('collection:4'), ODRL_P.terms.relation, DF.blankNode('n3-1')),
+ DF.quad(DF.blankNode('n3-1'), OWL.terms.inverseOf, DF.namedNode('rPred2')),
+
+ DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:1')),
+ DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:2')),
+ DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:3')),
+ DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:4')),
+ ]);
+
+ registrationStore.get.mockResolvedValue({ owner, description: {
+ name: 'entry',
+ resource_scopes: [ 'scope1', 'scope2' ],
+ resource_relations: { rPred: [ 'name' ], rPred2: [ 'name2' ],
+ '@reverse': { pred: [ 'name' ], pred2: [ 'name2' ] }},
+ }});
+ input.request.body!.resource_relations = { rPred: [ 'name' ], '@reverse': { pred: [ 'name' ] }};
+ input.request.parameters = { id: 'entry' };
+ await expect(handler.handle(input)).resolves.toEqual({
+ status: 200,
+ body: { _id: 'entry', user_access_policy_uri: 'TODO: implement policy UI' },
+ });
+ expect(policies.addRule).toHaveBeenCalledTimes(0);
+ expect(policies.removeData).toHaveBeenCalledTimes(1);
+ const newStore = policies.removeData.mock.calls[0][0];
+ expect(newStore).toBeRdfIsomorphic([
+ DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:3')),
+ DF.quad(DF.namedNode('entry'), ODRL.terms.partOf, DF.namedNode('collection:4')),
+ ]);
+ });
+
+ it('updates the stored derivation IDs.', async(): Promise => {
+ registrationStore.get.mockResolvedValue({ owner, description: {
+ name: 'name',
+ resource_scopes: [ 'scope1', 'scope2' ],
+ derived_from: [{ derivation_resource_id: 'd3', issuer: 'issuer3' }]
+ }});
+ input.request.body!.derived_from = [
+ { derivation_resource_id: 'd1', issuer: 'issuer1' },
+ { derivation_resource_id: 'd2', issuer: 'issuer2' },
+ ];
+ await expect(handler.handle(input)).resolves.toEqual({
+ status: 200,
+ body: { _id: 'name', user_access_policy_uri: 'TODO: implement policy UI' },
+ });
+ expect(derivationStore.set).toHaveBeenCalledTimes(2);
+ expect(derivationStore.set).toHaveBeenCalledWith('d1', 'issuer1');
+ expect(derivationStore.set).toHaveBeenCalledWith('d2', 'issuer2');
+ expect(derivationStore.delete).toHaveBeenCalledTimes(1);
+ expect(derivationStore.delete).toHaveBeenCalledWith('d3');
+ });
});
describe('with DELETE requests', (): void => {
diff --git a/packages/uma/test/unit/ticketing/strategy/AggregatorStrategy.test.ts b/packages/uma/test/unit/ticketing/strategy/AggregatorStrategy.test.ts
new file mode 100644
index 00000000..6a6e986c
--- /dev/null
+++ b/packages/uma/test/unit/ticketing/strategy/AggregatorStrategy.test.ts
@@ -0,0 +1,189 @@
+import { KeyValueStorage } from '@solid/community-server';
+import { Mocked } from 'vitest';
+import { ACCESS } from '../../../../src/credentials/Claims';
+import { ClaimSet } from '../../../../src/credentials/ClaimSet';
+import { RequiredClaim } from '../../../../src/errors/NeedInfoError';
+import { AggregatorStrategy } from '../../../../src/ticketing/strategy/AggregatorStrategy';
+import { TicketingStrategy } from '../../../../src/ticketing/strategy/TicketingStrategy';
+import { Ticket } from '../../../../src/ticketing/Ticket';
+import { UMA_SCOPES } from '../../../../src/ucp/util/Vocabularies';
+import { decodeAggregateId, encodeAggregateId } from '../../../../src/util/AggregatorUtil';
+import { RegistrationStore } from '../../../../src/util/RegistrationStore';
+import { Failure, Result, Success } from '../../../../src/util/Result';
+import { Permission } from '../../../../src/views/Permission';
+
+describe('AggregatorStrategy', (): void => {
+ let encodedIds: string[];
+ let derivedIds: string[];
+ let permissions: Permission[];
+ let ticket: Ticket;
+ let claims: ClaimSet;
+ let source: Mocked;
+ let registrationStore: Mocked;
+ let derivationStore: Mocked>;
+ let strategy: AggregatorStrategy;
+
+ beforeEach(async(): Promise => {
+ encodedIds = [ await encodeAggregateId('decoded'), await encodeAggregateId('not-decoded') ];
+ derivedIds = [ await encodeAggregateId('d1'), await encodeAggregateId('d2'), await encodeAggregateId('d3') ];
+ ticket = {
+ permissions: [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: encodedIds[0], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[0], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: derivedIds[1], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read', 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[2], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ ],
+ provided: {},
+ derivedIds: derivedIds,
+ };
+
+ claims = {
+ [ACCESS]: [
+ { resource_id: derivedIds[0], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ] },
+ { resource_id: derivedIds[1], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ] },
+ { resource_id: derivedIds[2], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ] }
+ ],
+ }
+
+ permissions = [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ ];
+
+ source = {
+ initializeTicket: vi.fn(async (permissions: Permission[]): Promise => ({ permissions, provided: {} })),
+ validateClaims: vi.fn(async (ticket: Ticket) => ticket),
+ resolveTicket: vi.fn().mockResolvedValue(Success([])),
+ } satisfies Partial as any;
+
+ registrationStore = {
+ get: vi.fn().mockResolvedValue({
+ owner: 'owner',
+ description: {
+ resource_scopes: [ 'derive' ],
+ derived_from: [
+ { derivation_resource_id: 'd1', issuer: 'issuer' },
+ { derivation_resource_id: 'd2', issuer: 'issuer' }
+ ],
+ },
+ }),
+ } satisfies Partial as any;
+
+ derivationStore = {
+ get: vi.fn().mockResolvedValue('issuer'),
+ } satisfies Partial> as any;
+
+ strategy = new AggregatorStrategy(source, registrationStore, derivationStore);
+ });
+
+ describe('initializeTicket', (): void => {
+ it('adds derived_from sources to the required permissions.', async(): Promise => {
+ await expect(strategy.initializeTicket(permissions)).resolves.toEqual({
+ permissions: [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: 'd1', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: 'd2', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ ],
+ derivedIds: [ 'd1', 'd2' ],
+ provided: {},
+ });
+ });
+
+ it('returns the source result if there are no derived resources.', async(): Promise => {
+ registrationStore.get.mockResolvedValueOnce({
+ owner: 'owner',
+ description: {
+ resource_scopes: [ 'read' ],
+ },
+ });
+ await expect(strategy.initializeTicket(permissions)).resolves.toEqual({
+ permissions,
+ provided: {},
+ derivedIds: [],
+ });
+ });
+
+ it('errors if there are derived identifiers combined with non-read permissions.', async(): Promise => {
+ permissions[0].resource_scopes = [ 'other' ];
+ await expect(strategy.initializeTicket(permissions)).rejects.toThrow(
+ 'Derived resources are only supported with http://www.w3.org/ns/odrl/2/read permissions, received other');
+ });
+ });
+
+ describe('validateClaims', (): void => {
+ it('handles derivation-read permissions and decodes IDs for the source.', async(): Promise => {
+ await expect(strategy.validateClaims(ticket, claims)).resolves.toEqual({
+ derivedIds: [ derivedIds[0], derivedIds[1], derivedIds[2] ],
+ permissions: [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: encodedIds[0], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[2], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ ],
+ provided: {},
+ });
+
+ expect(source.validateClaims).toHaveBeenCalledExactlyOnceWith(
+ {
+ derivedIds: [ derivedIds[0], derivedIds[1], derivedIds[2] ],
+ permissions: [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ // Only this one will be decoded as it is not derived but has a derivation-read scope
+ { resource_id: 'decoded', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[2], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ ],
+ provided: {},
+ },
+ claims);
+ });
+ });
+
+ describe('resolveTicket', (): void => {
+ it('returns success if all permissions were handled.', async(): Promise => {
+ ticket.permissions = [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: encodedIds[0], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ ];
+ source.resolveTicket.mockResolvedValueOnce(Success([
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: 'decoded', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ ]));
+ await expect(strategy.resolveTicket(ticket)).resolves.toEqual(Success([
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ // Results are encoded again to not leak internal identifiers
+ { resource_id: encodedIds[0], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ ]));
+ expect(source.resolveTicket).toHaveBeenCalledExactlyOnceWith({
+ ...ticket,
+ permissions: [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: 'decoded', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ ]
+ });
+ });
+
+ it('returns the required claims if derivation-read access is missing.', async(): Promise => {
+ ticket.permissions = [
+ { resource_id: 'id', resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: encodedIds[0], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ { resource_id: encodedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[1], resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]},
+ { resource_id: derivedIds[2], resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ]},
+ ]
+ await expect(strategy.resolveTicket(ticket)).resolves.toEqual(Failure([{
+ claim_token_format: 'urn:ietf:params:oauth:token-type:access_token',
+ issuer: 'issuer',
+ derivation_resource_id: derivedIds[2],
+ resource_scopes: [UMA_SCOPES['derivation-read']],
+ }]));
+ });
+ });
+});
diff --git a/packages/uma/test/unit/ticketing/strategy/ClaimEliminationStrategy.test.ts b/packages/uma/test/unit/ticketing/strategy/ClaimEliminationStrategy.test.ts
deleted file mode 100644
index ebeab5f4..00000000
--- a/packages/uma/test/unit/ticketing/strategy/ClaimEliminationStrategy.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { Mocked } from 'vitest';
-import { ClaimSet } from '../../../../src/credentials/ClaimSet';
-import { Requirements } from '../../../../src/credentials/Requirements';
-import { Authorizer } from '../../../../src/policies/authorizers/Authorizer';
-import { ClaimEliminationStrategy } from '../../../../src/ticketing/strategy/ClaimEliminationStrategy';
-import { Ticket } from '../../../../src/ticketing/Ticket';
-import { Permission } from '../../../../src/views/Permission';
-
-describe('ClaimEliminationStrategy', (): void => {
- const requirements: Requirements = { key: async() => true };
- const permissions: Permission[] = [ { resource_id: 'id', resource_scopes: [ 'scopes' ] } ];
-
- let authorizer: Mocked;
- let strategy: ClaimEliminationStrategy;
-
- beforeEach(async(): Promise => {
- authorizer = {
- credentials: vi.fn().mockResolvedValue(requirements),
- permissions: vi.fn(),
- };
-
- strategy = new ClaimEliminationStrategy(authorizer);
- });
-
- it('can initialize a ticket.', async(): Promise => {
- await expect(strategy.initializeTicket(permissions)).resolves.toEqual({
- permissions,
- required: requirements,
- provided: {},
- });
- });
-
- it('can validate claims.', async(): Promise => {
- const req1 = vi.fn().mockResolvedValue(true);
- const req2 = vi.fn().mockResolvedValue(false);
- const req3 = vi.fn().mockResolvedValue(false);
- const ticket: Ticket = {
- permissions,
- provided: {},
- required: [
- { claim1: req1, claim2: req2 },
- { claim3: req3 },
- ],
- };
- const claims: ClaimSet = { claim1: 'val1', claim2: 'val2' };
- await expect(strategy.validateClaims(ticket, claims)).resolves.toBe(ticket);
- expect(ticket).toEqual({
- permissions,
- provided: { claim1: 'val1', claim2: 'val2' },
- required: [
- { claim2: req2 },
- { claim3: req3 },
- ],
- });
- expect(req1).toHaveBeenCalledTimes(1);
- expect(req1).toHaveBeenLastCalledWith('val1');
- expect(req2).toHaveBeenCalledTimes(1);
- expect(req2).toHaveBeenLastCalledWith('val2');
- expect(req3).toHaveBeenCalledTimes(0);
- });
-
- it('successfully resolves a ticket if there are no requirements left.', async(): Promise => {
- const ticket: Ticket = {
- permissions,
- provided: {},
- required: [{}],
- };
- await expect(strategy.resolveTicket(ticket)).resolves.toEqual({ success: true, value: permissions })
- });
-
- it('rejects a ticket if there are still requirements.', async(): Promise => {
- const ticket: Ticket = {
- permissions,
- provided: {},
- required: [ requirements],
- };
- await expect(strategy.resolveTicket(ticket)).resolves.toEqual({ success: false, value: ticket.required })
- });
-});
diff --git a/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts
index 054fd840..920260aa 100644
--- a/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts
+++ b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts
@@ -1,6 +1,5 @@
import { Mocked } from 'vitest';
import { ClaimSet } from '../../../../src/credentials/ClaimSet';
-import { Requirements } from '../../../../src/credentials/Requirements';
import { Authorizer } from '../../../../src/policies/authorizers/Authorizer';
import { ImmediateAuthorizerStrategy } from '../../../../src/ticketing/strategy/ImmediateAuthorizerStrategy';
import { Ticket } from '../../../../src/ticketing/Ticket';
@@ -14,7 +13,6 @@ describe('ImmediateAuthorizerStrategy', (): void => {
beforeEach(async(): Promise => {
authorizer = {
- credentials: vi.fn(),
permissions: vi.fn().mockResolvedValue(permissions),
};
@@ -24,7 +22,6 @@ describe('ImmediateAuthorizerStrategy', (): void => {
it('initializes a ticket with empty requirements.', async(): Promise => {
await expect(strategy.initializeTicket(permissions)).resolves.toEqual({
permissions,
- required: [{}],
provided: {},
});
});
@@ -33,13 +30,11 @@ describe('ImmediateAuthorizerStrategy', (): void => {
const ticket: Ticket = {
permissions,
provided: {},
- required: [],
};
const claims: ClaimSet = { claim1: 'val1', claim2: 'val2' };
await expect(strategy.validateClaims(ticket, claims)).resolves.toEqual({
permissions,
provided: { claim1: 'val1', claim2: 'val2' },
- required: [],
});
});
@@ -47,28 +42,26 @@ describe('ImmediateAuthorizerStrategy', (): void => {
const ticket: Ticket = {
permissions,
provided: {},
- required: [],
};
const authResponse: Permission[] = [
- { resource_id: 'id1', resource_scopes: [ 'scopes' ] },
- { resource_id: 'id2', resource_scopes: [] }
+ { resource_id: 'id', resource_scopes: [ 'scopes' ] }
];
authorizer.permissions.mockResolvedValueOnce(authResponse);
await expect(strategy.resolveTicket(ticket)).resolves
- .toEqual({ success: true, value: [{ resource_id: 'id1', resource_scopes: [ 'scopes' ] }] });
+ .toEqual({ success: true, value: [{ resource_id: 'id', resource_scopes: [ 'scopes' ] }] });
});
it('rejects a ticket if it does not provide permissions.', async(): Promise => {
const ticket: Ticket = {
permissions,
provided: {},
- required: [],
};
const authResponse: Permission[] = [
- { resource_id: 'id1', resource_scopes: [] },
- { resource_id: 'id2', resource_scopes: [] }
+ { resource_id: 'id1', resource_scopes: [ 'scope1' ] },
+ { resource_id: 'id2', resource_scopes: [ 'scope2' ] }
];
authorizer.permissions.mockResolvedValueOnce(authResponse);
- await expect(strategy.resolveTicket(ticket)).resolves.toEqual({ success: false, value: [] });
+ await expect(strategy.resolveTicket(ticket)).resolves
+ .toEqual({ success: false, value: [{ resource_scopes: ['scopes'] }] });
});
});
diff --git a/packages/uma/test/unit/util/AggregatorUtil.test.ts b/packages/uma/test/unit/util/AggregatorUtil.test.ts
new file mode 100644
index 00000000..403a8565
--- /dev/null
+++ b/packages/uma/test/unit/util/AggregatorUtil.test.ts
@@ -0,0 +1,9 @@
+import { decodeAggregateId, encodeAggregateId } from '../../../src/util/AggregatorUtil';
+
+describe('AggregatorUtil', (): void => {
+ it('can encode and decode identifiers.', async(): Promise => {
+ const identifier = 'identifier';
+ const encoded = await encodeAggregateId(identifier);
+ await expect(decodeAggregateId(encoded)).resolves.toBe(identifier);
+ });
+});
diff --git a/test/integration/Aggregation.test.ts b/test/integration/Aggregation.test.ts
new file mode 100644
index 00000000..be246004
--- /dev/null
+++ b/test/integration/Aggregation.test.ts
@@ -0,0 +1,467 @@
+import { App, InternalServerError, joinUrl } from '@solid/community-server';
+import type { ResourceDescription } from '@solidlab/uma';
+import { RequiredClaim } from '@solidlab/uma';
+import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory';
+import path from 'node:path';
+import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil';
+import { generateCredentials, getToken, noTokenFetch, umaFetch } from '../util/UmaUtil';
+
+const [ aggregatorPort, aggUmaPort ] = getPorts('Aggregation');
+const [ srcCssPort, srcUmaPort ] = getPorts('AggregationSource');
+
+interface UmaConfig {
+ jwks_uri: string;
+ issuer: string;
+ permission_endpoint: string;
+ introspection_endpoint: string;
+ resource_registration_endpoint: string;
+ token_endpoint: string,
+ registration_endpoint: string,
+}
+
+function getUmaApp(port: number): Promise {
+ return instantiateFromConfig(
+ 'urn:uma:default:App',
+ path.join(__dirname, '../../packages/uma/config/default.json'),
+ {
+ 'urn:uma:variables:port': port,
+ 'urn:uma:variables:baseUrl': `http://localhost:${port}/uma`,
+ 'urn:uma:variables:eyePath': 'eye',
+ 'urn:uma:variables:backupFilePath': '',
+ }
+ );
+}
+
+function getCssApp(port: number): Promise {
+ return instantiateFromConfig(
+ 'urn:solid-server:default:App',
+ path.join(__dirname, '../../packages/css/config/default.json'),
+ {
+ ...getDefaultCssVariables(port),
+ 'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../packages/css/config/seed.json'),
+ },
+ );
+}
+
+describe('An aggregation setup', (): void => {
+ const user = `http://localhost:${srcCssPort}/alice/profile/card#me`;
+ const aggregator = `http://localhost:${aggregatorPort}/aggregator/profile/card#me`;
+ const container = `http://localhost:${srcCssPort}/alice/`;
+ const target = `http://localhost:${srcCssPort}/alice/test`;
+ let aggConfig: UmaConfig;
+ let srcConfig: UmaConfig;
+ // PAT used to access aggregator AS
+ let pat: string;
+ // ID of aggregated resource
+ let aggregatedResourceId: string;
+ // Derivation ID of source resource
+ let derivationId: string;
+ // Ticket that needs to be passed in between tests
+ let previousTicket: string;
+ // Token that gives derivation-read access
+ let accessToken: string;
+ let aggUmaApp: App;
+ let srcUmaApp: App;
+ let srcCssApp: App;
+
+ beforeAll(async(): Promise => {
+ setGlobalLoggerFactory(new WinstonLoggerFactory('off'));
+
+ aggUmaApp = await getUmaApp(aggUmaPort);
+
+ srcUmaApp = await getUmaApp(srcUmaPort);
+ srcCssApp = await getCssApp(srcCssPort);
+
+ await Promise.all([ aggUmaApp.start(), srcUmaApp.start(), srcCssApp.start() ]);
+ });
+
+ afterAll(async(): Promise => {
+ await Promise.all([ aggUmaApp.stop(), srcUmaApp.stop(), srcCssApp.stop() ]);
+ });
+
+ it('can register client credentials for the user/RS combination.', async(): Promise => {
+ await generateCredentials({
+ webId: user,
+ authorizationServer: `http://localhost:${srcUmaPort}/uma`,
+ resourceServer: `http://localhost:${srcCssPort}/`,
+ email: 'alice@example.org',
+ password: 'abc123'
+ });
+ });
+
+ it('can register client credentials for the aggregator.', async(): Promise => {
+ const configurationUrl = `http://localhost:${aggUmaPort}/uma/.well-known/uma2-configuration`;
+ const configResponse = await fetch(configurationUrl);
+ expect(configResponse.status).toBe(200);
+ aggConfig = await configResponse.json() as UmaConfig;
+ expect(aggConfig.registration_endpoint).toBeDefined();
+
+ let response = await fetch(aggConfig.registration_endpoint, {
+ method: 'POST',
+ headers: {
+ authorization: `WebID ${encodeURIComponent(aggregator)}`,
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({ client_uri: `http://localhost:${aggregatorPort}/` }),
+ });
+ expect(response.status).toBe(201);
+ const { client_id, client_secret } = await response.json() as { client_id: string, client_secret: string };
+
+ // Use credentials to generate PAT
+ const authString = `${encodeURIComponent(client_id)}:${encodeURIComponent(client_secret)}`;
+ const credentials = `Basic ${Buffer.from(authString).toString('base64')}`;
+ response = await fetch(aggConfig.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 json = await response.json() as { access_token: string, token_type: string };
+ expect(json.access_token).toBeDefined();
+ expect(json.token_type).toBeDefined();
+ pat = `${json.token_type} ${json.access_token}`;
+ });
+
+ it('sets up the initial source test data.', async(): Promise => {
+ // Policy that allows the creation of the initial resources
+ const policy = `
+ @prefix ex: .
+ @prefix odrl: .
+
+ ex:createPolicy a odrl:Agreement ;
+ odrl:uid ex:createPolicy ;
+ odrl:permission ex:createPermission .
+ ex:createPermission a odrl:Permission ;
+ odrl:action odrl:create ;
+ odrl:target <${container}> ;
+ odrl:assignee <${user}> ;
+ odrl:assigner <${user}> .
+ `;
+
+ // Create policy
+ const url = `http://localhost:${srcUmaPort}/uma/policies`;
+ let response = await fetch(url, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(user)}`, 'content-type': 'text/turtle' },
+ body: policy,
+ });
+ expect(response.status).toBe(201);
+
+ // Create resource
+ response = await umaFetch(target, {
+ method: 'PUT',
+ headers: { 'content-type': 'plain/text' },
+ body: 'this is test data',
+ }, user);
+ expect(response.status).toBe(201);
+ });
+
+ it('can set up the policies for the aggregator.', async(): Promise => {
+ const policy = `
+ @prefix ex: .
+ @prefix odrl: .
+
+ ex:aggregatorPolicy a odrl:Agreement ;
+ odrl:uid ex:aggregatorPolicy ;
+ odrl:permission ex:aggregatorPermission .
+ ex:aggregatorPermission a odrl:Permission ;
+ odrl:action odrl:read ;
+ odrl:target <${target}> ;
+ odrl:assignee <${aggregator}> ;
+ odrl:assigner <${user}> .
+ `;
+
+ // Create policy
+ const url = `http://localhost:${srcUmaPort}/uma/policies`;
+ let response = await fetch(url, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(user)}`, 'content-type': 'text/turtle' },
+ body: policy,
+ });
+ expect(response.status).toBe(201);
+ });
+
+ it('can register the aggregator resource.', async(): Promise => {
+ const description: ResourceDescription = {
+ name: `http://localhost:${aggregatorPort}/resource`,
+ resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ],
+ };
+ const response = await fetch(aggConfig.resource_registration_endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': pat,
+ },
+ body: JSON.stringify(description),
+ });
+ expect(response.status).toBe(201);
+ const { _id: umaId } = await response.json() as { _id: string };
+ expect(umaId).toBeDefined;
+ aggregatedResourceId = umaId;
+ });
+
+ it('can get the derivation ID of a resource.', async(): Promise => {
+ // Parse ticket and UMA server URL from header
+ const parsedHeader = await noTokenFetch(target);
+
+ // Find UMA server token endpoint
+ const configurationUrl = parsedHeader.as_uri + '/.well-known/uma2-configuration';
+ const configResponse = await fetch(configurationUrl);
+ expect(configResponse.status).toBe(200);
+ srcConfig = await configResponse.json() as UmaConfig;
+
+ // Send ticket request to UMA server and extract token from response
+ // This will fail because the policy does not (yet) allow `urn:knows:uma:scopes:derivation-creation`,
+ // only `odrl:read`.
+ const content: Record = {
+ grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
+ ticket: parsedHeader.ticket,
+ claim_token: encodeURIComponent(aggregator),
+ claim_token_format: 'urn:solidlab:uma:claims:formats:webid',
+ scope: 'urn:knows:uma:scopes:derivation-creation',
+ };
+ let response = await fetch(srcConfig.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(content),
+ });
+ expect(response.status).toBe(403);
+ const errorJson = await response.json() as { ticket: string, required_claims: { resource_scopes: string[] }[] };
+ expect(errorJson.required_claims).toEqual([{ resource_scopes: [ 'urn:knows:uma:scopes:derivation-creation' ] }]);
+ previousTicket = errorJson.ticket;
+ });
+
+ it('can get the derivation_id when having derivation-creation permissions.', async(): Promise => {
+ // Update policy to also support derivation-creation
+ const policy = `
+ @prefix ex: .
+ @prefix odrl: .
+
+ ex:aggregatorPolicy a odrl:Agreement ;
+ odrl:uid ex:aggregatorPolicy ;
+ odrl:permission ex:aggregatorPermission .
+ ex:aggregatorPermission a odrl:Permission ;
+ odrl:action odrl:read , ;
+ odrl:target <${target}> ;
+ odrl:assignee <${aggregator}> ;
+ odrl:assigner <${user}> .
+ `;
+
+ // Create policy
+ const url = `http://localhost:${srcUmaPort}/uma/policies/${encodeURIComponent('http://example.org/12345#aggregatorPolicy')}`;
+ const response = await fetch(url, {
+ method: 'PUT',
+ headers: { authorization: `WebID ${encodeURIComponent(user)}`, 'content-type': 'text/turtle' },
+ body: policy,
+ });
+ expect(response.status).toBe(204);
+
+ const token = await getToken(previousTicket, srcConfig.token_endpoint, aggregator,
+ 'urn:knows:uma:scopes:derivation-creation');
+ expect(token.derivation_resource_id).toBeDefined();
+ derivationId = token.derivation_resource_id!;
+ });
+
+ it('can update the aggregator resource registration with the derived_from id.', async(): Promise => {
+ // Update registration with derivation ID
+ const description: ResourceDescription = {
+ name: `http://localhost:${aggregatorPort}/resource`,
+ resource_scopes: [ 'read' ],
+ derived_from: [{
+ issuer: srcConfig.issuer,
+ derivation_resource_id: derivationId,
+ }]
+ };
+ const url = joinUrl(aggConfig.resource_registration_endpoint, encodeURIComponent(aggregatedResourceId));
+ const response = await fetch(url, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': pat,
+ },
+ body: JSON.stringify(description),
+ });
+ expect(response.status).toBe(200);
+ });
+
+ it('a client cannot read an aggregated resource without the necessary tokens.', async(): Promise => {
+ // We don't have an actual aggregator server so simulating the request
+ const body = [{
+ resource_id: aggregatedResourceId,
+ resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ],
+ }];
+ let response = await fetch(aggConfig.permission_endpoint, {
+ method: 'POST',
+ headers: {
+ 'Authorization': pat,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+ expect(response.status).toBe(201);
+ const { ticket } = await response.json() as { ticket: string };
+ expect(ticket).toBeDefined();
+
+ // This will fail because the derivation_read access token is missing
+ const content: Record = {
+ grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
+ ticket: ticket,
+ claim_token: encodeURIComponent(user),
+ claim_token_format: 'urn:solidlab:uma:claims:formats:webid'
+ };
+ response = await fetch(aggConfig.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(content),
+ });
+ expect(response.status).toBe(403);
+ const errorJson = await response.json() as { ticket: string, required_claims: RequiredClaim[] };
+ previousTicket = errorJson.ticket;
+ expect(errorJson.required_claims).toHaveLength(1);
+ expect(errorJson.required_claims[0].claim_token_format).toBe('urn:ietf:params:oauth:token-type:access_token');
+ expect(errorJson.required_claims[0].issuer).toBe(srcConfig.issuer);
+ expect(errorJson.required_claims[0].derivation_resource_id).toBe(derivationId);
+ expect(errorJson.required_claims[0].resource_scopes).toEqual(['urn:knows:uma:scopes:derivation-read']);
+ });
+
+ it('the client can request a derivation-read access token from the source AS.', async(): Promise => {
+ const content = {
+ grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
+ claim_token: encodeURIComponent(user),
+ claim_token_format: 'urn:solidlab:uma:claims:formats:webid',
+ permissions: [{
+ resource_id: derivationId,
+ resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ],
+ }],
+ };
+ let response = await fetch(srcConfig.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(content),
+ });
+ // Rejected because there is no policy in place yet
+ expect(response.status).toBe(403);
+
+ // Add policy allowing derivation-read access
+ const policy = `
+ @prefix ex: .
+ @prefix odrl: .
+
+ ex:userPolicy a odrl:Agreement ;
+ odrl:uid ex:userPolicy ;
+ odrl:permission ex:userPermission .
+ ex:userPermission a odrl:Permission ;
+ odrl:action ;
+ odrl:target <${target}> ;
+ odrl:assignee <${user}> ;
+ odrl:assigner <${user}> .
+ `;
+
+ // Create policy
+ const url = `http://localhost:${srcUmaPort}/uma/policies`;
+ response = await fetch(url, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(user)}`, 'content-type': 'text/turtle' },
+ body: policy,
+ });
+ expect(response.status).toBe(201);
+
+ // Token request should now succeed
+ response = await fetch(srcConfig.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(content),
+ });
+ expect(response.status).toBe(200);
+ const tokenJson = await response.json() as { access_token: string, token_type: string };
+ accessToken = tokenJson.access_token;
+ });
+
+ it('the client can not read the aggregated resource if no policy allows this.', async(): Promise => {
+ const content = {
+ grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
+ ticket: previousTicket,
+ claim_token: [
+ { claim_token: encodeURIComponent(user), claim_token_format: 'urn:solidlab:uma:claims:formats:webid' },
+ { claim_token: accessToken, claim_token_format: 'urn:ietf:params:oauth:token-type:access_token' }
+ ],
+ };
+ const response = await fetch(aggConfig.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(content),
+ });
+ expect(response.status).toBe(403);
+ });
+
+ it('the client can read the aggregated resource with valid tokens and policy.', async(): Promise => {
+ // Set the policy
+ const policy = `
+ @prefix ex: .
+ @prefix odrl: .
+
+ ex:userPolicy a odrl:Agreement ;
+ odrl:uid ex:userPolicy ;
+ odrl:permission ex:userPermission .
+ ex:userPermission a odrl:Permission ;
+ odrl:action odrl:read ;
+ odrl:target <${aggregatedResourceId}> ;
+ odrl:assignee <${user}> ;
+ odrl:assigner <${user}> .
+ `;
+
+ // Create policy
+ const url = `http://localhost:${aggUmaPort}/uma/policies`;
+ let response = await fetch(url, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(user)}`, 'content-type': 'text/turtle' },
+ body: policy,
+ });
+ expect(response.status).toBe(201);
+
+ // Getting the ticket
+ const body = [{
+ resource_id: aggregatedResourceId,
+ resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ],
+ }];
+ response = await fetch(aggConfig.permission_endpoint, {
+ method: 'POST',
+ headers: {
+ 'Authorization': pat,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+ expect(response.status).toBe(201);
+ const { ticket } = await response.json() as { ticket: string };
+ expect(ticket).toBeDefined();
+
+ // Getting the access token
+ const content = {
+ grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
+ ticket: ticket,
+ claim_token: [
+ { claim_token: encodeURIComponent(user), claim_token_format: 'urn:solidlab:uma:claims:formats:webid' },
+ { claim_token: accessToken, claim_token_format: 'urn:ietf:params:oauth:token-type:access_token' },
+ ],
+ };
+ response = await fetch(aggConfig.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(content),
+ });
+ expect(response.status).toBe(200);
+ const json = await response.json() as { access_token: string };
+ expect (json.access_token).toBeDefined();
+ });
+});
diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts
index e26a3487..d8daee12 100644
--- a/test/integration/Base.test.ts
+++ b/test/integration/Base.test.ts
@@ -1,4 +1,4 @@
-import { App, joinUrl } from '@solid/community-server';
+import { App } from '@solid/community-server';
import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory';
import { Parser, Writer } from 'n3';
import { readFile } from 'node:fs/promises';
@@ -25,17 +25,16 @@ describe('A server setup', (): void => {
'urn:uma:variables:eyePath': 'eye',
'urn:uma:variables:backupFilePath': '',
}
- ) as App;
+ );
cssApp = await instantiateFromConfig(
'urn:solid-server:default:App',
path.join(__dirname, '../../packages/css/config/default.json'),
{
...getDefaultCssVariables(cssPort),
- 'urn:solid-server:uma:variable:AuthorizationServer': `http://localhost:${umaPort}/`,
'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../packages/css/config/seed.json'),
},
- ) as App;
+ );
await Promise.all([ umaApp.start(), cssApp.start() ]);
});
diff --git a/test/integration/Demo.test.ts b/test/integration/Demo.test.ts
index 551ed14e..a82b66d5 100644
--- a/test/integration/Demo.test.ts
+++ b/test/integration/Demo.test.ts
@@ -38,7 +38,7 @@ describe('A demo server setup', (): void => {
'urn:uma:variables:policyContainer': policyContainer,
'urn:uma:variables:backupFilePath': '',
}
- ) as App;
+ );
cssApp = await instantiateFromConfig(
'urn:solid-server:default:App',
@@ -49,10 +49,9 @@ describe('A demo server setup', (): void => {
],
{
...getDefaultCssVariables(cssPort),
- 'urn:solid-server:uma:variable:AuthorizationServer': `http://localhost:${umaPort}/`,
'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../demo/seed.json'),
},
- ) as App;
+ );
await Promise.all([umaApp.start(), cssApp.start()]);
});
@@ -232,28 +231,6 @@ PREFIX ex:
resource_id: terms.resources.smartwatch,
resource_scopes: [ 'urn:example:css:modes:read' ]
}],
- contract:{
- '@context': 'http://www.w3.org/ns/odrl.jsonld',
- '@type': 'Agreement',
- uid: expect.any(String),
- 'http://purl.org/dc/terms/description': 'Agreement for HCP X to read Alice\'s health data for bariatric care.',
- 'https://w3id.org/dpv#hasLegalBasis': {
- '@id': 'https://w3id.org/dpv/legal/eu/gdpr#eu-gdpr:A9-2-a'
- },
- permission:[{
- '@type': 'Permission',
- action: 'https://w3id.org/oac#read',
- target: terms.resources.smartwatch,
- assigner: 'http://localhost:3000/ruben/profile/card#me',
- assignee: 'http://localhost:3000/alice/profile/card#me',
- constraint: [{
- '@type': 'Constraint',
- leftOperand: 'purpose',
- operator: 'eq',
- rightOperand: { '@id':'http://example.org/bariatric-care' }
- }]
- }]
- },
iat: expect.any(Number),
iss: `http://localhost:${umaPort}/uma`,
aud: 'solid',
diff --git a/test/integration/Odrl.test.ts b/test/integration/Odrl.test.ts
index 466b98df..c640ddec 100644
--- a/test/integration/Odrl.test.ts
+++ b/test/integration/Odrl.test.ts
@@ -26,17 +26,16 @@ describe('An ODRL server setup', (): void => {
'urn:uma:variables:eyePath': 'eye',
'urn:uma:variables:backupFilePath': '',
}
- ) as App;
+ );
cssApp = await instantiateFromConfig(
'urn:solid-server:default:App',
path.join(__dirname, '../../packages/css/config/default.json'),
{
...getDefaultCssVariables(cssPort),
- 'urn:solid-server:uma:variable:AuthorizationServer': `http://localhost:${umaPort}/`,
'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../packages/css/config/seed.json'),
},
- ) as App;
+ );
await Promise.all([umaApp.start(), cssApp.start()]);
});
diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts
index 0b3032b1..854f01ad 100644
--- a/test/integration/Oidc.test.ts
+++ b/test/integration/Oidc.test.ts
@@ -32,17 +32,16 @@ describe('A server supporting OIDC tokens', (): void => {
'urn:uma:variables:eyePath': 'eye',
'urn:uma:variables:backupFilePath': '',
}
- ) as App;
+ );
cssApp = await instantiateFromConfig(
'urn:solid-server:default:App',
path.join(__dirname, '../../packages/css/config/default.json'),
{
...getDefaultCssVariables(cssPort),
- 'urn:solid-server:uma:variable:AuthorizationServer': `http://localhost:${umaPort}/`,
'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../packages/css/config/seed.json'),
},
- ) as App;
+ );
const generator = new CachedJwkGenerator('ES256', 'jwks', new MemoryMapStorage());
privateKey = { ...await generator.getPrivateKey(), kid: 'kid' };
diff --git a/test/util/ServerUtil.ts b/test/util/ServerUtil.ts
index e7e10292..554bb5ba 100644
--- a/test/util/ServerUtil.ts
+++ b/test/util/ServerUtil.ts
@@ -1,8 +1,11 @@
+import { App } from '@solid/community-server';
import { ComponentsManager, IModuleState } from 'componentsjs';
import * as path from 'node:path';
const portNames = [
+ 'Aggregation',
+ 'AggregationSource',
'Base',
'Demo',
'ODRL',
@@ -29,10 +32,10 @@ export async function instantiateFromConfig(
componentUrl: string,
configPaths: string | string[],
variables?: Record,
-): Promise {
+): Promise {
// Initialize the Components.js loader
const mainModulePath = path.join(__dirname, '../../');
- const manager = await ComponentsManager.build({
+ const manager = await ComponentsManager.build({
mainModulePath,
logLevel: 'error',
moduleState: cachedModuleState,
diff --git a/test/util/UmaUtil.ts b/test/util/UmaUtil.ts
index 139dce2c..7a99d4d3 100644
--- a/test/util/UmaUtil.ts
+++ b/test/util/UmaUtil.ts
@@ -42,7 +42,8 @@ export async function findTokenEndpoint(uri: string): Promise {
* Calls the UMA token endpoint with a token and potentially the given WebID to receive a response.
* Will error if the response is not an access token.
*/
-export async function getToken(ticket: string, endpoint: string, webId?: string): Promise {
+export async function getToken(ticket: string, endpoint: string, webId?: string, scope?: string):
+ Promise {
const content: Record = {
grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
ticket: ticket,
@@ -51,6 +52,9 @@ export async function getToken(ticket: string, endpoint: string, webId?: string)
content.claim_token = encodeURIComponent(webId);
content.claim_token_format = 'urn:solidlab:uma:claims:formats:webid';
}
+ if (scope) {
+ content.scope = scope;
+ }
const response = await fetch(endpoint, {
method: 'POST',
@@ -90,8 +94,8 @@ export async function tokenFetch(token: DialogOutput, input: string | URL | glob
* This only works if the initial RS request fails,
* and the token request to the UMA server succeeds.
*/
-export async function umaFetch(input: string | URL | globalThis.Request, init?: RequestInit, webId?: string):
- Promise {
+export async function umaFetch(input: string | URL | globalThis.Request, init?: RequestInit, webId?: string,
+ scope?: string): Promise {
// Parse ticket and UMA server URL from header
const parsedHeader = await noTokenFetch(input, init);
@@ -99,7 +103,7 @@ export async function umaFetch(input: string | URL | globalThis.Request, init?:
const tokenEndpoint = await findTokenEndpoint(parsedHeader.as_uri);
// Send ticket request to UMA server and extract token from response
- const token = await getToken(parsedHeader.ticket, tokenEndpoint, webId);
+ const token = await getToken(parsedHeader.ticket, tokenEndpoint, webId, scope);
// Perform new call with token
return tokenFetch(token, input, init);