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);
+ });
+ });
});