Skip to content

Commit 1350d88

Browse files
committed
feat: Support VC claims
1 parent 0533c03 commit 1350d88

11 files changed

Lines changed: 528 additions & 96 deletions

File tree

packages/uma/config/credentials/verifiers/default.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
"derivationStore": { "@id": "urn:uma:default:DerivationStore" }
3636
}
3737
},
38+
{
39+
"TypedVerifier:_verifiers_key": "application/vc+jwt",
40+
"TypedVerifier:_verifiers_value": {
41+
"@id": "urn:uma:default:VcVerifier",
42+
"@type": "VcVerifier"
43+
}
44+
},
3845
{
3946
"TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:access_token",
4047
"TypedVerifier:_verifiers_value": { "@id": "urn:uma:default:OidcVerifier" }

packages/uma/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@
6262
"@httpland/authorization-parser": "^1.1.0",
6363
"@solid/access-token-verifier": "^1.2.0",
6464
"@solid/community-server": "^8.0.0-alpha.1",
65+
"@types/jsonpath": "^0.2.4",
6566
"@types/ms": "^2.1.0",
6667
"@types/n3": "^1.16.4",
6768
"asynchronous-handlers": "^1.0.2",
6869
"componentsjs": "^6.3.0",
6970
"global-logger-factory": "^1.0.0",
7071
"http-message-signatures": "^1.0.4",
7172
"jose": "^5.2.2",
73+
"jsonpath": "^1.3.0",
7274
"logform": "^2.6.0",
7375
"ms": "^2.1.3",
7476
"n3": "^1.17.2",

packages/uma/src/credentials/Claims.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ export const ORIGINAL = 'urn:solidlab:uma:claims:types:original';
55
export const PURPOSE = 'http://www.w3.org/ns/odrl/2/purpose';
66
export const LEGAL_BASIS = 'https://w3id.org/oac#LegalBasis';
77
export const ACCESS = 'urn:solidlab:uma:claims:types:access';
8+
export const VC = 'urn:solidlab:uma:claims:types:vc';
89

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

1920
return claims[claimType];
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { BadRequestHttpError } from '@solid/community-server';
2+
import { getLoggerFor } from 'global-logger-factory';
3+
import { decodeJwt, JWTPayload, jwtVerify } from 'jose';
4+
import { getJwks } from '../../util/JwtUtil';
5+
import { VC } from '../Claims';
6+
import { ClaimSet } from '../ClaimSet';
7+
import { Credential } from '../Credential';
8+
import { VC_JWT } from '../Formats';
9+
import { Verifier } from './Verifier';
10+
11+
// TODO:
12+
13+
// TODO: implementation probably based too much on current example format
14+
15+
/**
16+
* A Verifier for VC Tokens.
17+
*
18+
* To only allow tokens from certain options, set `verifyOptions` to { issuer: [ 'http://example.com/' ] }.
19+
*/
20+
export class VcVerifier implements Verifier {
21+
protected readonly logger = getLoggerFor(this);
22+
23+
public constructor(
24+
protected readonly verifyOptions: Record<string, unknown> = {},
25+
) {}
26+
27+
public async verify(credential: Credential): Promise<ClaimSet> {
28+
this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`);
29+
if (credential.format !== VC_JWT) {
30+
throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`);
31+
}
32+
33+
const unsafeDecoded = decodeJwt(credential.token);
34+
if (!unsafeDecoded.iss) {
35+
throw new BadRequestHttpError(`Token is missing the issuer claim.`);
36+
}
37+
38+
const jwkSet = await getJwks(unsafeDecoded.iss);
39+
const decoded = await jwtVerify(credential.token, jwkSet, this.verifyOptions);
40+
41+
// TODO: could extract all entries as separate jsonpath claims
42+
// TODO: currently only a single VC as input is accepted,
43+
// if the client provides multiple these would override each other
44+
const claims = this.extractVcClaims(decoded.payload);
45+
return { [VC]: claims };
46+
}
47+
48+
protected extractVcClaims(payload: JWTPayload): ClaimSet {
49+
if (!payload.vc || typeof payload.vc !== 'object') {
50+
throw new BadRequestHttpError(`Token is missing the vc claim.`);
51+
}
52+
53+
const vc = payload.vc as Record<string, unknown>;
54+
if (typeof vc.issuanceDate === 'string' && new Date(vc.issuanceDate) > new Date()) {
55+
throw new BadRequestHttpError(`VC is not yet valid, issued at ${vc.issuanceDate}.`);
56+
}
57+
if (typeof vc.validFrom === 'string' && new Date(vc.validFrom) > new Date()) {
58+
throw new BadRequestHttpError(`VC is not yet valid, valid from ${vc.validFrom}.`);
59+
}
60+
61+
if (typeof vc.expirationDate === 'string' && new Date(vc.expirationDate) < new Date()) {
62+
throw new BadRequestHttpError(`VC expired at ${vc.expirationDate}.`);
63+
}
64+
if (typeof vc.validUntil === 'string' && new Date(vc.validUntil) < new Date()) {
65+
throw new BadRequestHttpError(`VC expired at ${vc.validUntil}.`);
66+
}
67+
68+
if (typeof vc.credentialSubject !== 'object') {
69+
throw new BadRequestHttpError(`VC is missing the credentialSubject claim.`);
70+
}
71+
72+
this.logger.debug(`Validated VC claims ${JSON.stringify(payload)}`);
73+
74+
return vc;
75+
}
76+
}

