Skip to content

Commit 84c2d78

Browse files
committed
feat: Support purpose claim
1 parent 586042e commit 84c2d78

10 files changed

Lines changed: 228 additions & 41 deletions

File tree

documentation/getting-started.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ so some information might change depending on which version and branch you're us
4141
- [Publicly accessible resources](#publicly-accessible-resources)
4242
+ [Exchange ticket](#exchange-ticket)
4343
- [Authentication methods](#authentication-methods)
44+
- [Additional claims](#additional-claims)
4445
- [Customizing OIDC verification](#customizing-oidc-verification)
4546
+ [Generate token](#generate-token)
4647
+ [Use token](#use-token)
@@ -322,6 +323,20 @@ The `claim_token_format` explains to the AS how the `claim_token` should be inte
322323
In this case, this is a custom format designed for this server,
323324
where the token is a URL-encoded WebID.
324325

326+
It is also possible to provide multiple claims by using an array for `claim_token`:
327+
```json
328+
{
329+
"grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket",
330+
"ticket": "TICKET_ID",
331+
"claim_token": [
332+
{ "claim_token": "eyJhbGciOiJSUzI1NiIsInR5cCI...", "claim_token_format": "http://openid.net/specs/openid-connect-core-1_0.html#IDToken" },
333+
{ "claim_token": "https://w3id.org/dpv#ScientificResearch", "claim_token_format": "http://www.w3.org/ns/odrl/2/purpose" }
334+
]
335+
}
336+
```
337+
When multiple claims are provided, each claim must include both a `claim_token` and `claim_token_format`.
338+
The AS will verify all provided claims and use them collectively to determine authorization.
339+
325340
#### Authentication methods
326341

327342
The above claim token format indicates that the claim token should be interpreted as a valid WebID.
@@ -332,13 +347,23 @@ In that case the body is expected to be an OIDC ID token.
332347
Both Solid and standard OIDC tokens are supported.
333348
In case of standard tokens, the value of the `sub` field will be used to match the assignee in the policies.
334349

350+
Multiple authentication methods can be combined in a single request by providing multiple claims as shown above.
351+
This allows the AS to verify multiple credentials simultaneously and combine their claims.
352+
335353
The values that are extracted from the OIDC token are expected to be IRIs.
336354
In case the `sub` or `azp`, which is discussed below, values are not IRIs,
337355
the server wil internally convert them by URL encoding the value, and prepending them with `http://example.com/id/`.
338356
This means that your policies should reference the converted ID.
339357
For example, if your `sub` value is `my id`, your policy needs to target `http://example.com/id/my%20id`.
340358
This base URL will be updated in the future once we have settled on a fixed value.
341359

360+
##### Additional claims
361+
362+
Besides claims that identify the user and client, additional claims can also be provided containing additional information.
363+
Currently, the only additional claim that is supported is the `purpose` claim.
364+
This can be provided with a `claim_token_format` of `http://www.w3.org/ns/odrl/2/purpose`,
365+
and with the `claim_token` being the IRI of the purpose.
366+
342367
#### Customizing OIDC verification
343368

344369
Several configuration options can be added to further restrict authentication when using OIDC tokens,

packages/uma/config/credentials/verifiers/default.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
"@id": "urn:uma:default:TypedVerifier",
1414
"@type": "TypedVerifier",
1515
"verifiers": [
16+
{
17+
"TypedVerifier:_verifiers_key": "http://www.w3.org/ns/odrl/2/purpose",
18+
"TypedVerifier:_verifiers_value": {
19+
"@id": "urn:uma:default:KeyValueVerifier",
20+
"@type": "KeyValueVerifier"
21+
}
22+
},
1623
{
1724
"TypedVerifier:_verifiers_key": "urn:solidlab:uma:claims:formats:webid",
1825
"TypedVerifier:_verifiers_value": {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ClaimSet } from '../ClaimSet';
2+
import { Credential } from '../Credential';
3+
import { Verifier } from './Verifier';
4+
5+
/**
6+
* A verifier that assigns the credential token value as claims value, with the format being used as claims key.
7+
*/
8+
export class KeyValueVerifier implements Verifier {
9+
public constructor() {}
10+
11+
public async verify(credential: Credential): Promise<ClaimSet> {
12+
return {
13+
[credential.format]: credential.token
14+
}
15+
}
16+
}

packages/uma/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './credentials/verify/UnsecureVerifier';
1515
export * from './credentials/verify/OidcVerifier';
1616
export * from './credentials/verify/JwtVerifier';
1717
export * from './credentials/verify/IriVerifier';
18+
export * from './credentials/verify/KeyValueVerifier';
1819

1920
// Dialog
2021
export * from './dialog/AggregatorNegotiator';

packages/uma/src/policies/authorizers/OdrlAuthorizer.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { RDF } from '@solid/community-server';
22
import { getLoggerFor } from 'global-logger-factory';
33
import { DataFactory, Quad, Store } from 'n3';
44
import { EyelingReasoner, ODRL, ODRLEngineMultipleSteps, ODRLEvaluator } from 'odrl-evaluator';
5-
import { CLIENTID, WEBID } from '../../credentials/Claims';
5+
import { CLIENTID, PURPOSE, WEBID } from '../../credentials/Claims';
66
import { ClaimSet } from '../../credentials/ClaimSet';
77
import { basicPolicy } from '../../ucp/policy/ODRL';
88
import { PrioritizeProhibitionStrategy } from '../../ucp/policy/PrioritizeProhibitionStrategy';
@@ -14,6 +14,11 @@ import { Authorizer } from './Authorizer';
1414

1515
const { quad, namedNode, literal, blankNode } = DataFactory
1616

17+
const claimOperandMap: Record<string, string> = {
18+
[CLIENTID]: ODRL.deliveryChannel,
19+
[PURPOSE]: ODRL.purpose,
20+
} as const;
21+
1722
/**
1823
* Permission evaluation is performed as follows:
1924
*
@@ -70,15 +75,22 @@ export class OdrlAuthorizer implements Authorizer {
7075
);
7176

7277
const subject = typeof claims[WEBID] === 'string' ? claims[WEBID] : 'urn:solidlab:uma:id:anonymous';
73-
const clientQuads: Quad[] = [];
74-
const clientSubject = blankNode();
75-
if (typeof claims[CLIENTID] === 'string') {
76-
clientQuads.push(
77-
quad(clientSubject, RDF.terms.type, ODRL.terms.Constraint),
78-
quad(clientSubject, ODRL.terms.leftOperand, ODRL.terms.deliveryChannel),
79-
quad(clientSubject, ODRL.terms.operator, ODRL.terms.eq),
80-
quad(clientSubject, ODRL.terms.rightOperand, namedNode(claims[CLIENTID])),
81-
);
78+
const claimContextConstraints: { subject: ReturnType<typeof blankNode>; quads: Quad[] }[] = [];
79+
for (const [ claimKey, leftOperand ] of Object.entries(claimOperandMap)) {
80+
const claimValue = claims[claimKey];
81+
if (typeof claimValue !== 'string') {
82+
continue;
83+
}
84+
const claimSubject = blankNode();
85+
claimContextConstraints.push({
86+
subject: claimSubject,
87+
quads: [
88+
quad(claimSubject, RDF.terms.type, ODRL.terms.Constraint),
89+
quad(claimSubject, ODRL.terms.leftOperand, namedNode(leftOperand)),
90+
quad(claimSubject, ODRL.terms.operator, ODRL.terms.eq),
91+
quad(claimSubject, ODRL.terms.rightOperand, namedNode(claimValue)),
92+
],
93+
});
8294
}
8395

8496
for (const { resource_id, resource_scopes } of query) {
@@ -101,14 +113,13 @@ export class OdrlAuthorizer implements Authorizer {
101113
}
102114
const request = basicPolicy(requestPolicy);
103115
const requestStore = request.representation
104-
// Adding context triples for the client identifier, if there is one
105-
if (clientQuads.length > 0) {
116+
for (const contextConstraint of claimContextConstraints) {
106117
requestStore.addQuad(quad(
107118
namedNode(request.ruleIRIs[0]),
108119
namedNode('https://w3id.org/force/sotw#context'),
109-
clientSubject,
120+
contextConstraint.subject,
110121
));
111-
requestStore.addQuads(clientQuads);
122+
requestStore.addQuads(contextConstraint.quads);
112123
}
113124

114125
// evaluate policies

packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NamedNode } from '@rdfjs/types';
22
import { getLoggerFor } from 'global-logger-factory';
33
import { DataFactory as DF, Quad_Subject, Store } from 'n3';
44
import { ODRL } from 'odrl-evaluator';
5-
import { CLIENTID, WEBID } from '../../credentials/Claims';
5+
import { CLIENTID, PURPOSE, WEBID } from '../../credentials/Claims';
66
import { ClaimSet } from '../../credentials/ClaimSet';
77
import { ReadOnlyStore, UCRulesStorage } from '../../ucp/storage/UCRulesStorage';
88
import { Permission } from '../../views/Permission';
@@ -27,6 +27,11 @@ const dateComparators: NodeJS.Dict<(a: Date, b: Date) => boolean> = {
2727
[ODRL.gteq]: (a: Date, b: Date) => a >= b,
2828
};
2929

30+
const claimOperandMap: Record<string, string> = {
31+
[ODRL.deliveryChannel]: CLIENTID,
32+
[ODRL.purpose]: PURPOSE
33+
} as const;
34+
3035
/**
3136
* A simple authorizer that can handle basic ODRL policies with direct permissions and prohibitions,
3237
* without any complex constraints or inheritance.
@@ -151,7 +156,7 @@ export class SimpleOdrlAuthorizer implements Authorizer {
151156
* Determines if all constraints for the given rule are valid.
152157
* Returns true if all constraints are valid, false if any constraint is not valid,
153158
* and undefined if any constraint is too complex to evaluate.
154-
* Only supports deliveryChannel (for client ID) and dateTime constraints.
159+
* Only supports deliveryChannel (for client ID), purpose, and dateTime constraints.
155160
*/
156161
protected validateConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined {
157162
const constraints = policies.getObjects(rule, ODRL.terms.constraint, null).map(constraint => ({
@@ -165,15 +170,7 @@ export class SimpleOdrlAuthorizer implements Authorizer {
165170
}
166171
for (const constraint of constraints) {
167172
// Return undefined if any of these are too complex or unknown
168-
if (constraint.leftOperand.equals(ODRL.terms.deliveryChannel)) {
169-
if (!constraint.operator.equals(ODRL.terms.eq)) {
170-
return false;
171-
}
172-
const clientId = claims[CLIENTID];
173-
if (typeof clientId !== 'string' || constraint.rightOperand.value !== clientId) {
174-
return false;
175-
}
176-
} else if (constraint.leftOperand.equals(ODRL.terms.dateTime)) {
173+
if (constraint.leftOperand.equals(ODRL.terms.dateTime)) {
177174
const comparisonDate = new Date(constraint.rightOperand.value);
178175
const comparator = dateComparators[constraint.operator.value];
179176
if (!comparator) {
@@ -182,6 +179,15 @@ export class SimpleOdrlAuthorizer implements Authorizer {
182179
if (!comparator(new Date(), comparisonDate)) {
183180
return false;
184181
}
182+
} else if (claimOperandMap[constraint.leftOperand.value]) {
183+
const claimKey = claimOperandMap[constraint.leftOperand.value];
184+
if (!constraint.operator.equals(ODRL.terms.eq)) {
185+
return false;
186+
}
187+
const claimValue = claims[claimKey];
188+
if (typeof claimValue !== 'string' || constraint.rightOperand.value !== claimValue) {
189+
return false;
190+
}
185191
} else {
186192
// Unsupported constraint
187193
return;

packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { RDF, XSD } from '@solid/community-server';
33
import { DataFactory as DF, Parser, Store } from 'n3';
44
import { ODRL, ODRLEvaluator } from 'odrl-evaluator';
55
import { Mocked } from 'vitest';
6-
import { CLIENTID } from '../../../../src/credentials/Claims';
6+
import { CLIENTID, PURPOSE } from '../../../../src/credentials/Claims';
77
import { OdrlAuthorizer } from '../../../../src/policies/authorizers/OdrlAuthorizer';
88
import { basicPolicy } from '../../../../src/ucp/policy/ODRL';
99
import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage';
@@ -123,6 +123,30 @@ describe('OdrlAuthorizer', (): void => {
123123
]);
124124
});
125125

126+
it('adds purpose claim context using odrl:purpose', async(): Promise<void> => {
127+
const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' };
128+
const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }];
129+
130+
await expect(authorizer.permissions(claims, query)).resolves.toEqual([{ resource_id: 'rid', resource_scopes: [] }]);
131+
132+
expect(evaluate).toHaveBeenCalledTimes(1);
133+
const generatedRequest = evaluate.mock.calls[0][1];
134+
const purposeConstraintQuad = generatedRequest.find((quad) =>
135+
quad.predicate.equals(ODRL.terms.leftOperand) && quad.object.equals(ODRL.terms.purpose));
136+
137+
expect(purposeConstraintQuad).toBeDefined();
138+
const purposeConstraintSubject = purposeConstraintQuad!.subject;
139+
const purposeConstraintQuads = generatedRequest.filter((quad) =>
140+
quad.subject.equals(purposeConstraintSubject));
141+
142+
expect(purposeConstraintQuads).toEqualRdfQuadArray([
143+
DF.quad(purposeConstraintSubject, RDF.terms.type, ODRL.terms.Constraint),
144+
DF.quad(purposeConstraintSubject, ODRL.terms.leftOperand, ODRL.terms.purpose),
145+
DF.quad(purposeConstraintSubject, ODRL.terms.operator, ODRL.terms.eq),
146+
DF.quad(purposeConstraintSubject, ODRL.terms.rightOperand, DF.namedNode('https://w3id.org/dpv#ScientificResearch')),
147+
]);
148+
});
149+
126150
it('extracts the allowed scopes from the resulting report.', async(): Promise<void> => {
127151
const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }];
128152

packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Authorizer } from '../../../../src/policies/authorizers/Authorizer';
77
import { SimpleOdrlAuthorizer } from '../../../../src/policies/authorizers/SimpleOdrlAuthorizer';
88
import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage';
99
import { Permission } from '../../../../src/views/Permission';
10-
import { WEBID, CLIENTID } from '../../../../src/credentials/Claims';
10+
import { WEBID, CLIENTID, PURPOSE } from '../../../../src/credentials/Claims';
1111

1212
describe('SimpleOdrlAuthorizer', () => {
1313
const resource = 'res';
@@ -166,6 +166,38 @@ describe('SimpleOdrlAuthorizer', () => {
166166
expect(fallback.permissions).not.toHaveBeenCalled();
167167
});
168168

169+
it('returns permission if purpose constraint is satisfied', async () => {
170+
const rule = addRule({});
171+
addConstraint({
172+
rule,
173+
leftOperand: ODRL.terms.purpose,
174+
operator: ODRL.terms.eq,
175+
rightOperand: 'https://w3id.org/dpv#ScientificResearch',
176+
});
177+
const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' };
178+
179+
const result = await authorizer.permissions(claims, query);
180+
181+
expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]);
182+
expect(fallback.permissions).not.toHaveBeenCalled();
183+
});
184+
185+
it('returns empty if claim constraint is not satisfied', async () => {
186+
const rule = addRule({});
187+
addConstraint({
188+
rule,
189+
leftOperand: ODRL.terms.purpose,
190+
operator: ODRL.terms.eq,
191+
rightOperand: 'http://example.com/purpose-a',
192+
});
193+
const claims = { [PURPOSE]: 'http://example.com/purpose-b' };
194+
195+
const result = await authorizer.permissions(claims, query);
196+
197+
expect(result).toEqual([]);
198+
expect(fallback.permissions).not.toHaveBeenCalled();
199+
});
200+
169201
it('delegates to fallback if constraint is too complex', async () => {
170202
const rule = addRule({});
171203
store.addQuad(rule, ODRL.terms.constraint, DF.namedNode('constraint3'));

test/integration/Base.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Parser, Writer } from 'n3';
44
import { readFile } from 'node:fs/promises';
55
import * as path from 'node:path';
66
import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil';
7-
import { generateCredentials } from '../util/UmaUtil';
7+
import { generateCredentials, noTokenFetch, findTokenEndpoint, attemptTokenRequest, getToken } from '../util/UmaUtil';
88

99
const [ cssPort, umaPort ] = getPorts('Base');
1010

@@ -101,6 +101,7 @@ describe('A server setup', (): void => {
101101
});
102102

103103
describe('using ODRL authorization', (): void => {
104+
const owner = 'https://pod.woutslabbinck.com/profile/card#me';
104105
const collectionResource = `http://localhost:${cssPort}/alice/resource.txt`;
105106
let wwwAuthenticateHeader: string;
106107
let ticket: string;
@@ -184,8 +185,51 @@ describe('A server setup', (): void => {
184185
expect(response.status).toBe(401);
185186
});
186187

188+
it('supports purpose claims.', async(): Promise<void> => {
189+
const assignee = 'https://purpose.pod.example/profile/card#me';
190+
const purpose = 'https://w3id.org/dpv#ScientificResearch';
191+
192+
const policy = `
193+
@prefix ex: <http://example.org/>.
194+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
195+
196+
ex:purposePolicy a odrl:Agreement ;
197+
odrl:uid ex:purposePolicy ;
198+
odrl:permission ex:purposePermission .
199+
ex:purposePermission a odrl:Permission ;
200+
odrl:action odrl:read ;
201+
odrl:target <${collectionResource}> ;
202+
odrl:assignee <${assignee}> ;
203+
odrl:assigner <${owner}> ;
204+
odrl:constraint ex:purposeConstraint .
205+
ex:purposeConstraint a odrl:Constraint ;
206+
odrl:leftOperand odrl:purpose ;
207+
odrl:operator odrl:eq ;
208+
odrl:rightOperand <${purpose}> .
209+
`;
210+
211+
const policyResponse = await fetch(`http://localhost:${umaPort}/uma/policies`, {
212+
method: 'POST',
213+
headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
214+
body: policy,
215+
});
216+
expect(policyResponse.status).toBe(201);
217+
218+
// First request without purpose should be denied
219+
const { as_uri, ticket } = await noTokenFetch(collectionResource);
220+
const endpoint = await findTokenEndpoint(as_uri);
221+
const deniedResponse = await attemptTokenRequest(ticket, endpoint, assignee);
222+
expect(deniedResponse.status).toBe(403);
223+
224+
// Request new ticket and get token with both WebID and purpose claims
225+
const { ticket: newTicket } = await noTokenFetch(collectionResource);
226+
const tokenWithClaims = await getToken(newTicket, endpoint, assignee, undefined, [
227+
{ claim_token: purpose, claim_token_format: 'http://www.w3.org/ns/odrl/2/purpose' }
228+
]);
229+
expect(typeof tokenWithClaims.access_token).toBe('string');
230+
});
231+
187232
it('the resource can be made publicly accessible by having an anonymous assignee.', async(): Promise<void> => {
188-
const owner = 'https://pod.woutslabbinck.com/profile/card#me';
189233
const url = `http://localhost:${umaPort}/uma/policies`;
190234

191235
const policy = `
@@ -222,7 +266,6 @@ describe('A server setup', (): void => {
222266
});
223267

224268
it('the resource can be made publicly accessible by being a Set without assignee.', async(): Promise<void> => {
225-
const owner = 'https://pod.woutslabbinck.com/profile/card#me';
226269
const url = `http://localhost:${umaPort}/uma/policies`;
227270

228271
const policy = `

0 commit comments

Comments
 (0)