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
5 changes: 5 additions & 0 deletions documentation/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ so some information might change depending on which version and branch you're us
+ [Client application identification](#client-application-identification)
* [Adding or changing policies](#adding-or-changing-policies)
* [Policy backups](#policy-backups)
* [Data aggregation](#data-aggregation)

<small><i><a href='http://ecotrust-canada.github.io/markdown-toc/'>Table of contents generated with markdown-toc</a></i></small>

Expand Down Expand Up @@ -460,3 +461,7 @@ You want to change the line that defines the `urn:uma:variables:backupFilePath`
and set the string value to the path where you want the backup file to be stored,
e.g., `backup.ttl`.
When restarting the server, the contents of that file will be read to initialize policies on the server.

## Data aggregation

The UMA server implements the [Aggregator Specification](https://spec.knows.idlab.ugent.be/aggregator-protocol/latest/).
1 change: 1 addition & 0 deletions packages/uma/.componentsignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"Buffer",
"Error",
"EventEmitter",
"ForbiddenHttpError",
"Map",
"NodeJS.Dict",
"Permission",
Expand Down
2 changes: 1 addition & 1 deletion packages/uma/bin/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const launch = async () => {
variables['urn:uma:variables:policyBaseIRI'] = 'http://localhost:3000/';
variables['urn:uma:variables:policyDir'] = path.join(rootDir, './config/rules/policy');
variables['urn:uma:variables:eyePath'] = 'eye';
variables['urn:uma:variables:backupFilePath'] = 'backup.ttl';
variables['urn:uma:variables:backupFilePath'] = '';

const configPath = path.join(rootDir, './config/default.json');

Expand Down
7 changes: 6 additions & 1 deletion packages/uma/config/credentials/verifiers/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@
"TypedVerifier:_verifiers_value": {
"@id": "urn:uma:default:OidcVerifier",
"@type": "OidcVerifier",
"baseUrl": { "@id": "urn:uma:variables:baseUrl" }
"baseUrl": { "@id": "urn:uma:variables:baseUrl" },
"derivationStore": { "@id": "urn:uma:default:DerivationStore" }
}
},
{
"TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:access_token",
"TypedVerifier:_verifiers_value": { "@id": "urn:uma:default:OidcVerifier" }
},
{
"TypedVerifier:_verifiers_key": "urn:solidlab:uma:claims:formats:jwt",
"TypedVerifier:_verifiers_value": {
Expand Down
1 change: 1 addition & 0 deletions packages/uma/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"@type": "RoutedHttpRequestHandler",
"routes": [
{ "@id": "urn:uma:default:UmaConfigRoute" },
{ "@id": "urn:uma:default:OidcConfigRoute" },
{ "@id": "urn:uma:default:JwksRoute" },
{ "@id": "urn:uma:default:TokenRoute" },
{ "@id": "urn:uma:default:PermissionRegistrationRoute" },
Expand Down
15 changes: 10 additions & 5 deletions packages/uma/config/dialog/negotiators/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
"@graph": [
{
"@id": "urn:uma:default:Negotiator",
"@type": "ContractNegotiator",
"verifier": { "@id": "urn:uma:default:Verifier" },
"@type": "AggregatorNegotiator",
"ticketStore": { "@id": "urn:uma:default:TicketStore" },
"ticketingStrategy": { "@id": "urn:uma:default:TicketingStrategy" },
"tokenFactory": { "@id": "urn:uma:default:TokenFactory" }
"registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
"negotiator": {
"@type": "BaseNegotiator",
"verifier": { "@id": "urn:uma:default:Verifier" },
"ticketStore": { "@id": "urn:uma:default:TicketStore" },
"ticketingStrategy": { "@id": "urn:uma:default:TicketingStrategy" },
"tokenFactory": { "@id": "urn:uma:default:TokenFactory" }
}
}
]
}
}
4 changes: 4 additions & 0 deletions packages/uma/config/resources/storage/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
{
"@id": "urn:uma:default:ResourceRegistrationStore",
"@type": "MemoryMapStorage"
},
{
"@id": "urn:uma:default:DerivationStore",
"@type": "MemoryMapStorage"
}
]
}
9 changes: 9 additions & 0 deletions packages/uma/config/routes/discovery.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@
"@type": "HttpHandlerRoute",
"methods": [ "GET" ],
"handler": {
"@id": "urn:uma:default:ConfigRequestHandler",
"@type": "ConfigRequestHandler",
"baseUrl": { "@id": "urn:uma:variables:baseUrl" }
},
"path": "/uma/.well-known/uma2-configuration"
},
{
"comment": "Default OIDC decoding algorithms will look here for the JWKS, so can be useful to have it here as well",
"@id": "urn:uma:default:OidcConfigRoute",
"@type": "HttpHandlerRoute",
"methods": [ "GET" ],
"handler": { "@id": "urn:uma:default:ConfigRequestHandler" },
"path": "/uma/.well-known/openid-configuration"
}
]
}
1 change: 1 addition & 0 deletions packages/uma/config/routes/resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{
"@id": "urn:uma:default:ResourceRegistrationHandler",
"@type": "ResourceRegistrationRequestHandler",
"derivationStore": { "@id": "urn:uma:default:DerivationStore" },
"registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
"policies": { "@id": "urn:uma:default:RulesStorage" },
"validator": { "@id": "urn:uma:default:RequestValidator" }
Expand Down
12 changes: 0 additions & 12 deletions packages/uma/config/tickets/strategy/claim-elimination.json

This file was deleted.

11 changes: 8 additions & 3 deletions packages/uma/config/tickets/strategy/immediate-authorizer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
"@graph": [
{
"@id": "urn:uma:default:TicketingStrategy",
"@type": "ImmediateAuthorizerStrategy",
"authorizer": { "@id": "urn:uma:default:Authorizer" }
"@type": "AggregatorStrategy",
"registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
"derivationStore": { "@id": "urn:uma:default:DerivationStore" },
"strategy": {
"@type": "ImmediateAuthorizerStrategy",
"authorizer": { "@id": "urn:uma:default:Authorizer" }
}
}
]
}
}
1 change: 1 addition & 0 deletions packages/uma/src/credentials/Claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export const WEBID = 'urn:solidlab:uma:claims:types:webid';
export const CLIENTID = 'urn:solidlab:uma:claims:types:clientid';
export const PURPOSE = 'http://www.w3.org/ns/odrl/2/purpose';
export const LEGAL_BASIS = 'https://w3id.org/oac#LegalBasis';
export const ACCESS = 'urn:solidlab:uma:claims:types:access';
1 change: 1 addition & 0 deletions packages/uma/src/credentials/Formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export const JWT = 'urn:solidlab:uma:claims:formats:jwt';
export const UNSECURE = 'urn:solidlab:uma:claims:formats:webid';
export const OIDC = 'http://openid.net/specs/openid-connect-core-1_0.html#IDToken';
export const ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token';
4 changes: 0 additions & 4 deletions packages/uma/src/credentials/Requirements.ts

