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);