diff --git a/documentation/getting-started.md b/documentation/getting-started.md index 3356e800..f61ec12b 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -31,7 +31,7 @@ so some information might change depending on which version and branch you're us * [Authenticating as Resource Owner](#authenticating-as-resource-owner) * [Authenticating as Resource Server](#authenticating-as-resource-server) + [Requesting client credentials](#requesting-client-credentials) - + [Sending the credentials to the RS (CSS specific)](#sending-the-credentials-to-the-rs--css-specific-) + + [Sending the credentials to the RS (CSS specific)](#sending-the-credentials-to-the-rs-css-specific) + [Requesting a PAT as RS](#requesting-a-pat-as-rs) * [Resource registration](#resource-registration) + [About identifiers](#about-identifiers) @@ -49,7 +49,10 @@ so some information might change depending on which version and branch you're us - [Include `sub` claim in access token](#include-sub-claim-in-access-token) + [Use token](#use-token) * [Policies](#policies) - + [Client application identification](#client-application-identification) + + [Additional constraints](#additional-constraints) + - [Client application identification](#client-application-identification) + - [Purpose](#purpose) + - [Verifiable Credentials](#verifiable-credentials) * [Adding or changing policies](#adding-or-changing-policies) * [Policy backups](#policy-backups) * [Data aggregation](#data-aggregation) @@ -363,9 +366,47 @@ This base URL will be updated in the future once we have settled on a fixed valu ##### Additional claims Besides claims that identify the user and client, additional claims can also be provided containing additional information. -Currently, the only additional claim that is supported is the `purpose` claim. -This can be provided with a `claim_token_format` of `http://www.w3.org/ns/odrl/2/purpose`, -and with the `claim_token` being the IRI of the purpose. +These claims can be used to add extra constraints to ODRL policies. + +The purpose claim can be provided with a `claim_token_format` of `http://www.w3.org/ns/odrl/2/purpose`, +and with the `claim_token` being the IRI of the purpose, +e.g., `"https://w3id.org/dpv#ScientificResearch"`. + +Verifiable Credentials (VCs) can also be provided as additional claims using the token format `urn:solidlab:uma:credentials:jwt:vc`. +The `claim_token` should be a signed JWT containing a `vc` claim with the credential data. +For example, a decoded payload could be: +```json +{ + "exp": 1784989353, + "nbf": 1784902953, + "jti": "urn:uuid:fecdb1de-d2d8-40a1-90ff-0e3cc8bfa3e9", + "iss": "http://localhost:28080/alice", + "vc": { + "type": [ + "VerifiableCredential", + "http://localhost:28080/UserHcpRelationVC" + ], + "issuer": "http://localhost:28080/alice", + "issuanceDate": "2026-07-24T14:22:33.924Z", + "expirationDate": "2026-07-25T14:22:33.924Z", + "credentialSubject": { + "principal": "alice", + "rs:HCPs": { + "@id": "ex:users/0", + "rs:assignedPatientId": "https://ex.example.com/users/123" + }, + "@id": "https://rs.example.com/named-graphs#qr-data", + "@context": { + "rs": "https://rs.example.com/vocab#", + "ex": "https://ex.example.com/" + } + }, + "@context": [ + "https://www.w3.org/ns/credentials/v1" + ] + } +} +``` #### Customizing OIDC verification @@ -481,15 +522,21 @@ ex:permission a odrl:Permission ; ``` This policy says that the above WebID has access to the `create` scope on ``. -### Client application identification +### Additional constraints + +Beyond specifying which user and resource a permission applies to, +policies can also include constraints that add further conditions. +Constraints use the `odrl:constraint` predicate and follow the general pattern of +a `odrl:leftOperand`, `odrl:operator`, and `odrl:rightOperand`, +where the left operand identifies what to check, the operator defines how to compare, +and the right operand provides the expected value. -It is possible to create policies that restrict access based on the client application being used. -This can only be done when using an OIDC ID token for authentication. -The `azp` claim of the token will be used. +#### Client application identification -To restrict a policy to a certain client application, -a constraint needs to be added to the policy. -For this, we use the odrl:deliveryChannel left operand. +It is possible to restrict access based on the client application being used. +This can only be done when using an OIDC ID token for authentication, +and uses the `azp` or `client_id` claim of the token. +The left operand `odrl:deliveryChannel` is used to refer to the client application. To restrict a policy to only permit access when using the application `http://example.com/client`, the policy should look as follows: @@ -510,6 +557,75 @@ ex:constraint odrl:leftOperand odrl:deliveryChannel ; odrl:rightOperand . ``` +#### Purpose + +It is possible to restrict access based on the purpose for which the resource is being accessed. +This requires that the client provides a purpose claim during the token exchange, +as described in the [Additional claims](#additional-claims) section above. +The left operand `odrl:purpose` is used to refer to the provided purpose. + +To restrict a policy to only permit access for the purpose `https://w3id.org/dpv#ScientificResearch`: +```ttl +@prefix ex: . +@prefix odrl: . + +ex:usagePolicy a odrl:Agreement ; + odrl:uid ex:usagePolicy ; + odrl:permission ex:permission . +ex:permission a odrl:Permission ; + odrl:action odrl:read ; + odrl:target ; + odrl:assignee ; + odrl:constraint ex:constraint . +ex:constraint odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand . +``` + +#### Verifiable Credentials + +Policies can also restrict access based on attributes contained in a Verifiable Credential. +This requires that the client provides a VC claim during the token exchange, +as described in the [Additional claims](#additional-claims) section above. + +VC constraints follow the [ODRL VC profile](https://gitlab.com/gaia-x/lab/policy-reasoning/odrl-vc-profile) +and use a slightly different vocabulary. +The predicate `ovc:constraint` is used instead of `odrl:constraint`, +and `ovc:leftOperand` instead of `odrl:leftOperand`, +where the left operand value is a JSONPath expression evaluated against the credential data. +The `ovc:rightOperand` still works the same as in standard ODRL constraints. + +To restrict access to users whose VC contains +`rs:assignedPatientId: "https://ex.example.com/users/123"` +inside the `rs:HCPs` object of the credential subject: +```ttl +@prefix ex: . +@prefix odrl: . +@prefix ovc: . + +ex:usagePolicy a odrl:Agreement ; + odrl:uid ex:usagePolicy ; + odrl:permission ex:permission . +ex:permission a odrl:Permission ; + odrl:action odrl:read ; + odrl:target ; + ovc:constraint ex:constraint . +ex:constraint ovc:leftOperand "$.credentialSubject['rs:HCPs']['rs:assignedPatientId']" ; + odrl:operator odrl:eq ; + odrl:rightOperand "https://ex.example.com/users/123" . +``` + +Optionally, you can also require the credential to be of a specific type by adding +the `ovc:credentialSubjectType` property. +This requires the `type` field in the credential to contain the specified value: +```ttl +ex:constraint ovc:leftOperand "$.credentialSubject['rs:HCPs']['rs:assignedPatientId']" ; + odrl:operator odrl:eq ; + odrl:rightOperand "https://ex.example.com/users/123" ; + ovc:credentialSubjectType "http://localhost:28080/UserHcpRelationVC" . +``` + + ## Adding or changing policies For more details, see the [policy management API documentation](policy-management.md). diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index 1a67b1d6..2e166076 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -28,6 +28,5 @@ export * from './util/fetch/Fetcher'; export * from './util/fetch/BaseFetcher'; export * from './util/fetch/PausableFetcher'; export * from './util/fetch/RetryingFetcher'; -export * from './util/fetch/SignedFetcher'; export * from './util/fetch/StatusDependant'; export * from './util/fetch/StatusDependantServerConfigurator'; diff --git a/packages/css/src/util/fetch/SignedFetcher.ts b/packages/css/src/util/fetch/SignedFetcher.ts deleted file mode 100644 index 5a14f4cc..00000000 --- a/packages/css/src/util/fetch/SignedFetcher.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { InternalServerError, type JwkGenerator } from '@solid/community-server'; -import { getLoggerFor } from 'global-logger-factory'; -import { httpbis, type SigningKey } from 'http-message-signatures'; -import { BufferSource } from 'node:stream/web'; -import type { Fetcher, FetchParams } from './Fetcher'; - -const algMap = { - 'Ed25519': { name: 'Ed25519' }, - 'ES256': { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }, - 'ES384': { name: 'ECDSA', namedCurve: 'P-384', hash: 'SHA-384' }, - 'ES512': { name: 'ECDSA', namedCurve: 'P-512', hash: 'SHA-512' }, - 'HS256': { name: 'HMAC', hash: 'SHA-256' }, - 'HS384': { name: 'HMAC', hash: 'SHA-384' }, - 'HS512': { name: 'HMAC', hash: 'SHA-512' }, - 'PS256': { name: 'RSASSA-PSS', hash: 'SHA-256' }, - 'PS384': { name: 'RSASSA-PSS', hash: 'SHA-384' }, - 'PS512': { name: 'RSASSA-PSS', hash: 'SHA-512' }, - 'RS256': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - 'RS384': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' }, - 'RS512': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }, -} as const; - -/** - * A {@link Fetcher} wrapper that signes requests. - */ -export class SignedFetcher implements Fetcher { - protected readonly logger = getLoggerFor(this); - - constructor( - protected fetcher: Fetcher, - protected baseUrl: string, - protected keyGen: JwkGenerator, - ) {} - - public async fetch(...[ input, init ]: FetchParams): Promise { - const jwk = await this.keyGen.getPrivateKey(); - - const { alg, kid } = jwk; - if (alg === 'EdDSA') throw new InternalServerError('EdDSA signing is not supported'); - if (alg === 'ML-DSA-44') throw new InternalServerError('ML-DSA-44 signing is not supported'); - if (alg === 'ML-DSA-65') throw new InternalServerError('ML-DSA-65 signing is not supported'); - if (alg === 'ML-DSA-87') throw new InternalServerError('ML-DSA-87 signing is not supported'); - - const key: SigningKey = { - id: kid, - alg: alg, - async sign(data: BufferSource) { - const params = algMap[alg]; - const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['sign']); - return Buffer.from(await crypto.subtle.sign(params, key, data)); - }, - }; - - const url = input instanceof URL ? input.href : input instanceof Request ? input.url : input as string; - - const request = { - ...init ?? {}, - url, - method: init?.method ?? 'GET', - headers: {} as Record, - }; - new Headers(init?.headers).forEach((value, key) => request.headers[key] = value); - request.headers['Authorization'] = `HttpSig cred="${this.baseUrl}"`; - - const signed = await httpbis.signMessage({ - key, - fields: [ '@target-uri', '@method' ], - paramValues: { keyid: 'TODO' } - }, request); - - return await this.fetcher.fetch(url, signed); - } -} diff --git a/packages/css/test/unit/util/fetch/SignedFetcher.test.ts b/packages/css/test/unit/util/fetch/SignedFetcher.test.ts deleted file mode 100644 index c4958fb5..00000000 --- a/packages/css/test/unit/util/fetch/SignedFetcher.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { JwkGenerator } from '@solid/community-server'; -import { httpbis, SignConfig } from 'http-message-signatures'; -import { Mocked } from 'vitest'; -import { Fetcher } from '../../../../src/util/fetch/Fetcher'; -import { SignedFetcher } from '../../../../src/util/fetch/SignedFetcher'; - -vi.mock('http-message-signatures', () => ({ - httpbis: { signMessage: vi.fn() } -})); - -vi.mock('node:crypto', () => ({ - webcrypto: {}, -})); - -describe('SignedFetcher', (): void => { - const baseUrl = 'http://example.com'; - const jwk = { alg: 'ES256', kid: 'kid' }; - const signedMessage = 'signed'; - const signMessage = vi.mocked(httpbis.signMessage); - let keyGen: Mocked; - let source: Mocked; - let fetcher: SignedFetcher; - - beforeEach(async(): Promise => { - signMessage.mockClear(); - signMessage.mockResolvedValue(signedMessage as any); - - keyGen = { - alg: 'ES256', - getPrivateKey: vi.fn().mockResolvedValue(jwk), - getPublicKey: vi.fn(), - }; - - source = { - fetch: vi.fn().mockResolvedValue('result'), - }; - - fetcher = new SignedFetcher(source, baseUrl, keyGen); - }); - - it('performs the fetch with the signed request.', async(): Promise => { - await expect(fetcher.fetch('http://example.com', { method: 'DELETE', headers: { accept: 'text/turtle' } })).resolves.toBe('result'); - expect(signMessage).toHaveBeenCalledTimes(1); - expect(signMessage).toHaveBeenLastCalledWith( - { - key: expect.objectContaining({ alg: 'ES256', id: 'kid' }), - fields: [ '@target-uri', '@method' ], - paramValues: { keyid: 'TODO' } - }, - { - url: 'http://example.com', - method: 'DELETE', - headers: { accept: 'text/turtle', Authorization: 'HttpSig cred="http://example.com"' }, - }, - ); - expect(source.fetch).toHaveBeenCalledTimes(1); - expect(source.fetch).toHaveBeenLastCalledWith('http://example.com', 'signed'); - - // Testing the internal sign function - const importKeyMock = vitest.spyOn(crypto.subtle, 'importKey').mockResolvedValueOnce('key!' as any); - const signMock = vitest.spyOn(crypto.subtle, 'sign').mockResolvedValueOnce('signed!' as any); - const params = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }; - - const sign = (signMessage.mock.calls[0][0] as SignConfig).key.sign; - const data = Buffer.from('data'); - await expect(sign(data)).resolves.toEqual(Buffer.from('signed!')); - expect(importKeyMock).toHaveBeenCalledTimes(1); - expect(importKeyMock) - .toHaveBeenLastCalledWith('jwk', jwk, params, false, ['sign']); - expect(signMock).toHaveBeenCalledTimes(1); - expect(signMock).toHaveBeenLastCalledWith(params, 'key!', data); - }); -}); diff --git a/packages/uma/config/credentials/validators/http-message.json b/packages/uma/config/credentials/validators/http-message.json deleted file mode 100644 index da88fd28..00000000 --- a/packages/uma/config/credentials/validators/http-message.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "@context": [ - "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld" - ], - "@graph": [ - { - "@id": "urn:uma:default:RequestValidator", - "@type": "HttpMessageValidator" - } - ] -} diff --git a/packages/uma/config/credentials/verifiers/default.json b/packages/uma/config/credentials/verifiers/default.json index b8787f02..7f4d8ef0 100644 --- a/packages/uma/config/credentials/verifiers/default.json +++ b/packages/uma/config/credentials/verifiers/default.json @@ -32,10 +32,16 @@ "TypedVerifier:_verifiers_value": { "@id": "urn:uma:default:OidcVerifier", "@type": "OidcVerifier", - "baseUrl": { "@id": "urn:uma:variables:baseUrl" }, "derivationStore": { "@id": "urn:uma:default:DerivationStore" } } }, + { + "TypedVerifier:_verifiers_key": "application/vc+jwt", + "TypedVerifier:_verifiers_value": { + "@id": "urn:uma:default:VcVerifier", + "@type": "VcVerifier" + } + }, { "TypedVerifier:_verifiers_key": "urn:ietf:params:oauth:token-type:access_token", "TypedVerifier:_verifiers_value": { "@id": "urn:uma:default:OidcVerifier" } diff --git a/packages/uma/package.json b/packages/uma/package.json index 04d0ef18..8028f778 100644 --- a/packages/uma/package.json +++ b/packages/uma/package.json @@ -62,14 +62,15 @@ "@httpland/authorization-parser": "^1.1.0", "@solid/access-token-verifier": "^1.2.0", "@solid/community-server": "^8.0.0-alpha.1", + "@types/jsonpath": "^0.2.4", "@types/ms": "^2.1.0", "@types/n3": "^1.16.4", "asynchronous-handlers": "^1.0.2", "componentsjs": "^6.3.0", - "get-jwks": "^9.0.1", "global-logger-factory": "^1.0.0", "http-message-signatures": "^1.0.4", "jose": "^5.2.2", + "jsonpath": "^1.3.0", "logform": "^2.6.0", "ms": "^2.1.3", "n3": "^1.17.2", diff --git a/packages/uma/src/credentials/Claims.ts b/packages/uma/src/credentials/Claims.ts index fd16d158..5059b6e1 100644 --- a/packages/uma/src/credentials/Claims.ts +++ b/packages/uma/src/credentials/Claims.ts @@ -5,6 +5,7 @@ export const ORIGINAL = 'urn:solidlab:uma:claims:types:original'; 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. @@ -12,8 +13,8 @@ export const ACCESS = 'urn:solidlab:uma:claims:types:access'; export function getOriginalClaimValue(claims: NodeJS.Dict, claimType: string): unknown { const original = claims[ORIGINAL]; if (typeof original === 'object' && original !== null) { - const originalClaims = original as Record; - return originalClaims[claimType]; + const originalClaims = original as Record; + return originalClaims[claimType]; } return claims[claimType]; diff --git a/packages/uma/src/credentials/Formats.ts b/packages/uma/src/credentials/Formats.ts index 36fc4623..72773322 100644 --- a/packages/uma/src/credentials/Formats.ts +++ b/packages/uma/src/credentials/Formats.ts @@ -4,3 +4,4 @@ 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'; export const REFRESH_TOKEN = 'urn:ietf:params:oauth:token-type:refresh_token'; +export const VC_JWT = 'application/vc+jwt'; diff --git a/packages/uma/src/credentials/verify/JwtVerifier.ts b/packages/uma/src/credentials/verify/JwtVerifier.ts index 5675526a..df265d8d 100644 --- a/packages/uma/src/credentials/verify/JwtVerifier.ts +++ b/packages/uma/src/credentials/verify/JwtVerifier.ts @@ -1,6 +1,6 @@ -import buildGetJwks, { GetJwks } from 'get-jwks'; import { getLoggerFor } from 'global-logger-factory'; -import { decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose'; +import { decodeJwt, jwtVerify } from 'jose'; +import { getJwks } from '../../util/JwtUtil'; import { ClaimSet } from '../ClaimSet'; import { Credential } from '../Credential'; import { JWT } from '../Formats'; @@ -12,7 +12,6 @@ import { Verifier } from './Verifier'; */ export class JwtVerifier implements Verifier { protected readonly logger = getLoggerFor(this); - protected jwks:GetJwks = buildGetJwks(); constructor( private readonly allowedClaims: string[], @@ -34,23 +33,8 @@ export class JwtVerifier implements Verifier { throw new Error(`JWT should contain 'iss' claim.`); } - const params = decodeProtectedHeader(credential.token); - - if (!params.alg) { - throw new Error(`JWT should contain 'alg' header.`); - } - - if (!params.kid) { - throw new Error(`JWT should contain 'kid' header.`); - } - - const jwk = await this.jwks.getJwk({ - domain: claims.iss, - alg: params.alg, - kid: params.kid, - }); - - await jwtVerify(credential.token, Object.assign(jwk, { type: 'JWK' })); + const jwkSet = await getJwks(claims.iss); + await jwtVerify(credential.token, jwkSet); } for (const claim of Object.keys(claims)) { diff --git a/packages/uma/src/credentials/verify/OidcVerifier.ts b/packages/uma/src/credentials/verify/OidcVerifier.ts index 8e5f4c6d..caefcc66 100644 --- a/packages/uma/src/credentials/verify/OidcVerifier.ts +++ b/packages/uma/src/credentials/verify/OidcVerifier.ts @@ -1,15 +1,10 @@ import { createSolidTokenVerifier } from '@solid/access-token-verifier'; -import { - BadRequestHttpError, - ForbiddenHttpError, - InternalServerError, - joinUrl, - KeyValueStorage -} from '@solid/community-server'; +import { BadRequestHttpError, ForbiddenHttpError, InternalServerError, KeyValueStorage } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; -import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify } from 'jose'; +import { decodeJwt, jwtVerify } from 'jose'; import { AccessToken } from '../../tokens/AccessToken'; import { UMA_SCOPES } from '../../ucp/util/Vocabularies'; +import { getJwks } from '../../util/JwtUtil'; import { reType } from '../../util/ReType'; import { Permission } from '../../views/Permission'; import { ACCESS, CLIENTID, WEBID } from '../Claims'; @@ -21,8 +16,7 @@ import { Verifier } from './Verifier'; /** * 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. + * To only allow tokens from certain issuers, set `verifyOptions` to { issuer: [ 'http://example.com/' ] }. */ export class OidcVerifier implements Verifier { protected readonly logger = getLoggerFor(this); @@ -30,10 +24,8 @@ export class OidcVerifier implements Verifier { private readonly verifyToken = createSolidTokenVerifier(); public constructor( - protected readonly baseUrl: string, protected readonly derivationStore: KeyValueStorage, - protected readonly allowedIssuers: string[] = [], - protected readonly verifyOptions: Record = {}, + protected readonly verifyOptions: Record = {}, // JWTVerifyOptions ) {} /** @inheritdoc */ @@ -46,11 +38,10 @@ export class OidcVerifier implements Verifier { // 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'))) + (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 { @@ -64,18 +55,13 @@ export class OidcVerifier implements Verifier { } } - protected validateToken(payload: JWTPayload): void { - // TODO: disable audience check for now, need to investigate required values further - // if (payload.aud !== this.baseUrl && !(Array.isArray(payload.aud) && payload.aud.includes(this.baseUrl))) { - // throw new BadRequestHttpError('This server is not valid audience for the token'); - // } - if (!payload.iss || this.allowedIssuers.length > 0 && !this.allowedIssuers.includes(payload.iss)) { - throw new BadRequestHttpError('Unsupported issuer'); - } - } - protected async verifySolidToken(token: string): Promise<{ [WEBID]: string, [CLIENTID]?: string }> { const claims = await this.verifyToken(`Bearer ${token}`); + const issuers = this.verifyOptions.issuer; + const allowedIssuers = issuers !== undefined && (typeof issuers === 'string' ? [issuers] : issuers as string[]); + if (!claims.iss || (allowedIssuers && !allowedIssuers.includes(claims.iss))) { + throw new BadRequestHttpError('Unsupported issuer'); + } // Depends on the spec version which field to use const clientId = (claims as { azp?: string }).azp ?? claims.client_id; @@ -91,16 +77,7 @@ export class OidcVerifier implements Verifier { 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) { - throw new BadRequestHttpError(`Unable to access ${configUrl}`); - } - const config = await configResponse.json() as { jwks_uri?: string }; - if (!config.jwks_uri) { - throw new BadRequestHttpError(`Missing jwks_uri from ${configUrl}`); - } - const jwkSet = createRemoteJWKSet(new URL(config.jwks_uri)); + const jwkSet = await getJwks(issuer); const decoded = await jwtVerify(token, jwkSet, this.verifyOptions); if (format === OIDC) { diff --git a/packages/uma/src/credentials/verify/VcVerifier.ts b/packages/uma/src/credentials/verify/VcVerifier.ts new file mode 100644 index 00000000..ccac874f --- /dev/null +++ b/packages/uma/src/credentials/verify/VcVerifier.ts @@ -0,0 +1,76 @@ +import { BadRequestHttpError } from '@solid/community-server'; +import { getLoggerFor } from 'global-logger-factory'; +import { decodeJwt, JWTPayload, jwtVerify } from 'jose'; +import { getJwks } from '../../util/JwtUtil'; +import { VC } from '../Claims'; +import { ClaimSet } from '../ClaimSet'; +import { Credential } from '../Credential'; +import { VC_JWT } from '../Formats'; +import { Verifier } from './Verifier'; + +// TODO: + +// TODO: implementation probably based too much on current example format + +/** + * A Verifier for VC Tokens. + * + * To only allow tokens from certain options, set `verifyOptions` to { issuer: [ 'http://example.com/' ] }. + */ +export class VcVerifier implements Verifier { + protected readonly logger = getLoggerFor(this); + + public constructor( + protected readonly verifyOptions: Record = {}, + ) {} + + public async verify(credential: Credential): Promise { + this.logger.debug(`Verifying credential ${JSON.stringify(credential)}`); + if (credential.format !== VC_JWT) { + throw new BadRequestHttpError(`Token format ${credential.format} does not match this processor's format.`); + } + + const unsafeDecoded = decodeJwt(credential.token); + if (!unsafeDecoded.iss) { + throw new BadRequestHttpError(`Token is missing the issuer claim.`); + } + + 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 }; + } + + protected extractVcClaims(payload: JWTPayload): ClaimSet { + if (!payload.vc || typeof payload.vc !== 'object') { + throw new BadRequestHttpError(`Token is missing the vc claim.`); + } + + const vc = payload.vc as Record; + if (typeof vc.issuanceDate === 'string' && new Date(vc.issuanceDate) > new Date()) { + throw new BadRequestHttpError(`VC is not yet valid, issued at ${vc.issuanceDate}.`); + } + if (typeof vc.validFrom === 'string' && new Date(vc.validFrom) > new Date()) { + throw new BadRequestHttpError(`VC is not yet valid, valid from ${vc.validFrom}.`); + } + + if (typeof vc.expirationDate === 'string' && new Date(vc.expirationDate) < new Date()) { + throw new BadRequestHttpError(`VC expired at ${vc.expirationDate}.`); + } + if (typeof vc.validUntil === 'string' && new Date(vc.validUntil) < new Date()) { + throw new BadRequestHttpError(`VC expired at ${vc.validUntil}.`); + } + + if (typeof vc.credentialSubject !== 'object') { + throw new BadRequestHttpError(`VC is missing the credentialSubject claim.`); + } + + this.logger.debug(`Validated VC claims ${JSON.stringify(payload)}`); + + return vc; + } +} diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index 1b821485..afa9e5e0 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -17,6 +17,7 @@ export * from './credentials/verify/JwtVerifier'; export * from './credentials/verify/IriVerifier'; export * from './credentials/verify/RefreshTokenVerifier'; export * from './credentials/verify/KeyValueVerifier'; +export * from './credentials/verify/VcVerifier'; // Dialog export * from './dialog/AggregatorNegotiator'; @@ -87,7 +88,6 @@ export * from './util/http/server/JsonHttpErrorHandler'; export * from './util/http/server/JsonFormHttpHandler'; export * from './util/http/server/NodeHttpRequestResponseHandler'; export * from './util/http/server/RoutedHttpRequestHandler'; -export * from './util/http/validate/HttpMessageValidator'; export * from './util/http/validate/PatRequestValidator'; export * from './util/http/validate/RequestValidator'; @@ -107,7 +107,7 @@ export * from './ucp/util/Vocabularies'; // Util export * from './util/AggregatorUtil'; export * from './util/ConvertUtil'; -export * from './util/HttpMessageSignatures'; +export * from './util/JwtUtil'; export * from './util/RegistrationStore'; export * from './util/Result'; export * from './util/ReType'; diff --git a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts index afb7ddcf..33dbb118 100644 --- a/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/OdrlAuthorizer.ts @@ -9,6 +9,7 @@ import { PrioritizeProhibitionStrategy } from '../../ucp/policy/PrioritizeProhib import { Strategy } from '../../ucp/policy/Strategy'; import { UCPPolicy } from '../../ucp/policy/UsageControlPolicy'; import { UCRulesStorage } from '../../ucp/storage/UCRulesStorage'; +import { isIri } from '../../util/ConvertUtil'; import { Permission } from '../../views/Permission'; import { Authorizer } from './Authorizer'; @@ -76,9 +77,9 @@ export class OdrlAuthorizer implements Authorizer { const subject = typeof claims[WEBID] === 'string' ? claims[WEBID] : 'urn:solidlab:uma:id:anonymous'; const claimContextConstraints: { subject: ReturnType; quads: Quad[] }[] = []; - for (const [ claimKey, leftOperand ] of Object.entries(claimOperandMap)) { - const claimValue = claims[claimKey]; - if (typeof claimValue !== 'string') { + for (const [ key, value ] of Object.entries(claims)) { + const leftOperand = claimOperandMap[key] ?? key; + if (!isIri(leftOperand) || typeof value !== 'string') { continue; } const claimSubject = blankNode(); @@ -88,7 +89,7 @@ export class OdrlAuthorizer implements Authorizer { 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(claimValue)), + quad(claimSubject, ODRL.terms.rightOperand, namedNode(value)), ], }); } diff --git a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts index 1f08c6f5..3963a4f7 100644 --- a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts @@ -1,10 +1,12 @@ import { NamedNode } from '@rdfjs/types'; import { getLoggerFor } from 'global-logger-factory'; -import { DataFactory as DF, Quad_Subject, Store } from 'n3'; +import jp from 'jsonpath'; +import { DataFactory as DF, Quad_Subject } from 'n3'; import { ODRL } from 'odrl-evaluator'; -import { CLIENTID, PURPOSE, WEBID } from '../../credentials/Claims'; +import { CLIENTID, VC, WEBID } from '../../credentials/Claims'; import { ClaimSet } from '../../credentials/ClaimSet'; import { ReadOnlyStore, UCRulesStorage } from '../../ucp/storage/UCRulesStorage'; +import { OVC } from '../../ucp/util/Vocabularies'; import { Permission } from '../../views/Permission'; import { Authorizer } from './Authorizer'; @@ -28,8 +30,7 @@ const dateComparators: NodeJS.Dict<(a: Date, b: Date) => boolean> = { }; const claimOperandMap: Record = { - [ODRL.deliveryChannel]: CLIENTID, - [ODRL.purpose]: PURPOSE + [ODRL.deliveryChannel]: CLIENTID, } as const; /** @@ -110,8 +111,8 @@ export class SimpleOdrlAuthorizer implements Authorizer { } return ruleAssignees.some(ruleAssignee => assignees.some(assignee => assignee.equals(ruleAssignee))); }); - this.logger.warn('Rejecting request because no rules with a matching assignee or party collection were found'); if (rules.length === 0) { + this.logger.warn('Rejecting request because no rules with a matching assignee or party collection were found'); return []; } @@ -119,9 +120,10 @@ export class SimpleOdrlAuthorizer implements Authorizer { const validRules: Quad_Subject[] = []; for (const rule of rules) { const constraintResponse = this.validateConstraints(rule, policies, claims); - if (constraintResponse === true) { + const vcConstraintResponse = this.validateOvcConstraints(rule, policies, claims); + if (constraintResponse && vcConstraintResponse) { validRules.push(rule); - } else if (constraintResponse === undefined) { + } else if (constraintResponse === undefined || vcConstraintResponse === undefined) { return; } } @@ -168,6 +170,8 @@ export class SimpleOdrlAuthorizer implements Authorizer { if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) { return; } + // 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) { // Return undefined if any of these are too complex or unknown if (constraint.leftOperand.equals(ODRL.terms.dateTime)) { @@ -188,6 +192,11 @@ export class SimpleOdrlAuthorizer implements Authorizer { if (typeof claimValue !== 'string' || constraint.rightOperand.value !== claimValue) { return false; } + } else if (typeof claims[constraint.leftOperand.value] === 'string' + && constraint.operator.equals(ODRL.terms.eq)) { + if (constraint.rightOperand.value !== claims[constraint.leftOperand.value]) { + return false; + } } else { // Unsupported constraint return; @@ -195,4 +204,42 @@ export class SimpleOdrlAuthorizer implements Authorizer { } return true; } + + // https://gitlab.com/gaia-x/lab/policy-reasoning/odrl-vc-profile + protected validateOvcConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined { + const constraints = policies.getObjects(rule, OVC.terms.constraint, null).map(constraint => ({ + leftOperand: policies.getObjects(constraint, OVC.terms.leftOperand, null)[0], + operator: policies.getObjects(constraint, ODRL.terms.operator, null)[0], + rightOperand: policies.getObjects(constraint, ODRL.terms.rightOperand, null)[0], + credentialSubjectType: policies.getObjects(constraint, OVC.terms.credentialSubjectType, null)[0], + })); + // If any of these are undefined this is too complex to handle here (credentialSubjectType can be undefined) + if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) { + return; + } + // Can't match a VC constraint if there is no VC input + const vc = claims[VC]; + if (constraints.length > 0 && typeof vc !== 'object') { + return false; + } + + for (const constraint of constraints) { + // Only support odrl:eq for now + 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)) { + return false; + } + } + } + + return true; + } } diff --git a/packages/uma/src/ucp/util/Vocabularies.ts b/packages/uma/src/ucp/util/Vocabularies.ts index 2226bedd..c37d80f7 100644 --- a/packages/uma/src/ucp/util/Vocabularies.ts +++ b/packages/uma/src/ucp/util/Vocabularies.ts @@ -4,35 +4,35 @@ import { createVocabulary, extendVocabulary } from 'rdf-vocabulary'; export const DC = extendVocabulary(DC_CSS,'creator'); export const ODRL = createVocabulary( - 'http://www.w3.org/ns/odrl/2/', - 'AssetCollection', - 'Agreement', - 'Offer', - 'Permission', - 'Prohibition', - 'Duty', - 'Request', - 'Constraint', - 'source', - 'partOf', - 'action', - 'target', - 'assignee', - 'assigner', - 'constraint', - 'operator', - 'permission', - 'prohibition', - 'duty', - 'dateTime', - 'purpose', - 'leftOperand', - 'rightOperand', - 'gt', - 'lt', - 'eq', - 'uid', - 'read', + 'http://www.w3.org/ns/odrl/2/', + 'AssetCollection', + 'Agreement', + 'Offer', + 'Permission', + 'Prohibition', + 'Duty', + 'Request', + 'Constraint', + 'source', + 'partOf', + 'action', + 'target', + 'assignee', + 'assigner', + 'constraint', + 'operator', + 'permission', + 'prohibition', + 'duty', + 'dateTime', + 'purpose', + 'leftOperand', + 'rightOperand', + 'gt', + 'lt', + 'eq', + 'uid', + 'read', ); export const ODRL_P = createVocabulary( @@ -40,6 +40,13 @@ export const ODRL_P = createVocabulary( 'relation', ); +export const OVC = createVocabulary( + 'https://w3id.org/gaia-x/ovc/1/', + 'constraint', + 'credentialSubjectType', + 'leftOperand', +); + export const OWL = createVocabulary( 'http://www.w3.org/2002/07/owl#', 'inverseOf', diff --git a/packages/uma/src/util/HttpMessageSignatures.ts b/packages/uma/src/util/HttpMessageSignatures.ts deleted file mode 100644 index c04aed9b..00000000 --- a/packages/uma/src/util/HttpMessageSignatures.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type AlgJwk } from '@solid/community-server'; -import { httpbis, type Request as SignRequest, type SigningKey } from 'http-message-signatures'; -import crypto, { webcrypto } from 'node:crypto'; -import { BufferSource } from 'node:stream/web'; - -export async function signRequest( - url: string, - request: RequestInit & Omit, - jwk: AlgJwk -): Promise { - const key: SigningKey = { - id: jwk.kid, - alg: jwk.alg, - async sign(data: BufferSource) { - const params = algMap[jwk.alg]; - const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['sign']); - return Buffer.from(await crypto.subtle.sign(params, key, data)); - }, - }; - - return await httpbis.signMessage({ key, fields: [ '@target-uri', '@method' ] }, { ...request, url }); -} - -type AlgParams = webcrypto.RsaHashedImportParams | webcrypto.EcKeyImportParams | webcrypto.HmacImportParams - -export const algMap: Record = { - 'ES256': { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' }, - 'ES384': { name: 'ECDSA', hash: 'SHA-384', namedCurve: 'P-384' }, - 'ES512': { name: 'ECDSA', hash: 'SHA-512', namedCurve: 'P-512' }, - 'HS256': { name: 'HMAC', hash: 'SHA-256' }, - 'HS384': { name: 'HMAC', hash: 'SHA-384' }, - 'HS512': { name: 'HMAC', hash: 'SHA-512' }, - 'PS256': { name: 'RSASSA-PSS', hash: 'SHA-256' }, - 'PS384': { name: 'RSASSA-PSS', hash: 'SHA-384' }, - 'PS512': { name: 'RSASSA-PSS', hash: 'SHA-512' }, - 'RS256': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - 'RS384': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' }, - 'RS512': { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }, -} diff --git a/packages/uma/src/util/JwtUtil.ts b/packages/uma/src/util/JwtUtil.ts new file mode 100644 index 00000000..2c59913b --- /dev/null +++ b/packages/uma/src/util/JwtUtil.ts @@ -0,0 +1,32 @@ +import { BadRequestHttpError, joinUrl } from '@solid/community-server'; +import { createRemoteJWKSet, jwtVerify, JWTVerifyOptions, JWTVerifyResult } from 'jose'; + +export type JwkSet = ReturnType; + +/** + * Cache for JWKS records. + */ +export const jwksRecord: Record = {}; + +/** + * Builds a JWKS for the given issuer URl. + */ +export async function getJwks(issuer: string): Promise { + if (jwksRecord[issuer]) { + return jwksRecord[issuer]; + } + const configUrl = joinUrl(issuer, '/.well-known/openid-configuration'); + const configResponse = await fetch(configUrl); + if (configResponse.status !== 200) { + throw new BadRequestHttpError(`Unable to access ${configUrl}`); + } + const config = await configResponse.json() as { jwks_uri?: string, issuer?: string }; + if (config.issuer !== issuer) { + throw new BadRequestHttpError(`Issuer mismatch: expected ${issuer}, got ${config.issuer}`); + } + if (!config.jwks_uri) { + throw new BadRequestHttpError(`Missing jwks_uri from ${configUrl}`); + } + jwksRecord[issuer] = createRemoteJWKSet(new URL(config.jwks_uri)); + return jwksRecord[issuer]; +} diff --git a/packages/uma/src/util/http/validate/HttpMessageValidator.ts b/packages/uma/src/util/http/validate/HttpMessageValidator.ts deleted file mode 100644 index cb421424..00000000 --- a/packages/uma/src/util/http/validate/HttpMessageValidator.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { BadRequestHttpError, UnauthorizedHttpError } from '@solid/community-server'; -import buildGetJwks from 'get-jwks'; -import { verifyMessage } from 'http-message-signatures/lib/httpbis'; -import { SignatureParameters, VerifierFinder, VerifyingKey } from 'http-message-signatures/lib/types'; -import crypto from 'node:crypto'; -import { algMap } from '../../HttpMessageSignatures'; -import { HttpHandlerRequest } from '../models/HttpHandler'; -import { RequestValidator, RequestValidatorInput, RequestValidatorOutput } from './RequestValidator'; - -const authParserMod = import('@httpland/authorization-parser'); - -/** - * Validates requests using HTTP Message Signatures. - * This validator can not differentiate between individual owners - * and returns the server who signed the request as owner instead. - */ -export class HttpMessageValidator extends RequestValidator { - public async handle(input: RequestValidatorInput): Promise { - const signer = await this.extractRequestSigner(input.request); - if (!await this.verifyRequest(input.request, signer)) { - throw new UnauthorizedHttpError('Failed to verify signature'); - } - return { owner: signer }; - } - - protected async extractRequestSigner(request: HttpHandlerRequest): Promise { - const { authorization } = request.headers; - if (!authorization) { - throw new UnauthorizedHttpError('Missing authorization header in request.'); - } - - const { authScheme, params } = (await authParserMod).parseAuthorization(authorization); - if (authScheme !== 'HttpSig') { - throw new UnauthorizedHttpError(); - } - - if (!params || typeof params !== 'object' || !params.cred) { - throw new UnauthorizedHttpError(); - } - - let signer = params.cred; - if (signer.startsWith('"')) signer = signer.slice(1); - if (signer.endsWith('"')) signer = signer.slice(0,-1); - - return signer; - } - - protected async verifyRequest( - request: HttpHandlerRequest, - signer: string, - ): Promise { - const jwks = buildGetJwks(); - - const keyLookup: VerifierFinder = async (params: SignatureParameters) => { - const { alg, keyid } = params; - - try { - const jwk = await jwks.getJwk({ - domain: signer!, - alg: alg ?? '', - kid: keyid ?? '', - }); - - if (!alg) throw new BadRequestHttpError('Invalid HTTP message Signature parameters.'); - - const verifier: VerifyingKey = { - id: keyid, - algs: alg ? [ alg ] : [], - async verify(data: Buffer, signature: Buffer) { - try { - const params = algMap[alg]; - const key = await crypto.subtle.importKey('jwk', jwk, params, false, ['verify']); - return await crypto.subtle.verify(params, key, signature, data); - } catch (err) { console.log(err); return null } - }, - }; - - return verifier; - - } catch (err) { - throw new Error(`Something went wrong during signature checking: ${err.message}`) - } - }; - - const verified = await verifyMessage({ keyLookup }, request); - return verified ?? false; - } -} diff --git a/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts b/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts index 83155cc0..8fcbf30d 100644 --- a/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/JwtVerifier.test.ts @@ -1,21 +1,14 @@ -import * as getJwks from 'get-jwks'; import * as jose from 'jose'; import { Credential } from '../../../../src/credentials/Credential'; import { JwtVerifier } from '../../../../src/credentials/verify/JwtVerifier'; -vi.mock('get-jwks'); vi.mock('jose'); describe('JwtVerifier', (): void => { - const getJwkMock = vi.fn(); - const getJwksMock = vi.spyOn(getJwks, 'default').mockReturnValue({ - getJwk: getJwkMock, - } as any); const decodeMock = vi.spyOn(jose, 'decodeJwt'); - const decodeHeaderMock = vi.spyOn(jose, 'decodeProtectedHeader'); const verifyMock = vi.spyOn(jose, 'jwtVerify'); - const issuer = 'issuer'; + const issuer = 'http://example.com/issuer'; const credential: Credential = { format: 'urn:solidlab:uma:claims:formats:jwt', token: 'token', @@ -32,13 +25,6 @@ describe('JwtVerifier', (): void => { claim2: 'val2', }); - decodeHeaderMock.mockReturnValue({ - alg: 'alg', - kid: 'kid', - }); - - getJwkMock.mockResolvedValue({ key: 'value' }); - verifier = new JwtVerifier(allowedClaims, false, false); }); @@ -53,8 +39,6 @@ describe('JwtVerifier', (): void => { expect(decodeMock).toHaveBeenLastCalledWith(credential.token); // Verification is off - expect(decodeHeaderMock).toHaveBeenCalledTimes(0); - expect(getJwkMock).toHaveBeenCalledTimes(0); expect(verifyMock).toHaveBeenCalledTimes(0); }); @@ -64,8 +48,20 @@ describe('JwtVerifier', (): void => { }); describe('with verification enabled.', (): void => { + const remoteKeySet = 'remoteKeySet'; + const fetchMock = vi.spyOn(global, 'fetch'); + const createRemoteJWKSet = vi.spyOn(jose, 'createRemoteJWKSet'); beforeEach(async(): Promise => { + fetchMock.mockResolvedValue({ + status: 200, + json: vi.fn().mockResolvedValue({ + issuer, + jwks_uri: `${issuer}/jwks_uri` + }), + } as any); + createRemoteJWKSet.mockReturnValue(remoteKeySet as any); + verifier = new JwtVerifier(allowedClaims, false, true); }); @@ -74,26 +70,12 @@ describe('JwtVerifier', (): void => { await expect(verifier.verify(credential)).rejects.toThrow("JWT should contain 'iss' claim."); }); - it('errors if the header does not contain an alg.', async(): Promise => { - decodeHeaderMock.mockReturnValueOnce({ kid: 'kid' }); - await expect(verifier.verify(credential)).rejects.toThrow("JWT should contain 'alg' header."); - }); - - it('errors if the header does not contain a kid.', async(): Promise => { - decodeHeaderMock.mockReturnValueOnce({ alg: 'alg' }); - await expect(verifier.verify(credential)).rejects.toThrow("JWT should contain 'kid' header."); - }); - it('verifies the token.', async(): Promise => { await expect(verifier.verify(credential)).resolves.toEqual({ iss: issuer, claim1: 'val1', }); expect(decodeMock).toHaveBeenCalledTimes(1); expect(decodeMock).toHaveBeenLastCalledWith(credential.token); - expect(decodeHeaderMock).toHaveBeenCalledTimes(1); - expect(decodeHeaderMock).toHaveBeenLastCalledWith(credential.token); - expect(getJwkMock).toHaveBeenCalledTimes(1); - expect(getJwkMock).toHaveBeenLastCalledWith({ domain: 'issuer', alg: 'alg', kid: 'kid' }); expect(verifyMock).toHaveBeenCalledTimes(1); - expect(verifyMock).toHaveBeenLastCalledWith(credential.token, { key: 'value', type: 'JWK' }); + expect(verifyMock).toHaveBeenLastCalledWith(credential.token, remoteKeySet); }); }); }); diff --git a/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts index 735084e0..07df824f 100644 --- a/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts +++ b/packages/uma/test/unit/credentials/verify/OidcVerifier.test.ts @@ -1,11 +1,9 @@ import * as accessTokenVerifier from '@solid/access-token-verifier'; import { KeyValueStorage } from '@solid/community-server'; -import { JWTPayload } from 'jose'; import * as jose from 'jose'; -import { Mocked, MockInstance } from 'vitest'; -import { ACCESS } from '../../../../src/credentials/Claims'; +import { JWTPayload } from 'jose'; +import { Mocked } from 'vitest'; import { Credential } from '../../../../src/credentials/Credential'; -import { ACCESS_TOKEN } from '../../../../src/credentials/Formats'; import { OidcVerifier } from '../../../../src/credentials/verify/OidcVerifier'; vi.mock('jose', () => ({ @@ -16,7 +14,6 @@ vi.mock('jose', () => ({ describe('OidcVerifier', (): void => { const issuer = 'http://example.org/issuer'; - const baseUrl = 'http://example.com/uma'; let credential: Credential; let decodedToken: JWTPayload; @@ -39,13 +36,15 @@ describe('OidcVerifier', (): void => { decodedToken = { sub: 'sub', iss: issuer, - aud: baseUrl, }; vi.clearAllMocks(); fetchMock.mockResolvedValue({ status: 200, - json: vi.fn().mockResolvedValue({ jwks_uri: `${issuer}/jwks_uri` }), + json: vi.fn().mockResolvedValue({ + issuer, + jwks_uri: `${issuer}/jwks_uri` + }), } as any); decodeJwt.mockReturnValue(decodedToken); jwtVerify.mockResolvedValue({ payload: decodedToken } as any); @@ -53,14 +52,15 @@ describe('OidcVerifier', (): void => { verifierMock.mockResolvedValue({ webid: 'webId', - client_id: 'clientId' + client_id: 'clientId', + iss: issuer, }); derivationStore = { get: vi.fn(), } satisfies Partial> as any; - verifier = new OidcVerifier(baseUrl, derivationStore); + verifier = new OidcVerifier(derivationStore); }); it('errors on non-OIDC credentials.', async(): Promise => { @@ -68,19 +68,9 @@ describe('OidcVerifier', (): void => { .toThrow("Token format wrong does not match this processor's format."); }); - it('errors if the issuer is not allowed.', async(): Promise => { - verifier = new OidcVerifier(baseUrl, derivationStore, [ 'otherIssuer' ]); - await expect(verifier.verify(credential)).rejects.toThrow('Unsupported issuer'); - - verifier = new OidcVerifier(baseUrl, derivationStore, [ issuer ]); - await expect(verifier.verify(credential)).resolves.toEqual({ - ['urn:solidlab:uma:claims:types:webid']: 'sub', - }); - }); - describe('parsing a Solid OIDC token', (): void => { beforeEach(async(): Promise => { - decodeJwt.mockReturnValue({ ...decodedToken, aud: [ baseUrl, 'solid' ], webid: 'webId' }); + decodeJwt.mockReturnValue({ ...decodedToken, aud: [ 'solid' ], webid: 'webId' }); }); it('returns the extracted WebID.', async(): Promise => { @@ -94,6 +84,17 @@ describe('OidcVerifier', (): void => { verifierMock.mockRejectedValueOnce(new Error('bad data')); await expect(verifier.verify(credential)).rejects.toThrow('Error verifying OIDC Token: bad data'); }); + + it('errors if the issuer is not allowed.', async(): Promise => { + verifier = new OidcVerifier(derivationStore, { issuer: [ 'otherIssuer' ] }); + await expect(verifier.verify(credential)).rejects.toThrow('Unsupported issuer'); + + verifier = new OidcVerifier(derivationStore, { issuer: [ issuer ] }); + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'webId', + ['urn:solidlab:uma:claims:types:clientid']: 'clientId', + }); + }); }); describe('parsing a standard OIDC token', (): void => { @@ -116,6 +117,14 @@ describe('OidcVerifier', (): void => { ['urn:solidlab:uma:claims:types:clientid']: 'client', }); }); + + it('uses verification options.', async(): Promise => { + verifier = new OidcVerifier(derivationStore, { issuer: [ issuer ] }); + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:webid']: 'sub', + }); + expect(jwtVerify).toHaveBeenCalledExactlyOnceWith('token', remoteKeySet, { issuer: [ issuer ] }); + }); }); describe('parsing access tokens', (): void => { @@ -129,8 +138,7 @@ describe('OidcVerifier', (): void => { { resource_id: 'id2', resource_scopes: [ 'scope2', 'urn:knows:uma:scopes:derivation-read' ] }, { resource_id: 'id3', resource_scopes: [ 'scope3' ] }, ]; - decodedToken.iss = 'issuer'; - derivationStore.get.mockImplementation(async (id) => 'issuer'); + derivationStore.get.mockImplementation(async (id) => issuer); await expect(verifier.verify(credential)).resolves.toEqual({ ['urn:solidlab:uma:claims:types:access']: [ { resource_id: 'id1', resource_scopes: [ 'urn:knows:uma:scopes:derivation-read' ] }, @@ -143,10 +151,9 @@ describe('OidcVerifier', (): void => { decodedToken.permissions = [ { resource_id: 'id1', resource_scopes: [ 'scope1', 'urn:knows:uma:scopes:derivation-read' ] }, ]; - decodedToken.iss = 'wrong-issuer'; - derivationStore.get.mockImplementation(async (id) => 'issuer'); + derivationStore.get.mockImplementation(async (id) => 'other-issuer'); await expect(verifier.verify(credential)).rejects - .toThrow('Invalid issuer for id1, expected issuer but got wrong-issuer'); + .toThrow('Invalid issuer for id1, expected other-issuer but got http://example.org/issuer'); }); }); }); diff --git a/packages/uma/test/unit/credentials/verify/VcVerifier.test.ts b/packages/uma/test/unit/credentials/verify/VcVerifier.test.ts new file mode 100644 index 00000000..71560fc3 --- /dev/null +++ b/packages/uma/test/unit/credentials/verify/VcVerifier.test.ts @@ -0,0 +1,97 @@ +import * as jose from 'jose'; +import { Credential } from '../../../../src/credentials/Credential'; +import { VcVerifier } from '../../../../src/credentials/verify/VcVerifier'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(), + decodeJwt: vi.fn(), + jwtVerify: vi.fn(), +})); + +describe('VcVerifier', (): void => { + const issuer = 'http://example.org/issuer'; + let credential: Credential; + let decodedToken: any; + const remoteKeySet = 'remoteKeySet'; + + const decodeJwt = vi.spyOn(jose, 'decodeJwt'); + const jwtVerify = vi.spyOn(jose, 'jwtVerify'); + const createRemoteJWKSet = vi.spyOn(jose, 'createRemoteJWKSet'); + const fetchMock = vi.spyOn(global, 'fetch'); + + let verifier: VcVerifier; + + beforeEach(async(): Promise => { + credential = { + format: 'application/vc+jwt', + token: 'token', + }; + + decodedToken = { + iss: issuer, + vc: { + issuanceDate: new Date(Date.now() - 5000).toISOString(), + expirationDate: new Date(Date.now() + 5000).toISOString(), + credentialSubject: { garden: { fruit: 'apple' } }, + } + }; + + vi.clearAllMocks(); + fetchMock.mockResolvedValue({ + status: 200, + json: vi.fn().mockResolvedValue({ + issuer, + jwks_uri: `${issuer}/jwks_uri` + }), + } as any); + decodeJwt.mockReturnValue(decodedToken); + jwtVerify.mockResolvedValue({ payload: decodedToken } as any); + createRemoteJWKSet.mockReturnValue(remoteKeySet as any); + + verifier = new VcVerifier(); + }); + + it('errors on non-VC credentials.', async(): Promise => { + await expect(verifier.verify({ format: 'wrong', token: 'token' })).rejects + .toThrow(`Token format wrong does not match this processor's format.`); + }); + + it('errors if the token is missing the issuer claim.', async(): Promise => { + decodedToken.iss = undefined; + + await expect(verifier.verify(credential)).rejects + .toThrow('Token is missing the issuer claim.'); + }); + + it('errors if the token is missing the vc claim.', async(): Promise => { + decodedToken.vc = undefined; + + await expect(verifier.verify(credential)).rejects + .toThrow('Token is missing the vc claim.'); + }); + + it('errors if the VC is not yet valid.', async(): Promise => { + decodedToken.vc.issuanceDate = new Date(Date.now() + 5000).toISOString(); + + await expect(verifier.verify(credential)) + .rejects.toThrow(`VC is not yet valid, issued at ${decodedToken.vc.issuanceDate}.`); + }); + + it('errors if the VC is expired.', async(): Promise => { + decodedToken.vc.expirationDate = new Date(Date.now() - 5000).toISOString(); + + await expect(verifier.verify(credential)).rejects.toThrow(`VC expired at ${decodedToken.vc.expirationDate}.`); + }); + + it('errors if there is no credentialSubject.', async(): Promise => { + decodedToken.vc.credentialSubject = undefined; + + await expect(verifier.verify(credential)).rejects.toThrow('VC is missing the credentialSubject claim.'); + }); + + it('returns the VC as claim.', async(): Promise => { + await expect(verifier.verify(credential)).resolves.toEqual({ + ['urn:solidlab:uma:claims:types:vc']: decodedToken.vc, + }); + }); +}); diff --git a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts index 2385dd29..0d584b26 100644 --- a/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts +++ b/packages/uma/test/unit/policies/authorizers/OdrlAuthorizer.test.ts @@ -94,7 +94,8 @@ describe('OdrlAuthorizer', (): void => { expect(evaluate).toHaveBeenCalledTimes(1); expect(evaluate).toHaveBeenLastCalledWith( policyStore.getQuads(null, null, null, null), - [ ...new Store(requestQuads) ], + // Also contains sotw data generated by claims being present + expect.any(Array), sotw, ); }); @@ -123,7 +124,7 @@ describe('OdrlAuthorizer', (): void => { ]); }); - it('adds purpose claim context using odrl:purpose', async(): Promise => { + it('adds other claims to constraints', async(): Promise => { const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' }; const query: Permission[] = [{ resource_id: 'rid', resource_scopes: [ 'urn:example:css:modes:read' ] }]; diff --git a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts index 67b29622..77b3afbe 100644 --- a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts +++ b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts @@ -3,11 +3,13 @@ import { DataFactory as DF, Store } from 'n3'; import { randomUUID } from 'node:crypto'; import { ODRL } from 'odrl-evaluator'; import { Mocked } from 'vitest'; +import { CLIENTID, PURPOSE, VC, WEBID } from '../../../../src/credentials/Claims'; +import { ClaimSet } from '../../../../src/credentials/ClaimSet'; import { Authorizer } from '../../../../src/policies/authorizers/Authorizer'; import { SimpleOdrlAuthorizer } from '../../../../src/policies/authorizers/SimpleOdrlAuthorizer'; import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage'; +import { OVC } from '../../../../src/ucp/util/Vocabularies'; import { Permission } from '../../../../src/views/Permission'; -import { WEBID, CLIENTID, PURPOSE } from '../../../../src/credentials/Claims'; describe('SimpleOdrlAuthorizer', () => { const resource = 'res'; @@ -76,14 +78,12 @@ describe('SimpleOdrlAuthorizer', () => { }); it('delegates to fallback if no query is provided', async () => { - const result = await authorizer.permissions({}); - expect(result).toEqual(fallbackPermissions); + await expect(authorizer.permissions({})).resolves.toEqual(fallbackPermissions); expect(fallback.permissions).toHaveBeenCalledWith({}, undefined); }); it('returns empty if no rules match the resource', async () => { - const result = await authorizer.permissions({}, query); - expect(result).toEqual([]); + await expect(authorizer.permissions({}, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -91,18 +91,16 @@ describe('SimpleOdrlAuthorizer', () => { addRule({ assignee: 'user' }); const claims = { [WEBID]: 'user' }; - const result = await authorizer.permissions(claims, query); - - expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); }); it('returns permission for public access (no assignee)', async () => { addRule({}); - const result = await authorizer.permissions({}, query); - - expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + await expect(authorizer.permissions({}, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -110,27 +108,21 @@ describe('SimpleOdrlAuthorizer', () => { addRule({ assignee: 'other' }); const claims = { [WEBID]: 'user' }; - const result = await authorizer.permissions(claims, query); - - expect(result).toEqual([]); + await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); it('returns empty if rule is a prohibition', async () => { addRule({ linkPredicate: ODRL.terms.prohibition }); - const result = await authorizer.permissions({}, query); - - expect(result).toEqual([]); + await expect(authorizer.permissions({}, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); it('delegates to fallback if rule has unsupported type', async () => { addRule({ linkPredicate: DF.namedNode('unsupported') }); - const result = await authorizer.permissions({}, query); - - expect(result).toEqual(fallbackPermissions); + await expect(authorizer.permissions({}, query)).resolves.toEqual(fallbackPermissions); expect(fallback.permissions).toHaveBeenCalledWith({}, query); }); @@ -144,9 +136,7 @@ describe('SimpleOdrlAuthorizer', () => { }); const claims = { [CLIENTID]: 'clientB' }; - const result = await authorizer.permissions(claims, query); - - expect(result).toEqual([]); + await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -160,13 +150,12 @@ describe('SimpleOdrlAuthorizer', () => { }); const claims = { [CLIENTID]: 'clientA' }; - const result = await authorizer.permissions(claims, query); - - expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); }); - it('returns permission if purpose constraint is satisfied', async () => { + it('returns permission if other constraints are satisfied', async () => { const rule = addRule({}); addConstraint({ rule, @@ -176,13 +165,12 @@ describe('SimpleOdrlAuthorizer', () => { }); const claims = { [PURPOSE]: 'https://w3id.org/dpv#ScientificResearch' }; - const result = await authorizer.permissions(claims, query); - - expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); }); - it('returns empty if claim constraint is not satisfied', async () => { + it('returns empty if other constraints are not satisfied', async () => { const rule = addRule({}); addConstraint({ rule, @@ -192,9 +180,7 @@ describe('SimpleOdrlAuthorizer', () => { }); const claims = { [PURPOSE]: 'http://example.com/purpose-b' }; - const result = await authorizer.permissions(claims, query); - - expect(result).toEqual([]); + await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -202,9 +188,7 @@ describe('SimpleOdrlAuthorizer', () => { const rule = addRule({}); store.addQuad(rule, ODRL.terms.constraint, DF.namedNode('constraint3')); - const result = await authorizer.permissions({}, query); - - expect(result).toEqual(fallbackPermissions); + await expect(authorizer.permissions({}, query)).resolves.toEqual(fallbackPermissions); expect(fallback.permissions).toHaveBeenCalledWith({}, query); }); @@ -217,9 +201,7 @@ describe('SimpleOdrlAuthorizer', () => { rightOperand: new Date(Date.now() + 1000000).toISOString(), }); - const result = await authorizer.permissions({}, query); - - expect(result).toEqual([]); + await expect(authorizer.permissions({}, query)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -232,9 +214,74 @@ describe('SimpleOdrlAuthorizer', () => { rightOperand: new Date(Date.now() + 1000000).toISOString(), }); - const result = await authorizer.permissions({}, query); + await expect(authorizer.permissions({}, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('delegates to fallback if OVC constraint is too complex', async () => { + const rule = addRule({}); + store.addQuad(rule, OVC.terms.constraint, DF.namedNode('constraint3')); + + await expect(authorizer.permissions({}, query)).resolves.toEqual(fallbackPermissions); + expect(fallback.permissions).toHaveBeenCalledWith({}, query); + }); + + it('returns empty if there is an OVC constraint but no VC claim.', async(): Promise => { + const rule = addRule({}); + + const jsonPath = '$.credentialSubject.garden.fruit'; + const constraint = DF.namedNode(`ovc-constraint-${randomUUID()}`); + store.addQuad(rule, OVC.terms.constraint, constraint); + store.addQuad(constraint, OVC.terms.leftOperand, DF.namedNode(jsonPath)); + store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.eq); + store.addQuad(constraint, ODRL.terms.rightOperand, DF.literal('apple')); + + await expect(authorizer.permissions({}, query)).resolves.toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns permissions if the OVC constraint is satisfied.', async(): Promise => { + const rule = addRule({}); + + const jsonPath = '$.credentialSubject.garden.fruit'; + const constraint = DF.namedNode(`ovc-constraint-${randomUUID()}`); + store.addQuad(rule, OVC.terms.constraint, constraint); + store.addQuad(constraint, OVC.terms.leftOperand, DF.namedNode(jsonPath)); + store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.eq); + store.addQuad(constraint, ODRL.terms.rightOperand, DF.literal('apple')); + store.addQuad(constraint, OVC.terms.credentialSubjectType, DF.namedNode('http://example.com/type')); + + const claims = { [VC]: { + type: [ 'http://example.com/type' ], + credentialSubject: { garden: { fruit: 'apple' }} + }}; + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('returns empty if the OVC constraint is not satisfied.', async(): Promise => { + const rule = addRule({}); + + const jsonPath = '$.credentialSubject.garden.fruit'; + const constraint = DF.namedNode(`ovc-constraint-${randomUUID()}`); + store.addQuad(rule, OVC.terms.constraint, constraint); + store.addQuad(constraint, OVC.terms.leftOperand, DF.namedNode(jsonPath)); + store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.eq); + store.addQuad(constraint, ODRL.terms.rightOperand, DF.literal('apple')); + store.addQuad(constraint, OVC.terms.credentialSubjectType, DF.namedNode('http://example.com/type')); + + let claims: ClaimSet = { [VC]: { + credentialSubject: { garden: { fruit: 'apple' }} + }}; + await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); + claims = { [VC]: { + type: [ 'http://example.com/type' ], + credentialSubject: { garden: { fruit: 'pear' }} + }}; + await expect(authorizer.permissions(claims, query)).resolves.toEqual([]); - expect(result).toEqual([{ resource_id: resource, resource_scopes: [scope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -249,9 +296,7 @@ describe('SimpleOdrlAuthorizer', () => { addRule({}); addRule({ target: resource2, action: odrlScope2 }); - const result = await authorizer.permissions({}, multiQuery); - - expect(result).toEqual([ + await expect(authorizer.permissions({}, multiQuery)).resolves.toEqual([ { resource_id: resource, resource_scopes: [scope] }, { resource_id: resource2, resource_scopes: [scope2] }, ]); @@ -268,9 +313,7 @@ describe('SimpleOdrlAuthorizer', () => { // rule for resource2 has an unsupported link predicate, triggering fallback addRule({ target: resource2, linkPredicate: DF.namedNode('unsupported') }); - const result = await authorizer.permissions({}, multiQuery); - - expect(result).toEqual(fallbackPermissions); + await expect(authorizer.permissions({}, multiQuery)).resolves.toEqual(fallbackPermissions); expect(fallback.permissions).toHaveBeenCalledWith({}, multiQuery); }); @@ -279,9 +322,8 @@ describe('SimpleOdrlAuthorizer', () => { const writeQuery: Permission[] = [{ resource_id: resource, resource_scopes: [writeScope] }]; addRule({ action: 'http://www.w3.org/ns/odrl/2/modify' }); - const result = await authorizer.permissions({}, writeQuery); - - expect(result).toEqual([{ resource_id: resource, resource_scopes: [writeScope] }]); + await expect(authorizer.permissions({}, writeQuery)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [writeScope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -290,9 +332,8 @@ describe('SimpleOdrlAuthorizer', () => { const appendQuery: Permission[] = [{ resource_id: resource, resource_scopes: [appendScope] }]; addRule({ action: 'http://www.w3.org/ns/odrl/2/modify' }); - const result = await authorizer.permissions({}, appendQuery); - - expect(result).toEqual([{ resource_id: resource, resource_scopes: [appendScope] }]); + await expect(authorizer.permissions({}, appendQuery)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [appendScope] }]); expect(fallback.permissions).not.toHaveBeenCalled(); }); @@ -301,20 +342,15 @@ describe('SimpleOdrlAuthorizer', () => { const writeQuery: Permission[] = [{ resource_id: resource, resource_scopes: [writeScope] }]; addRule({ action: 'http://www.w3.org/ns/odrl/2/append' }); - const result = await authorizer.permissions({}, writeQuery); - - expect(result).toEqual([]); + await expect(authorizer.permissions({}, writeQuery)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); it('does not grant odrl:modify scope when rule only has odrl:write action', async () => { - const modifyScope = 'urn:example:css:modes:write'; const rawModifyQuery: Permission[] = [{ resource_id: resource, resource_scopes: ['http://www.w3.org/ns/odrl/2/modify'] }]; addRule({ action: 'http://www.w3.org/ns/odrl/2/write' }); - const result = await authorizer.permissions({}, rawModifyQuery); - - expect(result).toEqual([]); + await expect(authorizer.permissions({}, rawModifyQuery)).resolves.toEqual([]); expect(fallback.permissions).not.toHaveBeenCalled(); }); }); diff --git a/packages/uma/test/unit/util/HttpMessageSignatures.test.ts b/packages/uma/test/unit/util/HttpMessageSignatures.test.ts deleted file mode 100644 index 6809e1ec..00000000 --- a/packages/uma/test/unit/util/HttpMessageSignatures.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AlgJwk } from '@solid/community-server'; -import { httpbis } from 'http-message-signatures'; -import { exportJWK, generateKeyPair, GenerateKeyPairResult } from 'jose'; -import crypto from 'node:crypto'; -import { beforeAll } from 'vitest'; -import { signRequest } from '../../../src/util/HttpMessageSignatures'; - -describe('HttpMessageSignatures', (): void => { - const url = 'https://example.com/foo'; - const alg = 'ES256'; - let keys: GenerateKeyPairResult; - let publicKey: AlgJwk; - let privateKey: AlgJwk; - - beforeAll(async(): Promise => { - keys = await generateKeyPair(alg); - publicKey = { ...await exportJWK(keys.publicKey), alg, kid: 'public' }; - privateKey = { ...await exportJWK(keys.privateKey), alg, kid: 'private' }; - }); - - describe('#signRequest', (): void => { - it('adds the signature headers to the request.', async(): Promise => { - const request = { method: 'GET', headers: { accept: 'text/plain' }, body: 'text' }; - const signedRequest = await signRequest(url, request, privateKey); - expect(signedRequest).toMatchObject(request); - expect(signedRequest.headers['Signature-Input']).includes('sig=("@target-uri" "@method")'); - const verified = await httpbis.verifyMessage({ - keyLookup: async() => ({ - async verify(data: Buffer, signature: Buffer) { - const params = { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' }; - const key = await crypto.subtle.importKey('jwk', publicKey, params, false, ['verify']); - return await crypto.subtle.verify(params, key, signature, data); - }, - })}, - signedRequest, - ); - expect(verified).toBe(true); - }); - }); -}); diff --git a/packages/uma/test/unit/util/JwtUtil.test.ts b/packages/uma/test/unit/util/JwtUtil.test.ts new file mode 100644 index 00000000..c4647e5f --- /dev/null +++ b/packages/uma/test/unit/util/JwtUtil.test.ts @@ -0,0 +1,74 @@ +import * as jose from 'jose'; +import { getJwks, jwksRecord } from '../../../src/util/JwtUtil'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(), + decodeJwt: vi.fn(), + jwtVerify: vi.fn(), +})); + +describe('JwtUtil', (): void => { + const issuer = 'http://example.org/issuer'; + const remoteKeySet = 'remoteKeySet'; + const fetchMock = vi.spyOn(global, 'fetch'); + const createRemoteJWKSet = vi.spyOn(jose, 'createRemoteJWKSet'); + + beforeEach(async(): Promise => { + vi.clearAllMocks(); + delete jwksRecord[issuer]; + + fetchMock.mockResolvedValue({ + status: 200, + json: vi.fn().mockResolvedValue({ + issuer, + jwks_uri: `${issuer}/jwks_uri` + }), + } as any); + + createRemoteJWKSet.mockReturnValue(remoteKeySet as any); + }); + + describe('#getJwks', (): void => { + it('returns a JWKSet for the given issuer.', async(): Promise => { + await expect(getJwks(issuer)).resolves.toBe(remoteKeySet); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith('http://example.org/issuer/.well-known/openid-configuration'); + expect(createRemoteJWKSet).toHaveBeenCalledExactlyOnceWith(new URL('http://example.org/issuer/jwks_uri')); + }); + + it('errors if the OpenID config cannot be fetched.', async(): Promise => { + fetchMock.mockResolvedValue({ status: 500 } as any); + + await expect(getJwks(issuer)).rejects.toThrow('Unable to access http://example.org/issuer/.well-known/openid-configuration'); + }); + + it('caches results.', async(): Promise => { + await expect(getJwks(issuer)).resolves.toBe(remoteKeySet); + fetchMock.mockResolvedValue({ status: 500 } as any); + await expect(getJwks(issuer)).resolves.toBe(remoteKeySet); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('errors if there is an issuer mismatch.', async(): Promise => { + fetchMock.mockResolvedValue({ + status: 200, + json: vi.fn().mockResolvedValue({ + issuer: 'http://example.org/other-issuer', + jwks_uri: `${issuer}/jwks_uri` + }), + } as any); + + await expect(getJwks(issuer)).rejects.toThrow(`Issuer mismatch: expected http://example.org/issuer, got http://example.org/other-issuer`); + }); + + it('errors if there is no jwks_uri in the OpenID configuration.', async(): Promise => { + fetchMock.mockResolvedValue({ + status: 200, + json: vi.fn().mockResolvedValue({ + issuer, + }), + } as any); + + await expect(getJwks(issuer)).rejects.toThrow(`Missing jwks_uri from http://example.org/issuer/.well-known/openid-configuration`); + }); + }); +}); diff --git a/packages/uma/test/unit/util/http/validate/HttpMessageValidator.test.ts b/packages/uma/test/unit/util/http/validate/HttpMessageValidator.test.ts deleted file mode 100644 index 1a998b7b..00000000 --- a/packages/uma/test/unit/util/http/validate/HttpMessageValidator.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { AlgJwk, UnauthorizedHttpError } from '@solid/community-server'; -import { exportJWK, generateKeyPair, GenerateKeyPairResult } from 'jose'; -import { beforeAll, Mock } from 'vitest'; -import { HttpHandlerRequest } from '../../../../../src/util/http/models/HttpHandler'; -import { HttpMessageValidator } from '../../../../../src/util/http/validate/HttpMessageValidator'; -import { signRequest } from '../../../../../src/util/HttpMessageSignatures'; - -vi.mock('get-jwks'); - -describe('HttpMessageValidator', (): void => { - const url = 'https://example.com/foo'; - const alg = 'ES256'; - let keys: GenerateKeyPairResult; - let publicKey: AlgJwk; - let privateKey: AlgJwk; - let getJwk: Mock<() => unknown>; - let request: HttpHandlerRequest; - - const validator = new HttpMessageValidator(); - - beforeAll(async(): Promise => { - keys = await generateKeyPair(alg); - publicKey = { ...await exportJWK(keys.publicKey), alg, kid: 'public' }; - privateKey = { ...await exportJWK(keys.privateKey), alg, kid: 'private' }; - - const getJwks = await import('get-jwks'); - getJwk = vi.fn().mockResolvedValue(publicKey); - (getJwks.default as unknown as Mock).mockReturnValue({ getJwk } as any); - - const baseRequest = { - method: 'POST', - headers: { authorization: 'HttpSig cred="https://example.com/bar"' }, - body: 'text' - }; - const signedRequest = await signRequest(url, baseRequest, privateKey); - request = { - url: new URL(url), - method: signedRequest.method, - headers: signedRequest.headers as any, - parameters: {}, - } - }); - - it('returns the signer as the owner.', async(): Promise => { - await expect(validator.handle({ request })).resolves.toEqual({ owner: 'https://example.com/bar' }); - expect(getJwk).toHaveBeenCalledTimes(1); - expect(getJwk).toHaveBeenLastCalledWith({ - domain: 'https://example.com/bar', - alg, - kid: 'private', - }); - }); - - it('errors if the authorization header is missing.', async(): Promise => { - request.headers = {}; - await expect(validator.handle({ request })).rejects.toThrow('Missing authorization header in request.'); - }); - - it('errors if the authorization scheme is not HttpSig.', async(): Promise => { - request.headers.authorization = 'Basic 123'; - await expect(validator.handle({ request })).rejects.toThrow(UnauthorizedHttpError); - }); - - it('errors if no `cred` parameter could be extracted from the header.', async(): Promise => { - request.headers.authorization = 'HttpSig pear'; - await expect(validator.handle({ request })).rejects.toThrow(UnauthorizedHttpError); - }); - - it('errors if the signature used a wrong key.', async(): Promise => { - keys = await generateKeyPair(alg); - const otherKey = { ...await exportJWK(keys.privateKey), alg, kid: 'private' }; - const baseRequest = { - method: 'POST', - headers: { authorization: 'HttpSig cred="https://example.com/bar"' }, - body: 'text' - }; - const signedRequest = await signRequest(url, baseRequest, otherKey as AlgJwk); - request = { - url: new URL(url), - method: signedRequest.method, - headers: signedRequest.headers as any, - parameters: {}, - } - await expect(validator.handle({ request })).rejects.toThrow('Failed to verify signature'); - }); -}); diff --git a/test/integration/Demo.test.ts b/test/integration/Demo.test.ts index 6b88bf06..45002e3b 100644 --- a/test/integration/Demo.test.ts +++ b/test/integration/Demo.test.ts @@ -1,9 +1,9 @@ import { App } from '@solid/community-server'; import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory'; -import { Parser, Store } from 'n3'; +import { UnsecuredJWT } from 'jose'; import * as path from 'node:path'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; -import { findTokenEndpoint, getToken, noTokenFetch, generateCredentials, tokenFetch, umaFetch } from '../util/UmaUtil'; +import { findTokenEndpoint, generateCredentials, getToken, noTokenFetch, tokenFetch, umaFetch } from '../util/UmaUtil'; const [ cssPort, umaPort ] = getPorts('Demo'); @@ -142,69 +142,40 @@ _:rename a solid:InsertDeletePatch; }.`, }, terms.agents.ruben); expect(response.status).toBe(205); - - // TODO: Do I need this though - // Add necessary triples to WebID - response = await fetch(terms.agents.ruben, { - method: 'PATCH', - headers: { 'content-type': 'text/n3' }, - body: ` -@prefix solid: . - -_:rename a solid:InsertDeletePatch; - solid:inserts { - <${terms.agents.ruben}> solid:umaServer - }.`, - }); - expect(response.status).toBe(205); - }); - - it('finds the UMA server of the user in their WebID.', async(): Promise => { - // TODO: what is the point of any of this? the as_uri response should have this data? - // TODO: find out why it doesn't work though as the term does get added at the end of the previous test - const response = await fetch(terms.agents.ruben, { - headers: { 'accept': 'text/turtle' }, - }); - expect(response.status).toBe(200); - const parser = new Parser({ baseIRI: terms.agents.ruben }); - const store = new Store(parser.parse(await response.text())); - expect(store.countQuads(terms.agents.ruben, terms.solid.umaServer, null, null)).toBe(1); - const umaServer = store.getObjects(terms.agents.ruben, terms.solid.umaServer, null)[0].value; }); it('can add a healthcare policy to the server.', async(): Promise => { - // TODO: policy currently not linking to constraints as these need to be added to the ODRL evaluator - // odrl:constraint , - // . const healthcare_patient_policy = `PREFIX dcterms: -PREFIX eu-gdpr: -PREFIX oac: -PREFIX odrl: -PREFIX xsd: - -PREFIX ex: - - a odrl:Agreement ; - odrl:uid ex:HCPX-agreement ; - odrl:profile oac: ; - odrl:permission . - - a odrl:Permission ; - odrl:action odrl:read ; - odrl:target <${terms.resources.smartwatch}> ; - odrl:assigner <${terms.agents.ruben}> ; - odrl:assignee <${terms.agents.alice}> . - - a odrl:Constraint ; - odrl:leftOperand odrl:purpose ; # can also be oac:Purpose, to conform with OAC profile - odrl:operator odrl:eq ; - odrl:rightOperand ex:bariatric-care . - - a odrl:Constraint ; - odrl:leftOperand oac:LegalBasis ; - odrl:operator odrl:eq ; - odrl:rightOperand eu-gdpr:A9-2-a .` + PREFIX eu-gdpr: + PREFIX oac: + PREFIX odrl: + PREFIX xsd: + + PREFIX ex: + + a odrl:Agreement ; + odrl:uid ex:HCPX-agreement ; + odrl:profile oac: ; + odrl:permission . + + a odrl:Permission ; + odrl:action odrl:read ; + odrl:target <${terms.resources.smartwatch}> ; + odrl:assigner <${terms.agents.ruben}> ; + odrl:assignee <${terms.agents.alice}> ; + odrl:constraint , + . + + a odrl:Constraint ; + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand ex:bariatric-care . + + a odrl:Constraint ; + odrl:leftOperand oac:LegalBasis ; + odrl:operator odrl:eq ; + odrl:rightOperand eu-gdpr:A9-2-a .` const medicalPolicyCreationResponse = await fetch(policyContainer, { method: 'POST', @@ -215,15 +186,25 @@ PREFIX ex: }); it('requires authorized access for patient data.', async(): Promise => { - // TODO: should do the steps individually here so we can check the contents of the tokens/tickets // Parse ticket and UMA server URL from header const parsedHeader = await noTokenFetch(terms.resources.smartwatch); // Find UMA server token endpoint const tokenEndpoint = await findTokenEndpoint(parsedHeader.as_uri); + const jwt = new UnsecuredJWT({ + 'http://www.w3.org/ns/odrl/2/purpose': 'http://example.org/bariatric-care', + 'urn:solidlab:uma:claims:types:webid': terms.agents.alice, + 'https://w3id.org/oac#LegalBasis': 'https://w3id.org/dpv/legal/eu/gdpr#A9-2-a' + }).encode(); + // Send ticket request to UMA server and extract token from response - const token = await getToken(parsedHeader.ticket, tokenEndpoint, terms.agents.alice); + const token = await getToken( + parsedHeader.ticket, + tokenEndpoint, + undefined, + undefined, + [{ claim_token: jwt, claim_token_format: 'urn:solidlab:uma:claims:formats:jwt' }]); const accessToken = JSON.parse(Buffer.from(token.access_token.split('.')[1], 'base64').toString()); expect(accessToken).toMatchObject({ permissions:[{ @@ -235,7 +216,7 @@ PREFIX ex: aud: 'solid', exp: expect.any(Number), jti: expect.any(String), - }) + }); // Perform new call with token const response = await tokenFetch(token, terms.resources.smartwatch); diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts index f8521d2b..42ad7f68 100644 --- a/test/integration/Oidc.test.ts +++ b/test/integration/Oidc.test.ts @@ -73,7 +73,7 @@ describe('A server supporting OIDC tokens', (): void => { return; } if (req.url!.endsWith('/.well-known/openid-configuration')) { - res.end(JSON.stringify({ jwks_uri: idpUrl })); + res.end(JSON.stringify({ jwks_uri: idpUrl, issuer: idpUrl })); return; } // Exposing private keys is fine right @@ -216,7 +216,6 @@ describe('A server supporting OIDC tokens', (): void => { }); const endpoint = await findTokenEndpoint(as_uri); - // TODO: also add token that fails const jwk = await importJWK(privateKey, privateKey.alg); const jwt = await new SignJWT({ azp: client }) .setSubject(sub) @@ -389,4 +388,90 @@ describe('A server supporting OIDC tokens', (): void => { expect(response.status).toBe(204); }); }); + + describe('accessing a resource using a VC.', (): void => { + const resource = `http://localhost:${cssPort}/alice/vc`; + const policy = ` + @prefix ex: . + @prefix odrl: . + @prefix ovc: . + + ex:policyVc a odrl:Set; + odrl:uid ex:policyVc ; + odrl:permission ex:permissionVc . + + ex:permissionVc a odrl:Permission ; + odrl:assigner <${webId}>; + odrl:action odrl:read , odrl:create , odrl:modify ; + odrl:target ; + ovc:constraint ex:constraintVc . + + ex:constraintVc a odrl:Constraint ; + ovc:leftOperand "$.credentialSubject['kss:HCPs']['kss:assignedPatientId']" ; + odrl:operator odrl:eq ; + odrl:rightOperand ; + ovc:credentialSubjectType .`; + + it('can set up the policy.', async(): Promise => { + const response = await fetch(policyEndpoint, { + method: 'POST', + headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' }, + body: policy, + }); + expect(response.status).toBe(201); + }); + + it('can get an access token.', async(): Promise => { + const { as_uri, ticket } = await noTokenFetch(resource, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'hello', + }); + const endpoint = await findTokenEndpoint(as_uri); + + const jwk = await importJWK(privateKey, privateKey.alg); + const jwt = await new SignJWT({ + vc: { + type: [ + 'VerifiableCredential', + 'http://example.org/UserHcpRelationVC', + ], + issuanceDate: new Date(Date.now() - 5000).toISOString(), + expirationDate: new Date(Date.now() + 5000).toISOString(), + credentialSubject: { + 'kss:HCPs': { + 'kss:assignedPatientId': 'http://example.org/patient-id', + }, + }, + }, + }).setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuedAt() + .setIssuer(idpUrl) + .setAudience(`http://localhost:${umaPort}/uma`) + .setJti(randomUUID()) + .sign(jwk); + + const content: Record = { + grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket', + ticket: ticket, + claim_token: jwt, + claim_token_format: 'application/vc+jwt', + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(content), + }); + expect(response.status).toBe(200); + }); + + it('can remove the policy.', async(): Promise => { + const response = await fetch(joinUrl(policyEndpoint, encodeURIComponent('http://example.org/policyVc')), { + method: 'DELETE', + headers: { authorization: `WebID ${encodeURIComponent(webId)}`, 'content-type': 'text/turtle' }, + }); + expect(response.status).toBe(204); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 9a7bb991..c0373ff3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5668,14 +5668,15 @@ __metadata: "@httpland/authorization-parser": "npm:^1.1.0" "@solid/access-token-verifier": "npm:^1.2.0" "@solid/community-server": "npm:^8.0.0-alpha.1" + "@types/jsonpath": "npm:^0.2.4" "@types/ms": "npm:^2.1.0" "@types/n3": "npm:^1.16.4" asynchronous-handlers: "npm:^1.0.2" componentsjs: "npm:^6.3.0" - get-jwks: "npm:^9.0.1" global-logger-factory: "npm:^1.0.0" http-message-signatures: "npm:^1.0.4" jose: "npm:^5.2.2" + jsonpath: "npm:^1.3.0" logform: "npm:^2.6.0" ms: "npm:^2.1.3" n3: "npm:^1.17.2" @@ -5975,6 +5976,13 @@ __metadata: languageName: node linkType: hard +"@types/jsonpath@npm:^0.2.4": + version: 0.2.4 + resolution: "@types/jsonpath@npm:0.2.4" + checksum: 10c0/3fdf725e5db61a0abb0afa95667e561a0e5f3604f30b609497af135606f4721449d7235ca8304da4984fb2ac4f6d290ad49de5ad6878740555542ddd28ee0b69 + languageName: node + linkType: hard + "@types/keygrip@npm:*": version: 1.0.6 resolution: "@types/keygrip@npm:1.0.6" @@ -6900,18 +6908,6 @@ __metadata: languageName: node linkType: hard -"asn1.js@npm:^5.3.0": - version: 5.4.1 - resolution: "asn1.js@npm:5.4.1" - dependencies: - bn.js: "npm:^4.0.0" - inherits: "npm:^2.0.1" - minimalistic-assert: "npm:^1.0.0" - safer-buffer: "npm:^2.1.0" - checksum: 10c0/b577232fa6069cc52bb128e564002c62b2b1fe47f7137bdcd709c0b8495aa79cee0f8cc458a831b2d8675900eea0d05781b006be5e1aa4f0ae3577a73ec20324 - languageName: node - linkType: hard - "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -7022,13 +7018,6 @@ __metadata: languageName: node linkType: hard -"bn.js@npm:^4.0.0, bn.js@npm:^4.11.9": - version: 4.12.0 - resolution: "bn.js@npm:4.12.0" - checksum: 10c0/9736aaa317421b6b3ed038ff3d4491935a01419ac2d83ddcfebc5717385295fcfcf0c57311d90fe49926d0abbd7a9dbefdd8861e6129939177f7e67ebc645b21 - languageName: node - linkType: hard - "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -7057,13 +7046,6 @@ __metadata: languageName: node linkType: hard -"brorand@npm:^1.1.0": - version: 1.1.0 - resolution: "brorand@npm:1.1.0" - checksum: 10c0/6f366d7c4990f82c366e3878492ba9a372a73163c09871e80d82fb4ae0d23f9f8924cb8a662330308206e6b3b76ba1d528b4601c9ef73c2166b440b2ea3b7571 - languageName: node - linkType: hard - "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -8122,21 +8104,6 @@ __metadata: languageName: node linkType: hard -"elliptic@npm:^6.5.4": - version: 6.5.5 - resolution: "elliptic@npm:6.5.5" - dependencies: - bn.js: "npm:^4.11.9" - brorand: "npm:^1.1.0" - hash.js: "npm:^1.0.0" - hmac-drbg: "npm:^1.0.1" - inherits: "npm:^2.0.4" - minimalistic-assert: "npm:^1.0.1" - minimalistic-crypto-utils: "npm:^1.0.1" - checksum: 10c0/3e591e93783a1b66f234ebf5bd3a8a9a8e063a75073a35a671e03e3b25253b6e33ac121f7efe9b8808890fffb17b40596cc19d01e6e8d1fa13b9a56ff65597c8 - languageName: node - linkType: hard - "emoji-regex@npm:^10.3.0": version: 10.4.0 resolution: "emoji-regex@npm:10.4.0" @@ -8400,6 +8367,24 @@ __metadata: languageName: node linkType: hard +"escodegen@npm:^2.1.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 10c0/e1450a1f75f67d35c061bf0d60888b15f62ab63aef9df1901cffc81cffbbb9e8b3de237c5502cf8613a017c1df3a3003881307c78835a1ab54d8c8d2206e01d3 + languageName: node + linkType: hard + "eslint-scope@npm:^5.1.1": version: 5.1.1 resolution: "eslint-scope@npm:5.1.1" @@ -8486,6 +8471,26 @@ __metadata: languageName: node linkType: hard +"esprima@npm:1.2.5": + version: 1.2.5 + resolution: "esprima@npm:1.2.5" + bin: + esparse: ./bin/esparse.js + esvalidate: ./bin/esvalidate.js + checksum: 10c0/634f272901b48174b84fd59ae6d9fbe03af38cfaa60501d8c0c7227d96ac53aef232e6fafda7c9760e50997f675e1c828e0b29aa39b092982960acf5e937db8f + languageName: node + linkType: hard + +"esprima@npm:^4.0.1": + version: 4.0.1 + resolution: "esprima@npm:4.0.1" + bin: + esparse: ./bin/esparse.js + esvalidate: ./bin/esvalidate.js + checksum: 10c0/ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3 + languageName: node + linkType: hard + "esquery@npm:^1.4.2": version: 1.5.0 resolution: "esquery@npm:1.5.0" @@ -8979,17 +8984,6 @@ __metadata: languageName: node linkType: hard -"get-jwks@npm:^9.0.1": - version: 9.0.1 - resolution: "get-jwks@npm:9.0.1" - dependencies: - jwk-to-pem: "npm:^2.0.4" - lru-cache: "npm:^10.0.0" - node-fetch: "npm:^2.6.1" - checksum: 10c0/3afc372721f71c19dfeba9d8c1c1215799d2940eb6d2f0b09cc6f1ef7d6ff5d1707b9ed42e231dbef1741f537ecca14691bba48426510966fee174f1fc427201 - languageName: node - linkType: hard - "get-pkg-repo@npm:^4.2.1": version: 4.2.1 resolution: "get-pkg-repo@npm:4.2.1" @@ -9292,7 +9286,7 @@ __metadata: languageName: node linkType: hard -"hash.js@npm:^1.0.0, hash.js@npm:^1.0.3, hash.js@npm:^1.1.7": +"hash.js@npm:^1.1.7": version: 1.1.7 resolution: "hash.js@npm:1.1.7" dependencies: @@ -9320,17 +9314,6 @@ __metadata: languageName: node linkType: hard -"hmac-drbg@npm:^1.0.1": - version: 1.0.1 - resolution: "hmac-drbg@npm:1.0.1" - dependencies: - hash.js: "npm:^1.0.3" - minimalistic-assert: "npm:^1.0.0" - minimalistic-crypto-utils: "npm:^1.0.1" - checksum: 10c0/f3d9ba31b40257a573f162176ac5930109816036c59a09f901eb2ffd7e5e705c6832bedfff507957125f2086a0ab8f853c0df225642a88bf1fcaea945f20600d - languageName: node - linkType: hard - "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -9594,7 +9577,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10140,14 +10123,14 @@ __metadata: languageName: node linkType: hard -"jwk-to-pem@npm:^2.0.4": - version: 2.0.5 - resolution: "jwk-to-pem@npm:2.0.5" +"jsonpath@npm:^1.3.0": + version: 1.3.0 + resolution: "jsonpath@npm:1.3.0" dependencies: - asn1.js: "npm:^5.3.0" - elliptic: "npm:^6.5.4" - safe-buffer: "npm:^5.0.1" - checksum: 10c0/307cacfbf4f38b4c4c77bcf3e578ef0d1d9536a9323e4f5be7e55e777d9df4fb5e89abbbfaf7c080b9f9da1241217f10391e4114c7a72cac66411d374427eeb9 + esprima: "npm:1.2.5" + static-eval: "npm:2.1.1" + underscore: "npm:1.13.6" + checksum: 10c0/c7464919dd012837091febc393f4f743b82c422e3e8b4f2677119e7f8a37d8fd32f4969699c029fce3f31569a400b275e9ef089047a06548235089989be35bbe languageName: node linkType: hard @@ -10624,20 +10607,13 @@ __metadata: languageName: node linkType: hard -"minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1": +"minimalistic-assert@npm:^1.0.1": version: 1.0.1 resolution: "minimalistic-assert@npm:1.0.1" checksum: 10c0/96730e5601cd31457f81a296f521eb56036e6f69133c0b18c13fe941109d53ad23a4204d946a0d638d7f3099482a0cec8c9bb6d642604612ce43ee536be3dddd languageName: node linkType: hard -"minimalistic-crypto-utils@npm:^1.0.1": - version: 1.0.1 - resolution: "minimalistic-crypto-utils@npm:1.0.1" - checksum: 10c0/790ecec8c5c73973a4fbf2c663d911033e8494d5fb0960a4500634766ab05d6107d20af896ca2132e7031741f19888154d44b2408ada0852446705441383e9f8 - languageName: node - linkType: hard - "minimatch@npm:9.0.5, minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" @@ -10870,7 +10846,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7, node-fetch@npm:^2.7.0": +"node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -12439,7 +12415,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -12460,7 +12436,7 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.1.0": +"safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 @@ -12674,7 +12650,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.1": +"source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 @@ -12866,6 +12842,15 @@ __metadata: languageName: node linkType: hard +"static-eval@npm:2.1.1": + version: 2.1.1 + resolution: "static-eval@npm:2.1.1" + dependencies: + escodegen: "npm:^2.1.0" + checksum: 10c0/ad8ab8f86e6f82e3ff6c80d60a4c0ccfb086082cf594fa71a5c38cb6ed59607660e7a3e0103f3583ca38716744321d487bbe55a5ae2c6a9c965b5cf4d4cf2960 + languageName: node + linkType: hard + "statuses@npm:2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1" @@ -13571,6 +13556,13 @@ __metadata: languageName: node linkType: hard +"underscore@npm:1.13.6": + version: 1.13.6 + resolution: "underscore@npm:1.13.6" + checksum: 10c0/5f57047f47273044c045fddeb8b141dafa703aa487afd84b319c2495de2e685cecd0b74abec098292320d518b267c0c4598e45aa47d4c3628d0d4020966ba521 + languageName: node + linkType: hard + "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0"