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
17 changes: 15 additions & 2 deletions documentation/policy-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,21 @@ The current implementation supports the following requests on the UMA server:
These requests comply with some restrictions:

- When the URL contains a policy ID, it must be URI encoded.
- The request must have its `Authorization` header set to the owners WebID.
More on that [later](#authentication).
- Every request requires a valid Authorization header, which is detailed below.

### Authorization

The policy API supports similar authentication tokens as the UMA API,
but expects them in the Authorization header,
as the body is already used for other purposes.
Two authorization methods are supported: OIDC tokens, both Solid and standard, and unsafe WebID strings.

To use OIDC, the `Bearer` authorization scheme needs to be used, followed by the token.
For example, `Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI...`.

To directly pass a WebID, the `WebID` scheme can be used together with a URL encoded WebID.
For example, `Authorization: WebID http%3A%2F%2Fexample.com%2Fprofile%2Fcard%23me`.
No validation is performed in this case, so this should only be used for development and debugging purposes.

### Creating policies

Expand Down
21 changes: 21 additions & 0 deletions packages/uma/config/credentials/parsers/default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"@context": [
"https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld"
],
"@graph": [
{
"@id": "urn:uma:default:CredentialParser",
"@type": "MappedSchemeParser",
"schemeMap": [
{
"MappedSchemeParser:_schemeMap_key": "WebID",
"MappedSchemeParser:_schemeMap_value": "urn:solidlab:uma:claims:formats:webid"
},
{
"MappedSchemeParser:_schemeMap_key": "Bearer",
"MappedSchemeParser:_schemeMap_value": "http://openid.net/specs/openid-connect-core-1_0.html#IDToken"
}
]
}
]
}
1 change: 1 addition & 0 deletions packages/uma/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"https://linkedsoftwaredependencies.org/bundles/npm/asynchronous-handlers/^1.0.0/components/context.jsonld"
],
"import": [
"sai-uma:config/credentials/parsers/default.json",
"sai-uma:config/credentials/verifiers/default.json",
"sai-uma:config/dialog/negotiators/default.json",
"sai-uma:config/policies/authorizers/default.json",
Expand Down
6 changes: 4 additions & 2 deletions packages/uma/config/routes/accessrequests.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
"@id": "urn:uma:default:AccessRequestHandler",
"@type": "BaseHandler",
"controller": { "@id": "urn:uma:default:AccessRequestController" },
"credentialParser": { "@id": "urn:uma:default:CredentialParser" },
"verifier": { "@id": "urn:uma:default:Verifier" },
"handleLogMessage": "received access request/grants request",
"patchContentType": "application/json"
"patchContentType": "application/json"
},
{
"@id": "urn:uma:default:AccessRequestRoute",
Expand All @@ -33,7 +35,7 @@
"OPTIONS",
"PATCH",
"GET",
"DELETE",
"DELETE",
"PUT"
],
"handler": { "@id": "urn:uma:default:AccessRequestHandler" },
Expand Down
4 changes: 3 additions & 1 deletion packages/uma/config/routes/policies.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
"@id": "urn:uma:default:PolicyHandler",
"@type": "BaseHandler",
"controller": { "@id": "urn:uma:default:PolicyController" },
"credentialParser": { "@id": "urn:uma:default:CredentialParser" },
"verifier": { "@id": "urn:uma:default:Verifier" },
"handleLogMessage": "received policy request",
"patchContentType": "application/sparql-update"
"patchContentType": "application/sparql-update"
},
{
"@id": "urn:uma:default:PolicyRoute",
Expand Down
9 changes: 9 additions & 0 deletions packages/uma/src/credentials/CredentialParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { AsyncHandler } from 'asynchronous-handlers';
import { HttpHandlerRequest } from '../util/http/models/HttpHandler';
import { Credential } from './Credential';

/**
* Converts the contents of a request to a Credential token,
* generally by parsing the Authorization header.
*/
export abstract class CredentialParser extends AsyncHandler<HttpHandlerRequest, Credential> {}
31 changes: 31 additions & 0 deletions packages/uma/src/credentials/parse/MappedSchemeParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ForbiddenHttpError, NotImplementedHttpError, UnauthorizedHttpError } from '@solid/community-server';
import { HttpHandlerRequest } from '../../util/http/models/HttpHandler';
import { Credential } from '../Credential';
import { CredentialParser } from '../CredentialParser';