This file was deleted.

80 changes: 61 additions & 19 deletions packages/uma/src/credentials/verify/OidcVerifier.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { createSolidTokenVerifier } from '@solid/access-token-verifier';
import { BadRequestHttpError, joinUrl } from '@solid/community-server';
import {
BadRequestHttpError,
ForbiddenHttpError,
InternalServerError,
joinUrl,
KeyValueStorage
} from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify, JWTVerifyOptions } from 'jose';
import { CLIENTID, WEBID } from '../Claims';
import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify } from 'jose';
import { AccessToken } from '../../tokens/AccessToken';
import { UMA_SCOPES } from '../../ucp/util/Vocabularies';
import { reType } from '../../util/ReType';
import { Permission } from '../../views/Permission';
import { ACCESS, CLIENTID, WEBID } from '../Claims';
import { ClaimSet } from '../ClaimSet';
import { Credential } from '../Credential';
import { OIDC } from '../Formats';
import { ACCESS_TOKEN, OIDC } from '../Formats';
import { Verifier } from './Verifier';

/**
* A Verifier for OIDC ID Tokens.
* A Verifier for OIDC Tokens.
*
* The `allowedIssuers` list can be used to only allow tokens from these issuers.
* Default is an empty list, which allows all issuers.
Expand All @@ -21,31 +31,33 @@ export class OidcVerifier implements Verifier {

public constructor(
protected readonly baseUrl: string,
protected readonly derivationStore: KeyValueStorage<string, string>,
protected readonly allowedIssuers: string[] = [],
protected readonly verifyOptions: Record<string, unknown> = {},
) {}

/** @inheritdoc */
public async verify(credential: Credential): Promise<ClaimSet> {
this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`);
if (credential.format !== OIDC) {
if (credential.format !== OIDC && credential.format !== ACCESS_TOKEN) {
throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`);
}

// We first need to determine if this is a Solid OIDC token or a standard one
const unsafeDecoded = decodeJwt(credential.token);
const isSolidToken = unsafeDecoded.aud === 'solid' ||
(Array.isArray(unsafeDecoded.aud) && unsafeDecoded.aud.includes('solid'));
const isSolidToken = (unsafeDecoded.aud === 'solid' ||
(Array.isArray(unsafeDecoded.aud) && unsafeDecoded.aud.includes('solid')))
&& typeof unsafeDecoded.webid === 'string';

try {
this.validateToken(unsafeDecoded);
if (isSolidToken) {
return await this.verifySolidToken(credential.token);
} else {
return await this.verifyStandardToken(credential.token, unsafeDecoded.iss!);
return await this.verifyStandardToken(credential.token, credential.format, unsafeDecoded.iss!);
}
} catch (error: unknown) {
const message = `Error verifying OIDC ID Token: ${(error as Error).message}`;
const message = `Error verifying OIDC Token: ${(error as Error).message}`;

this.logger.debug(message);
throw new BadRequestHttpError(message);
Expand Down Expand Up @@ -77,8 +89,8 @@ export class OidcVerifier implements Verifier {
});
}