packages/uma/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './credentials/verify/JwtVerifier';
1717
export * from './credentials/verify/IriVerifier';
1818
export * from './credentials/verify/RefreshTokenVerifier';
1919
export * from './credentials/verify/KeyValueVerifier';
20+
export * from './credentials/verify/VcVerifier';
2021

2122
// Dialog
2223
export * from './dialog/AggregatorNegotiator';

packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import { NamedNode } from '@rdfjs/types';
22
import { getLoggerFor } from 'global-logger-factory';
33
import { DataFactory as DF, Quad_Subject, Store } from 'n3';
44
import { ODRL } from 'odrl-evaluator';
5-
import { CLIENTID, PURPOSE, WEBID } from '../../credentials/Claims';
5+
import { CLIENTID, PURPOSE, VC, WEBID } from '../../credentials/Claims';
66
import { ClaimSet } from '../../credentials/ClaimSet';
77
import { ReadOnlyStore, UCRulesStorage } from '../../ucp/storage/UCRulesStorage';
8+
import { OVC } from '../../ucp/util/Vocabularies';
9+
import { array, reType, string } from '../../util/ReType';
810
import { Permission } from '../../views/Permission';
911
import { Authorizer } from './Authorizer';
12+
import jp from 'jsonpath';
1013

1114
const ANONYMOUS = DF.namedNode('urn:solidlab:uma:id:anonymous');
1215

@@ -28,7 +31,7 @@ const dateComparators: NodeJS.Dict<(a: Date, b: Date) => boolean> = {
2831
};
2932

3033
const claimOperandMap: Record<string, string> = {
31-
[ODRL.deliveryChannel]: CLIENTID,
34+
[ODRL.deliveryChannel]: CLIENTID,
3235
} as const;
3336