/**
* Interprets Bearer Authorization headers as OIDC tokens.
*/
export class MappedSchemeParser extends CredentialParser {
public constructor(
protected readonly schemeMap: Record<string, string>,
) {
super();
}

public async canHandle(request: HttpHandlerRequest): Promise<void> {
if (!request.headers.authorization) {
throw new UnauthorizedHttpError('Missing Authorization header.');
}
const scheme = request.headers.authorization.split(' ', 1)[0];
if (!this.schemeMap[scheme]) {
throw new ForbiddenHttpError(`Unsupported Authorization scheme ${scheme}.`);
}
}

public async handle(request: HttpHandlerRequest): Promise<Credential> {
const scheme = request.headers.authorization.split(' ', 1)[0];
const token = request.headers.authorization.slice(scheme.length + 1);
return { token, format: this.schemeMap[scheme] };
}
}
5 changes: 5 additions & 0 deletions packages/uma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
export * from './credentials/ClaimSet';
export * from './credentials/Requirements';
export * from './credentials/Credential';
export * from './credentials/CredentialParser';
export * from './credentials/Formats';

// CredentialParsers
export * from './credentials/parse/MappedSchemeParser';

// Verifiers
export * from './credentials/verify/Verifier';
Expand Down
45 changes: 31 additions & 14 deletions packages/uma/src/routes/BaseHandler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { BadRequestHttpError, MethodNotAllowedHttpError } from "@solid/community-server";
import { BadRequestHttpError, ForbiddenHttpError, MethodNotAllowedHttpError } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { BaseController } from "../controller/BaseController";
import { HttpHandler, HttpHandlerContext, HttpHandlerRequest, HttpHandlerResponse } from "../util/http/models/HttpHandler";
import { verifyHttpCredentials } from "../util/routeSpecific/middlewareUtil";
import { BaseController } from '../controller/BaseController';
import { WEBID } from '../credentials/Claims';
import { ClaimSet } from '../credentials/ClaimSet';
import { CredentialParser } from '../credentials/CredentialParser';
import { Verifier } from '../credentials/verify/Verifier';
import {
HttpHandler,
HttpHandlerContext,
HttpHandlerRequest,
HttpHandlerResponse
} from '../util/http/models/HttpHandler';

