Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions documentation/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,36 @@ ex:permission a odrl:Permission ;
```
This policy says that the above WebID has access to the `create` scope on `<http://localhost:3000/alice/private/>`.

### 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: <http://example.org/1707120963224#> .
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .

ex:usagePolicy a odrl:Agreement ;
odrl:permission ex:permission .
ex:permission a odrl:Permission ;
odrl:action odrl:create ;
odrl:target <http://localhost:3000/alice/private/> ;
odrl:assignee <https://woslabbi.pod.knows.idlab.ugent.be/profile/card#me> ;
odrl:constraint ex:constraint .
ex:constraint odrl:leftOperand odrl:purpose ;
odrl:operator odrl:eq ;
odrl:rightOperand <http://example.com/client> .
```

## Adding or changing policies

For more details, see the [policy management API documentation](policy-management.md).
1 change: 1 addition & 0 deletions packages/ucp/src/util/Vocabularies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const ODRL = createVocabulary(
'Prohibition',
'Duty',
'Request',
'Constraint',
'source',
'partOf',
'action',
Expand Down
4 changes: 3 additions & 1 deletion packages/uma/src/credentials/verify/OidcVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,16 @@ 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)}`);

return ({
// 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 }
});
}

Expand Down
38 changes: 33 additions & 5 deletions packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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] = [];
Expand All @@ -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],
Expand Down
147 changes: 144 additions & 3 deletions test/integration/Oidc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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(`
Expand All @@ -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;
Expand Down Expand Up @@ -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: <http://example.org/>.
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
@prefix dct: <http://purl.org/dc/terms/>.
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 <http://localhost:${cssPort}/alice/> ;
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<void> => {
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<void> => {
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<string, string> = {
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 = `
Expand Down Expand Up @@ -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: <http://example.org/>.
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
@prefix dct: <http://purl.org/dc/terms/>.
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 <http://localhost:${cssPort}/bob/> ;
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<void> => {
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<void> => {
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<string, string> = {
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);
});
});
});