|
| 1 | +import { |
| 2 | + asyncToArray, |
| 3 | + BasicRepresentation, |
| 4 | + getLoggerFor, |
| 5 | + guardedStreamFrom, |
| 6 | + INTERNAL_QUADS, |
| 7 | + OperationHttpHandler, |
| 8 | + OperationHttpHandlerInput, |
| 9 | + PREFERRED_PREFIX_TERM, |
| 10 | + RDF, |
| 11 | + RepresentationConverter, |
| 12 | + ResponseDescription, |
| 13 | + SOLID_META, |
| 14 | + XSD |
| 15 | +} from '@solid/community-server'; |
| 16 | +import { DataFactory as DF, Literal, Quad } from 'n3'; |
| 17 | +import { UmaClaims, UmaClient } from '../uma/UmaClient'; |
| 18 | +import type { Fetcher } from '../util/fetch/Fetcher'; |
| 19 | +import { OwnerUtil } from '../util/OwnerUtil'; |
| 20 | +import { DCTERMS, DPV, ODRL, TE } from '../util/Vocabularies'; |
| 21 | + |
| 22 | +/** |
| 23 | + * A handler that wraps the outgoing data in a trust envelope, |
| 24 | + * based on the contents of the UMA access token. |
| 25 | + * As the trust envelope is combined with the data response, |
| 26 | + * this only works with RDF response data. |
| 27 | + * It is also assumed the identifier of the resource is the main subject for the RDF response. |
| 28 | + * |
| 29 | + * As this class needs to have access to both authorization data and response data, |
| 30 | + * it is situated before the RepresentationStore stack. |
| 31 | + * This complicates the situation a bit as this means data conversion |
| 32 | + * can no longer be handled by the RepresentationStore. |
| 33 | + * An alternative solution where this is not the case, |
| 34 | + * would require the authorization data to somehow get passed along to the RepresentationStore request. |
| 35 | + * For example, as ephemeral metadata. |
| 36 | + * The issue there is that it might create issues that this metadata is mixed with the data, |
| 37 | + * which could be an example for PATCH, for example. |
| 38 | + * |
| 39 | + * Many assumptions are still made in the code. |
| 40 | + */ |
| 41 | +export class TrustEnvelopeHttpHandler extends OperationHttpHandler { |
| 42 | + protected readonly logger = getLoggerFor(this); |
| 43 | + |
| 44 | + public constructor( |
| 45 | + protected readonly converter: RepresentationConverter, |
| 46 | + protected readonly operationHandler: OperationHttpHandler, |
| 47 | + protected readonly umaClient: UmaClient, |
| 48 | + protected readonly ownerUtil: OwnerUtil, |
| 49 | + protected readonly fetcher: Fetcher, |
| 50 | + ) { |
| 51 | + super(); |
| 52 | + } |
| 53 | + |
| 54 | + public async canHandle(operation: OperationHttpHandlerInput): Promise<void> { |
| 55 | + return this.operationHandler.canHandle(operation); |
| 56 | + } |
| 57 | + |
| 58 | + public async handle(input: OperationHttpHandlerInput): Promise<ResponseDescription> { |
| 59 | + if (input.operation.method !== 'GET') { |
| 60 | + return this.operationHandler.handle(input); |
| 61 | + } |
| 62 | + |
| 63 | + // TODO: simultaneous promises for speedup |
| 64 | + const response = await this.operationHandler.handle(input); |
| 65 | + |
| 66 | + const conversionArgs = { |
| 67 | + preferences: { type: {[INTERNAL_QUADS]: 1}}, |
| 68 | + identifier: input.operation.target, |
| 69 | + representation: new BasicRepresentation(response.data!, response.metadata!), |
| 70 | + } |
| 71 | + try { |
| 72 | + await this.converter.canHandle(conversionArgs); |
| 73 | + } catch { |
| 74 | + return response; |
| 75 | + } |
| 76 | + |
| 77 | + let owners: string[]; |
| 78 | + let verified: UmaClaims | undefined; |
| 79 | + try { |
| 80 | + owners = await this.ownerUtil.findOwners(input.operation.target); |
| 81 | + verified = await this.introspect(input, owners); |
| 82 | + } catch { |
| 83 | + return response; |
| 84 | + } |
| 85 | + |
| 86 | + if (!verified) { |
| 87 | + return response; |
| 88 | + } |
| 89 | + |
| 90 | + const envelopeQuads = this.generateEnvelopeData(verified, owners); |
| 91 | + |
| 92 | + const quadData = await this.converter.handle(conversionArgs); |
| 93 | + |
| 94 | + // TODO: inefficient stream merging |
| 95 | + const mergedData = [ |
| 96 | + ...await asyncToArray(quadData.data), |
| 97 | + ...envelopeQuads, |
| 98 | + ]; |
| 99 | + quadData.data = guardedStreamFrom(mergedData); |
| 100 | + |
| 101 | + quadData.metadata.addQuad(DCTERMS.terms.namespace, PREFERRED_PREFIX_TERM, 'dcterms', SOLID_META.terms.ResponseMetadata); |
| 102 | + quadData.metadata.addQuad(DPV.terms.namespace, PREFERRED_PREFIX_TERM, 'dpv', SOLID_META.terms.ResponseMetadata); |
| 103 | + quadData.metadata.addQuad('http://example.com/ns/', PREFERRED_PREFIX_TERM, 'ex', SOLID_META.terms.ResponseMetadata); |
| 104 | + quadData.metadata.addQuad(ODRL.terms.namespace, PREFERRED_PREFIX_TERM, 'odrl', SOLID_META.terms.ResponseMetadata); |
| 105 | + quadData.metadata.addQuad(TE.terms.namespace, PREFERRED_PREFIX_TERM, 'te', SOLID_META.terms.ResponseMetadata); |
| 106 | + |
| 107 | + const preferredType = input.operation.preferences.type ?? {[response.metadata!.contentType!]: 1}; |
| 108 | + const originalType = await this.converter.handleSafe({ |
| 109 | + preferences: { type: preferredType }, |
| 110 | + identifier: input.operation.target, |
| 111 | + representation: quadData, |
| 112 | + }); |
| 113 | + |
| 114 | + return { |
| 115 | + data: originalType.data, |
| 116 | + metadata: originalType.metadata, |
| 117 | + statusCode: response.statusCode, |
| 118 | + }; |
| 119 | + } |
| 120 | + |
| 121 | + protected async introspect(input: OperationHttpHandlerInput, owners: string[]) { |
| 122 | + const authorization = input.request.headers.authorization; |
| 123 | + if (!authorization || !authorization.match(/.+ .+/)) { |
| 124 | + return; |
| 125 | + } |
| 126 | + const token = authorization.split(' ')[1]; |
| 127 | + console.log('TOKEN', token, owners); |
| 128 | + if (owners.length === 0) { |
| 129 | + return; |
| 130 | + } |
| 131 | + const issuer = await this.ownerUtil.findIssuer(owners[0]); |
| 132 | + if (!issuer) { |
| 133 | + return; |
| 134 | + } |
| 135 | + |
| 136 | + // TODO: the UMA client `verifyOpaqueToken` seems to combine two different conflicting ideas, |
| 137 | + // so just performing introspection here |
| 138 | + const config = await this.umaClient.fetchUmaConfig(issuer); |
| 139 | + const res = await this.fetcher.fetch(config.introspection_endpoint, { |
| 140 | + method: 'POST', |
| 141 | + headers: { |
| 142 | + 'Content-Type': 'application/x-www-form-urlencoded', |
| 143 | + 'Accept': 'application/json', |
| 144 | + }, |
| 145 | + body: `token_type_hint=access_token&token=${token}`, |
| 146 | + }); |
| 147 | + |
| 148 | + if (res.status >= 400) { |
| 149 | + throw new Error(`Unable to introspect UMA RPT for Authorization Server '${config.issuer}'`); |
| 150 | + } |
| 151 | + |
| 152 | + return res.json(); |
| 153 | + } |
| 154 | + |
| 155 | + protected generateEnvelopeData(verified: UmaClaims, owners?: string[]): Quad[] { |
| 156 | + const envelope = DF.namedNode('http://example.com/ns/envelope'); |
| 157 | + const dataProv = DF.namedNode('http://example.com/ns/dataProvenance'); |
| 158 | + const policyProv = DF.namedNode('http://example.com/ns/policyProvenance'); |
| 159 | + |
| 160 | + const signedEnvelope = DF.namedNode('http://example.com/ns/signedEnvelope'); |
| 161 | + const signedDataProv = DF.namedNode('http://example.com/ns/signedDataProvenance'); |
| 162 | + const signedPolicyProv = DF.namedNode('http://example.com/ns/signedPolicyProvenance'); |
| 163 | + |
| 164 | + const quads: Quad[] = []; |
| 165 | + |
| 166 | + let date: Literal | undefined; |
| 167 | + if (verified.iat) { |
| 168 | + date = DF.literal(new Date(verified.iat).toISOString(), XSD.terms.dateTime); |
| 169 | + } |
| 170 | + |
| 171 | + quads.push( |
| 172 | + DF.quad(envelope, RDF.terms.type, TE.terms.TrustEnvelope), |
| 173 | + DF.quad(envelope, TE.terms.provenance, dataProv), |
| 174 | + DF.quad(envelope, TE.terms.provenance, policyProv), |
| 175 | + DF.quad(envelope, TE.terms.sign, signedEnvelope), |
| 176 | + DF.quad(dataProv, RDF.terms.type, TE.terms.DataProvenance), |
| 177 | + DF.quad(dataProv, TE.terms.sign, signedDataProv), |
| 178 | + DF.quad(policyProv, RDF.terms.type, TE.terms.PolicyProvenance), |
| 179 | + DF.quad(policyProv, TE.terms.sign, signedPolicyProv), |
| 180 | + ); |
| 181 | + |
| 182 | + if (date) { |
| 183 | + quads.push( |
| 184 | + DF.quad(envelope, DCTERMS.terms.issued, date), |
| 185 | + DF.quad(dataProv, DCTERMS.terms.issued, date), |
| 186 | + DF.quad(policyProv, DCTERMS.terms.issued, date), |
| 187 | + ); |
| 188 | + } |
| 189 | + if (verified.iss) { |
| 190 | + quads.push(DF.quad(dataProv, TE.terms.sender, DF.namedNode(verified.iss))) |
| 191 | + } |
| 192 | + for (const owner of owners ?? []) { |
| 193 | + quads.push(DF.quad(policyProv, TE.terms.rightsHolder, DF.namedNode(owner))); |
| 194 | + } |
| 195 | + for (const permission of verified.permissions ?? []) { |
| 196 | + // TODO: these need to linked together in a more correct way |
| 197 | + quads.push( |
| 198 | + // TODO: this is the UMA ID, need to convert to local ID |
| 199 | + DF.quad(envelope, DPV.terms.hasData, DF.namedNode(permission.resource_id)), |
| 200 | + DF.quad(dataProv, DPV.terms.hasDataSubject, DF.namedNode(permission.resource_id)), |
| 201 | + ); |
| 202 | + for (const policy of permission.policies ?? []) { |
| 203 | + quads.push( |
| 204 | + DF.quad(envelope, ODRL.terms.hasPolicy, DF.namedNode(policy)), |
| 205 | + ); |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + if (verified.requestClaims) { |
| 210 | + const webId = verified.requestClaims['urn:solidlab:uma:claims:types:webid']; |
| 211 | + const purpose = verified.requestClaims['http://www.w3.org/ns/odrl/2/purpose']; |
| 212 | + if (typeof webId === 'string') { |
| 213 | + quads.push(DF.quad(policyProv, TE.terms.recipient, DF.namedNode(webId))); |
| 214 | + } |
| 215 | + if (typeof purpose === 'string') { |
| 216 | + // TODO: purpose not actually part of trust envelope, just dumping this in here for now |
| 217 | + quads.push(DF.quad(policyProv, DF.namedNode(TE.namespace + 'TODO-purpose'), DF.namedNode(purpose))); |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + return quads; |
| 222 | + } |
| 223 | +} |
0 commit comments