From fc988f69ec74e7ec534d098a9e6aa6078943fe06 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 26 Nov 2025 15:01:18 +0100 Subject: [PATCH] feat: Convert non-IRI claims to IRIs --- documentation/getting-started.md | 7 +++ .../config/credentials/verifiers/default.json | 6 +++ .../uma/src/credentials/verify/IriVerifier.ts | 32 ++++++++++++ packages/uma/src/index.ts | 1 + packages/uma/src/util/ConvertUtil.ts | 9 ++++ .../credentials/verify/IriVerifier.test.ts | 50 +++++++++++++++++++ .../uma/test/unit/util/ConvertUtil.test.ts | 10 +++- test/integration/Oidc.test.ts | 6 +-- 8 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 packages/uma/src/credentials/verify/IriVerifier.ts create mode 100644 packages/uma/test/unit/credentials/verify/IriVerifier.test.ts diff --git a/documentation/getting-started.md b/documentation/getting-started.md index 7cc717c..7356ce4 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -236,6 +236,13 @@ In that case the body is expected to be an OIDC ID token. Both Solid and standard OIDC tokens are supported. In case of standard tokens, the value of the `sub` field will be used to match the assignee in the policies. +The values that are extracted from the OIDC token are expected to be IRIs. +In case the `sub` or `azp`, which is discussed below, values are not IRIs, +the server wil internally convert them by URL encoding the value, and prepending them with `http://example.com/id/`. +This means that your policies should reference the converted ID. +For example, if your `sub` value is `my id`, your policy needs to target `http://example.com/id/my%20id`. +This base URL will be updated in the future once we have settled on a fixed value. + #### Customizing OIDC verification Several configuration options can be added to further restrict authentication when using OIDC tokens, diff --git a/packages/uma/config/credentials/verifiers/default.json b/packages/uma/config/credentials/verifiers/default.json index 51c11e4..7862b72 100644 --- a/packages/uma/config/credentials/verifiers/default.json +++ b/packages/uma/config/credentials/verifiers/default.json @@ -5,6 +5,12 @@ "@graph": [ { "@id": "urn:uma:default:Verifier", + "@type": "IriVerifier", + "verifier": { "@id": "urn:uma:default:TypedVerifier" }, + "baseUrl": "http://example.com/id/" + }, + { + "@id": "urn:uma:default:TypedVerifier", "@type": "TypedVerifier", "verifiers": [ { diff --git a/packages/uma/src/credentials/verify/IriVerifier.ts b/packages/uma/src/credentials/verify/IriVerifier.ts new file mode 100644 index 0000000..fb92fa0 --- /dev/null +++ b/packages/uma/src/credentials/verify/IriVerifier.ts @@ -0,0 +1,32 @@ +import { joinUrl } from '@solid/community-server'; +import { isIri } from '../../util/ConvertUtil'; +import { CLIENTID, WEBID } from '../Claims'; +import { ClaimSet } from '../ClaimSet'; +import { Credential } from '../Credential'; +import { Verifier } from './Verifier'; + +/** + * Converts the user ID and client ID values to IRIs in case they are not already IRIs. + */ +export class IriVerifier implements Verifier { + public constructor( + protected readonly verifier: Verifier, + protected readonly baseUrl: string, + ) {} + + public async verify(credential: Credential): Promise { + const claims = await this.verifier.verify(credential); + return { + ...claims, + ...typeof claims[WEBID] === 'string' ? { [WEBID]: this.toIri(claims[WEBID]) } : {}, + ...typeof claims[CLIENTID] === 'string' ? { [CLIENTID]: this.toIri(claims[CLIENTID]) } : {}, + }; + } + + protected toIri(value: string): string { + if (isIri(value)) { + return value; + } + return joinUrl(this.baseUrl, encodeURIComponent(value)); + } +} diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index b8737f6..9f01f70 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -15,6 +15,7 @@ export * from './credentials/verify/TypedVerifier'; export * from './credentials/verify/UnsecureVerifier'; export * from './credentials/verify/OidcVerifier'; export * from './credentials/verify/JwtVerifier'; +export * from './credentials/verify/IriVerifier'; // Dialog export * from './dialog/Input'; diff --git a/packages/uma/src/util/ConvertUtil.ts b/packages/uma/src/util/ConvertUtil.ts index e91a9f1..00f05d6 100644 --- a/packages/uma/src/util/ConvertUtil.ts +++ b/packages/uma/src/util/ConvertUtil.ts @@ -36,6 +36,15 @@ export function isPrimitive(val: unknown): val is string | number | boolean { return typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean'; } +export const IRI_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/; + +/** + * Uses a heuristic to estimate if a string is a valid IRI. + */ +export function isIri(input: string): boolean { + return IRI_REGEX.test(input); +} + /** * Write an N3 store to a string (in turtle format) */ diff --git a/packages/uma/test/unit/credentials/verify/IriVerifier.test.ts b/packages/uma/test/unit/credentials/verify/IriVerifier.test.ts new file mode 100644 index 0000000..1b6f6e0 --- /dev/null +++ b/packages/uma/test/unit/credentials/verify/IriVerifier.test.ts @@ -0,0 +1,50 @@ +import { Mocked } from 'vitest'; +import { CLIENTID, WEBID } from '../../../../src/credentials/Claims'; +import { Credential } from '../../../../src/credentials/Credential'; +import { IriVerifier } from '../../../../src/credentials/verify/IriVerifier'; +import { Verifier } from '../../../../src/credentials/verify/Verifier'; + +describe('IriVerifier', (): void => { + const credential: Credential = { token: 'token', format: 'format' }; + const baseUrl = 'http://example.com/id/'; + let source: Mocked; + let verifier: IriVerifier; + + beforeEach(async(): Promise => { + source = { + verify: vi.fn(), + }; + + verifier = new IriVerifier(source, baseUrl); + }); + + it('keeps the original user and client ID if they already are IRIs', async(): Promise => { + source.verify.mockResolvedValueOnce({ + [WEBID]: 'http://example.org/webId', + [CLIENTID]: 'http://example.org/clientId', + fruit: 'apple', + }); + await expect(verifier.verify(credential)).resolves.toEqual({ + [WEBID]: 'http://example.org/webId', + [CLIENTID]: 'http://example.org/clientId', + fruit: 'apple', + }); + expect(source.verify).toHaveBeenCalledTimes(1); + expect(source.verify).toHaveBeenLastCalledWith(credential); + }); + + it('changes the user and client ID to IRIs when required.', async(): Promise => { + source.verify.mockResolvedValueOnce({ + [WEBID]: 'webId', + [CLIENTID]: 'clientId', + fruit: 'http://example.org/apple', + }); + await expect(verifier.verify(credential)).resolves.toEqual({ + [WEBID]: 'http://example.com/id/webId', + [CLIENTID]: 'http://example.com/id/clientId', + fruit: 'http://example.org/apple', + }); + expect(source.verify).toHaveBeenCalledTimes(1); + expect(source.verify).toHaveBeenLastCalledWith(credential); + }); +}); diff --git a/packages/uma/test/unit/util/ConvertUtil.test.ts b/packages/uma/test/unit/util/ConvertUtil.test.ts index 8a21f9a..b55cfef 100644 --- a/packages/uma/test/unit/util/ConvertUtil.test.ts +++ b/packages/uma/test/unit/util/ConvertUtil.test.ts @@ -1,4 +1,4 @@ -import { isPrimitive, formToJson, jsonToForm } from '../../../src/util/ConvertUtil'; +import { isPrimitive, formToJson, jsonToForm, isIri } from '../../../src/util/ConvertUtil'; describe('ConvertUtil', (): void => { describe('#formToJson', (): void => { @@ -33,4 +33,12 @@ describe('ConvertUtil', (): void => { expect(isPrimitive({})).toBe(false); }); }); + + describe('#isIri', (): void => { + it('estimates if the string is a valid IRI.', async(): Promise => { + expect(isIri('apple')).toBe(false); + expect(isIri('urn:apple')).toBe(true); + expect(isIri('urn:ap ple')).toBe(false); + }); + }); }); diff --git a/test/integration/Oidc.test.ts b/test/integration/Oidc.test.ts index e155845..faadc73 100644 --- a/test/integration/Oidc.test.ts +++ b/test/integration/Oidc.test.ts @@ -93,7 +93,7 @@ describe('A server supporting OIDC tokens', (): void => { odrl:permission ex:permissionStandard . ex:permissionStandard a odrl:Permission ; - odrl:assignee <${sub}> ; + odrl:assignee ; odrl:assigner <${webId}>; odrl:action odrl:read , odrl:create , odrl:modify ; odrl:target .`; @@ -154,7 +154,7 @@ describe('A server supporting OIDC tokens', (): void => { odrl:permission ex:permissionStandardClient . ex:permissionStandardClient a odrl:Permission ; - odrl:assignee <${sub}> ; + odrl:assignee ; odrl:assigner <${webId}> ; odrl:action odrl:read , odrl:create , odrl:modify ; odrl:target ; @@ -163,7 +163,7 @@ describe('A server supporting OIDC tokens', (): void => { ex:constraintStandardClient odrl:leftOperand odrl:purpose ; odrl:operator odrl:eq ; - odrl:rightOperand <${client}> .`; + odrl:rightOperand .`; it('can set up the policy.', async(): Promise => { const response = await fetch(policyEndpoint, {