From 9d3bf038fda1fd78827e1333e0c01e2c3ea16dbe Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 10 Nov 2025 15:13:40 +0100 Subject: [PATCH] feat: Support client IDs in ODRL policies I wanted to use the `odrl:deliveryChannel` constraint for this, but unfortunately v0.5.0 of the ODRL evaluator only supports `odrl:purpose` and `odrl:dateTime`. See https://github.com/SolidLabResearch/ODRL-Evaluator/blob/v0.5.0/ODRL-Support.md#left-operands. Since we are not yet using `odrl:purpose`, I'm using that one until this is resolved, as this feature is required sooner than purpose support, with the (hopefully not too naive) idea to resolve this soon. --- documentation/getting-started.md | 30 ++++ packages/ucp/src/util/Vocabularies.ts | 1 + .../src/credentials/verify/OidcVerifier.ts | 4 +- .../policies/authorizers/OdrlAuthorizer.ts | 38 ++++- test/integration/Oidc.test.ts | 147 +++++++++++++++++- 5 files changed, 211 insertions(+), 9 deletions(-) diff --git a/documentation/getting-started.md b/documentation/getting-started.md index 191e09c..7cc717c 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -309,6 +309,36 @@ ex:permission a odrl:Permission ; ``` This policy says that the above WebID has access to the `create` scope on ``. +### Client application identification + +It is possible to create policies that restrict access based on the client application being used. +This can only be done when using an OIDC ID token for authentication. +The `azp` claim of the token will be used. + +To restrict a policy to a certain client application, +a constraint needs to be added to the policy. +Due to some issues with internal libraries, +the `odrl:purpose` constraint is currently used to identify the client. +This will be fixed in the near future. + +To restrict a policy to only permit access when using the application `http://example.com/client`, +the policy should look as follows: +```ttl +@prefix ex: . +@prefix odrl: . + +ex:usagePolicy a odrl:Agreement ; + odrl:permission ex:permission . +ex:permission a odrl:Permission ; + odrl:action odrl:create ; + odrl:target ; + odrl:assignee ; + odrl:constraint ex:constraint . +ex:constraint odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand . +``` + ## Adding or changing policies For more details, see the [policy management API documentation](policy-management.md). diff --git a/packages/ucp/src/util/Vocabularies.ts b/packages/ucp/src/util/Vocabularies.ts index c01b944..d3557b8 100644 --- a/packages/ucp/src/util/Vocabularies.ts +++ b/packages/ucp/src/util/Vocabularies.ts @@ -118,6 +118,7 @@ export const ODRL = createVocabulary( 'Prohibition', 'Duty', 'Request', + 'Constraint', 'source', 'partOf', 'action', diff --git a/packages/uma/src/credentials/verify/OidcVerifier.ts b/packages/uma/src/credentials/verify/OidcVerifier.ts index a1b2475..c59555d 100644 --- a/packages/uma/src/credentials/verify/OidcVerifier.ts +++ b/packages/uma/src/credentials/verify/OidcVerifier.ts @@ -62,6 +62,8 @@ export class OidcVerifier implements Verifier { protected async verifySolidToken(token: string): Promise<{ [WEBID]: string, [CLIENTID]?: string }> { const claims = await this.verifyToken(`Bearer ${token}`); + // Depends on the spec version which field to use + const clientId = (claims as { azp?: string }).azp ?? claims.client_id; this.logger.info(`Authenticated via a Solid OIDC. ${JSON.stringify(claims)}`); @@ -69,7 +71,7 @@ export class OidcVerifier implements Verifier { // TODO: would have to use different value than "WEBID" // TODO: still want to use WEBID as external value potentially? [WEBID]: claims.webid, - ...claims.client_id && { [CLIENTID]: claims.client_id } + ...clientId && { [CLIENTID]: clientId } }); } diff --git a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts index 9938b2a..37b2deb 100644 --- a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts @@ -1,16 +1,16 @@ import { BadRequestHttpError, DC, NotImplementedHttpError, RDF } from '@solid/community-server'; -import { basicPolicy, ODRL, UCPPolicy, UCRulesStorage } from '@solidlab/ucp'; +import { basicPolicy, ODRL, UCPConstraint, UCPPolicy, UCRulesStorage } from '@solidlab/ucp'; import { getLoggerFor } from 'global-logger-factory'; -import { DataFactory, Literal, NamedNode, Quad_Subject, Store, Writer } 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 { WEBID } from '../../credentials/Claims'; +import { CLIENTID, WEBID } from '../../credentials/Claims'; import { ClaimSet } from '../../credentials/ClaimSet'; import { Requirements } from '../../credentials/Requirements'; import { Permission } from '../../views/Permission'; import { Authorizer } from './Authorizer'; -const {quad, namedNode, literal} = DataFactory +const { quad, namedNode, literal, blankNode } = DataFactory /** * Permission evaluation is performed as follows: @@ -71,6 +71,23 @@ export class OdrlAuthorizer implements Authorizer { ); const subject = typeof claims[WEBID] === 'string' ? claims[WEBID] : 'urn:solidlab:uma:id:anonymous'; + const clientQuads: Quad[] = []; + const clientSubject = blankNode(); + if (typeof claims[CLIENTID] === 'string') { + clientQuads.push( + quad(clientSubject, RDF.terms.type, ODRL.terms.Constraint), + // TODO: using purpose as other constraints are not supported in current version of ODRL evaluator + // https://github.com/SolidLabResearch/ODRL-Evaluator/blob/v0.5.0/ODRL-Support.md#left-operands + quad(clientSubject, ODRL.terms.leftOperand, namedNode(ODRL.namespace + 'purpose')), + quad(clientSubject, ODRL.terms.operator, ODRL.terms.eq), + quad(clientSubject, ODRL.terms.rightOperand, namedNode(claims[CLIENTID])), + ); + // constraints.push({ + // type: ODRL.namespace + 'deliveryChannel', + // operator: ODRL.eq, + // value: namedNode(claims[CLIENTID]), + // }); + } for (const {resource_id, resource_scopes} of query) { grantedPermissions[resource_id] = []; @@ -87,7 +104,18 @@ export class OdrlAuthorizer implements Authorizer { } ] } - const requestStore = basicPolicy(requestPolicy).representation + const request = basicPolicy(requestPolicy); + const requestStore = request.representation + // Adding context triples for the client identifier, if there is one + if (clientQuads.length > 0) { + requestStore.addQuad(quad( + namedNode(request.ruleIRIs[0]), + namedNode('https://w3id.org/force/sotw#context'), + clientSubject, + )); + requestStore.addQuads(clientQuads); + } + // evaluate policies const reports = await this.odrlEvaluator.evaluate( [...policyStore], diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts index 3fa706a..2b32d08 100644 --- a/test/integration/Oidc.test.ts +++ b/test/integration/Oidc.test.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; import { findTokenEndpoint, noTokenFetch } from '../util/UmaUtil'; -const [ cssPort, umaPort ] = getPorts('Policies'); +const [ cssPort, umaPort ] = getPorts('OIDC'); const idpPort = umaPort + 100; describe('A server supporting OIDC tokens', (): void => { @@ -47,7 +47,6 @@ describe('A server supporting OIDC tokens', (): void => { privateKey = { ...await generator.getPrivateKey(), kid: 'kid' }; const publicKey = { ...await generator.getPublicKey(), kid: 'kid' } idp = createServer((req, res) => { - console.log(req.url); if (req.url!.endsWith('/card')) { res.writeHead(200, { 'content-type': 'text/turtle' }); res.end(` @@ -64,6 +63,12 @@ describe('A server supporting OIDC tokens', (): void => { return; } res.writeHead(200, { 'content-type': 'application/json' }); + if (req.url!.endsWith('/client')) { + res.end(JSON.stringify({ + '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], + })); + return; + } if (req.url!.endsWith('/.well-known/openid-configuration')) { res.end(JSON.stringify({ jwks_uri: idpUrl })); return; @@ -136,9 +141,76 @@ describe('A server supporting OIDC tokens', (): void => { }); }); + describe('accessing a resource using a standard OIDC token with a specific client.', (): void => { + const resource = `http://localhost:${cssPort}/alice/standardClient`; + const sub = '123456'; + const client = 'my-client'; + const policy = ` + @prefix ex: . + @prefix odrl: . + @prefix dct: . + ex:policyStandardClient a odrl:Set; + odrl:uid ex:policyStandardClient ; + odrl:permission ex:permissionStandardClient . + + ex:permissionStandardClient a odrl:Permission ; + odrl:assignee <${sub}> ; + odrl:assigner <${webId}> ; + odrl:action odrl:read , odrl:create , odrl:modify ; + odrl:target ; + odrl:constraint ex:constraintStandardClient. + + ex:constraintStandardClient + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand <${client}> .`; + + it('can set up the policy.', async(): Promise => { + const response = await fetch(policyEndpoint, { + method: 'POST', + headers: { authorization: webId, 'content-type': 'text/turtle' }, + body: policy, + }); + expect(response.status).toBe(201); + }); + + it('can get an access token.', async(): Promise => { + const { as_uri, ticket } = await noTokenFetch(resource, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'hello', + }); + const endpoint = await findTokenEndpoint(as_uri); + + // TODO: also add token that fails + const jwk = await importJWK(privateKey, privateKey.alg); + const jwt = await new SignJWT({ azp: client }) + .setSubject(sub) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuedAt() + .setIssuer(idpUrl) + .setAudience(`http://localhost:${umaPort}/uma`) + .setJti(randomUUID()) + .sign(jwk); + + const content: Record = { + grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket', + ticket: ticket, + claim_token: jwt, + claim_token_format: oidcFormat, + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(content), + }); + expect(response.status).toBe(200); + }); + }); describe('accessing a resource using a Solid OIDC token.', (): void => { - const resource = `http://localhost:${cssPort}/alice/standard`; + const resource = `http://localhost:${cssPort}/alice/solid`; // Using dummy server so we can spoof WebID const alice = idpUrl + 'alice/profile/card#me'; const policy = ` @@ -199,4 +271,73 @@ describe('A server supporting OIDC tokens', (): void => { expect(response.status).toBe(200); }); }); + + describe('accessing a resource using a Solid OIDC token with a specific client.', (): void => { + const resource = `http://localhost:${cssPort}/bob/solidClient`; + // Using dummy server so we can spoof WebID + const bob = idpUrl + 'bob/profile/card#me'; + const client = idpUrl + 'client'; + const policy = ` + @prefix ex: . + @prefix odrl: . + @prefix dct: . + ex:policySolidClient a odrl:Set; + odrl:uid ex:policySolidClient ; + odrl:permission ex:permissionSolidClient . + + ex:permissionSolidClient a odrl:Permission ; + odrl:assignee <${bob}> ; + odrl:assigner <${webId}> ; + odrl:action odrl:read , odrl:create , odrl:modify ; + odrl:target ; + odrl:constraint ex:constraintSolidClient. + + ex:constraintSolidClient + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand <${client}> .`; + + it('can set up the policy.', async(): Promise => { + const response = await fetch(policyEndpoint, { + method: 'POST', + headers: { authorization: webId, 'content-type': 'text/turtle' }, + body: policy, + }); + expect(response.status).toBe(201); + }); + + it('can get an access token.', async(): Promise => { + const { as_uri, ticket } = await noTokenFetch(resource, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'hello', + }); + const endpoint = await findTokenEndpoint(as_uri); + + const jwk = await importJWK(privateKey, privateKey.alg); + const jwt = await new SignJWT({ webid: bob, azp: client }) + .setSubject(bob) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuedAt() + .setIssuer(idpUrl) + .setAudience([ 'solid', `http://localhost:${umaPort}/uma` ]) + .setJti(randomUUID()) + .setExpirationTime(Date.now() + 5000) + .sign(jwk); + + const content: Record = { + grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket', + ticket: ticket, + claim_token: jwt, + claim_token_format: oidcFormat, + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(content), + }); + expect(response.status).toBe(200); + }); + }); });