Skip to content

Commit 350a1da

Browse files
committed
feat: Support purpose claim
1 parent 47504e5 commit 350a1da

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
- [Enabling optional token features](#enabling-optional-token-features)
@@ -325,6 +326,20 @@ The `claim_token_format` explains to the AS how the `claim_token` should be inte
325326
In this case, this is a custom format designed for this server,
326327
where the token is a URL-encoded WebID.
327328

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

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

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

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

347372
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
@@ -16,6 +16,7 @@ export * from './credentials/verify/OidcVerifier';
1616
export * from './credentials/verify/JwtVerifier';
1717
export * from './credentials/verify/IriVerifier';
1818
export * from './credentials/verify/RefreshTokenVerifier';
19+
export * from './credentials/verify/KeyValueVerifier';
1920

2021
// Dialog
2122
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 newResource = `http://localhost:${cssPort}/alice/resource.txt`;
105106
const newResource2 = `http://localhost:${cssPort}/alice/private/resource.txt`;
106107
let wwwAuthenticateHeader: string;
@@ -238,8 +239,51 @@ describe('A server setup', (): void => {
238239
expect(response.status).toBe(401);
239240
});
240241

242+
it('supports purpose claims.', async(): Promise<void> => {
243+
const assignee = 'https://purpose.pod.example/profile/card#me';
244+
const purpose = 'https://w3id.org/dpv#ScientificResearch';
245+
246+
const policy = `
247+
@prefix ex: <http://example.org/>.
248+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
249+
250+
ex:purposePolicy a odrl:Agreement ;
251+
odrl:uid ex:purposePolicy ;
252+
odrl:permission ex:purposePermission .
253+
ex:purposePermission a odrl:Permission ;
254+
odrl:action odrl:read ;
255+
odrl:target <${newResource}> ;
256+
odrl:assignee <${assignee}> ;
257+
odrl:assigner <${owner}> ;
258+
odrl:constraint ex:purposeConstraint .
259+
ex:purposeConstraint a odrl:Constraint ;
260+
odrl:leftOperand odrl:purpose ;
261+
odrl:operator odrl:eq ;
262+
odrl:rightOperand <${purpose}> .
263+
`;
264+
265+
const policyResponse = await fetch(`http://localhost:${umaPort}/uma/policies`, {
266+
method: 'POST',
267+
headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
268+
body: policy,
269+
});
270+
expect(policyResponse.status).toBe(201);
271+
272+
// First request without purpose should be denied
273+
const { as_uri, ticket } = await noTokenFetch(newResource);
274+
const endpoint = await findTokenEndpoint(as_uri);
275+
const deniedResponse = await attemptTokenRequest(ticket, endpoint, assignee);
276+
expect(deniedResponse.status).toBe(403);
277+
278+
// Request new ticket and get token with both WebID and purpose claims
279+
const { ticket: newTicket } = await noTokenFetch(newResource);
280+
const tokenWithClaims = await getToken(newTicket, endpoint, assignee, undefined, [
281+
{ claim_token: purpose, claim_token_format: 'http://www.w3.org/ns/odrl/2/purpose' }
282+
]);
283+
expect(typeof tokenWithClaims.access_token).toBe('string');
284+
});
285+
241286
it('the resource can be made publicly accessible by having an anonymous assignee.', async(): Promise<void> => {
242-
const owner = 'https://pod.woutslabbinck.com/profile/card#me';
243287
const url = `http://localhost:${umaPort}/uma/policies`;
244288

245289
const policy = `
@@ -276,7 +320,6 @@ describe('A server setup', (): void => {
276320
});
277321

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

282325
const policy = `

0 commit comments

Comments
 (0)