diff --git a/documentation/policy-management.md b/documentation/policy-management.md index 9643d32..c37e666 100644 --- a/documentation/policy-management.md +++ b/documentation/policy-management.md @@ -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 diff --git a/packages/uma/config/credentials/parsers/default.json b/packages/uma/config/credentials/parsers/default.json new file mode 100644 index 0000000..da51298 --- /dev/null +++ b/packages/uma/config/credentials/parsers/default.json @@ -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" + } + ] + } + ] +} diff --git a/packages/uma/config/default.json b/packages/uma/config/default.json index 82a0858..bb22250 100644 --- a/packages/uma/config/default.json +++ b/packages/uma/config/default.json @@ -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", diff --git a/packages/uma/config/routes/accessrequests.json b/packages/uma/config/routes/accessrequests.json index ac7dd41..1d10440 100644 --- a/packages/uma/config/routes/accessrequests.json +++ b/packages/uma/config/routes/accessrequests.json @@ -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", @@ -33,7 +35,7 @@ "OPTIONS", "PATCH", "GET", - "DELETE", + "DELETE", "PUT" ], "handler": { "@id": "urn:uma:default:AccessRequestHandler" }, diff --git a/packages/uma/config/routes/policies.json b/packages/uma/config/routes/policies.json index d454e4f..2801e6f 100644 --- a/packages/uma/config/routes/policies.json +++ b/packages/uma/config/routes/policies.json @@ -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", diff --git a/packages/uma/src/credentials/CredentialParser.ts b/packages/uma/src/credentials/CredentialParser.ts new file mode 100644 index 0000000..bb8f39d --- /dev/null +++ b/packages/uma/src/credentials/CredentialParser.ts @@ -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 {} diff --git a/packages/uma/src/credentials/parse/MappedSchemeParser.ts b/packages/uma/src/credentials/parse/MappedSchemeParser.ts new file mode 100644 index 0000000..4c07616 --- /dev/null +++ b/packages/uma/src/credentials/parse/MappedSchemeParser.ts @@ -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, + ) { + super(); + } + + public async canHandle(request: HttpHandlerRequest): Promise { + 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 { + const scheme = request.headers.authorization.split(' ', 1)[0]; + const token = request.headers.authorization.slice(scheme.length + 1); + return { token, format: this.schemeMap[scheme] }; + } +} diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index 3cbc409..b8737f6 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -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'; diff --git a/packages/uma/src/routes/BaseHandler.ts b/packages/uma/src/routes/BaseHandler.ts index d8bed07..0f95d74 100644 --- a/packages/uma/src/routes/BaseHandler.ts +++ b/packages/uma/src/routes/BaseHandler.ts @@ -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. @@ -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(); } @@ -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, request.parameters.id, credentials); - case 'PUT': return this.handlePut(request as HttpHandlerRequest, 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, request.parameters.id, userId); + case 'PUT': return this.handlePut(request as HttpHandlerRequest, 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, credentials); + case 'GET': return this.handleGet(userId); + case 'POST': return this.handlePost(request as HttpHandlerRequest, userId); default: throw new MethodNotAllowedHttpError(); } } diff --git a/packages/uma/src/util/routeSpecific/middlewareUtil.ts b/packages/uma/src/util/routeSpecific/middlewareUtil.ts deleted file mode 100644 index ff8aa50..0000000 --- a/packages/uma/src/util/routeSpecific/middlewareUtil.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BadRequestHttpError, UnauthorizedHttpError } from '@solid/community-server'; -import { HttpHandlerRequest } from "../http/models/HttpHandler"; - -/** - * Check HTTP credentials for resource owner or requesting party. - * Currently only fetches the content of the `authorization` header, but should be adapted in the future. - * Goal: use one of the verification methods defined in packages/uma/src/credentials/verify - * @param request HttpHandlerRequest to check credentials for - * @returns credentials belonging to the resource owner or requesting party that made the request - */ -export const verifyHttpCredentials = (request: HttpHandlerRequest): string => { - if (typeof request.headers['authorization'] === 'string') - return request.headers['authorization']; - else throw new UnauthorizedHttpError('Missing Authorization header'); -} diff --git a/packages/uma/test/unit/credentials/parse/MappedSchemeParser.test.ts b/packages/uma/test/unit/credentials/parse/MappedSchemeParser.test.ts new file mode 100644 index 0000000..38297f2 --- /dev/null +++ b/packages/uma/test/unit/credentials/parse/MappedSchemeParser.test.ts @@ -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 => { + const request = { headers: {} } as any; + await expect(parser.canHandle(request)).rejects.toThrow(UnauthorizedHttpError); + }); + + it('rejects unknown schemes.', async(): Promise => { + const request = { headers: { authorization: 'unknown value' } } as any; + await expect(parser.canHandle(request)).rejects.toThrow(ForbiddenHttpError); + }); + + it('accepts known schemes.', async(): Promise => { + const request = { headers: { authorization: 'scheme1 value' } } as any; + await expect(parser.canHandle(request)).resolves.toBeUndefined(); + }); + + it('returns the parsed header value.', async(): Promise => { + const request = { headers: { authorization: 'scheme1 value' } } as any; + await expect(parser.handle(request)).resolves.toEqual({ token: 'value', format: 'format1' }); + }); +}); diff --git a/scripts/init-test-policies.ts b/scripts/init-test-policies.ts index 5ec6b6d..c8e39a7 100644 --- a/scripts/init-test-policies.ts +++ b/scripts/init-test-policies.ts @@ -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`); diff --git a/scripts/seed-uma-ODRL-policy.ts b/scripts/seed-uma-ODRL-policy.ts index b1ac008..c5c323d 100644 --- a/scripts/seed-uma-ODRL-policy.ts +++ b/scripts/seed-uma-ODRL-policy.ts @@ -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) { @@ -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)}` } }); } } @@ -67,4 +67,4 @@ async function main() { }); } -main(); \ No newline at end of file +main(); diff --git a/scripts/test-uma-ODRL-policy-access-requests.ts b/scripts/test-uma-ODRL-policy-access-requests.ts index 3e349d7..6a5e27f 100644 --- a/scripts/test-uma-ODRL-policy-access-requests.ts +++ b/scripts/test-uma-ODRL-policy-access-requests.ts @@ -44,7 +44,7 @@ const setup = async (): Promise => { 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) } @@ -67,7 +67,7 @@ const teardown = async (): Promise => { POLICY_URL, { method: 'GET', headers: { - 'authorization': RESOURCE_OWNER + 'authorization': `WebID ${encodeURIComponent(RESOURCE_OWNER)}` } } ); @@ -76,7 +76,7 @@ const teardown = async (): Promise => { 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(); @@ -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), } @@ -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 }) } @@ -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)}`, } } ); diff --git a/scripts/test-uma-ODRL-policy.ts b/scripts/test-uma-ODRL-policy.ts index f1e9a53..5a174eb 100644 --- a/scripts/test-uma-ODRL-policy.ts +++ b/scripts/test-uma-ODRL-policy.ts @@ -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'; diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts index 2cd4c85..b259a2d 100644 --- a/test/integration/Base.test.ts +++ b/test/integration/Base.test.ts @@ -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); @@ -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); diff --git a/test/integration/Odrl.test.ts b/test/integration/Odrl.test.ts index 999824b..c2743b5 100644 --- a/test/integration/Odrl.test.ts +++ b/test/integration/Odrl.test.ts @@ -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); diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts index 2b32d08..e155845 100644 --- a/test/integration/Oidc.test.ts +++ b/test/integration/Oidc.test.ts @@ -101,7 +101,7 @@ describe('A server supporting OIDC tokens', (): void => { it('can set up the policy.', async(): Promise => { const response = await fetch(policyEndpoint, { method: 'POST', - headers: { authorization: webId, 'content-type': 'text/turtle' }, + headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' }, body: policy, }); expect(response.status).toBe(201); @@ -168,7 +168,7 @@ describe('A server supporting OIDC tokens', (): void => { it('can set up the policy.', async(): Promise => { const response = await fetch(policyEndpoint, { method: 'POST', - headers: { authorization: webId, 'content-type': 'text/turtle' }, + headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' }, body: policy, }); expect(response.status).toBe(201); @@ -230,7 +230,7 @@ describe('A server supporting OIDC tokens', (): void => { it('can set up the policy.', async(): Promise => { const response = await fetch(policyEndpoint, { method: 'POST', - headers: { authorization: webId, 'content-type': 'text/turtle' }, + headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' }, body: policy, }); expect(response.status).toBe(201); @@ -300,7 +300,7 @@ describe('A server supporting OIDC tokens', (): void => { it('can set up the policy.', async(): Promise => { const response = await fetch(policyEndpoint, { method: 'POST', - headers: { authorization: webId, 'content-type': 'text/turtle' }, + headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' }, body: policy, }); expect(response.status).toBe(201); diff --git a/test/integration/Policies.test.ts b/test/integration/Policies.test.ts index c856276..2126e42 100644 --- a/test/integration/Policies.test.ts +++ b/test/integration/Policies.test.ts @@ -60,7 +60,10 @@ async function fetchPolicy(method: string, webId: string, id?: string, data?: st Promise { return fetch(policyEndpoint + (id ? `/${encodeURIComponent(id)}` : ''), { method, - headers: { authorization: webId, 'content-type': patch ? 'application/sparql-update' : 'text/turtle' }, + headers: { + authorization: `WebID ${encodeURIComponent(webId)}`, + 'content-type': patch ? 'application/sparql-update' : 'text/turtle' + }, ... data ? { body: data } : {}, }); }