From 10e5e2287cdf1264f1f20d71d13c48450185b866 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 5 Aug 2026 09:00:21 +0200 Subject: [PATCH 1/2] feat: Allow multiple values for the same claim type --- packages/uma/src/credentials/ClaimSet.ts | 4 +- packages/uma/src/credentials/Claims.ts | 16 +----- .../uma/src/credentials/verify/IriVerifier.ts | 20 +++---- .../uma/src/credentials/verify/JwtVerifier.ts | 15 +++--- .../credentials/verify/KeyValueVerifier.ts | 2 +- .../src/credentials/verify/OidcVerifier.ts | 13 +++-- .../credentials/verify/UnsecureVerifier.ts | 6 +-- .../uma/src/credentials/verify/VcVerifier.ts | 8 +-- packages/uma/src/dialog/BaseNegotiator.ts | 6 +-- packages/uma/src/dialog/ContractNegotiator.ts | 6 +-- .../policies/authorizers/OdrlAuthorizer.ts | 39 ++++++++------ .../authorizers/SimpleOdrlAuthorizer.ts | 47 +++++++++------- .../policies/authorizers/WebIdAuthorizer.ts | 6 ++- packages/uma/src/routes/BaseHandler.ts | 15 ++++-- packages/uma/src/routes/ClientRegistration.ts | 6 +-- packages/uma/src/routes/Collection.ts | 9 ++-- .../ticketing/strategy/AggregatorStrategy.ts | 3 +- .../strategy/ImmediateAuthorizerStrategy.ts | 8 ++- packages/uma/src/ucp/policy/ODRL.ts | 8 ++- .../uma/src/ucp/policy/UsageControlPolicy.ts | 3 +- .../uma/test/unit/credentials/Claims.test.ts | 16 ------ .../credentials/verify/IriVerifier.test.ts | 50 +++++++++++------ .../credentials/verify/JwtVerifier.test.ts | 4 +- .../credentials/verify/OidcVerifier.test.ts | 16 +++--- .../credentials/verify/TypedVerifier.test.ts | 4 +- .../verify/UnsecureVerifier.test.ts | 7 ++- .../credentials/verify/VcVerifier.test.ts | 2 +- .../test/unit/dialog/BaseNegotiator.test.ts | 20 ++++--- .../unit/dialog/ContractNegotiator.test.ts | 14 +++-- .../authorizers/OdrlAuthorizer.test.ts | 18 +++---- .../authorizers/SimpleOdrlAuthorizer.test.ts | 54 ++++++++++++++----- .../authorizers/WebIdAuthorizer.test.ts | 4 +- .../unit/routes/ClientRegistration.test.ts | 2 +- .../uma/test/unit/routes/Collection.test.ts | 2 +- .../ImmediateAuthorizerStrategy.test.ts | 24 ++++++++- .../uma/test/unit/ucp/policy/ODRL.test.ts | 16 ++++++ test/integration/Base.test.ts | 37 +++++++++++-- 37 files changed, 318 insertions(+), 212 deletions(-) delete mode 100644 packages/uma/test/unit/credentials/Claims.test.ts diff --git a/packages/uma/src/credentials/ClaimSet.ts b/packages/uma/src/credentials/ClaimSet.ts index ffb875ff..3aba6306 100644 --- a/packages/uma/src/credentials/ClaimSet.ts +++ b/packages/uma/src/credentials/ClaimSet.ts @@ -1,7 +1,7 @@ /** * A Set of Claims about the Client. - * + * * TODO: Might exchange this for a QuadStore? */ -export type ClaimSet = NodeJS.Dict; +export type ClaimSet = NodeJS.Dict; diff --git a/packages/uma/src/credentials/Claims.ts b/packages/uma/src/credentials/Claims.ts index 5059b6e1..8cbc7f64 100644 --- a/packages/uma/src/credentials/Claims.ts +++ b/packages/uma/src/credentials/Claims.ts @@ -1,21 +1,9 @@ export const WEBID = 'urn:solidlab:uma:claims:types:webid'; export const CLIENTID = 'urn:solidlab:uma:claims:types:clientid'; -export const ORIGINAL = 'urn:solidlab:uma:claims:types:original'; +export const ORIGINAL_WEBID = 'urn:solidlab:uma:claims:types:original:webid'; +export const ORIGINAL_CLIENTID = 'urn:solidlab:uma:claims:types:original: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'; export const VC = 'urn:solidlab:uma:claims:types:vc'; - -/** - * Resolves a claim value by preferring an ORIGINAL claim-set entry when present. - */ -export function getOriginalClaimValue(claims: NodeJS.Dict, claimType: string): unknown { - const original = claims[ORIGINAL]; - if (typeof original === 'object' && original !== null) { - const originalClaims = original as Record; - return originalClaims[claimType]; - } - - return claims[claimType]; -} diff --git a/packages/uma/src/credentials/verify/IriVerifier.ts b/packages/uma/src/credentials/verify/IriVerifier.ts index b7b4bf57..56f33f35 100644 --- a/packages/uma/src/credentials/verify/IriVerifier.ts +++ b/packages/uma/src/credentials/verify/IriVerifier.ts @@ -1,6 +1,6 @@ import { joinUrl } from '@solid/community-server'; import { isIri } from '../../util/ConvertUtil'; -import { CLIENTID, ORIGINAL, WEBID } from '../Claims'; +import { CLIENTID, ORIGINAL_CLIENTID, ORIGINAL_WEBID, WEBID } from '../Claims'; import { ClaimSet } from '../ClaimSet'; import { Credential } from '../Credential'; import { Verifier } from './Verifier'; @@ -19,17 +19,19 @@ export class IriVerifier implements Verifier { const result = { ...claims }; const original: Record = {}; - for (const claim of [WEBID, CLIENTID]) { - if (typeof claims[claim] === 'string' && !isIri(claims[claim])) { - result[claim] = joinUrl(this.baseUrl, encodeURIComponent(claims[claim])); - original[claim] = claims[claim]; + for (const [ claimType, original ] of [[WEBID, ORIGINAL_WEBID], [CLIENTID, ORIGINAL_CLIENTID]]) { + if (Array.isArray(claims[claimType])) { + result[original] = []; + for (let i = 0; i < claims[claimType].length; i += 1) { + const entry = claims[claimType][i]; + result[original].push(entry); + if (typeof entry === 'string' && !isIri(entry)) { + result[claimType]![i] = joinUrl(this.baseUrl, encodeURIComponent(entry)); + } + } } } - if (Object.keys(original).length > 0) { - result[ORIGINAL] = original; - } - return result; } } diff --git a/packages/uma/src/credentials/verify/JwtVerifier.ts b/packages/uma/src/credentials/verify/JwtVerifier.ts index df265d8d..e34aa977 100644 --- a/packages/uma/src/credentials/verify/JwtVerifier.ts +++ b/packages/uma/src/credentials/verify/JwtVerifier.ts @@ -27,6 +27,7 @@ export class JwtVerifier implements Verifier { } const claims = decodeJwt(credential.token); + const result: ClaimSet = {}; if (this.verifyJwt) { if (!claims.iss) { @@ -38,16 +39,14 @@ export class JwtVerifier implements Verifier { } for (const claim of Object.keys(claims)) { - if (!this.allowedClaims.includes(claim)) { - if (this.errorOnExtraClaims) { - throw new Error(`Claim '${claim}' not allowed.`); - } - - delete claims[claim]; + if (this.allowedClaims.includes(claim)) { + result[claim] = Array.isArray(claims[claim]) ? claims[claim] : [claims[claim]]; + } else if (this.errorOnExtraClaims) { + throw new Error(`Claim '${claim}' not allowed.`); } } - this.logger.debug(`Returning discovered claims: ${JSON.stringify(claims)}`) - return claims; + this.logger.debug(`Returning discovered claims: ${JSON.stringify(result)}`) + return result; } } diff --git a/packages/uma/src/credentials/verify/KeyValueVerifier.ts b/packages/uma/src/credentials/verify/KeyValueVerifier.ts index 753648e0..a72676ed 100644 --- a/packages/uma/src/credentials/verify/KeyValueVerifier.ts +++ b/packages/uma/src/credentials/verify/KeyValueVerifier.ts @@ -10,7 +10,7 @@ export class KeyValueVerifier implements Verifier { public async verify(credential: Credential): Promise { return { - [credential.format]: credential.token + [credential.format]: [ credential.token ], } } } diff --git a/packages/uma/src/credentials/verify/OidcVerifier.ts b/packages/uma/src/credentials/verify/OidcVerifier.ts index caefcc66..82944ab8 100644 --- a/packages/uma/src/credentials/verify/OidcVerifier.ts +++ b/packages/uma/src/credentials/verify/OidcVerifier.ts @@ -55,7 +55,7 @@ export class OidcVerifier implements Verifier { } } - protected async verifySolidToken(token: string): Promise<{ [WEBID]: string, [CLIENTID]?: string }> { + protected async verifySolidToken(token: string): Promise { const claims = await this.verifyToken(`Bearer ${token}`); const issuers = this.verifyOptions.issuer; const allowedIssuers = issuers !== undefined && (typeof issuers === 'string' ? [issuers] : issuers as string[]); @@ -70,13 +70,12 @@ export class OidcVerifier implements Verifier { return ({ // TODO: would have to use different value than "WEBID" // TODO: still want to use WEBID as external value potentially? - [WEBID]: claims.webid, - ...clientId && { [CLIENTID]: clientId } + [WEBID]: [ claims.webid ], + ...clientId && { [CLIENTID]: [ clientId ] } }); } - protected async verifyStandardToken(token: string, format: string, issuer: string): - Promise<{ [WEBID]?: string, [CLIENTID]?: string, [ACCESS]?: Permission[] }> { + protected async verifyStandardToken(token: string, format: string, issuer: string): Promise { const jwkSet = await getJwks(issuer); const decoded = await jwtVerify(token, jwkSet, this.verifyOptions); @@ -86,8 +85,8 @@ export class OidcVerifier implements Verifier { } const client = decoded.payload.azp as string | undefined; return { - [WEBID]: decoded.payload.sub, - ...client && { [CLIENTID]: client } + [WEBID]: [ decoded.payload.sub ], + ...client && { [CLIENTID]: [ client ] } }; } else if (format === ACCESS_TOKEN) { const iss = decoded.payload.iss; diff --git a/packages/uma/src/credentials/verify/UnsecureVerifier.ts b/packages/uma/src/credentials/verify/UnsecureVerifier.ts index 9a78d06c..e8a47495 100644 --- a/packages/uma/src/credentials/verify/UnsecureVerifier.ts +++ b/packages/uma/src/credentials/verify/UnsecureVerifier.ts @@ -30,9 +30,9 @@ export class UnsecureVerifier implements Verifier { } try { - const claims = { - [WEBID]: new URL(decodeURIComponent(raw[0])).toString(), - [CLIENTID]: raw.length === 2 && new URL(decodeURIComponent(raw[1])).toString() + const claims: ClaimSet = { + [WEBID]: [ new URL(decodeURIComponent(raw[0])).toString() ], + ...raw.length === 2 && { [CLIENTID]: [ new URL(decodeURIComponent(raw[1])).toString() ] } }; this.logger.info(`Authenticated as via unsecure verifier. ${JSON.stringify(claims)}`); diff --git a/packages/uma/src/credentials/verify/VcVerifier.ts b/packages/uma/src/credentials/verify/VcVerifier.ts index ccac874f..04e10ca7 100644 --- a/packages/uma/src/credentials/verify/VcVerifier.ts +++ b/packages/uma/src/credentials/verify/VcVerifier.ts @@ -38,14 +38,10 @@ export class VcVerifier implements Verifier { const jwkSet = await getJwks(unsafeDecoded.iss); const decoded = await jwtVerify(credential.token, jwkSet, this.verifyOptions); - // TODO: could extract all entries as separate jsonpath claims - // TODO: currently only a single VC as input is accepted, - // if the client provides multiple these would override each other - const claims = this.extractVcClaims(decoded.payload); - return { [VC]: claims }; + return { [VC]: [ this.extractVcClaims(decoded.payload) ] }; } - protected extractVcClaims(payload: JWTPayload): ClaimSet { + protected extractVcClaims(payload: JWTPayload): Record { if (!payload.vc || typeof payload.vc !== 'object') { throw new BadRequestHttpError(`Token is missing the vc claim.`); } diff --git a/packages/uma/src/dialog/BaseNegotiator.ts b/packages/uma/src/dialog/BaseNegotiator.ts index cfbcd7eb..c61b0bd1 100644 --- a/packages/uma/src/dialog/BaseNegotiator.ts +++ b/packages/uma/src/dialog/BaseNegotiator.ts @@ -1,7 +1,7 @@ import { BadRequestHttpError, ForbiddenHttpError, HttpErrorClass, KeyValueStorage } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; import { randomUUID } from 'node:crypto'; -import { getOriginalClaimValue, WEBID } from '../credentials/Claims'; +import { ORIGINAL_WEBID, WEBID } from '../credentials/Claims'; import { Verifier } from '../credentials/verify/Verifier'; import { NeedInfoError, RequiredClaim } from '../errors/NeedInfoError'; import { getOperationLogger } from '../logging/OperationLogger'; @@ -61,12 +61,12 @@ export class BaseNegotiator implements Negotiator { // ... on success, create Access Token if (resolved.success) { const partial = this.isPartialResult(updatedTicket.permissions, resolved.value); - const tokenSub = getOriginalClaimValue(updatedTicket.provided, WEBID); + const tokenSubs = updatedTicket.provided[ORIGINAL_WEBID] || updatedTicket.provided[WEBID]; // Retrieve / create instantiated policy const { token, tokenType } = await this.tokenFactory.serialize({ permissions: resolved.value, - ...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}), + ...(Array.isArray(tokenSubs) && typeof tokenSubs[0] === 'string' ? { sub: tokenSubs[0] } : {}), }); this.logger.debug(`Minted token ${JSON.stringify(token)}`); diff --git a/packages/uma/src/dialog/ContractNegotiator.ts b/packages/uma/src/dialog/ContractNegotiator.ts index 4154d44f..383c3346 100644 --- a/packages/uma/src/dialog/ContractNegotiator.ts +++ b/packages/uma/src/dialog/ContractNegotiator.ts @@ -1,6 +1,6 @@ import { createErrorMessage, KeyValueStorage } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; -import { getOriginalClaimValue, WEBID } from '../credentials/Claims'; +import { ORIGINAL_WEBID, WEBID } from '../credentials/Claims'; import { Verifier } from '../credentials/verify/Verifier'; import { RequiredClaim } from '../errors/NeedInfoError'; import { ContractManager } from '../policies/contracts/ContractManager'; @@ -149,13 +149,13 @@ export class ContractNegotiator extends BaseNegotiator { let permissions: Permission[] = Object.values(permissionMap); this.logger.debug(`granting permissions: ${JSON.stringify(permissions)}`); - const tokenSub = getOriginalClaimValue(ticket.provided, WEBID); + const tokenSubs = ticket.provided[ORIGINAL_WEBID] || ticket.provided[WEBID]; // Create response const tokenContents: AccessToken = { permissions, contract, - ...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}), + ...(Array.isArray(tokenSubs) && typeof tokenSubs[0] === 'string' ? { sub: tokenSubs[0] } : {}), }; this.logger.debug(`resolved result ${JSON.stringify(contract)}`); diff --git a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts index 33dbb118..dfe58368 100644 --- a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts @@ -75,23 +75,28 @@ export class OdrlAuthorizer implements Authorizer { literal(new Date().toISOString(), namedNode("http://www.w3.org/2001/XMLSchema#dateTime"))), ); - const subject = typeof claims[WEBID] === 'string' ? claims[WEBID] : 'urn:solidlab:uma:id:anonymous'; + let subjects = claims[WEBID] && claims[WEBID].filter(id => typeof id === 'string'); + if (!subjects || subjects.length === 0) { + subjects = [ 'urn:solidlab:uma:id:anonymous' ]; + } const claimContextConstraints: { subject: ReturnType; quads: Quad[] }[] = []; - for (const [ key, value ] of Object.entries(claims)) { - const leftOperand = claimOperandMap[key] ?? key; - if (!isIri(leftOperand) || typeof value !== 'string') { - continue; + for (const [ key, values ] of Object.entries(claims)) { + for (const value of values ?? []) { + const leftOperand = claimOperandMap[key] ?? key; + if (!isIri(leftOperand) || typeof value !== 'string') { + continue; + } + const claimSubject = blankNode(); + claimContextConstraints.push({ + subject: claimSubject, + quads: [ + quad(claimSubject, RDF.terms.type, ODRL.terms.Constraint), + quad(claimSubject, ODRL.terms.leftOperand, namedNode(leftOperand)), + quad(claimSubject, ODRL.terms.operator, ODRL.terms.eq), + quad(claimSubject, ODRL.terms.rightOperand, namedNode(value)), + ], + }); } - const claimSubject = blankNode(); - claimContextConstraints.push({ - subject: claimSubject, - quads: [ - quad(claimSubject, RDF.terms.type, ODRL.terms.Constraint), - quad(claimSubject, ODRL.terms.leftOperand, namedNode(leftOperand)), - quad(claimSubject, ODRL.terms.operator, ODRL.terms.eq), - quad(claimSubject, ODRL.terms.rightOperand, namedNode(value)), - ], - }); } for (const { resource_id, resource_scopes } of query) { @@ -101,14 +106,14 @@ export class OdrlAuthorizer implements Authorizer { // 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}]`); + this.logger.info(`Evaluating Request [S R AR]: [${subjects.join(', ')} ${resource_id} ${action}]`); const requestPolicy: UCPPolicy = { type: ODRL.Request, rules: [ { action: action, resource: resource_id, - requestingParty: subject + requestingParty: subjects } ] } diff --git a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts index 3963a4f7..60cbd347 100644 --- a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts @@ -96,12 +96,13 @@ export class SimpleOdrlAuthorizer implements Authorizer { return []; } - let user = claims[WEBID]; let assignees: NamedNode[] = [ ANONYMOUS ]; - if (typeof user === 'string') { - const userNode = DF.namedNode(user); - assignees.push(userNode); - assignees.push(...(policies.getObjects(user, ODRL.terms.partOf, null) as NamedNode[])); + for (const user of claims[WEBID] ?? []) { + if (typeof user === 'string') { + const userNode = DF.namedNode(user); + assignees.push(userNode); + assignees.push(...(policies.getObjects(user, ODRL.terms.partOf, null) as NamedNode[])); + } } rules = rules.filter(rule => { const ruleAssignees = policies.getObjects(rule, ODRL.terms.assignee, null); @@ -173,6 +174,7 @@ export class SimpleOdrlAuthorizer implements Authorizer { // TODO: would want middleware step where credentials and other stuff are already extracted into RDF values // so both ODRL authorizers don't have to bother with this for (const constraint of constraints) { + const claimValues = claims[constraint.leftOperand.value]; // Return undefined if any of these are too complex or unknown if (constraint.leftOperand.equals(ODRL.terms.dateTime)) { const comparisonDate = new Date(constraint.rightOperand.value); @@ -188,13 +190,11 @@ export class SimpleOdrlAuthorizer implements Authorizer { if (!constraint.operator.equals(ODRL.terms.eq)) { return false; } - const claimValue = claims[claimKey]; - if (typeof claimValue !== 'string' || constraint.rightOperand.value !== claimValue) { + if (!claims[claimKey]?.some(claim => claim === constraint.rightOperand.value)) { return false; } - } else if (typeof claims[constraint.leftOperand.value] === 'string' - && constraint.operator.equals(ODRL.terms.eq)) { - if (constraint.rightOperand.value !== claims[constraint.leftOperand.value]) { + } else if (claimValues?.every(claim => typeof claim === 'string') && constraint.operator.equals(ODRL.terms.eq)) { + if (!claimValues?.some(claim => claim === constraint.rightOperand.value)) { return false; } } else { @@ -217,9 +217,12 @@ export class SimpleOdrlAuthorizer implements Authorizer { if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) { return; } + if (constraints.length === 0) { + return true; + } // Can't match a VC constraint if there is no VC input - const vc = claims[VC]; - if (constraints.length > 0 && typeof vc !== 'object') { + const vcs = claims[VC]; + if (!vcs || vcs?.length === 0) { return false; } @@ -228,15 +231,21 @@ export class SimpleOdrlAuthorizer implements Authorizer { if (!constraint.operator.equals(ODRL.terms.eq)) { return; } - const results = jp.query(vc, constraint.leftOperand.value).flat(); - if (!results.some(result => result === constraint.rightOperand.value)) { - return false; - } - if (constraint.credentialSubjectType) { - const types = jp.query(vc, '$.type').flat(); - if (!types.some(typ => constraint.credentialSubjectType.value === typ)) { + const foundMatchedVc = vcs.some(vc => { + const results = jp.query(vc, constraint.leftOperand.value).flat(); + if (!results.some(result => result === constraint.rightOperand.value)) { return false; } + if (constraint.credentialSubjectType) { + const types = jp.query(vc, '$.type').flat(); + if (!types.some(typ => constraint.credentialSubjectType.value === typ)) { + return false; + } + } + return true; + }); + if (!foundMatchedVc) { + return false; } } diff --git a/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts b/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts index e0e174ae..71c939e5 100644 --- a/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/WebIdAuthorizer.ts @@ -23,9 +23,11 @@ export class WebIdAuthorizer implements Authorizer { public async permissions(claims: ClaimSet, query?: Partial[]): Promise { this.logger.info(`Calculating permissions. ${JSON.stringify({ claims, query })}`); - const webid = claims[WEBID]; + const webids = claims[WEBID]; - if (!(typeof webid === 'string' && this.webids.includes(webid))) return []; + if (!Array.isArray(webids) || !webids.some(webId => typeof webId === 'string' && this.webids.includes(webId))) { + return []; + } return (query ?? []).map( (permission): Permission => ({ diff --git a/packages/uma/src/routes/BaseHandler.ts b/packages/uma/src/routes/BaseHandler.ts index d312bffd..03d7484e 100644 --- a/packages/uma/src/routes/BaseHandler.ts +++ b/packages/uma/src/routes/BaseHandler.ts @@ -1,4 +1,10 @@ -import { BadRequestHttpError, ForbiddenHttpError, joinUrl, MethodNotAllowedHttpError } from '@solid/community-server'; +import { + BadRequestHttpError, + ForbiddenHttpError, + joinUrl, + MethodNotAllowedHttpError, + UnauthorizedHttpError +} from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; import { BaseController } from '../controller/BaseController'; import { WEBID } from '../credentials/Claims'; @@ -61,10 +67,11 @@ export class BaseHandler extends HttpHandler { const credential = await this.credentialParser.handleSafe(request); const claims = await this.verifier.verify(credential); - const userId = claims[WEBID]; - if (typeof userId !== 'string') { - throw new ForbiddenHttpError(`Missing claim ${WEBID}.`); + const userIds = claims[WEBID]?.filter((webId): webId is string => typeof webId === 'string') ?? []; + if (userIds.length === 0) { + throw new UnauthorizedHttpError(); } + const userId = userIds[0]; if (request.parameters?.id) { switch (request.method) { diff --git a/packages/uma/src/routes/ClientRegistration.ts b/packages/uma/src/routes/ClientRegistration.ts index b81ba413..1c1e6a34 100644 --- a/packages/uma/src/routes/ClientRegistration.ts +++ b/packages/uma/src/routes/ClientRegistration.ts @@ -71,11 +71,11 @@ export class ClientRegistrationRequestHandler extends HttpHandler { public async handle({ request }: HttpHandlerContext): Promise { const credential = await this.credentialParser.handleSafe(request); const claims = await this.verifier.verify(credential); - const userId = claims[WEBID]; - - if (typeof userId !== 'string') { + const userIds = claims[WEBID]?.filter((webId): webId is string => typeof webId === 'string') ?? []; + if (userIds.length === 0) { throw new UnauthorizedHttpError(); } + const userId = userIds[0]; switch (request.method) { case 'GET': return this.getClients(request, userId); diff --git a/packages/uma/src/routes/Collection.ts b/packages/uma/src/routes/Collection.ts index 7e94d993..03898d0f 100644 --- a/packages/uma/src/routes/Collection.ts +++ b/packages/uma/src/routes/Collection.ts @@ -7,7 +7,7 @@ import { KeyValueStorage, MethodNotAllowedHttpError, NotFoundHttpError, - RDF + RDF, UnauthorizedHttpError } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; import { DataFactory as DF, NamedNode, Store } from 'n3'; @@ -63,10 +63,11 @@ export class CollectionRequestHandler extends HttpHandler { public async handle({ request }: HttpHandlerContext): Promise> { const credential = await this.credentialParser.handleSafe(request); const claims = await this.verifier.verify(credential); - const userId = claims[WEBID]; - if (typeof userId !== 'string') { - throw new ForbiddenHttpError(`Missing claim ${WEBID}.`); + const userIds = claims[WEBID]?.filter((webId): webId is string => typeof webId === 'string') ?? []; + if (userIds.length === 0) { + throw new UnauthorizedHttpError(); } + const userId = userIds[0]; switch (request.method) { case 'GET': return this.handleGet(request, userId); diff --git a/packages/uma/src/ticketing/strategy/AggregatorStrategy.ts b/packages/uma/src/ticketing/strategy/AggregatorStrategy.ts index e68cc1a2..7c60c355 100644 --- a/packages/uma/src/ticketing/strategy/AggregatorStrategy.ts +++ b/packages/uma/src/ticketing/strategy/AggregatorStrategy.ts @@ -1,5 +1,6 @@ import { BadRequestHttpError, InternalServerError, KeyValueStorage } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; +import { AccessRequest } from '../../controller/AccessRequestController'; import { ACCESS } from '../../credentials/Claims'; import { ClaimSet } from '../../credentials/ClaimSet'; import { RequiredClaim } from '../../errors/NeedInfoError'; @@ -61,7 +62,7 @@ export class AggregatorStrategy implements TicketingStrategy { // 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]) { + for (const { resource_id: id, resource_scopes: scopes } of claims[ACCESS] as AccessRequest[]) { if (scopes.includes(UMA_SCOPES['derivation-read'])) { derivedReadIds.add(id); } diff --git a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts index 4aa5de71..f224495d 100644 --- a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts +++ b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts @@ -38,7 +38,13 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy { this.logger.info(`Validating claims. ${JSON.stringify({ ticket, claims })}`); for (const key of Object.keys(claims)) { - ticket.provided[key] = claims[key]; + if (!claims[key]) { + continue; + } + if (!Array.isArray(ticket.provided[key])) { + ticket.provided[key] = []; + } + ticket.provided[key].push(...claims[key]); } return ticket; diff --git a/packages/uma/src/ucp/policy/ODRL.ts b/packages/uma/src/ucp/policy/ODRL.ts index 226e9fd0..92051f26 100644 --- a/packages/uma/src/ucp/policy/ODRL.ts +++ b/packages/uma/src/ucp/policy/ODRL.ts @@ -43,7 +43,13 @@ export function createRuleQuads(rule: UCPRule, policyIRI?: string): { quads: Qua const ruleIRI = "urn:ucp:rule:" + randomUUID(); quads.push(quad(namedNode(ruleIRI), ODRL.terms.action, namedNode(rule.action))) quads.push(quad(namedNode(ruleIRI), ODRL.terms.target, namedNode(rule.resource))) - quads.push(quad(namedNode(ruleIRI), ODRL.terms.assignee, namedNode(rule.requestingParty))) + if (Array.isArray(rule.requestingParty)) { + for (const party of rule.requestingParty) { + quads.push(quad(namedNode(ruleIRI), ODRL.terms.assignee, namedNode(party))) + } + } else { + quads.push(quad(namedNode(ruleIRI), ODRL.terms.assignee, namedNode(rule.requestingParty))) + } if (rule.owner) { quads.push(quad(namedNode(ruleIRI), ODRL.terms.assigner, namedNode(rule.owner))) } diff --git a/packages/uma/src/ucp/policy/UsageControlPolicy.ts b/packages/uma/src/ucp/policy/UsageControlPolicy.ts index d84ee089..d58752e1 100644 --- a/packages/uma/src/ucp/policy/UsageControlPolicy.ts +++ b/packages/uma/src/ucp/policy/UsageControlPolicy.ts @@ -15,7 +15,7 @@ export interface UCPRule { type?:string, // default Permission action: string, resource: string, - requestingParty: string, + requestingParty: string | string[], owner?: string, constraints?: UCPConstraint[] } @@ -40,4 +40,3 @@ export interface SimplePolicy { // identifier of the rule ruleIRIs: string[]; } - diff --git a/packages/uma/test/unit/credentials/Claims.test.ts b/packages/uma/test/unit/credentials/Claims.test.ts deleted file mode 100644 index 8aa345b5..00000000 --- a/packages/uma/test/unit/credentials/Claims.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getOriginalClaimValue, ORIGINAL, WEBID } from '../../../src/credentials/Claims'; - -describe('Claims', (): void => { - describe('#getOriginalClaimValue', (): void => { - it('prefers original claim values when present.', async(): Promise => { - expect(getOriginalClaimValue({ - [WEBID]: 'http://example.com/id/user', - [ORIGINAL]: { [WEBID]: 'user' }, - }, WEBID)).toBe('user'); - }); - - it('falls back to top-level claim values.', async(): Promise => { - expect(getOriginalClaimValue({ [WEBID]: 'http://example.com/id/user' }, WEBID)).toBe('http://example.com/id/user'); - }); - }); -}); diff --git a/packages/uma/test/unit/credentials/verify/IriVerifier.test.ts b/packages/uma/test/unit/credentials/verify/IriVerifier.test.ts index d24a9487..72422a53 100644 --- a/packages/uma/test/unit/credentials/verify/IriVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/IriVerifier.test.ts @@ -1,5 +1,5 @@ import { Mocked } from 'vitest'; -import { CLIENTID, ORIGINAL, WEBID } from '../../../../src/credentials/Claims'; +import { CLIENTID, ORIGINAL_CLIENTID, ORIGINAL_WEBID, WEBID } from '../../../../src/credentials/Claims'; import { Credential } from '../../../../src/credentials/Credential'; import { IriVerifier } from '../../../../src/credentials/verify/IriVerifier'; import { Verifier } from '../../../../src/credentials/verify/Verifier'; @@ -20,14 +20,16 @@ describe('IriVerifier', (): void => { it('keeps the original user and client ID if they already are IRIs', async(): Promise => { source.verify.mockResolvedValueOnce({ - [WEBID]: 'http://example.org/webId', - [CLIENTID]: 'http://example.org/clientId', - fruit: 'apple', + [WEBID]: [ 'http://example.org/webId' ], + [CLIENTID]: [ 'http://example.org/clientId' ], + fruit: [ 'apple' ], }); await expect(verifier.verify(credential)).resolves.toEqual({ - [WEBID]: 'http://example.org/webId', - [CLIENTID]: 'http://example.org/clientId', - fruit: 'apple', + [WEBID]: [ 'http://example.org/webId' ], + [CLIENTID]: [ 'http://example.org/clientId' ], + [ORIGINAL_WEBID]: [ 'http://example.org/webId' ], + [ORIGINAL_CLIENTID]: [ 'http://example.org/clientId' ], + fruit: [ 'apple' ], }); expect(source.verify).toHaveBeenCalledTimes(1); expect(source.verify).toHaveBeenLastCalledWith(credential); @@ -35,20 +37,34 @@ describe('IriVerifier', (): void => { it('changes the user and client ID to IRIs when required.', async(): Promise => { source.verify.mockResolvedValueOnce({ - [WEBID]: 'webId', - [CLIENTID]: 'clientId', - fruit: 'http://example.org/apple', + [WEBID]: [ 'webId' ], + [CLIENTID]: [ 'clientId' ], + fruit: [ 'http://example.org/apple' ], }); await expect(verifier.verify(credential)).resolves.toEqual({ - [WEBID]: 'http://example.com/id/webId', - [CLIENTID]: 'http://example.com/id/clientId', - [ORIGINAL]: { - [WEBID]: 'webId', - [CLIENTID]: 'clientId', - }, - fruit: 'http://example.org/apple', + [WEBID]: [ 'http://example.com/id/webId' ], + [CLIENTID]: [ 'http://example.com/id/clientId' ], + [ORIGINAL_WEBID]: [ 'webId' ], + [ORIGINAL_CLIENTID]: [ 'clientId' ], + fruit: [ 'http://example.org/apple' ], }); expect(source.verify).toHaveBeenCalledTimes(1); expect(source.verify).toHaveBeenLastCalledWith(credential); }); + + it('normalizes each claim entry independently when multiple values are provided.', async(): Promise => { + source.verify.mockResolvedValueOnce({ + [WEBID]: [ 'webId', 'http://example.org/iri-user', 7 as any ], + [CLIENTID]: [ 'clientId', 'http://example.org/iri-client', true as any ], + fruit: [ 'apple' ], + }); + + await expect(verifier.verify(credential)).resolves.toEqual({ + [WEBID]: [ 'http://example.com/id/webId', 'http://example.org/iri-user', 7 ], + [CLIENTID]: [ 'http://example.com/id/clientId', 'http://example.org/iri-client', true ], + [ORIGINAL_WEBID]: [ 'webId', 'http://example.org/iri-user', 7 ], + [ORIGINAL_CLIENTID]: [ 'clientId', 'http://example.org/iri-client', true ], + fruit: [ 'apple' ], + }); + }); }); diff --git a/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts b/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts index 8fcbf30d..81b800cf 100644 --- a/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts @@ -34,7 +34,7 @@ describe('JwtVerifier', (): void => { }); it('returns the allowed claims.', async(): Promise => { - await expect(verifier.verify(credential)).resolves.toEqual({ iss: issuer, claim1: 'val1', }); + await expect(verifier.verify(credential)).resolves.toEqual({ iss: [ issuer ], claim1: [ 'val1' ], }); expect(decodeMock).toHaveBeenCalledTimes(1); expect(decodeMock).toHaveBeenLastCalledWith(credential.token); @@ -71,7 +71,7 @@ describe('JwtVerifier', (): void => { }); it('verifies the token.', async(): Promise => { - await expect(verifier.verify(credential)).resolves.toEqual({ iss: issuer, claim1: 'val1', }); + await expect(verifier.verify(credential)).resolves.toEqual({ iss: [ issuer ], claim1: [ 'val1' ], }); expect(decodeMock).toHaveBeenCalledTimes(1); expect(decodeMock).toHaveBeenLastCalledWith(credential.token); expect(verifyMock).toHaveBeenCalledTimes(1); diff --git a/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts index 07df824f..fb06bcb4 100644 --- a/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts @@ -75,8 +75,8 @@ describe('OidcVerifier', (): void => { it('returns the extracted WebID.', async(): Promise => { await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'webId', - ['urn:solidlab:uma:claims:types:clientid']: 'clientId', + ['urn:solidlab:uma:claims:types:webid']: [ 'webId' ], + ['urn:solidlab:uma:claims:types:clientid']: [ 'clientId' ], }); }); @@ -91,8 +91,8 @@ describe('OidcVerifier', (): void => { verifier = new OidcVerifier(derivationStore, { issuer: [ issuer ] }); await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'webId', - ['urn:solidlab:uma:claims:types:clientid']: 'clientId', + ['urn:solidlab:uma:claims:types:webid']: [ 'webId' ], + ['urn:solidlab:uma:claims:types:clientid']: [ 'clientId' ], }); }); }); @@ -105,7 +105,7 @@ describe('OidcVerifier', (): void => { it('returns the extracted identity.', async(): Promise => { await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'sub', + ['urn:solidlab:uma:claims:types:webid']: [ 'sub' ], }); }); @@ -113,15 +113,15 @@ describe('OidcVerifier', (): void => { jwtVerify.mockResolvedValue({ payload: { ...decodedToken, azp: 'client' } } as any); await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'sub', - ['urn:solidlab:uma:claims:types:clientid']: 'client', + ['urn:solidlab:uma:claims:types:webid']: [ 'sub' ], + ['urn:solidlab:uma:claims:types:clientid']: [ 'client' ], }); }); it('uses verification options.', async(): Promise => { verifier = new OidcVerifier(derivationStore, { issuer: [ issuer ] }); await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'sub', + ['urn:solidlab:uma:claims:types:webid']: [ 'sub' ], }); expect(jwtVerify).toHaveBeenCalledExactlyOnceWith('token', remoteKeySet, { issuer: [ issuer ] }); }); diff --git a/packages/uma/test/unit/credentials/verify/TypedVerifier.test.ts b/packages/uma/test/unit/credentials/verify/TypedVerifier.test.ts index a996510f..5bbf2e9f 100644 --- a/packages/uma/test/unit/credentials/verify/TypedVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/TypedVerifier.test.ts @@ -4,8 +4,8 @@ import { Verifier } from '../../../../src/credentials/verify/Verifier'; describe('TypedVerifier', (): void => { const claims: ClaimSet[] = [ - { key: 'value1' }, - { key: 'value2' }, + { key: [ 'value1' ] }, + { key: [ 'value2' ] }, ] let verifiers: Record; let verifier: TypedVerifier; diff --git a/packages/uma/test/unit/credentials/verify/UnsecureVerifier.test.ts b/packages/uma/test/unit/credentials/verify/UnsecureVerifier.test.ts index 492de248..4c6e3a9b 100644 --- a/packages/uma/test/unit/credentials/verify/UnsecureVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/UnsecureVerifier.test.ts @@ -18,8 +18,7 @@ describe('UnsecureVerifier', (): void => { format: 'urn:solidlab:uma:claims:formats:webid', token: encodeURIComponent('http://example.com/#me'), })).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'http://example.com/#me', - ['urn:solidlab:uma:claims:types:clientid']: false, + ['urn:solidlab:uma:claims:types:webid']: [ 'http://example.com/#me' ], }); }); @@ -28,8 +27,8 @@ describe('UnsecureVerifier', (): void => { format: 'urn:solidlab:uma:claims:formats:webid', token: `${encodeURIComponent('http://example.com/#me')}:${encodeURIComponent('http://example.com/#client')}`, })).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'http://example.com/#me', - ['urn:solidlab:uma:claims:types:clientid']: 'http://example.com/#client', + ['urn:solidlab:uma:claims:types:webid']: [ 'http://example.com/#me' ], + ['urn:solidlab:uma:claims:types:clientid']: [ 'http://example.com/#client' ], }); }); diff --git a/packages/uma/test/unit/credentials/verify/VcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/VcVerifier.test.ts index 71560fc3..8058565f 100644 --- a/packages/uma/test/unit/credentials/verify/VcVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/VcVerifier.test.ts @@ -91,7 +91,7 @@ describe('VcVerifier', (): void => { it('returns the VC as claim.', async(): Promise => { await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:vc']: decodedToken.vc, + ['urn:solidlab:uma:claims:types:vc']: [ decodedToken.vc ], }); }); }); diff --git a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts index 493fe441..a6341d68 100644 --- a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts +++ b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts @@ -1,6 +1,6 @@ import { ForbiddenHttpError, KeyValueStorage } from '@solid/community-server'; import { Mocked } from 'vitest'; -import { ORIGINAL, WEBID } from '../../../src/credentials/Claims'; +import { ORIGINAL_WEBID, WEBID } from '../../../src/credentials/Claims'; import { ClaimSet } from '../../../src/credentials/ClaimSet'; import { Verifier } from '../../../src/credentials/verify/Verifier'; import { BaseNegotiator } from '../../../src/dialog/BaseNegotiator'; @@ -18,10 +18,10 @@ describe('BaseNegotiator', (): void => { { resource_id: 'id2', resource_scopes: [ 'scope2' ] }, ] }; - const claims: ClaimSet = { claim1: 'value1', claim2: 'value2' }; + const claims: ClaimSet = { claim1: [ 'value1' ], claim2: [ 'value2' ] }; const ticket: Ticket = { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ], - provided: { claim: 'value' }, + provided: { claim: [ 'value' ] }, }; const token: SerializedToken = { token: 'token', tokenType: 'type' }; let ticketData: Map; @@ -90,7 +90,7 @@ describe('BaseNegotiator', (): void => { { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] }); expect(refreshTokenIssuer.issue).toHaveBeenCalledTimes(1); expect(refreshTokenIssuer.issue).toHaveBeenLastCalledWith( - { claim: 'value' }, + { claim: [ 'value' ] }, [{ resource_id: 'id1', resource_scopes: [ 'scope1' ] }]); }); @@ -162,7 +162,7 @@ describe('BaseNegotiator', (): void => { { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] }); expect(refreshTokenIssuer.issue).toHaveBeenCalledTimes(1); expect(refreshTokenIssuer.issue).toHaveBeenLastCalledWith( - { claim: 'value' }, + { claim: [ 'value' ] }, [{ resource_id: 'id1', resource_scopes: [ 'scope1' ] }]); }); @@ -190,7 +190,7 @@ describe('BaseNegotiator', (): void => { { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] }); expect(refreshTokenIssuer.issue).toHaveBeenCalledTimes(1); expect(refreshTokenIssuer.issue).toHaveBeenLastCalledWith( - { claim: 'value' }, + { claim: [ 'value' ] }, [{ resource_id: 'id1', resource_scopes: [ 'scope1' ] }]); }); @@ -215,7 +215,7 @@ describe('BaseNegotiator', (): void => { const webId = 'https://example.com/profile/card#me'; ticketingStrategy.validateClaims.mockResolvedValueOnce({ ...ticket, - provided: { [WEBID]: webId }, + provided: { [WEBID]: [ webId ] }, }); await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves @@ -231,10 +231,8 @@ describe('BaseNegotiator', (): void => { ticketingStrategy.validateClaims.mockResolvedValueOnce({ ...ticket, provided: { - [WEBID]: 'http://example.com/id/user', - [ORIGINAL]: { - [WEBID]: 'user', - }, + [WEBID]: [ 'http://example.com/id/user' ], + [ORIGINAL_WEBID]: [ 'user' ], }, }); diff --git a/packages/uma/test/unit/dialog/ContractNegotiator.test.ts b/packages/uma/test/unit/dialog/ContractNegotiator.test.ts index d9e64857..8e024429 100644 --- a/packages/uma/test/unit/dialog/ContractNegotiator.test.ts +++ b/packages/uma/test/unit/dialog/ContractNegotiator.test.ts @@ -1,6 +1,6 @@ import { ForbiddenHttpError, KeyValueStorage } from '@solid/community-server'; import { Mocked, MockInstance } from 'vitest'; -import { ORIGINAL, WEBID } from '../../../src/credentials/Claims'; +import { ORIGINAL_WEBID, WEBID } from '../../../src/credentials/Claims'; import { ClaimSet } from '../../../src/credentials/ClaimSet'; import { Verifier } from '../../../src/credentials/verify/Verifier'; import { ContractNegotiator } from '../../../src/dialog/ContractNegotiator'; @@ -18,10 +18,10 @@ describe('ContractNegotiator', (): void => { { resource_id: 'id2', resource_scopes: [ 'scope2' ] }, ] }; - const claims: ClaimSet = { claim1: 'value1', claim2: 'value2' }; + const claims: ClaimSet = { claim1: [ 'value1' ], claim2: [ 'value2' ] }; const ticket: Ticket = { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ], - provided: { claim: 'value' }, + provided: { claim: [ 'value' ] }, }; const token: SerializedToken = { token: 'token', tokenType: 'type' }; const contract: ODRLContract = { @@ -140,7 +140,7 @@ describe('ContractNegotiator', (): void => { const webId = 'https://example.com/profile/card#me'; ticketingStrategy.validateClaims.mockResolvedValueOnce({ ...ticket, - provided: { [WEBID]: webId }, + provided: { [WEBID]: [ webId ] }, }); await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves @@ -157,10 +157,8 @@ describe('ContractNegotiator', (): void => { ticketingStrategy.validateClaims.mockResolvedValueOnce({ ...ticket, provided: { - [WEBID]: 'http://example.com/id/user', - [ORIGINAL]: { - [WEBID]: 'user', - }, + [WEBID]: [ 'http://example.com/id/user' ], + [ORIGINAL_WEBID]: [ 'user' ], }, }); diff --git a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts index 0d584b26..53ea4b49 100644 --- a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts +++ b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts @@ -5,18 +5,13 @@ import { ODRL, ODRLEvaluator } from 'odrl-evaluator'; import { Mocked } from 'vitest'; import { CLIENTID, PURPOSE } from '../../../../src/credentials/Claims'; import { OdrlAuthorizer } from '../../../../src/policies/authorizers/OdrlAuthorizer'; -import { basicPolicy } from '../../../../src/ucp/policy/ODRL'; +import * as odrl from '../../../../src/ucp/policy/ODRL'; import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage'; import { Permission } from '../../../../src/views/Permission'; const now = new Date(); vi.useFakeTimers({ now }); -vi.mock('../../../../src/ucp/policy/ODRL', async(importOriginal) => ({ - ...await importOriginal(), - basicPolicy: vi.fn(), -})); - describe('OdrlAuthorizer', (): void => { const sotw = [ DF.quad( DF.namedNode('http://example.com/request/currentTime'), @@ -25,6 +20,7 @@ describe('OdrlAuthorizer', (): void => { )]; const evaluate = vi.spyOn(ODRLEvaluator.prototype, 'evaluate'); + const basicPolicy = vi.spyOn(odrl, 'basicPolicy'); const requestQuads = [ DF.quad(DF.namedNode('req'), RDF.terms.type, DF.namedNode('Request')) ]; let policyStore = new Store([ DF.quad(DF.namedNode('policy'), RDF.terms.type, DF.namedNode('Policy')) ]); @@ -65,7 +61,7 @@ describe('OdrlAuthorizer', (): void => { rules: [{ action: 'http://www.w3.org/ns/odrl/2/read', resource: 'rid', - requestingParty: 'urn:solidlab:uma:id:anonymous' + requestingParty: [ 'urn:solidlab:uma:id:anonymous' ], }], }); expect(evaluate).toHaveBeenCalledTimes(1); @@ -77,7 +73,7 @@ describe('OdrlAuthorizer', (): void => { }); it('calls the evaluator with the WebID claim if there is one.', async(): Promise => { - const claims = { 'urn:solidlab:uma:claims:types:webid': 'http://example.com/#me' }; + const claims = { 'urn:solidlab:uma:claims:types:webid': [ 'http://example.com/#me' ] }; const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; // No result as the current evaluate mock returns an empty list @@ -88,7 +84,7 @@ describe('OdrlAuthorizer', (): void => { rules: [{ action: 'http://www.w3.org/ns/odrl/2/read', resource: 'rid', - requestingParty: 'http://example.com/#me' + requestingParty: [ 'http://example.com/#me' ], }], }); expect(evaluate).toHaveBeenCalledTimes(1); @@ -101,7 +97,7 @@ describe('OdrlAuthorizer', (): void => { }); it('adds client claim context using odrl:deliveryChannel', async(): Promise => { - const claims = { [CLIENTID]: 'client-a' }; + const claims = { [CLIENTID]: [ 'client-a' ] }; const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; await expect(authorizer.permissions(claims, query)).resolves.toEqual([{ resource_id: 'rid', resource_scopes: [] }]); @@ -125,7 +121,7 @@ describe('OdrlAuthorizer', (): void => { }); it('adds other claims to constraints', async(): Promise => { - const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' }; + const claims = { [PURPOSE]: [ 'https://w3id.org/dpv#ScientificResearch' ] }; const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; await expect(authorizer.permissions(claims, query)).resolves.toEqual([{ resource_id: 'rid', resource_scopes: [] }]); diff --git a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts index 77b3afbe..bbc8d0c7 100644 --- a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts +++ b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts @@ -89,7 +89,16 @@ describe('SimpleOdrlAuthorizer', () => { it('returns permission if rule matches resource, action, and assignee', async () => { addRule({ assignee: 'user' }); - const claims = { [WEBID]: 'user' }; + const claims = { [WEBID]: [ 'user' ] }; + + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('matches assignee when one of multiple WEBID claim values matches.', async () => { + addRule({ assignee: 'member' }); + const claims = { [WEBID]: [ 'user', 'member' ] }; await expect(authorizer.permissions(claims, query)) .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); @@ -106,7 +115,7 @@ describe('SimpleOdrlAuthorizer', () => { it('returns empty if assignee does not match', async () => { addRule({ assignee: 'other' }); - const claims = { [WEBID]: 'user' }; + const claims = { [WEBID]: [ 'user' ] }; await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); @@ -134,7 +143,7 @@ describe('SimpleOdrlAuthorizer', () => { operator: ODRL.terms.eq, rightOperand: 'clientA', }); - const claims = { [CLIENTID]: 'clientB' }; + const claims = { [CLIENTID]: [ 'clientB' ] }; await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); @@ -148,7 +157,7 @@ describe('SimpleOdrlAuthorizer', () => { operator: ODRL.terms.eq, rightOperand: 'clientA', }); - const claims = { [CLIENTID]: 'clientA' }; + const claims = { [CLIENTID]: [ 'clientA' ] }; await expect(authorizer.permissions(claims, query)) .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); @@ -163,7 +172,7 @@ describe('SimpleOdrlAuthorizer', () => { operator: ODRL.terms.eq, rightOperand: 'https://w3id.org/dpv#ScientificResearch', }); - const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' }; + const claims = { [PURPOSE]: [ 'https://w3id.org/dpv#ScientificResearch' ] }; await expect(authorizer.permissions(claims, query)) .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); @@ -178,7 +187,7 @@ describe('SimpleOdrlAuthorizer', () => { operator: ODRL.terms.eq, rightOperand: 'http://example.com/purpose-a', }); - const claims = { [PURPOSE]: 'http://example.com/purpose-b' }; + const claims = { [PURPOSE]: [ 'http://example.com/purpose-b' ] }; await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); @@ -252,10 +261,31 @@ describe('SimpleOdrlAuthorizer', () => { store.addQuad(constraint, ODRL.terms.rightOperand, DF.literal('apple')); store.addQuad(constraint, OVC.terms.credentialSubjectType, DF.namedNode('http://example.com/type')); - const claims = { [VC]: { + const claims = { [VC]: [{ type: [ 'http://example.com/type' ], credentialSubject: { garden: { fruit: 'apple' }} - }}; + }]}; + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns permissions when any VC entry satisfies the OVC constraint.', async(): Promise => { + const rule = addRule({}); + + const jsonPath = '$.credentialSubject.garden.fruit'; + const constraint = DF.namedNode(`ovc-constraint-${randomUUID()}`); + store.addQuad(rule, OVC.terms.constraint, constraint); + store.addQuad(constraint, OVC.terms.leftOperand, DF.namedNode(jsonPath)); + store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.eq); + store.addQuad(constraint, ODRL.terms.rightOperand, DF.literal('apple')); + store.addQuad(constraint, OVC.terms.credentialSubjectType, DF.namedNode('http://example.com/type')); + + const claims = { [VC]: [ + { type: [ 'http://example.com/type' ], credentialSubject: { garden: { fruit: 'pear' }}}, + { type: [ 'http://example.com/type' ], credentialSubject: { garden: { fruit: 'apple' }}}, + ]}; + await expect(authorizer.permissions(claims, query)) .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); @@ -272,14 +302,14 @@ describe('SimpleOdrlAuthorizer', () => { store.addQuad(constraint, ODRL.terms.rightOperand, DF.literal('apple')); store.addQuad(constraint, OVC.terms.credentialSubjectType, DF.namedNode('http://example.com/type')); - let claims: ClaimSet = { [VC]: { + let claims: ClaimSet = { [VC]: [{ credentialSubject: { garden: { fruit: 'apple' }} - }}; + }]}; await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); - claims = { [VC]: { + claims = { [VC]: [{ type: [ 'http://example.com/type' ], credentialSubject: { garden: { fruit: 'pear' }} - }}; + }]}; await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); diff --git a/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts index 7e8cb395..98862508 100644 --- a/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts +++ b/packages/uma/test/unit/policies/authorizers/WebIdAuthorizer.test.ts @@ -12,7 +12,7 @@ describe('WebIdAuthorizer', (): void => { it('returns empty permissions if there is no WebID match.', async(): Promise => { await expect(authorizer.permissions({})).resolves.toEqual([]); - await expect(authorizer.permissions({ [WEBID]: 'unknown' })).resolves.toEqual([]); + await expect(authorizer.permissions({ [WEBID]: [ 'unknown' ] })).resolves.toEqual([]); }); it('returns full permissions if there is a matching WebID.', async(): Promise => { @@ -21,7 +21,7 @@ describe('WebIdAuthorizer', (): void => { { resource_id: 'id2' }, { resource_scopes: [ 'scope3' ]}, ]; - await expect(authorizer.permissions({ [WEBID]: webIds[0] }, query)).resolves.toEqual([ + await expect(authorizer.permissions({ [WEBID]: [ webIds[0] ] }, query)).resolves.toEqual([ { resource_id: 'id1', resource_scopes: [ 'scope1' ]}, { resource_id: 'id2', resource_scopes: [ 'urn:solidlab:uma:scopes:any' ]}, { resource_id: 'urn:solidlab:uma:resources:any', resource_scopes: [ 'scope3' ]}, diff --git a/packages/uma/test/unit/routes/ClientRegistration.test.ts b/packages/uma/test/unit/routes/ClientRegistration.test.ts index 3e062ac6..29d377b3 100644 --- a/packages/uma/test/unit/routes/ClientRegistration.test.ts +++ b/packages/uma/test/unit/routes/ClientRegistration.test.ts @@ -43,7 +43,7 @@ describe('ClientRegistration', (): void => { } satisfies Partial as any; verifier = { - verify: vi.fn().mockResolvedValue({ [WEBID]: webId }), + verify: vi.fn().mockResolvedValue({ [WEBID]: [ webId ] }), }; storage = { diff --git a/packages/uma/test/unit/routes/Collection.test.ts b/packages/uma/test/unit/routes/Collection.test.ts index 1ef6c36e..4d62d6ef 100644 --- a/packages/uma/test/unit/routes/Collection.test.ts +++ b/packages/uma/test/unit/routes/Collection.test.ts @@ -41,7 +41,7 @@ describe('Collection', (): void => { } satisfies Partial as any; verifier = { - verify: vi.fn().mockResolvedValue({ [WEBID]: userId }), + verify: vi.fn().mockResolvedValue({ [WEBID]: [ userId ] }), } satisfies Partial as any; ownershipStore = { diff --git a/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts index 06cf879b..5f5050d3 100644 --- a/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts +++ b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts @@ -31,10 +31,30 @@ describe('ImmediateAuthorizerStrategy', (): void => { permissions, provided: {}, }; - const claims: ClaimSet = { claim1: 'val1', claim2: 'val2' }; + const claims: ClaimSet = { claim1: [ 'val1' ], claim2: [ 'val2' ] }; await expect(strategy.validateClaims(ticket, claims)).resolves.toEqual({ permissions, - provided: { claim1: 'val1', claim2: 'val2' }, + provided: { claim1: [ 'val1' ], claim2: [ 'val2' ] }, + }); + }); + + it('appends claim values to existing provided arrays.', async(): Promise => { + const ticket: Ticket = { + permissions, + provided: { + claim1: [ 'existing' ], + claim3: [ 'keep' ], + }, + }; + const claims: ClaimSet = { claim1: [ 'val1', 'val2' ], claim2: [ 'val3' ] }; + + await expect(strategy.validateClaims(ticket, claims)).resolves.toEqual({ + permissions, + provided: { + claim1: [ 'existing', 'val1', 'val2' ], + claim2: [ 'val3' ], + claim3: [ 'keep' ], + }, }); }); diff --git a/packages/uma/test/unit/ucp/policy/ODRL.test.ts b/packages/uma/test/unit/ucp/policy/ODRL.test.ts index a8546318..a8e736b9 100644 --- a/packages/uma/test/unit/ucp/policy/ODRL.test.ts +++ b/packages/uma/test/unit/ucp/policy/ODRL.test.ts @@ -55,5 +55,21 @@ describe('ODRL', (): void => { store.countQuads(constraint, ODRL.terms.rightOperand, DF.literal(now.toISOString(), XSD.terms.dateTime), null), ).toBe(1); }); + + it('creates one assignee quad per requesting party when requestingParty is an array.', async(): Promise => { + policy.rules[0].requestingParty = [ + 'http://example.com/me', + 'http://example.com/teammate', + ]; + + const result = basicPolicy(policy, 'http://example.com/policy'); + const ruleTerm = result.ruleIRIs[0]; + const assignees = result.representation.getObjects(ruleTerm, ODRL.terms.assignee, null); + + expect(assignees.map((term) => term.value).sort()).toEqual([ + 'http://example.com/me', + 'http://example.com/teammate', + ]); + }); }); }); diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts index c98149ac..0185cf0f 100644 --- a/test/integration/Base.test.ts +++ b/test/integration/Base.test.ts @@ -1,4 +1,4 @@ -import { App } from '@solid/community-server'; +import { App, joinUrl } from '@solid/community-server'; import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory'; import { Parser, Writer } from 'n3'; import { readFile } from 'node:fs/promises'; @@ -102,6 +102,7 @@ describe('A server setup', (): void => { describe('using ODRL authorization', (): void => { const owner = 'https://pod.woutslabbinck.com/profile/card#me'; + const assignee = 'https://woslabbi.pod.knows.idlab.ugent.be/profile/card#me'; const newResource = `http://localhost:${cssPort}/alice/resource.txt`; const newResource2 = `http://localhost:${cssPort}/alice/private/resource.txt`; let wwwAuthenticateHeader: string; @@ -142,12 +143,10 @@ describe('A server setup', (): void => { }); it('AS: responds with a token when receiving the ticket.', async(): Promise => { - const claim_token = 'https://woslabbi.pod.knows.idlab.ugent.be/profile/card#me'; - const content = { grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket', ticket, - claim_token: encodeURIComponent(claim_token), + claim_token: encodeURIComponent(assignee), claim_token_format: 'urn:solidlab:uma:claims:formats:webid', }; @@ -317,6 +316,12 @@ describe('A server setup', (): void => { const getResponse = await fetch(newResource); expect(getResponse.status).toBe(200); await expect(getResponse.text()).resolves.toEqual('Some new text!'); + + const response = await fetch(joinUrl(url, encodeURIComponent('http://example.org/publicAnonPolicy')), { + method: 'DELETE', + headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' }, + }); + expect(response.status).toBe(204); }); it('the resource can be made publicly accessible by being a Set without assignee.', async(): Promise => { @@ -352,6 +357,30 @@ describe('A server setup', (): void => { const getResponse = await fetch(newResource); expect(getResponse.status).toBe(200); await expect(getResponse.text()).resolves.toEqual('Some new text!'); + + const response = await fetch(joinUrl(url, encodeURIComponent('http://example.org/publicPolicy')), { + method: 'DELETE', + headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' }, + }); + expect(response.status).toBe(204); + }); + + it('still works correctly with multiple claims of the same information type.', async(): Promise => { + const { as_uri, ticket } = await noTokenFetch(newResource); + const endpoint = await findTokenEndpoint(as_uri); + const response1 = await attemptTokenRequest(ticket, endpoint, undefined, [ + { claim_token: encodeURIComponent(assignee), claim_token_format: 'urn:solidlab:uma:claims:formats:webid' }, + { claim_token: encodeURIComponent('http://example.com/wrong'), claim_token_format: 'urn:solidlab:uma:claims:formats:webid' }, + ]); + expect(response1.status).toBe(200); + + // Also works with claims in reverse order + const { ticket: newTicket } = await noTokenFetch(newResource); + const response2 = await attemptTokenRequest(newTicket, endpoint, undefined, [ + { claim_token: encodeURIComponent('http://example.com/wrong'), claim_token_format: 'urn:solidlab:uma:claims:formats:webid' }, + { claim_token: encodeURIComponent(assignee), claim_token_format: 'urn:solidlab:uma:claims:formats:webid' }, + ]); + expect(response2.status).toBe(200); }); }); }); From 2800ba75f08b1a57c9ba1a9d7531b14a397fd3e6 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 10 Aug 2026 13:40:16 +0200 Subject: [PATCH 2/2] fix: Prevent collection data from being returned through policies --- packages/uma/src/util/routeSpecific/get.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uma/src/util/routeSpecific/get.ts b/packages/uma/src/util/routeSpecific/get.ts index 0d992621..e3873532 100644 --- a/packages/uma/src/util/routeSpecific/get.ts +++ b/packages/uma/src/util/routeSpecific/get.ts @@ -86,7 +86,7 @@ const buildPolicyRetrievalQuery = (policyID: string, resourceOwner: string) => ` * @returns a store containing the policy and its permissions */ export const getPolicy = (store: Store, policyID: string, resourceOwner: string) => - executeGet(store, buildPolicyRetrievalQuery(policyID, resourceOwner), ['policy', 'perm', 'target']); + executeGet(store, buildPolicyRetrievalQuery(policyID, resourceOwner), ['policy', 'perm']); /** * Build a query to retrieve all policies for a given client. @@ -120,7 +120,7 @@ const buildPoliciesRetrievalQuery = (resourceOwner: string) => ` * @returns a store containing all policies and their permissions */ export const getPolicies = (store: Store, resourceOwner: string) => - executeGet(store, buildPoliciesRetrievalQuery(resourceOwner), ['perm', 'target']); + executeGet(store, buildPoliciesRetrievalQuery(resourceOwner), ['perm']); // TODO: slight improvement over existing solution so constraints get returned but definitely not ideal yet function permissionToQuads(store: Store, permission: Quad_Subject): Quad[] {