/**
* Base handler for policy and access request endpoints.
Expand All @@ -18,19 +26,23 @@ import { verifyHttpCredentials } from "../util/routeSpecific/middlewareUtil";
* - **GET** `/` - retrieve all policies (including their rules) or access requests
* - **POST** `/` - create new policy or access request
*/
export abstract class BaseHandler extends HttpHandler {
export class BaseHandler extends HttpHandler {

protected readonly logger = getLoggerFor(this);

/**
* @param controller reference to the controller implementing the policy/access request logic
* @param credentialParser parses the request headers to find the credential format and token
* @param verifier verifies the credential token and extracts the claims
* @param handleLogMessage message to log at the start of each handled request
* @param patchContentType expected content type for PATCH requests (e.g. `application/json` or `application/sparql-update`)
*/
constructor(
protected readonly controller: BaseController,
private readonly handleLogMessage: string,
private readonly patchContentType: string,
protected readonly credentialParser: CredentialParser,
protected readonly verifier: Verifier,
protected readonly handleLogMessage: string,
protected readonly patchContentType: string,
) {
super();
}
Expand All @@ -48,20 +60,25 @@ export abstract class BaseHandler extends HttpHandler {
if (request.method === 'OPTIONS')
return this.handleOptions();

const credentials = verifyHttpCredentials(request);
const credential = await this.credentialParser.handleSafe(request);
const claims = await this.verifier.verify(credential);
const userId = claims[WEBID];
if (typeof userId !== 'string') {
throw new ForbiddenHttpError(`Missing claim ${WEBID}.`);
}

if (request.parameters?.id) {
switch (request.method) {
case 'GET': return this.handleSingleGet(request.parameters.id, credentials);
case 'PATCH': return this.handlePatch(request as HttpHandlerRequest<string>, request.parameters.id, credentials);
case 'PUT': return this.handlePut(request as HttpHandlerRequest<string>, request.parameters.id, credentials);
case 'DELETE': return this.handleDelete(request.parameters.id, credentials);
case 'GET': return this.handleSingleGet(request.parameters.id, userId);
case 'PATCH': return this.handlePatch(request as HttpHandlerRequest<string>, request.parameters.id, userId);
case 'PUT': return this.handlePut(request as HttpHandlerRequest<string>, request.parameters.id, userId);
case 'DELETE': return this.handleDelete(request.parameters.id, userId);
default: throw new MethodNotAllowedHttpError();
}
} else {
switch (request.method) {
case 'GET': return this.handleGet(credentials);
case 'POST': return this.handlePost(request as HttpHandlerRequest<string>, credentials);
case 'GET': return this.handleGet(userId);
case 'POST': return this.handlePost(request as HttpHandlerRequest<string>, userId);
default: throw new MethodNotAllowedHttpError();
}
}
Expand Down
15 changes: 0 additions & 15 deletions packages/uma/src/util/routeSpecific/middlewareUtil.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ForbiddenHttpError, UnauthorizedHttpError } from '@solid/community-server';
import { MappedSchemeParser } from '../../../../src/credentials/parse/MappedSchemeParser';

describe('MappedSchemeParser', (): void => {
let map = {
'scheme1': 'format1',
'scheme2': 'format2',
};

const parser = new MappedSchemeParser(map);

it('rejects requests without authorization headers.', async(): Promise<void> => {
const request = { headers: {} } as any;
await expect(parser.canHandle(request)).rejects.toThrow(UnauthorizedHttpError);
});

it('rejects unknown schemes.', async(): Promise<void> => {
const request = { headers: { authorization: 'unknown value' } } as any;
await expect(parser.canHandle(request)).rejects.toThrow(ForbiddenHttpError);
});

it('accepts known schemes.', async(): Promise<void> => {
const request = { headers: { authorization: 'scheme1 value' } } as any;
await expect(parser.canHandle(request)).resolves.toBeUndefined();
});

it('returns the parsed header value.', async(): Promise<void> => {
const request = { headers: { authorization: 'scheme1 value' } } as any;
await expect(parser.handle(request)).resolves.toEqual({ token: 'value', format: 'format1' });
});
});
2 changes: 1 addition & 1 deletion scripts/init-test-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function main() {
console.log(`=== Trying to initialize test policies.\n`);
const response = await fetch(url, {
method: 'POST',
headers: { authorization: owner, 'content-type': 'text/turtle' },
headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
body,
});
console.log(`= Status: ${response.status}\n`);
Expand Down
6 changes: 3 additions & 3 deletions scripts/seed-uma-ODRL-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as readline from 'readline';
import { seedingPolicies, seedingPolicies2, seedingPolicies3 } from './util/policyExamples';

async function seedForOneClient(id: string) {
await fetch("http://localhost:4000/uma/policies", { method: 'POST', headers: { 'Authorization': id, 'Content-Type': 'text/turtle' }, body: Buffer.from(seedingPolicies3(id), 'utf-8') });
await fetch("http://localhost:4000/uma/policies", { method: 'POST', headers: { 'Authorization': `WebID ${encodeURIComponent(id)}`, 'Content-Type': 'text/turtle' }, body: Buffer.from(seedingPolicies3(id), 'utf-8') });
}

async function deleteForOneClient(id: string) {
Expand All @@ -25,7 +25,7 @@ async function deleteForOneClient(id: string) {
for (const policyId of policyIds) {
await fetch(`http://localhost:4000/uma/policies/${encodeURIComponent(policyId)}`, {
method: 'DELETE',
headers: { 'Authorization': id }
headers: { 'Authorization': `WebID ${encodeURIComponent(id)}` }
});
}
}
Expand Down Expand Up @@ -67,4 +67,4 @@ async function main() {
});
}

main();
main();
12 changes: 6 additions & 6 deletions scripts/test-uma-ODRL-policy-access-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const setup = async (): Promise<Result> => {
POLICY_URL, {
method: 'POST',
headers: {
'authorization': RESOURCE_OWNER,
'authorization': `WebID ${encodeURIComponent(RESOURCE_OWNER)}`,
'content-type': 'text/turtle'
}, body: SETUP_POLICIES(RESOURCE_PARENT, RESOURCE, RESOURCE_OWNER)
}
Expand All @@ -67,7 +67,7 @@ const teardown = async (): Promise<Result> => {
POLICY_URL, {
method: 'GET',
headers: {
'authorization': RESOURCE_OWNER
'authorization': `WebID ${encodeURIComponent(RESOURCE_OWNER)}`
}
}
);
Expand All @@ -76,7 +76,7 @@ const teardown = async (): Promise<Result> => {
const policyIDs = store.getSubjects(null, "http://www.w3.org/ns/odrl/2/Agreement", null).map((subject) => subject.id);

await Promise.all(policyIDs.map((policyID) =>
fetch(`${POLICY_URL}/${encodeURIComponent(policyID)}`, { method: 'DELETE', headers: { 'authorization': RESOURCE_OWNER } })
fetch(`${POLICY_URL}/${encodeURIComponent(policyID)}`, { method: 'DELETE', headers: { 'authorization': `WebID ${encodeURIComponent(RESOURCE_OWNER)}` } })
));

return success();
Expand All @@ -92,7 +92,7 @@ const createAccessRequest = async (clientID: string, accessRequestID: string): P
ACCESS_REQUEST_URL, {
method: 'POST',
headers: {
'authorization': clientID,
'authorization': `WebID ${encodeURIComponent(clientID)}`,
'content-type': 'text/turtle'
}, body: ACCESS_REQUEST(accessRequestID, RESOURCE, clientID),
}
Expand All @@ -107,7 +107,7 @@ const updateAccessRequest = async (clientID: string, accessRequestID: string, st
`${ACCESS_REQUEST_URL}/${encodeURIComponent(accessRequestID)}`, {
method: 'PATCH',
headers: {
'authorization': clientID,
'authorization': `WebID ${encodeURIComponent(clientID)}`,
'content-type': 'application/json'
}, body: JSON.stringify({ status: status })
}
Expand All @@ -125,7 +125,7 @@ const deleteAccesRequest = async (clientID: string, accessRequestID: string): Pr
`${ACCESS_REQUEST_URL}/${encodeURIComponent(accessRequestID)}`, {
method: 'DELETE',
headers: {
'authorization': clientID
'authorization': `WebID ${encodeURIComponent(clientID)}`,
}
}
);
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-uma-ODRL-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { policyA, policyB, policyC, badPolicy1, changePolicy1, changePolicy95e, putPolicy95e, putPolicyB } from "./util/policyExamples";

const endpoint = (extra: string = '') => 'http://localhost:4000/uma/policies' + extra;
const client = (client: string = 'a') => `https://pod.${client}.com/profile/card#me`;
const client = (client: string = 'a') => `WebID ${encodeURIComponent(`https://pod.${client}.com/profile/card#me`)}`;
const policyId1 = 'http://example.org/usagePolicy1';
const policyId95e = 'urn:uuid:95efe0e8-4fb7-496d-8f3c-4d78c97829bc'
const badPolicyId = 'nonExistentPolicy';
Expand Down
4 changes: 2 additions & 2 deletions test/integration/Base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('A server setup', (): void => {

const response = await fetch(url, {
method: 'POST',
headers: { authorization: owner, 'content-type': 'text/turtle' },
headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
body,
});
expect(response.status).toBe(201);
Expand Down Expand Up @@ -176,7 +176,7 @@ describe('A server setup', (): void => {

const policyResponse = await fetch(url, {
method: 'POST',
headers: { authorization: owner, 'content-type': 'text/turtle' },
headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
body: policy,
});
expect(policyResponse.status).toBe(201);
Expand Down
2 changes: 1 addition & 1 deletion test/integration/Odrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('An ODRL server setup', (): void => {

const response = await fetch(url, {
method: 'POST',
headers: { authorization: owner, 'content-type': 'text/turtle' },
headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
body,
});
expect(response.status).toBe(201);
Expand Down
Loading