Skip to content
Open
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
31 changes: 27 additions & 4 deletions documentation/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ so some information might change depending on which version and branch you're us
- [Publicly accessible resources](#publicly-accessible-resources)
+ [Exchange ticket](#exchange-ticket)
- [Authentication methods](#authentication-methods)
- [Additional claims](#additional-claims)
- [Customizing OIDC verification](#customizing-oidc-verification)
+ [Generate token](#generate-token)
+ [Use token](#use-token)
Expand Down Expand Up @@ -322,6 +323,20 @@ The `claim_token_format` explains to the AS how the `claim_token` should be inte
In this case, this is a custom format designed for this server,
where the token is a URL-encoded WebID.

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

#### Authentication methods

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

Multiple authentication methods can be combined in a single request by providing multiple claims as shown above.
This allows the AS to verify multiple credentials simultaneously and combine their claims.

The values that are extracted from the OIDC token are expected to be IRIs.
In case the `sub` or `azp`, which is discussed below, values are not IRIs,
the server wil internally convert them by URL encoding the value, and prepending them with `http://example.com/id/`.
This means that your policies should reference the converted ID.
For example, if your `sub` value is `my id`, your policy needs to target `http://example.com/id/my%20id`.
This base URL will be updated in the future once we have settled on a fixed value.

##### Additional claims

Besides claims that identify the user and client, additional claims can also be provided containing additional information.
Currently, the only additional claim that is supported is the `purpose` claim.
This can be provided with a `claim_token_format` of `http://www.w3.org/ns/odrl/2/purpose`,
and with the `claim_token` being the IRI of the purpose.

#### Customizing OIDC verification

Several configuration options can be added to further restrict authentication when using OIDC tokens,
Expand Down Expand Up @@ -421,9 +446,7 @@ 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.
For this, we use the odrl:deliveryChannel left operand.

To restrict a policy to only permit access when using the application `http://example.com/client`,
the policy should look as follows:
Expand All @@ -439,7 +462,7 @@ ex:permission a odrl:Permission ;
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 ;
ex:constraint odrl:leftOperand odrl:deliveryChannel ;
odrl:operator odrl:eq ;
odrl:rightOperand <http://example.com/client> .
```
Expand Down
7 changes: 7 additions & 0 deletions packages/uma/config/credentials/verifiers/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
"@id": "urn:uma:default:TypedVerifier",
"@type": "TypedVerifier",
"verifiers": [
{
"TypedVerifier:_verifiers_key": "http://www.w3.org/ns/odrl/2/purpose",
"TypedVerifier:_verifiers_value": {
"@id": "urn:uma:default:KeyValueVerifier",
"@type": "KeyValueVerifier"
}
},
{
"TypedVerifier:_verifiers_key": "urn:solidlab:uma:claims:formats:webid",
"TypedVerifier:_verifiers_value": {
Expand Down
16 changes: 16 additions & 0 deletions packages/uma/src/credentials/verify/KeyValueVerifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ClaimSet } from '../ClaimSet';
import { Credential } from '../Credential';
import { Verifier } from './Verifier';

/**
* A verifier that assigns the credential token value as claims value, with the format being used as claims key.
*/
export class KeyValueVerifier implements Verifier {
public constructor() {}

public async verify(credential: Credential): Promise<ClaimSet> {
return {
[credential.format]: credential.token
}
}
}
1 change: 1 addition & 0 deletions packages/uma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './credentials/verify/UnsecureVerifier';
export * from './credentials/verify/OidcVerifier';
export * from './credentials/verify/JwtVerifier';
export * from './credentials/verify/IriVerifier';
export * from './credentials/verify/KeyValueVerifier';

// Dialog
export * from './dialog/AggregatorNegotiator';
Expand Down
53 changes: 28 additions & 25 deletions packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { BadRequestHttpError, DC, RDF } from '@solid/community-server';
import { RDF } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { DataFactory, Quad, Store, Writer } from 'n3';
import { EyelingReasoner, EyeReasoner, ODRLEngineMultipleSteps, ODRLEvaluator } from 'odrl-evaluator';
import { CLIENTID, WEBID } from '../../credentials/Claims';
import { DataFactory, Quad, Store } from 'n3';
import { EyelingReasoner, ODRL, ODRLEngineMultipleSteps, ODRLEvaluator } from 'odrl-evaluator';
import { CLIENTID, PURPOSE, WEBID } from '../../credentials/Claims';
import { ClaimSet } from '../../credentials/ClaimSet';
import { basicPolicy } from '../../ucp/policy/ODRL';
import { PrioritizeProhibitionStrategy } from '../../ucp/policy/PrioritizeProhibitionStrategy';
import { Strategy } from '../../ucp/policy/Strategy';
import { UCPPolicy } from '../../ucp/policy/UsageControlPolicy';
import { UCRulesStorage } from '../../ucp/storage/UCRulesStorage';
import { ODRL } from '../../ucp/util/Vocabularies';
import { Permission } from '../../views/Permission';
import { Authorizer } from './Authorizer';

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

const claimOperandMap: Record<string, string> = {
[CLIENTID]: ODRL.deliveryChannel,
[PURPOSE]: ODRL.purpose,
} as const;

/**
* Permission evaluation is performed as follows:
*
Expand Down Expand Up @@ -71,22 +75,22 @@ 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]),
// });
const claimContextConstraints: { subject: ReturnType<typeof blankNode>; quads: Quad[] }[] = [];
for (const [ claimKey, leftOperand ] of Object.entries(claimOperandMap)) {
const claimValue = claims[claimKey];
if (typeof claimValue !== 'string') {
continue;
}
const claimSubject = blankNode();
claimContextConstraints.push({
subject: claimSubject,
quads: [
quad(claimSubject, RDF.terms.type, ODRL.terms.Constraint),
quad(claimSubject, ODRL.terms.leftOperand, namedNode(leftOperand)),
quad(claimSubject, ODRL.terms.operator, ODRL.terms.eq),
quad(claimSubject, ODRL.terms.rightOperand, namedNode(claimValue)),
],
});
}

for (const { resource_id, resource_scopes } of query) {
Expand All @@ -109,14 +113,13 @@ export class OdrlAuthorizer implements Authorizer {
}
const request = basicPolicy(requestPolicy);
const requestStore = request.representation
// Adding context triples for the client identifier, if there is one
if (clientQuads.length > 0) {
for (const contextConstraint of claimContextConstraints) {
requestStore.addQuad(quad(
namedNode(request.ruleIRIs[0]),
namedNode('https://w3id.org/force/sotw#context'),
clientSubject,
contextConstraint.subject,
));
requestStore.addQuads(clientQuads);
requestStore.addQuads(contextConstraint.quads);
}

// evaluate policies
Expand Down
29 changes: 17 additions & 12 deletions packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NamedNode } from '@rdfjs/types';
import { getLoggerFor } from 'global-logger-factory';
import { DataFactory as DF, Quad_Subject, Store } from 'n3';
import { ODRL } from 'odrl-evaluator';
import { CLIENTID, WEBID } from '../../credentials/Claims';
import { CLIENTID, PURPOSE, WEBID } from '../../credentials/Claims';
import { ClaimSet } from '../../credentials/ClaimSet';
import { ReadOnlyStore, UCRulesStorage } from '../../ucp/storage/UCRulesStorage';
import { Permission } from '../../views/Permission';
Expand All @@ -27,6 +27,11 @@ const dateComparators: NodeJS.Dict<(a: Date, b: Date) => boolean> = {
[ODRL.gteq]: (a: Date, b: Date) => a >= b,
};

const claimOperandMap: Record<string, string> = {
[ODRL.deliveryChannel]: CLIENTID,
[ODRL.purpose]: PURPOSE
} as const;

/**
* A simple authorizer that can handle basic ODRL policies with direct permissions and prohibitions,
* without any complex constraints or inheritance.
Expand Down Expand Up @@ -151,7 +156,7 @@ export class SimpleOdrlAuthorizer implements Authorizer {
* Determines if all constraints for the given rule are valid.
* Returns true if all constraints are valid, false if any constraint is not valid,
* and undefined if any constraint is too complex to evaluate.
* Only supports purpose (for client ID) and dateTime constraints.
* Only supports deliveryChannel (for client ID), purpose, and dateTime constraints.
*/
protected validateConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined {
const constraints = policies.getObjects(rule, ODRL.terms.constraint, null).map(constraint => ({
Expand All @@ -165,16 +170,7 @@ export class SimpleOdrlAuthorizer implements Authorizer {
}
for (const constraint of constraints) {
// Return undefined if any of these are too complex or unknown
// TODO: because of weird hack described in OdrlAuthorizer, needs to change to term that makes more sense
if (constraint.leftOperand.equals(ODRL.terms.purpose)) {
if (!constraint.operator.equals(ODRL.terms.eq)) {
return false;
}
const clientId = claims[CLIENTID];
if (typeof clientId !== 'string' || constraint.rightOperand.value !== clientId) {
return false;
}
} else if (constraint.leftOperand.equals(ODRL.terms.dateTime)) {
if (constraint.leftOperand.equals(ODRL.terms.dateTime)) {
const comparisonDate = new Date(constraint.rightOperand.value);
const comparator = dateComparators[constraint.operator.value];
if (!comparator) {
Expand All @@ -183,6 +179,15 @@ export class SimpleOdrlAuthorizer implements Authorizer {
if (!comparator(new Date(), comparisonDate)) {
return false;
}
} else if (claimOperandMap[constraint.leftOperand.value]) {
const claimKey = claimOperandMap[constraint.leftOperand.value];
if (!constraint.operator.equals(ODRL.terms.eq)) {
return false;
}
const claimValue = claims[claimKey];
if (typeof claimValue !== 'string' || constraint.rightOperand.value !== claimValue) {
return false;
}
} else {
// Unsupported constraint
return;
Expand Down
56 changes: 53 additions & 3 deletions packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { NotImplementedHttpError, RDF, XSD } from '@solid/community-server';
import 'jest-rdf';
import { RDF, XSD } from '@solid/community-server';
import { DataFactory as DF, Parser, Store } from 'n3';
import { ODRLEvaluator } from 'odrl-evaluator';
import { ODRL, ODRLEvaluator } from 'odrl-evaluator';
import { Mocked } from 'vitest';
import { CLIENTID, PURPOSE } from '../../../../src/credentials/Claims';
import { OdrlAuthorizer } from '../../../../src/policies/authorizers/OdrlAuthorizer';
import { basicPolicy } from '../../../../src/ucp/policy/ODRL';
import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage';
Expand Down Expand Up @@ -34,7 +36,7 @@ describe('OdrlAuthorizer', (): void => {
evaluate.mockResolvedValue([]);

vi.mocked(basicPolicy).mockReturnValue({
ruleIRIs:[],
ruleIRIs: [ 'urn:req-rule' ],
policyIRI: 'req',
representation: new Store(requestQuads),
});
Expand Down Expand Up @@ -97,6 +99,54 @@ describe('OdrlAuthorizer', (): void => {
);
});

it('adds client claim context using odrl:deliveryChannel', async(): Promise<void> => {
const claims = { [CLIENTID]: 'client-a' };
const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }];

await expect(authorizer.permissions(claims, query)).resolves.toEqual([{ resource_id: 'rid', resource_scopes: [] }]);

expect(evaluate).toHaveBeenCalledTimes(1);
const generatedRequest = evaluate.mock.calls[0][1];
const clientConstraintQuad = generatedRequest.find((quad) =>
quad.predicate.equals(ODRL.terms.leftOperand) && quad.object.equals(ODRL.terms.deliveryChannel));

expect(clientConstraintQuad).toBeDefined();
const clientConstraintSubject = clientConstraintQuad!.subject;
const clientConstraintQuads = generatedRequest.filter((quad) =>
quad.subject.equals(clientConstraintSubject));

expect(clientConstraintQuads).toEqualRdfQuadArray([
DF.quad(clientConstraintSubject, RDF.terms.type, ODRL.terms.Constraint),
DF.quad(clientConstraintSubject, ODRL.terms.leftOperand, ODRL.terms.deliveryChannel),
DF.quad(clientConstraintSubject, ODRL.terms.operator, ODRL.terms.eq),
DF.quad(clientConstraintSubject, ODRL.terms.rightOperand, DF.namedNode('client-a')),
]);
});

it('adds purpose claim context using odrl:purpose', async(): Promise<void> => {
const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' };
const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }];

await expect(authorizer.permissions(claims, query)).resolves.toEqual([{ resource_id: 'rid', resource_scopes: [] }]);

expect(evaluate).toHaveBeenCalledTimes(1);
const generatedRequest = evaluate.mock.calls[0][1];
const purposeConstraintQuad = generatedRequest.find((quad) =>
quad.predicate.equals(ODRL.terms.leftOperand) && quad.object.equals(ODRL.terms.purpose));

expect(purposeConstraintQuad).toBeDefined();
const purposeConstraintSubject = purposeConstraintQuad!.subject;
const purposeConstraintQuads = generatedRequest.filter((quad) =>
quad.subject.equals(purposeConstraintSubject));

expect(purposeConstraintQuads).toEqualRdfQuadArray([
DF.quad(purposeConstraintSubject, RDF.terms.type, ODRL.terms.Constraint),
DF.quad(purposeConstraintSubject, ODRL.terms.leftOperand, ODRL.terms.purpose),
DF.quad(purposeConstraintSubject, ODRL.terms.operator, ODRL.terms.eq),
DF.quad(purposeConstraintSubject, ODRL.terms.rightOperand, DF.namedNode('https://w3id.org/dpv#ScientificResearch')),
]);
});

it('extracts the allowed scopes from the resulting report.', async(): Promise<void> => {
const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }];

Expand Down
Loading
Loading