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
4 changes: 2 additions & 2 deletions packages/uma/src/credentials/ClaimSet.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

/**
* A Set of Claims about the Client.
*
*
* TODO: Might exchange this for a QuadStore?
*/
export type ClaimSet = NodeJS.Dict<unknown>;
export type ClaimSet = NodeJS.Dict<unknown[]>;
16 changes: 2 additions & 14 deletions packages/uma/src/credentials/Claims.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@

export const WEBID = 'urn:solidlab:uma:claims:types:webid';
export const CLIENTID = 'urn:solidlab:uma:claims:types:clientid';
export const ORIGINAL = 'urn:solidlab:uma:claims:types:original';
export const ORIGINAL_WEBID = 'urn:solidlab:uma:claims:types:original:webid';
export const ORIGINAL_CLIENTID = 'urn:solidlab:uma:claims:types:original: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';
export const VC = 'urn:solidlab:uma:claims:types:vc';

/**
* Resolves a claim value by preferring an ORIGINAL claim-set entry when present.
*/
export function getOriginalClaimValue(claims: NodeJS.Dict<unknown>, claimType: string): unknown {
const original = claims[ORIGINAL];
if (typeof original === 'object' && original !== null) {
const originalClaims = original as Record<string, unknown>;
return originalClaims[claimType];
}

return claims[claimType];
}
20 changes: 11 additions & 9 deletions packages/uma/src/credentials/verify/IriVerifier.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { joinUrl } from '@solid/community-server';
import { isIri } from '../../util/ConvertUtil';
import { CLIENTID, ORIGINAL, WEBID } from '../Claims';
import { CLIENTID, ORIGINAL_CLIENTID, ORIGINAL_WEBID, WEBID } from '../Claims';
import { ClaimSet } from '../ClaimSet';
import { Credential } from '../Credential';
import { Verifier } from './Verifier';
Expand All @@ -19,17 +19,19 @@ export class IriVerifier implements Verifier {
const result = { ...claims };

const original: Record<string, string> = {};
for (const claim of [WEBID, CLIENTID]) {
if (typeof claims[claim] === 'string' && !isIri(claims[claim])) {
result[claim] = joinUrl(this.baseUrl, encodeURIComponent(claims[claim]));
original[claim] = claims[claim];
for (const [ claimType, original ] of [[WEBID, ORIGINAL_WEBID], [CLIENTID, ORIGINAL_CLIENTID]]) {
if (Array.isArray(claims[claimType])) {
result[original] = [];
for (let i = 0; i < claims[claimType].length; i += 1) {
const entry = claims[claimType][i];
result[original].push(entry);
if (typeof entry === 'string' && !isIri(entry)) {
result[claimType]![i] = joinUrl(this.baseUrl, encodeURIComponent(entry));
}
}
}
}

if (Object.keys(original).length > 0) {
result[ORIGINAL] = original;
}

return result;
}
}
15 changes: 7 additions & 8 deletions packages/uma/src/credentials/verify/JwtVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class JwtVerifier implements Verifier {
}

const claims = decodeJwt(credential.token);
const result: ClaimSet = {};

if (this.verifyJwt) {
if (!claims.iss) {
Expand All @@ -38,16 +39,14 @@ export class JwtVerifier implements Verifier {
}

for (const claim of Object.keys(claims)) {
if (!this.allowedClaims.includes(claim)) {
if (this.errorOnExtraClaims) {
throw new Error(`Claim '${claim}' not allowed.`);
}

delete claims[claim];
if (this.allowedClaims.includes(claim)) {
result[claim] = Array.isArray(claims[claim]) ? claims[claim] : [claims[claim]];
} else if (this.errorOnExtraClaims) {
throw new Error(`Claim '${claim}' not allowed.`);
}
}

this.logger.debug(`Returning discovered claims: ${JSON.stringify(claims)}`)
return claims;
this.logger.debug(`Returning discovered claims: ${JSON.stringify(result)}`)
return result;
}
}
2 changes: 1 addition & 1 deletion packages/uma/src/credentials/verify/KeyValueVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class KeyValueVerifier implements Verifier {

public async verify(credential: Credential): Promise<ClaimSet> {
return {
[credential.format]: credential.token
[credential.format]: [ credential.token ],
}
}
}
13 changes: 6 additions & 7 deletions packages/uma/src/credentials/verify/OidcVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class OidcVerifier implements Verifier {
}
}

protected async verifySolidToken(token: string): Promise<{ [WEBID]: string, [CLIENTID]?: string }> {
protected async verifySolidToken(token: string): Promise<ClaimSet> {
const claims = await this.verifyToken(`Bearer ${token}`);
const issuers = this.verifyOptions.issuer;
const allowedIssuers = issuers !== undefined && (typeof issuers === 'string' ? [issuers] : issuers as string[]);
Expand All @@ -70,13 +70,12 @@ export class OidcVerifier implements Verifier {
return ({
// TODO: would have to use different value than "WEBID"
// TODO: still want to use WEBID as external value potentially?
[WEBID]: claims.webid,
...clientId && { [CLIENTID]: clientId }
[WEBID]: [ claims.webid ],
...clientId && { [CLIENTID]: [ clientId ] }
});
}

protected async verifyStandardToken(token: string, format: string, issuer: string):
Promise<{ [WEBID]?: string, [CLIENTID]?: string, [ACCESS]?: Permission[] }> {
protected async verifyStandardToken(token: string, format: string, issuer: string): Promise<ClaimSet> {
const jwkSet = await getJwks(issuer);
const decoded = await jwtVerify(token, jwkSet, this.verifyOptions);

Expand All @@ -86,8 +85,8 @@ export class OidcVerifier implements Verifier {
}
const client = decoded.payload.azp as string | undefined;
return {
[WEBID]: decoded.payload.sub,
...client && { [CLIENTID]: client }
[WEBID]: [ decoded.payload.sub ],
...client && { [CLIENTID]: [ client ] }
};
} else if (format === ACCESS_TOKEN) {
const iss = decoded.payload.iss;
Expand Down
6 changes: 3 additions & 3 deletions packages/uma/src/credentials/verify/UnsecureVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export class UnsecureVerifier implements Verifier {
}

try {
const claims = {
[WEBID]: new URL(decodeURIComponent(raw[0])).toString(),
[CLIENTID]: raw.length === 2 && new URL(decodeURIComponent(raw[1])).toString()
const claims: ClaimSet = {
[WEBID]: [ new URL(decodeURIComponent(raw[0])).toString() ],
...raw.length === 2 && { [CLIENTID]: [ new URL(decodeURIComponent(raw[1])).toString() ] }
};

this.logger.info(`Authenticated as via unsecure verifier. ${JSON.stringify(claims)}`);
Expand Down
8 changes: 2 additions & 6 deletions packages/uma/src/credentials/verify/VcVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,10 @@ export class VcVerifier implements Verifier {
const jwkSet = await getJwks(unsafeDecoded.iss);
const decoded = await jwtVerify(credential.token, jwkSet, this.verifyOptions);

// TODO: could extract all entries as separate jsonpath claims
// TODO: currently only a single VC as input is accepted,
// if the client provides multiple these would override each other
const claims = this.extractVcClaims(decoded.payload);
return { [VC]: claims };
return { [VC]: [ this.extractVcClaims(decoded.payload) ] };
}

protected extractVcClaims(payload: JWTPayload): ClaimSet {
protected extractVcClaims(payload: JWTPayload): Record<string, unknown> {
if (!payload.vc || typeof payload.vc !== 'object') {
throw new BadRequestHttpError(`Token is missing the vc claim.`);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/uma/src/dialog/BaseNegotiator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BadRequestHttpError, ForbiddenHttpError, HttpErrorClass, KeyValueStorage } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { randomUUID } from 'node:crypto';
import { getOriginalClaimValue, WEBID } from '../credentials/Claims';
import { ORIGINAL_WEBID, WEBID } from '../credentials/Claims';
import { Verifier } from '../credentials/verify/Verifier';
import { NeedInfoError, RequiredClaim } from '../errors/NeedInfoError';
import { getOperationLogger } from '../logging/OperationLogger';
Expand Down Expand Up @@ -61,12 +61,12 @@ export class BaseNegotiator implements Negotiator {
// ... on success, create Access Token
if (resolved.success) {
const partial = this.isPartialResult(updatedTicket.permissions, resolved.value);
const tokenSub = getOriginalClaimValue(updatedTicket.provided, WEBID);
const tokenSubs = updatedTicket.provided[ORIGINAL_WEBID] || updatedTicket.provided[WEBID];

// Retrieve / create instantiated policy
const { token, tokenType } = await this.tokenFactory.serialize({
permissions: resolved.value,
...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}),
...(Array.isArray(tokenSubs) && typeof tokenSubs[0] === 'string' ? { sub: tokenSubs[0] } : {}),
});
this.logger.debug(`Minted token ${JSON.stringify(token)}`);

Expand Down
6 changes: 3 additions & 3 deletions packages/uma/src/dialog/ContractNegotiator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createErrorMessage, KeyValueStorage } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { getOriginalClaimValue, WEBID } from '../credentials/Claims';
import { ORIGINAL_WEBID, WEBID } from '../credentials/Claims';
import { Verifier } from '../credentials/verify/Verifier';
import { RequiredClaim } from '../errors/NeedInfoError';
import { ContractManager } from '../policies/contracts/ContractManager';
Expand Down Expand Up @@ -149,13 +149,13 @@ export class ContractNegotiator extends BaseNegotiator {
let permissions: Permission[] = Object.values(permissionMap);
this.logger.debug(`granting permissions: ${JSON.stringify(permissions)}`);

const tokenSub = getOriginalClaimValue(ticket.provided, WEBID);
const tokenSubs = ticket.provided[ORIGINAL_WEBID] || ticket.provided[WEBID];

// Create response
const tokenContents: AccessToken = {
permissions,
contract,
...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}),
...(Array.isArray(tokenSubs) && typeof tokenSubs[0] === 'string' ? { sub: tokenSubs[0] } : {}),
};

this.logger.debug(`resolved result ${JSON.stringify(contract)}`);
Expand Down
39 changes: 22 additions & 17 deletions packages/uma/src/policies/authorizers/OdrlAuthorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,28 @@ export class OdrlAuthorizer implements Authorizer {
literal(new Date().toISOString(), namedNode("http://www.w3.org/2001/XMLSchema#dateTime"))),
);

const subject = typeof claims[WEBID] === 'string' ? claims[WEBID] : 'urn:solidlab:uma:id:anonymous';
let subjects = claims[WEBID] && claims[WEBID].filter(id => typeof id === 'string');
if (!subjects || subjects.length === 0) {
subjects = [ 'urn:solidlab:uma:id:anonymous' ];
}
const claimContextConstraints: { subject: ReturnType<typeof blankNode>; quads: Quad[] }[] = [];
for (const [ key, value ] of Object.entries(claims)) {
const leftOperand = claimOperandMap[key] ?? key;
if (!isIri(leftOperand) || typeof value !== 'string') {
continue;
for (const [ key, values ] of Object.entries(claims)) {
for (const value of values ?? []) {
const leftOperand = claimOperandMap[key] ?? key;
if (!isIri(leftOperand) || typeof value !== '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(value)),
],
});
}
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(value)),
],
});
}

for (const { resource_id, resource_scopes } of query) {
Expand All @@ -101,14 +106,14 @@ export class OdrlAuthorizer implements Authorizer {
// IMO this should either happen on the RS,
// or the policies should just use the "CSS" modes (not really though)
const action = scopeCssToOdrl.get(scope) ?? scope;
this.logger.info(`Evaluating Request [S R AR]: [${subject} ${resource_id} ${action}]`);
this.logger.info(`Evaluating Request [S R AR]: [${subjects.join(', ')} ${resource_id} ${action}]`);
const requestPolicy: UCPPolicy = {
type: ODRL.Request,
rules: [
{
action: action,
resource: resource_id,
requestingParty: subject
requestingParty: subjects
}
]
}
Expand Down
47 changes: 28 additions & 19 deletions packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,13 @@ export class SimpleOdrlAuthorizer implements Authorizer {
return [];
}

let user = claims[WEBID];
let assignees: NamedNode[] = [ ANONYMOUS ];
if (typeof user === 'string') {
const userNode = DF.namedNode(user);
assignees.push(userNode);
assignees.push(...(policies.getObjects(user, ODRL.terms.partOf, null) as NamedNode[]));
for (const user of claims[WEBID] ?? []) {
if (typeof user === 'string') {
const userNode = DF.namedNode(user);
assignees.push(userNode);
assignees.push(...(policies.getObjects(user, ODRL.terms.partOf, null) as NamedNode[]));
}
}
rules = rules.filter(rule => {
const ruleAssignees = policies.getObjects(rule, ODRL.terms.assignee, null);
Expand Down Expand Up @@ -173,6 +174,7 @@ export class SimpleOdrlAuthorizer implements Authorizer {
// TODO: would want middleware step where credentials and other stuff are already extracted into RDF values
// so both ODRL authorizers don't have to bother with this
for (const constraint of constraints) {
const claimValues = claims[constraint.leftOperand.value];
// Return undefined if any of these are too complex or unknown
if (constraint.leftOperand.equals(ODRL.terms.dateTime)) {
const comparisonDate = new Date(constraint.rightOperand.value);
Expand All @@ -188,13 +190,11 @@ export class SimpleOdrlAuthorizer implements Authorizer {
if (!constraint.operator.equals(ODRL.terms.eq)) {
return false;
}
const claimValue = claims[claimKey];
if (typeof claimValue !== 'string' || constraint.rightOperand.value !== claimValue) {
if (!claims[claimKey]?.some(claim => claim === constraint.rightOperand.value)) {
return false;
}
} else if (typeof claims[constraint.leftOperand.value] === 'string'
&& constraint.operator.equals(ODRL.terms.eq)) {
if (constraint.rightOperand.value !== claims[constraint.leftOperand.value]) {
} else if (claimValues?.every(claim => typeof claim === 'string') && constraint.operator.equals(ODRL.terms.eq)) {
if (!claimValues?.some(claim => claim === constraint.rightOperand.value)) {
return false;
}
} else {
Expand All @@ -217,9 +217,12 @@ export class SimpleOdrlAuthorizer implements Authorizer {
if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) {
return;
}
if (constraints.length === 0) {
return true;
}
// Can't match a VC constraint if there is no VC input
const vc = claims[VC];
if (constraints.length > 0 && typeof vc !== 'object') {
const vcs = claims[VC];
if (!vcs || vcs?.length === 0) {
return false;
}

Expand All @@ -228,15 +231,21 @@ export class SimpleOdrlAuthorizer implements Authorizer {
if (!constraint.operator.equals(ODRL.terms.eq)) {
return;
}
const results = jp.query(vc, constraint.leftOperand.value).flat();
if (!results.some(result => result === constraint.rightOperand.value)) {
return false;
}
if (constraint.credentialSubjectType) {
const types = jp.query(vc, '$.type').flat();
if (!types.some(typ => constraint.credentialSubjectType.value === typ)) {
const foundMatchedVc = vcs.some(vc => {
const results = jp.query(vc, constraint.leftOperand.value).flat();
if (!results.some(result => result === constraint.rightOperand.value)) {
return false;
}
if (constraint.credentialSubjectType) {
const types = jp.query(vc, '$.type').flat();
if (!types.some(typ => constraint.credentialSubjectType.value === typ)) {
return false;
}
}
return true;
});
if (!foundMatchedVc) {
return false;
}
}

Expand Down
6 changes: 4 additions & 2 deletions packages/uma/src/policies/authorizers/WebIdAuthorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ export class WebIdAuthorizer implements Authorizer {
public async permissions(claims: ClaimSet, query?: Partial<Permission>[]): Promise<Permission[]> {
this.logger.info(`Calculating permissions. ${JSON.stringify({ claims, query })}`);

const webid = claims[WEBID];
const webids = claims[WEBID];

if (!(typeof webid === 'string' && this.webids.includes(webid))) return [];
if (!Array.isArray(webids) || !webids.some(webId => typeof webId === 'string' && this.webids.includes(webId))) {
return [];
}

return (query ?? []).map(
(permission): Permission => ({
Expand Down
Loading
Loading