From 28ccd17cc42e110b3de059478f93b5a8cebb04cc Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 25 Aug 2026 10:32:31 +0200 Subject: [PATCH] feat: Support client authentication for UMA APIs --- .../config/credentials/validators/pat.json | 18 ++++- packages/uma/src/index.ts | 1 + .../CredentialVerifierRequestValidator.ts | 30 ++++++++ .../util/http/validate/PatRequestValidator.ts | 13 +++- ...CredentialVerifierRequestValidator.test.ts | 71 +++++++++++++++++++ .../http/validate/PatRequestValidator.test.ts | 26 +++---- 6 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 packages/uma/src/util/http/validate/CredentialVerifierRequestValidator.ts create mode 100644 packages/uma/test/unit/util/http/validate/CredentialVerifierRequestValidator.test.ts diff --git a/packages/uma/config/credentials/validators/pat.json b/packages/uma/config/credentials/validators/pat.json index 947fdc66..45a7aa12 100644 --- a/packages/uma/config/credentials/validators/pat.json +++ b/packages/uma/config/credentials/validators/pat.json @@ -1,12 +1,24 @@ { "@context": [ - "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld" + "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^8.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/asynchronous-handlers/^1.0.0/components/context.jsonld" ], "@graph": [ { "@id": "urn:uma:default:RequestValidator", - "@type": "PatRequestValidator", - "storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" } + "@type": "StatusWaterfallHandler", + "handlers": [ + { + "@type": "PatRequestValidator", + "storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" } + }, + { + "@type": "CredentialVerifierRequestValidator", + "credentialParser": { "@id": "urn:uma:default:CredentialParser" }, + "verifier": { "@id": "urn:uma:default:Verifier" } + } + ] } ] } diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index afa9e5e0..58e5273e 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -88,6 +88,7 @@ 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/CredentialVerifierRequestValidator'; export * from './util/http/validate/PatRequestValidator'; export * from './util/http/validate/RequestValidator'; diff --git a/packages/uma/src/util/http/validate/CredentialVerifierRequestValidator.ts b/packages/uma/src/util/http/validate/CredentialVerifierRequestValidator.ts new file mode 100644 index 00000000..feae4f18 --- /dev/null +++ b/packages/uma/src/util/http/validate/CredentialVerifierRequestValidator.ts @@ -0,0 +1,30 @@ +import { UnauthorizedHttpError } from '@solid/community-server'; +import { WEBID } from '../../../credentials/Claims'; +import { CredentialParser } from '../../../credentials/CredentialParser'; +import { Verifier } from '../../../credentials/verify/Verifier'; +import { RequestValidator, RequestValidatorInput, RequestValidatorOutput } from './RequestValidator'; + +/** + * Validates requests by verifying the Credential and extracting the owner. + */ +export class CredentialVerifierRequestValidator extends RequestValidator { + public constructor( + protected readonly credentialParser: CredentialParser, + protected readonly verifier: Verifier, + ) { + super(); + } + + public async canHandle({ request }: RequestValidatorInput): Promise { + await this.credentialParser.canHandle(request); + } + + public async handle({ request }: RequestValidatorInput): Promise { + const result = await this.credentialParser.handle(request); + const claims = await this.verifier.verify(result); + if (!claims[WEBID] || claims[WEBID].length === 0) { + throw new UnauthorizedHttpError('Could not determine owner.'); + } + return { owner: claims[WEBID][0] as string}; + } +} diff --git a/packages/uma/src/util/http/validate/PatRequestValidator.ts b/packages/uma/src/util/http/validate/PatRequestValidator.ts index a1fde1ce..871c0be9 100644 --- a/packages/uma/src/util/http/validate/PatRequestValidator.ts +++ b/packages/uma/src/util/http/validate/PatRequestValidator.ts @@ -27,17 +27,24 @@ export class PatRequestValidator extends RequestValidator { this.storage = storage; } - public async handle({ request }: RequestValidatorInput): Promise { + public async canHandle({ request }: RequestValidatorInput): Promise { const { authorization } = request.headers; if (!authorization || !/^Bearer /ui.test(authorization)) { throw new UnauthorizedHttpError('No Bearer Authorization header specified.'); } const token = authorization?.replace(/^Bearer/, '')?.trimStart(); - const patEntries = await this.storage.find(PAT_STORAGE_TYPE, { pat: token }); - if (patEntries.length === 0) { + const patIds = await this.storage.findIds(PAT_STORAGE_TYPE, { pat: token }); + if (patIds.length === 0) { throw new ForbiddenHttpError('Unknown PAT.'); } + } + + public async handle({ request }: RequestValidatorInput): Promise { + const { authorization } = request.headers; + + const token = authorization?.replace(/^Bearer/, '')?.trimStart(); + const patEntries = await this.storage.find(PAT_STORAGE_TYPE, { pat: token }); if (patEntries[0].expiration < Date.now()) { throw new ForbiddenHttpError('Expired PAT.'); } diff --git a/packages/uma/test/unit/util/http/validate/CredentialVerifierRequestValidator.test.ts b/packages/uma/test/unit/util/http/validate/CredentialVerifierRequestValidator.test.ts new file mode 100644 index 00000000..e01b505e --- /dev/null +++ b/packages/uma/test/unit/util/http/validate/CredentialVerifierRequestValidator.test.ts @@ -0,0 +1,71 @@ +import { BadRequestHttpError } from '@solid/community-server'; +import { Mocked } from 'vitest'; +import { WEBID } from '../../../../../src/credentials/Claims'; +import { ClaimSet } from '../../../../../src/credentials/ClaimSet'; +import { CredentialParser } from '../../../../../src/credentials/CredentialParser'; +import { Verifier } from '../../../../../src/credentials/verify/Verifier'; +import { + CredentialVerifierRequestValidator +} from '../../../../../src/util/http/validate/CredentialVerifierRequestValidator'; +import { RequestValidatorInput } from '../../../../../src/util/http/validate/RequestValidator'; + +describe('CredentialVerifierRequestValidator', (): void => { + const webid = 'https://example.com/profile/card#me'; + let input: RequestValidatorInput; + let claims: ClaimSet; + let tokenResponse: { token: string, format: string }; + + let credentialParser: Mocked; + let verifier: Mocked; + let validator: CredentialVerifierRequestValidator; + + beforeEach(async(): Promise => { + input = { + request: { + url: new URL('http://example.com/foo'), + method: 'GET', + headers: { authorization: 'Bearer token' }, + } + }; + + tokenResponse = { + token: 'token', + format: 'format', + }; + + claims = { + [WEBID]: [ webid ], + }; + + credentialParser = { + canHandle: vi.fn(), + handle: vi.fn().mockResolvedValue(tokenResponse), + } satisfies Partial as any; + + verifier = { + verify: vi.fn().mockResolvedValue(claims), + }; + + validator = new CredentialVerifierRequestValidator(credentialParser, verifier); + }); + + it('can handle requests the verifier can handle.', async(): Promise => { + await expect(validator.canHandle(input)).resolves.toBeUndefined(); + expect(credentialParser.canHandle).toHaveBeenLastCalledWith(input.request); + + credentialParser.canHandle.mockRejectedValueOnce(new BadRequestHttpError('bad data')); + await expect(validator.canHandle(input)).rejects.toThrow('bad data'); + }); + + it('returns the WEBID claim as owner.', async(): Promise => { + await expect(validator.handle(input)).resolves.toEqual({ owner: webid }); + expect(credentialParser.handle).toHaveBeenLastCalledWith(input.request); + expect(verifier.verify).toHaveBeenLastCalledWith(tokenResponse); + }); + + it('errors if there is no WEBID claim.', async(): Promise => { + claims = {}; + verifier.verify.mockResolvedValueOnce(claims); + await expect(validator.handle(input)).rejects.toThrow('Could not determine owner.'); + }); +}); diff --git a/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts b/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts index e85a78dd..ed41d719 100644 --- a/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts +++ b/packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts @@ -30,31 +30,31 @@ describe('PatRequestValidator', (): void => { storage = { find: vi.fn().mockResolvedValue([{ expiration: Date.now() + 5000, registration: registrationId }]), + findIds: vi.fn().mockResolvedValue([ registrationId ]), get: vi.fn().mockResolvedValue({ userId }), } as any; validator = new PatRequestValidator(storage as any); }); - it('returns the stored user as owner.', async(): Promise => { - await expect(validator.handle({ request })).resolves.toEqual({ owner: userId }); - expect(storage.find).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { pat }); - expect(storage.get).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, registrationId); + it('can only handle Bearer tokens.', async(): Promise => { + request.headers.authorization = 'Basic 1234'; + await expect(validator.canHandle({ request })).rejects.toThrow('No Bearer Authorization header specified.'); }); - it('errors on non-Bearer tokens.', async(): Promise => { - request.headers.authorization = 'Basic 1234'; - await expect(validator.handle({ request })).rejects.toThrow('No Bearer Authorization header specified.'); + it('can only handle known tokens.', async(): Promise => { + storage.findIds.mockResolvedValueOnce([]); + await expect(validator.canHandle({ request })).rejects.toThrow('Unknown PAT.'); }); - it('errors on non-Bearer tokens.', async(): Promise => { - request.headers.authorization = 'Basic 1234'; - await expect(validator.handle({ request })).rejects.toThrow('No Bearer Authorization header specified.'); + it('can handle known Bearer tokens.', async(): Promise => { + await expect(validator.canHandle({ request })).resolves.toBeUndefined(); }); - it('errors if no matched token was found.', async(): Promise => { - storage.find.mockResolvedValueOnce([]); - await expect(validator.handle({ request })).rejects.toThrow('Unknown PAT.'); + it('returns the stored user as owner.', async(): Promise => { + await expect(validator.handle({ request })).resolves.toEqual({ owner: userId }); + expect(storage.find).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { pat }); + expect(storage.get).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, registrationId); }); it('errors if the PAT is expired.', async(): Promise => {