protected async verifyStandardToken(token: string, issuer: string):
Promise<{ [WEBID]: string, [CLIENTID]?: string }> {
protected async verifyStandardToken(token: string, format: string, issuer: string):
Promise<{ [WEBID]?: string, [CLIENTID]?: string, [ACCESS]?: Permission[] }> {
const configUrl = joinUrl(issuer, '/.well-known/openid-configuration');
const configResponse = await fetch(configUrl);
if (configResponse.status !== 200) {
Expand All @@ -90,13 +102,43 @@ export class OidcVerifier implements Verifier {
}
const jwkSet = createRemoteJWKSet(new URL(config.jwks_uri));
const decoded = await jwtVerify(token, jwkSet, this.verifyOptions);
if (!decoded.payload.sub) {
throw new BadRequestHttpError('Invalid OIDC token: missing `sub` claim');

if (format === OIDC) {
if (!decoded.payload.sub) {
throw new BadRequestHttpError('Invalid OIDC ID token: missing `sub` claim');
}
const client = decoded.payload.azp as string | undefined;
return {
[WEBID]: decoded.payload.sub,
...client && { [CLIENTID]: client }
};
} else if (format === ACCESS_TOKEN) {
const iss = decoded.payload.iss;
// TODO: generalize this so the derivation-read specifics are not in this class
reType(decoded.payload, AccessToken);
const permissions: Permission[] = [];
for (const { resource_id: id, resource_scopes: scopes } of decoded.payload.permissions) {
// Need to make sure the token was issued by the corresponding issuer
if (scopes.includes(UMA_SCOPES['derivation-read'])) {
const issuer = await this.derivationStore.get(id);
if (!issuer) {
this.logger.warn(`Received access token for unknown aggregated id ${id}, ignoring permissions.`);
}
if (issuer !== iss) {
this.logger.warn(`Received access token for aggregated id ${id} with wrong issuer: ${iss
} instead of ${issuer}, rejection request.`);
throw new ForbiddenHttpError(`Invalid issuer for ${id}, expected ${issuer} but got ${iss}`);
}
permissions.push({ resource_id: id, resource_scopes: [ UMA_SCOPES['derivation-read']] });
} else {
// TODO: we could just accept the access permissions here, but this could potentially be unsafe
this.logger.warn(`Received unexpected permissions in access token: ${scopes}`);
}
}
return {
[ACCESS]: permissions,
}
}
const client = decoded.payload.azp as string | undefined;
return ({
[WEBID]: decoded.payload.sub,
...client && { [CLIENTID]: client }
});
throw new InternalServerError(`Unsupported claim format ${format}`);
}
}
61 changes: 61 additions & 0 deletions packages/uma/src/dialog/AggregatorNegotiator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { BadRequestHttpError, KeyValueStorage } from '@solid/community-server';
import { Ticket } from '../ticketing/Ticket';
import { UMA_SCOPES } from '../ucp/util/Vocabularies';
import { encodeAggregateId } from '../util/AggregatorUtil';
import { RegistrationStore } from '../util/RegistrationStore';
import { DialogInput } from './Input';
import { Negotiator } from './Negotiator';
import { DialogOutput } from './Output';

/**
* Ensures the `derivation_resource_id` is present in Token responses when required for aggregators.
*/
export class AggregatorNegotiator implements Negotiator {
public constructor(
protected readonly negotiator: Negotiator,
protected readonly ticketStore: KeyValueStorage<string, Ticket>,
protected readonly registrationStore: RegistrationStore,
) {}

public async negotiate(input: DialogInput): Promise<DialogOutput> {
const scopes = input.scope?.split(' ') ?? [];
// This class is only relevant for derivation-creation requests
if (!scopes.includes(UMA_SCOPES['derivation-creation'])) {
return this.negotiator.negotiate(input);
}

return this.negotiateDerivationCreation(input);
}

protected async negotiateDerivationCreation(input: DialogInput): Promise<DialogOutput> {
// This needs to happen first as the source negotiator might already delete the stored ticket
const ticket = await this.getTicket(input);
if (ticket.permissions.length !== 1) {
throw new BadRequestHttpError('Aggregate token requests require exactly 1 target resource identifier');
}
const resourceId = ticket.permissions[0].resource_id;

// Add the new derivation-creation scope to the token
ticket.permissions[0].resource_scopes.push(UMA_SCOPES['derivation-creation']);
await this.ticketStore.set(input.ticket!, ticket);

const result = await this.negotiator.negotiate(input);
const derivationId = await encodeAggregateId(resourceId);
return {
...result,
derivation_resource_id: derivationId,
}
}

protected async getTicket(input: DialogInput): Promise<Ticket> {
if (input.ticket) {
const ticket = await this.ticketStore.get(input.ticket);
if (!ticket) {
throw new BadRequestHttpError('Unknown ticket ID');
}
return ticket;
} else {
throw new BadRequestHttpError('Aggregators are only supported when using tickets.');
}
}
}
Loading