3437
/**
@@ -109,18 +112,19 @@ export class SimpleOdrlAuthorizer implements Authorizer {
109112
}
110113
return ruleAssignees.some(ruleAssignee => assignees.some(assignee => assignee.equals(ruleAssignee)));
111114
});
112-
this.logger.warn('Rejecting request because no rules with a matching assignee or party collection were found');
113115
if (rules.length === 0) {
116+
this.logger.warn('Rejecting request because no rules with a matching assignee or party collection were found');
114117
return [];
115118
}
116119

117120
// Check simple constraints
118121
const validRules: Quad_Subject[] = [];
119122
for (const rule of rules) {
120123
const constraintResponse = this.validateConstraints(rule, policies, claims);
121-
if (constraintResponse === true) {
124+
const vcConstraintResponse = this.validateOvcConstraints(rule, policies, claims);
125+
if (constraintResponse && vcConstraintResponse) {
122126
validRules.push(rule);
123-
} else if (constraintResponse === undefined) {
127+
} else if (constraintResponse === undefined || vcConstraintResponse === undefined) {
124128
return;
125129
}
126130
}
@@ -201,4 +205,42 @@ export class SimpleOdrlAuthorizer implements Authorizer {
201205
}
202206
return true;
203207
}
208+
209+
// https://gitlab.com/gaia-x/lab/policy-reasoning/odrl-vc-profile
210+
protected validateOvcConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined {
211+
const constraints = policies.getObjects(rule, OVC.terms.constraint, null).map(constraint => ({
212+
leftOperand: policies.getObjects(constraint, OVC.terms.leftOperand, null)[0],
213+
operator: policies.getObjects(constraint, ODRL.terms.operator, null)[0],
214+
rightOperand: policies.getObjects(constraint, ODRL.terms.rightOperand, null)[0],
215+
credentialSubjectType: policies.getObjects(constraint, OVC.terms.credentialSubjectType, null)[0],
216+
}));
217+
// If any of these are undefined this is too complex to handle here (credentialSubjectType can be undefined)
218+
if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) {
219+
return;
220+
}
221+
// Can't match a VC constraint if there is no VC input
222+
const vc = claims[VC];
223+
if (constraints.length > 0 && typeof vc !== 'object') {
224+
return false;
225+
}
226+
227+
for (const constraint of constraints) {
228+
// Only support odrl:eq for now
229+
if (!constraint.operator.equals(ODRL.terms.eq)) {
230+
return;
231+
}
232+
const results = jp.query(vc, constraint.leftOperand.value).flat();
233+
if (!results.some(result => result === constraint.rightOperand.value)) {
234+
return false;
235+
}
236+
if (constraint.credentialSubjectType) {
237+
const types = jp.query(vc, '$.type').flat();
238+
if (!types.some(typ => constraint.credentialSubjectType.value === typ)) {
239+
return false;
240+
}
241+
}
242+
}
243+
244+
return true;
245+
}
204246
}

packages/uma/src/ucp/util/Vocabularies.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,49 @@ import { createVocabulary, extendVocabulary } from 'rdf-vocabulary';
44
export const DC = extendVocabulary(DC_CSS,'creator');
55

66
export const ODRL = createVocabulary(
7-
'http://www.w3.org/ns/odrl/2/',
8-
'AssetCollection',
9-
'Agreement',
10-
'Offer',
11-
'Permission',
12-
'Prohibition',
13-
'Duty',
14-
'Request',
15-
'Constraint',
16-
'source',
17-
'partOf',
18-
'action',
19-
'target',
20-
'assignee',
21-
'assigner',
22-
'constraint',
23-
'operator',
24-
'permission',
25-
'prohibition',
26-
'duty',
27-
'dateTime',
28-
'purpose',
29-
'leftOperand',
30-
'rightOperand',
31-
'gt',
32-
'lt',
33-
'eq',
34-
'uid',
35-
'read',
7+
'http://www.w3.org/ns/odrl/2/',
8+
'AssetCollection',
9+
'Agreement',
10+
'Offer',
11+
'Permission',
12+
'Prohibition',
13+
'Duty',
14+
'Request',
15+
'Constraint',
16+
'source',
17+
'partOf',
18+
'action',
19+
'target',
20+
'assignee',
21+
'assigner',
22+
'constraint',
23+
'operator',
24+
'permission',
25+
'prohibition',
26+
'duty',
27+
'dateTime',
28+
'purpose',
29+
'leftOperand',
30+
'rightOperand',
31+
'gt',
32+
'lt',
33+
'eq',
34+
'uid',
35+
'read',
3636
);
3737

3838
export const ODRL_P = createVocabulary(
3939
'https://w3id.org/force/odrl3proposal#',
4040
'relation',
4141
);
4242

43+
export const OVC = createVocabulary(
44+
'https://w3id.org/gaia-x/ovc/1/',
45+
'constraint',
46+
'credentialSubjectType',
47+
'leftOperand',
48+
);
49+
4350
export const OWL = createVocabulary(
4451
'http://www.w3.org/2002/07/owl#',
4552
'inverseOf',

0 commit comments

Comments
 (0)