Skip to content

Commit faf86fc

Browse files
committed
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.
1 parent bcce24f commit faf86fc

File tree

5 files changed

+211
-9
lines changed

5 files changed

+211
-9
lines changed

documentation/getting-started.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,36 @@ ex:permission a odrl:Permission ;
309309
```
310310
This policy says that the above WebID has access to the `create` scope on `<http://localhost:3000/alice/private/>`.
311311

312+
### Client application identification
313+
314+
It is possible to create policies that restrict access based on the client application being used.
315+
This can only be done when using an OIDC ID token for authentication.
316+
The `azp` claim of the token will be used.
317+
318+
To restrict a policy to a certain client application,
319+
a constraint needs to be added to the policy.
320+
Due to some issues with internal libraries,
321+
the `odrl:purpose` constraint is currently used to identify the client.
322+
This will be fixed in the near future.
323+
324+
To restrict a policy to only permit access when using the application `http://example.com/client`,
325+
the policy should look as follows:
326+
```ttl
327+
@prefix ex: <http://example.org/1707120963224#> .
328+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
329+
330+
ex:usagePolicy a odrl:Agreement ;
331+
odrl:permission ex:permission .
332+
ex:permission a odrl:Permission ;
333+
odrl:action odrl:create ;
334+
odrl:target <http://localhost:3000/alice/private/> ;
335+
odrl:assignee <https://woslabbi.pod.knows.idlab.ugent.be/profile/card#me> ;
336+
odrl:constraint ex:constraint .
337+
ex:constraint odrl:leftOperand odrl:purpose ;
338+
odrl:operator odrl:eq ;
339+
odrl:rightOperand <http://example.com/client> .
340+
```
341+
312342
## Adding or changing policies
313343

314344
For more details, see the [policy management API documentation](policy-management.md).

