diff --git a/documentation/getting-started.md b/documentation/getting-started.md
index e7eb5a2c..2cdf078b 100644
--- a/documentation/getting-started.md
+++ b/documentation/getting-started.md
@@ -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)
@@ -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.
@@ -332,6 +347,9 @@ 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/`.
@@ -339,6 +357,13 @@ 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,
@@ -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:
@@ -439,7 +462,7 @@ ex:permission a odrl:Permission ;
odrl:target ;
odrl:assignee ;
odrl:constraint ex:constraint .
-ex:constraint odrl:leftOperand odrl:purpose ;
+ex:constraint odrl:leftOperand odrl:deliveryChannel ;
odrl:operator odrl:eq ;
odrl:rightOperand .
```
diff --git a/packages/uma/config/credentials/verifiers/default.json b/packages/uma/config/credentials/verifiers/default.json
index 66a3c16b..6cab6dd4 100644
--- a/packages/uma/config/credentials/verifiers/default.json
+++ b/packages/uma/config/credentials/verifiers/default.json
@@ -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": {
diff --git a/packages/uma/src/credentials/verify/KeyValueVerifier.ts b/packages/uma/src/credentials/verify/KeyValueVerifier.ts
new file mode 100644
index 00000000..753648e0
--- /dev/null
+++ b/packages/uma/src/credentials/verify/KeyValueVerifier.ts
@@ -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 {
+ return {
+ [credential.format]: credential.token
+ }
+ }
+}
diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts
index 0f29eb84..d6c2b37f 100644
--- a/packages/uma/src/index.ts
+++ b/packages/uma/src/index.ts
@@ -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';
diff --git a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
index 8e791e75..afb7ddcf 100644
--- a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
@@ -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 = {
+ [CLIENTID]: ODRL.deliveryChannel,
+ [PURPOSE]: ODRL.purpose,
+} as const;
+
/**
* Permission evaluation is performed as follows:
*
@@ -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; 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) {
@@ -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
diff --git a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts
index 3f90620e..1f08c6f5 100644
--- a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts
@@ -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';
@@ -27,6 +27,11 @@ const dateComparators: NodeJS.Dict<(a: Date, b: Date) => boolean> = {
[ODRL.gteq]: (a: Date, b: Date) => a >= b,
};
+const claimOperandMap: Record = {
+ [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.
@@ -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 => ({
@@ -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) {
@@ -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;
diff --git a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts
index 6e24d1f2..2385dd29 100644
--- a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts
+++ b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts
@@ -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';
@@ -34,7 +36,7 @@ describe('OdrlAuthorizer', (): void => {
evaluate.mockResolvedValue([]);
vi.mocked(basicPolicy).mockReturnValue({
- ruleIRIs:[],
+ ruleIRIs: [ 'urn:req-rule' ],
policyIRI: 'req',
representation: new Store(requestQuads),
});
@@ -97,6 +99,54 @@ describe('OdrlAuthorizer', (): void => {
);
});
+ it('adds client claim context using odrl:deliveryChannel', async(): Promise => {
+ 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 => {
+ 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 => {
const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }];
diff --git a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts
index 1aa39399..67b29622 100644
--- a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts
+++ b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts
@@ -7,7 +7,7 @@ import { Authorizer } from '../../../../src/policies/authorizers/Authorizer';
import { SimpleOdrlAuthorizer } from '../../../../src/policies/authorizers/SimpleOdrlAuthorizer';
import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage';
import { Permission } from '../../../../src/views/Permission';
-import { WEBID, CLIENTID } from '../../../../src/credentials/Claims';
+import { WEBID, CLIENTID, PURPOSE } from '../../../../src/credentials/Claims';
describe('SimpleOdrlAuthorizer', () => {
const resource = 'res';
@@ -134,11 +134,11 @@ describe('SimpleOdrlAuthorizer', () => {
expect(fallback.permissions).toHaveBeenCalledWith({}, query);
});
- it('returns empty if constraint is not satisfied (purpose)', async () => {
+ it('returns empty if constraint is not satisfied (deliveryChannel)', async () => {
const rule = addRule({});
addConstraint({
rule,
- leftOperand: ODRL.terms.purpose,
+ leftOperand: ODRL.terms.deliveryChannel,
operator: ODRL.terms.eq,
rightOperand: 'clientA',
});
@@ -150,11 +150,11 @@ describe('SimpleOdrlAuthorizer', () => {
expect(fallback.permissions).not.toHaveBeenCalled();
});
- it('returns permission if constraint is satisfied (purpose)', async () => {
+ it('returns permission if constraint is satisfied (deliveryChannel)', async () => {
const rule = addRule({});
addConstraint({
rule,
- leftOperand: ODRL.terms.purpose,
+ leftOperand: ODRL.terms.deliveryChannel,
operator: ODRL.terms.eq,
rightOperand: 'clientA',
});
@@ -166,6 +166,38 @@ describe('SimpleOdrlAuthorizer', () => {
expect(fallback.permissions).not.toHaveBeenCalled();
});
+ it('returns permission if purpose constraint is satisfied', async () => {
+ const rule = addRule({});
+ addConstraint({
+ rule,
+ leftOperand: ODRL.terms.purpose,
+ operator: ODRL.terms.eq,
+ rightOperand: 'https://w3id.org/dpv#ScientificResearch',
+ });
+ const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' };
+
+ const result = await authorizer.permissions(claims, query);
+
+ expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]);
+ expect(fallback.permissions).not.toHaveBeenCalled();
+ });
+
+ it('returns empty if claim constraint is not satisfied', async () => {
+ const rule = addRule({});
+ addConstraint({
+ rule,
+ leftOperand: ODRL.terms.purpose,
+ operator: ODRL.terms.eq,
+ rightOperand: 'http://example.com/purpose-a',
+ });
+ const claims = { [PURPOSE]: 'http://example.com/purpose-b' };
+
+ const result = await authorizer.permissions(claims, query);
+
+ expect(result).toEqual([]);
+ expect(fallback.permissions).not.toHaveBeenCalled();
+ });
+
it('delegates to fallback if constraint is too complex', async () => {
const rule = addRule({});
store.addQuad(rule, ODRL.terms.constraint, DF.namedNode('constraint3'));
diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts
index 7474b55c..fde41e92 100644
--- a/test/integration/Base.test.ts
+++ b/test/integration/Base.test.ts
@@ -4,7 +4,7 @@ import { Parser, Writer } from 'n3';
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil';
-import { generateCredentials } from '../util/UmaUtil';
+import { generateCredentials, noTokenFetch, findTokenEndpoint, attemptTokenRequest, getToken } from '../util/UmaUtil';
const [ cssPort, umaPort ] = getPorts('Base');
@@ -101,6 +101,7 @@ describe('A server setup', (): void => {
});
describe('using ODRL authorization', (): void => {
+ const owner = 'https://pod.woutslabbinck.com/profile/card#me';
const collectionResource = `http://localhost:${cssPort}/alice/resource.txt`;
let wwwAuthenticateHeader: string;
let ticket: string;
@@ -184,8 +185,51 @@ describe('A server setup', (): void => {
expect(response.status).toBe(401);
});
+ it('supports purpose claims.', async(): Promise => {
+ const assignee = 'https://purpose.pod.example/profile/card#me';
+ const purpose = 'https://w3id.org/dpv#ScientificResearch';
+
+ const policy = `
+ @prefix ex: .
+ @prefix odrl: .
+
+ ex:purposePolicy a odrl:Agreement ;
+ odrl:uid ex:purposePolicy ;
+ odrl:permission ex:purposePermission .
+ ex:purposePermission a odrl:Permission ;
+ odrl:action odrl:read ;
+ odrl:target <${collectionResource}> ;
+ odrl:assignee <${assignee}> ;
+ odrl:assigner <${owner}> ;
+ odrl:constraint ex:purposeConstraint .
+ ex:purposeConstraint a odrl:Constraint ;
+ odrl:leftOperand odrl:purpose ;
+ odrl:operator odrl:eq ;
+ odrl:rightOperand <${purpose}> .
+ `;
+
+ const policyResponse = await fetch(`http://localhost:${umaPort}/uma/policies`, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
+ body: policy,
+ });
+ expect(policyResponse.status).toBe(201);
+
+ // First request without purpose should be denied
+ const { as_uri, ticket } = await noTokenFetch(collectionResource);
+ const endpoint = await findTokenEndpoint(as_uri);
+ const deniedResponse = await attemptTokenRequest(ticket, endpoint, assignee);
+ expect(deniedResponse.status).toBe(403);
+
+ // Request new ticket and get token with both WebID and purpose claims
+ const { ticket: newTicket } = await noTokenFetch(collectionResource);
+ const tokenWithClaims = await getToken(newTicket, endpoint, assignee, undefined, [
+ { claim_token: purpose, claim_token_format: 'http://www.w3.org/ns/odrl/2/purpose' }
+ ]);
+ expect(typeof tokenWithClaims.access_token).toBe('string');
+ });
+
it('the resource can be made publicly accessible by having an anonymous assignee.', async(): Promise => {
- const owner = 'https://pod.woutslabbinck.com/profile/card#me';
const url = `http://localhost:${umaPort}/uma/policies`;
const policy = `
@@ -222,7 +266,6 @@ describe('A server setup', (): void => {
});
it('the resource can be made publicly accessible by being a Set without assignee.', async(): Promise => {
- const owner = 'https://pod.woutslabbinck.com/profile/card#me';
const url = `http://localhost:${umaPort}/uma/policies`;
const policy = `
diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts
index 2e5a4f1a..ca075be9 100644
--- a/test/integration/Oidc.test.ts
+++ b/test/integration/Oidc.test.ts
@@ -1,4 +1,4 @@
-import { AlgJwk, App, CachedJwkGenerator, MemoryMapStorage } from '@solid/community-server';
+import { AlgJwk, App, CachedJwkGenerator, joinUrl, MemoryMapStorage } from '@solid/community-server';
import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory';
import { importJWK, SignJWT } from 'jose';
import { randomUUID } from 'node:crypto';
@@ -22,7 +22,7 @@ describe('A server supporting OIDC tokens', (): void => {
const oidcFormat = 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken';
beforeAll(async(): Promise => {
- setGlobalLoggerFactory(new WinstonLoggerFactory('info'));
+ setGlobalLoggerFactory(new WinstonLoggerFactory('off'));
umaApp = await instantiateFromConfig(
'urn:uma:default:App',
@@ -157,6 +157,14 @@ describe('A server supporting OIDC tokens', (): void => {
});
expect(response.status).toBe(200);
});
+
+ it('can remove the policy.', async(): Promise => {
+ const response = await fetch(joinUrl(policyEndpoint, encodeURIComponent('http://example.org/policyStandard')), {
+ method: 'DELETE',
+ headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' },
+ });
+ expect(response.status).toBe(204);
+ });
});
describe('accessing a resource using a standard OIDC token with a specific client.', (): void => {
@@ -179,7 +187,7 @@ describe('A server supporting OIDC tokens', (): void => {
odrl:constraint ex:constraintStandardClient.
ex:constraintStandardClient
- odrl:leftOperand odrl:purpose ;
+ odrl:leftOperand odrl:deliveryChannel ;
odrl:operator odrl:eq ;
odrl:rightOperand .`;
@@ -225,6 +233,14 @@ describe('A server supporting OIDC tokens', (): void => {
});
expect(response.status).toBe(200);
});
+
+ it('can remove the policy.', async(): Promise => {
+ const response = await fetch(joinUrl(policyEndpoint, encodeURIComponent('http://example.org/policyStandardClient')), {
+ method: 'DELETE',
+ headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' },
+ });
+ expect(response.status).toBe(204);
+ });
});
describe('accessing a resource using a Solid OIDC token.', (): void => {
@@ -278,6 +294,14 @@ describe('A server supporting OIDC tokens', (): void => {
});
expect(response.status).toBe(200);
});
+
+ it('can remove the policy.', async(): Promise => {
+ const response = await fetch(joinUrl(policyEndpoint, encodeURIComponent('http://example.org/policySolid')), {
+ method: 'DELETE',
+ headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' },
+ });
+ expect(response.status).toBe(204);
+ });
});
describe('accessing a resource using a Solid OIDC token with a specific client.', (): void => {
@@ -301,7 +325,7 @@ describe('A server supporting OIDC tokens', (): void => {
odrl:constraint ex:constraintSolidClient.
ex:constraintSolidClient
- odrl:leftOperand odrl:purpose ;
+ odrl:leftOperand odrl:deliveryChannel ;
odrl:operator odrl:eq ;
odrl:rightOperand <${client}> .`;
@@ -348,5 +372,13 @@ describe('A server supporting OIDC tokens', (): void => {
});
expect(response.status).toBe(200);
});
+
+ it('can remove the policy.', async(): Promise => {
+ const response = await fetch(joinUrl(policyEndpoint, encodeURIComponent('http://example.org/policySolidClient')), {
+ method: 'DELETE',
+ headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' },
+ });
+ expect(response.status).toBe(204);
+ });
});
});
diff --git a/test/util/UmaUtil.ts b/test/util/UmaUtil.ts
index 7a99d4d3..495502d0 100644
--- a/test/util/UmaUtil.ts
+++ b/test/util/UmaUtil.ts
@@ -1,5 +1,5 @@
-import { DialogOutput } from '@solidlab/uma';
-import {joinUrl} from '@solid/community-server';
+import { joinUrl } from '@solid/community-server';
+import { DialogInput, DialogOutput } from '@solidlab/uma';
/**
* The initial request to a RS without a token.
@@ -39,28 +39,50 @@ export async function findTokenEndpoint(uri: string): Promise {
}
/**
- * Calls the UMA token endpoint with a token and potentially the given WebID to receive a response.
- * Will error if the response is not an access token.
+ * Attempts a token request that may fail.
+ * Returns the response without enforcing a specific status code.
+ * Useful for testing error cases like 403 responses.
+ * Claims are always sent in array format when present.
*/
-export async function getToken(ticket: string, endpoint: string, webId?: string, scope?: string):
- Promise {
- const content: Record = {
+export async function attemptTokenRequest(ticket: string, endpoint: string, webId?: string,
+ additionalClaims?: Array<{ claim_token: string, claim_token_format: string }>, scope?: string):
+ Promise {
+ const content: DialogInput = {
grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
ticket: ticket,
};
- if (webId) {
- content.claim_token = encodeURIComponent(webId);
- content.claim_token_format = 'urn:solidlab:uma:claims:formats:webid';
+
+ const claims = [
+ ...(webId ? [{
+ claim_token: encodeURIComponent(webId),
+ claim_token_format: 'urn:solidlab:uma:claims:formats:webid',
+ }] : []),
+ ...(additionalClaims ?? []),
+ ];
+
+ if (claims.length > 0) {
+ content.claim_token = claims;
}
if (scope) {
content.scope = scope;
}
- const response = await fetch(endpoint, {
+ return fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(content),
});
+}
+
+/**
+ * Calls the UMA token endpoint with a ticket and potentially the given WebID to receive a response.
+ * Will error if the response is not an access token.
+ * Optionally supports additional claims beyond the WebID.
+ */
+export async function getToken(ticket: string, endpoint: string, webId?: string, scope?: string,
+ additionalClaims?: Array<{ claim_token: string, claim_token_format: string }>):
+ Promise {
+ const response = await attemptTokenRequest(ticket, endpoint, webId, additionalClaims, scope);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/json');