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');