packages/ucp/src/util/Vocabularies.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export const ODRL = createVocabulary(
118118
'Prohibition',
119119
'Duty',
120120
'Request',
121+
'Constraint',
121122
'source',
122123
'partOf',
123124
'action',

packages/uma/src/credentials/verify/OidcVerifier.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,16 @@ export class OidcVerifier implements Verifier {
6262

6363
protected async verifySolidToken(token: string): Promise<{ [WEBID]: string, [CLIENTID]?: string }> {
6464
const claims = await this.verifyToken(`Bearer ${token}`);
65+
// Depends on the spec version which field to use
66+
const clientId = (claims as { azp?: string }).azp ?? claims.client_id;
6567

6668
this.logger.info(`Authenticated via a Solid OIDC. ${JSON.stringify(claims)}`);
6769

6870
return ({
6971
// TODO: would have to use different value than "WEBID"
7072
// TODO: still want to use WEBID as external value potentially?
7173
[WEBID]: claims.webid,
72-
...claims.client_id && { [CLIENTID]: claims.client_id }
74+
...clientId && { [CLIENTID]: clientId }
7375
});
7476
}
7577

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { BadRequestHttpError, DC, NotImplementedHttpError, RDF } from '@solid/community-server';
2-
import { basicPolicy, ODRL, UCPPolicy, UCRulesStorage } from '@solidlab/ucp';
2+
import { basicPolicy, ODRL, UCPConstraint, UCPPolicy, UCRulesStorage } from '@solidlab/ucp';
33
import { getLoggerFor } from 'global-logger-factory';
4-
import { DataFactory, Literal, NamedNode, Quad_Subject, Store, Writer } from 'n3';
4+
import { DataFactory, Literal, NamedNode, Quad, Quad_Subject, Store, Writer } from 'n3';
55
import { EyeReasoner, ODRLEngineMultipleSteps, ODRLEvaluator } from 'odrl-evaluator'
66
import { createVocabulary } from 'rdf-vocabulary';
7-
import { WEBID } from '../../credentials/Claims';
7+
import { CLIENTID, WEBID } from '../../credentials/Claims';
88
import { ClaimSet } from '../../credentials/ClaimSet';
99
import { Requirements } from '../../credentials/Requirements';
1010
import { Permission } from '../../views/Permission';
1111
import { Authorizer } from './Authorizer';
1212

13-
const {quad, namedNode, literal} = DataFactory
13+
const { quad, namedNode, literal, blankNode } = DataFactory
1414

1515
/**
1616
* Permission evaluation is performed as follows:
@@ -71,6 +71,23 @@ export class OdrlAuthorizer implements Authorizer {
7171
);
7272

7373
const subject = typeof claims[WEBID] === 'string' ? claims[WEBID] : 'urn:solidlab:uma:id:anonymous';
74+
const clientQuads: Quad[] = [];
75+
const clientSubject = blankNode();
76+
if (typeof claims[CLIENTID] === 'string') {
77+
clientQuads.push(
78+
quad(clientSubject, RDF.terms.type, ODRL.terms.Constraint),
79+
// TODO: using purpose as other constraints are not supported in current version of ODRL evaluator
80+
// https://github.com/SolidLabResearch/ODRL-Evaluator/blob/v0.5.0/ODRL-Support.md#left-operands
81+
quad(clientSubject, ODRL.terms.leftOperand, namedNode(ODRL.namespace + 'purpose')),
82+
quad(clientSubject, ODRL.terms.operator, ODRL.terms.eq),
83+
quad(clientSubject, ODRL.terms.rightOperand, namedNode(claims[CLIENTID])),
84+
);
85+
// constraints.push({
86+
// type: ODRL.namespace + 'deliveryChannel',
87+
// operator: ODRL.eq,
88+
// value: namedNode(claims[CLIENTID]),
89+
// });
90+
}
7491

7592
for (const {resource_id, resource_scopes} of query) {
7693
grantedPermissions[resource_id] = [];
@@ -87,7 +104,18 @@ export class OdrlAuthorizer implements Authorizer {
87104
}
88105
]
89106
}
90-
const requestStore = basicPolicy(requestPolicy).representation
107+
const request = basicPolicy(requestPolicy);
108+
const requestStore = request.representation
109+
// Adding context triples for the client identifier, if there is one
110+
if (clientQuads.length > 0) {
111+
requestStore.addQuad(quad(
112+
namedNode(request.ruleIRIs[0]),
113+
namedNode('https://w3id.org/force/sotw#context'),
114+
clientSubject,
115+
));
116+
requestStore.addQuads(clientQuads);
117+
}
118+
91119
// evaluate policies
92120
const reports = await this.odrlEvaluator.evaluate(
93121
[...policyStore],

test/integration/Oidc.test.ts

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import path from 'node:path';
77
import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil';
88
import { findTokenEndpoint, noTokenFetch } from '../util/UmaUtil';
99

10-
const [ cssPort, umaPort ] = getPorts('Policies');
10+
const [ cssPort, umaPort ] = getPorts('OIDC');
1111
const idpPort = umaPort + 100;
1212

1313
describe('A server supporting OIDC tokens', (): void => {
@@ -47,7 +47,6 @@ describe('A server supporting OIDC tokens', (): void => {
4747
privateKey = { ...await generator.getPrivateKey(), kid: 'kid' };
4848
const publicKey = { ...await generator.getPublicKey(), kid: 'kid' }
4949
idp = createServer((req, res) => {
50-
console.log(req.url);
5150
if (req.url!.endsWith('/card')) {
5251
res.writeHead(200, { 'content-type': 'text/turtle' });
5352
res.end(`
@@ -64,6 +63,12 @@ describe('A server supporting OIDC tokens', (): void => {
6463
return;
6564
}
6665
res.writeHead(200, { 'content-type': 'application/json' });
66+
if (req.url!.endsWith('/client')) {
67+
res.end(JSON.stringify({
68+
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
69+
}));
70+
return;
71+
}
6772
if (req.url!.endsWith('/.well-known/openid-configuration')) {
6873
res.end(JSON.stringify({ jwks_uri: idpUrl }));
6974
return;
@@ -136,9 +141,76 @@ describe('A server supporting OIDC tokens', (): void => {
136141
});
137142
});
138143

144+
describe('accessing a resource using a standard OIDC token with a specific client.', (): void => {
145+
const resource = `http://localhost:${cssPort}/alice/standardClient`;
146+
const sub = '123456';
147+
const client = 'my-client';
148+
const policy = `
149+
@prefix ex: <http://example.org/>.
150+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
151+
@prefix dct: <http://purl.org/dc/terms/>.
152+
ex:policyStandardClient a odrl:Set;
153+
odrl:uid ex:policyStandardClient ;
154+
odrl:permission ex:permissionStandardClient .
155+
156+
ex:permissionStandardClient a odrl:Permission ;
157+
odrl:assignee <${sub}> ;
158+
odrl:assigner <${webId}> ;
159+
odrl:action odrl:read , odrl:create , odrl:modify ;
160+
odrl:target <http://localhost:${cssPort}/alice/> ;
161+
odrl:constraint ex:constraintStandardClient.
162+
163+
ex:constraintStandardClient
164+
odrl:leftOperand odrl:purpose ;
165+
odrl:operator odrl:eq ;
166+
odrl:rightOperand <${client}> .`;
167+
168+
it('can set up the policy.', async(): Promise<void> => {
169+
const response = await fetch(policyEndpoint, {
170+
method: 'POST',
171+
headers: { authorization: webId, 'content-type': 'text/turtle' },
172+
body: policy,
173+
});
174+
expect(response.status).toBe(201);
175+
});
176+
177+
it('can get an access token.', async(): Promise<void> => {
178+
const { as_uri, ticket } = await noTokenFetch(resource, {
179+
method: 'PUT',
180+
headers: { 'content-type': 'text/plain' },
181+
body: 'hello',
182+
});
183+
const endpoint = await findTokenEndpoint(as_uri);
184+
185+
// TODO: also add token that fails
186+
const jwk = await importJWK(privateKey, privateKey.alg);
187+
const jwt = await new SignJWT({ azp: client })
188+
.setSubject(sub)
189+
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
190+
.setIssuedAt()
191+
.setIssuer(idpUrl)
192+
.setAudience(`http://localhost:${umaPort}/uma`)
193+
.setJti(randomUUID())
194+
.sign(jwk);
195+
196+
const content: Record<string, string> = {
197+
grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
198+
ticket: ticket,
199+
claim_token: jwt,
200+
claim_token_format: oidcFormat,
201+
};
202+
203+
const response = await fetch(endpoint, {
204+
method: 'POST',
205+
headers: { 'content-type': 'application/json' },
206+
body: JSON.stringify(content),
207+
});
208+
expect(response.status).toBe(200);
209+
});
210+
});
139211

140212
describe('accessing a resource using a Solid OIDC token.', (): void => {
141-
const resource = `http://localhost:${cssPort}/alice/standard`;
213+
const resource = `http://localhost:${cssPort}/alice/solid`;
142214
// Using dummy server so we can spoof WebID
143215
const alice = idpUrl + 'alice/profile/card#me';
144216
const policy = `
@@ -199,4 +271,73 @@ describe('A server supporting OIDC tokens', (): void => {
199271
expect(response.status).toBe(200);
200272
});
201273
});
274+
275+
describe('accessing a resource using a Solid OIDC token with a specific client.', (): void => {
276+
const resource = `http://localhost:${cssPort}/bob/solidClient`;
277+
// Using dummy server so we can spoof WebID
278+
const bob = idpUrl + 'bob/profile/card#me';
279+
const client = idpUrl + 'client';
280+
const policy = `
281+
@prefix ex: <http://example.org/>.
282+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
283+
@prefix dct: <http://purl.org/dc/terms/>.
284+
ex:policySolidClient a odrl:Set;
285+
odrl:uid ex:policySolidClient ;
286+
odrl:permission ex:permissionSolidClient .
287+
288+
ex:permissionSolidClient a odrl:Permission ;
289+
odrl:assignee <${bob}> ;
290+
odrl:assigner <${webId}> ;
291+
odrl:action odrl:read , odrl:create , odrl:modify ;
292+
odrl:target <http://localhost:${cssPort}/bob/> ;
293+
odrl:constraint ex:constraintSolidClient.
294+
295+
ex:constraintSolidClient
296+
odrl:leftOperand odrl:purpose ;
297+
odrl:operator odrl:eq ;
298+
odrl:rightOperand <${client}> .`;
299+
300+
it('can set up the policy.', async(): Promise<void> => {
301+
const response = await fetch(policyEndpoint, {
302+
method: 'POST',
303+
headers: { authorization: webId, 'content-type': 'text/turtle' },
304+
body: policy,
305+
});
306+
expect(response.status).toBe(201);
307+
});
308+
309+
it('can get an access token.', async(): Promise<void> => {
310+
const { as_uri, ticket } = await noTokenFetch(resource, {
311+
method: 'PUT',
312+
headers: { 'content-type': 'text/plain' },
313+
body: 'hello',
314+
});
315+
const endpoint = await findTokenEndpoint(as_uri);
316+
317+
const jwk = await importJWK(privateKey, privateKey.alg);
318+
const jwt = await new SignJWT({ webid: bob, azp: client })
319+
.setSubject(bob)
320+
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
321+
.setIssuedAt()
322+
.setIssuer(idpUrl)
323+
.setAudience([ 'solid', `http://localhost:${umaPort}/uma` ])
324+
.setJti(randomUUID())
325+
.setExpirationTime(Date.now() + 5000)
326+
.sign(jwk);
327+
328+
const content: Record<string, string> = {
329+
grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
330+
ticket: ticket,
331+
claim_token: jwt,
332+
claim_token_format: oidcFormat,
333+
};
334+
335+
const response = await fetch(endpoint, {
336+
method: 'POST',
337+
headers: { 'content-type': 'application/json' },
338+
body: JSON.stringify(content),
339+
});
340+
expect(response.status).toBe(200);
341+
});
342+
});
202343
});

0 commit comments

Comments
 (0)