From ebecef4f35c96cadf1d0efa0a2247eddcc37a765 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 26 May 2026 13:56:14 +0200 Subject: [PATCH 1/4] feat: Split resource_scopes per resource for error responses --- .../src/ticketing/strategy/ImmediateAuthorizerStrategy.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts index 664da7a..cd76e3a 100644 --- a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts +++ b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts @@ -67,11 +67,9 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy { } if (unmatchedPermissions.length > 0) { - // TODO: due to the current format, scopes are not linked to resources, - // so this will be weird for requests with multiple target resources. - return Failure([{ - resource_scopes: unmatchedPermissions.flatMap((perm) => perm.resource_scopes), - }]); + // TODO: RequiredClaim has no field indicating for which the scopes are missing, could add one. + // The resource_scopes field itself is already custom (added because of aggregation spec) + return Failure(unmatchedPermissions.map((perm) => ({ resource_scopes: perm.resource_scopes }))); } return Success(permissions); From fc762cc9ed3089875ed8796ebabdf9d1e1919c5f Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 26 May 2026 14:10:32 +0200 Subject: [PATCH 2/4] feat: Allow ImmediateAuthorizerStrategy to return partial results --- .../strategy/ImmediateAuthorizerStrategy.ts | 10 +++++++- .../ImmediateAuthorizerStrategy.test.ts | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts index cd76e3a..4aa5de7 100644 --- a/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts +++ b/packages/uma/src/ticketing/strategy/ImmediateAuthorizerStrategy.ts @@ -10,12 +10,17 @@ import { Authorizer } from "../../policies/authorizers/Authorizer"; /** * A TicketingStrategy that simply stores provided Claims, and calculates all * available Permissions from them upon resolution. + * + * If `allowPartialSuccess` is set to true, + * the strategy will return all available permissions, + * even if not all required permissions are granted. */ export class ImmediateAuthorizerStrategy implements TicketingStrategy { protected readonly logger = getLoggerFor(this); constructor( protected authorizer: Authorizer, + protected readonly allowPartialSuccess = false, ) {} /** @inheritdoc */ @@ -45,9 +50,12 @@ export class ImmediateAuthorizerStrategy implements TicketingStrategy { const permissions = await this.calculatePermissions(ticket); + // Always fail if no results at all, even with allowPartialSuccess if (permissions.length === 0) return Failure([]); - // TODO: if, in the future, we want to allow partial results, this will need to change + // With partial success enabled, any non-empty authorization result is accepted + if (this.allowPartialSuccess) return Success(permissions); + // Verify all required scopes have been granted const unmatchedPermissions: Permission[] = []; for (const required of ticket.permissions) { diff --git a/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts index 920260a..06cf879 100644 --- a/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts +++ b/packages/uma/test/unit/ticketing/strategy/ImmediateAuthorizerStrategy.test.ts @@ -64,4 +64,29 @@ describe('ImmediateAuthorizerStrategy', (): void => { await expect(strategy.resolveTicket(ticket)).resolves .toEqual({ success: false, value: [{ resource_scopes: ['scopes'] }] }); }); + + it('resolves with partial permissions when allowPartialSuccess is enabled.', async(): Promise => { + const partialStrategy = new ImmediateAuthorizerStrategy(authorizer, true); + const ticket: Ticket = { + permissions: [{ resource_id: 'id', resource_scopes: [ 'read', 'write' ] }], + provided: {}, + }; + const authResponse: Permission[] = [ + { resource_id: 'id', resource_scopes: [ 'read' ] }, + ]; + authorizer.permissions.mockResolvedValueOnce(authResponse); + await expect(partialStrategy.resolveTicket(ticket)).resolves + .toEqual({ success: true, value: authResponse }); + }); + + it('rejects when no permissions are resolved, even with allowPartialSuccess enabled.', async(): Promise => { + const partialStrategy = new ImmediateAuthorizerStrategy(authorizer, true); + const ticket: Ticket = { + permissions, + provided: {}, + }; + authorizer.permissions.mockResolvedValueOnce([]); + await expect(partialStrategy.resolveTicket(ticket)).resolves + .toEqual({ success: false, value: [] }); + }); }); From afa61209438f2875439daedb16cf8ce11109a358 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 8 Jun 2026 09:43:17 +0200 Subject: [PATCH 3/4] feat: Return tokens with partial permissions when enabled --- packages/uma/config/enable-partial.json | 12 + .../strategy/immediate-authorizer.json | 1 + packages/uma/src/dialog/BaseNegotiator.ts | 28 ++ packages/uma/src/dialog/Output.ts | 1 + .../test/unit/dialog/BaseNegotiator.test.ts | 24 +- test/integration/Partial.test.ts | 252 ++++++++++++++++++ test/util/ServerUtil.ts | 1 + 7 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 packages/uma/config/enable-partial.json create mode 100644 test/integration/Partial.test.ts diff --git a/packages/uma/config/enable-partial.json b/packages/uma/config/enable-partial.json new file mode 100644 index 0000000..f927cd1 --- /dev/null +++ b/packages/uma/config/enable-partial.json @@ -0,0 +1,12 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld" + ], + "@graph": [ + { + "@id": "urn:uma:default:ImmediateAuthorizerStrategy", + "@type": "ImmediateAuthorizerStrategy", + "allowPartialSuccess": true + } + ] +} diff --git a/packages/uma/config/tickets/strategy/immediate-authorizer.json b/packages/uma/config/tickets/strategy/immediate-authorizer.json index cede677..ae69440 100644 --- a/packages/uma/config/tickets/strategy/immediate-authorizer.json +++ b/packages/uma/config/tickets/strategy/immediate-authorizer.json @@ -9,6 +9,7 @@ "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }, "derivationStore": { "@id": "urn:uma:default:DerivationStore" }, "strategy": { + "@id": "urn:uma:default:ImmediateAuthorizerStrategy", "@type": "ImmediateAuthorizerStrategy", "authorizer": { "@id": "urn:uma:default:Authorizer" } } diff --git a/packages/uma/src/dialog/BaseNegotiator.ts b/packages/uma/src/dialog/BaseNegotiator.ts index f61f530..f72a806 100644 --- a/packages/uma/src/dialog/BaseNegotiator.ts +++ b/packages/uma/src/dialog/BaseNegotiator.ts @@ -9,6 +9,7 @@ import { TicketingStrategy } from '../ticketing/strategy/TicketingStrategy'; import { Ticket } from '../ticketing/Ticket'; import { TokenFactory } from '../tokens/TokenFactory'; import { reType } from '../util/ReType'; +import { Permission } from '../views/Permission'; import { DialogInput } from './Input'; import { Negotiator } from './Negotiator'; import { DialogOutput } from './Output'; @@ -55,6 +56,7 @@ export class BaseNegotiator implements Negotiator { // ... on success, create Access Token if (resolved.success) { + const partial = this.isPartialResult(updatedTicket.permissions, resolved.value); // Retrieve / create instantiated policy const { token, tokenType } = await this.tokenFactory.serialize({ permissions: resolved.value }); @@ -68,6 +70,7 @@ export class BaseNegotiator implements Negotiator { return ({ access_token: token, token_type: tokenType, + ...(partial ? { partial: true } : {}), }); } @@ -87,6 +90,31 @@ export class BaseNegotiator implements Negotiator { }); } + /** + * Checks if the granted permissions are a partial result of the requested permissions. + * + * Currently, the responsibility to verify that a result is partial lies here and not with the strategy. + * An alternative would be to let the strategy include an additional parameter indicating the result is partial. + */ + protected isPartialResult(requested: Permission[], granted: Permission[]): boolean { + if (requested.length !== granted.length) { + return true; + } + + for (const request of requested) { + const match = granted.find((grant): boolean => + grant.resource_id === request.resource_id && + grant.resource_scopes.length === request.resource_scopes.length && + request.resource_scopes.every((scope): boolean => grant.resource_scopes.includes(scope)) + ); + if (!match) { + return true; + } + } + + return false; + } + /** * Helper function that retrieves a Ticket from the TicketStore if it exists, * or initializes a new one otherwise. diff --git a/packages/uma/src/dialog/Output.ts b/packages/uma/src/dialog/Output.ts index 36d55b6..f5c7f17 100644 --- a/packages/uma/src/dialog/Output.ts +++ b/packages/uma/src/dialog/Output.ts @@ -7,6 +7,7 @@ export const DialogOutput = ({ access_token: string, refresh_token: $(string), token_type: string, + partial: $(boolean), expires_in: $(number), upgraded: $(boolean), derivation_resource_id: $(string), diff --git a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts index e2ba3b1..6fe3273 100644 --- a/packages/uma/test/unit/dialog/BaseNegotiator.test.ts +++ b/packages/uma/test/unit/dialog/BaseNegotiator.test.ts @@ -49,7 +49,7 @@ describe('BaseNegotiator', (): void => { validateClaims: vi.fn().mockResolvedValue(ticket), resolveTicket: vi.fn().mockResolvedValue({ success: true, - value: { resource_id: 'id1', resource_scopes: [ 'scope1' ] }, + value: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ], }), }; @@ -76,7 +76,7 @@ describe('BaseNegotiator', (): void => { expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(0); expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); expect(tokenFactory.serialize).toHaveBeenLastCalledWith( - { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] }); }); it('errors if there is no existing ticket and no permission request.', async(): Promise => { @@ -128,7 +128,7 @@ describe('BaseNegotiator', (): void => { expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(0); expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); expect(tokenFactory.serialize).toHaveBeenLastCalledWith( - { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] }); }); it('errors if invalid credentials are provided.', async(): Promise => { @@ -152,7 +152,7 @@ describe('BaseNegotiator', (): void => { expect(ticketingStrategy.validateClaims).toHaveBeenLastCalledWith(ticket, claims); expect(tokenFactory.serialize).toHaveBeenCalledTimes(1); expect(tokenFactory.serialize).toHaveBeenLastCalledWith( - { permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } }); + { permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] }); }); it('supports multiple claim tokens.', async(): Promise => { @@ -171,4 +171,20 @@ describe('BaseNegotiator', (): void => { expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(2); expect(ticketingStrategy.validateClaims).toHaveBeenCalledWith(ticket, claims); }); + + it('includes partial=true when resolved permissions do not cover all requested scopes.', async(): Promise => { + ticketingStrategy.initializeTicket.mockResolvedValueOnce({ + permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1', 'scope2' ] } ], + provided: {}, + }); + ticketingStrategy.resolveTicket.mockResolvedValueOnce({ + success: true, + value: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ], + }); + + await expect(negotiator.negotiate({ + permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1', 'scope2' ] } ] + })) + .resolves.toEqual({ access_token: 'token', token_type: 'type', partial: true }); + }); }); diff --git a/test/integration/Partial.test.ts b/test/integration/Partial.test.ts new file mode 100644 index 0000000..a6b8b9e --- /dev/null +++ b/test/integration/Partial.test.ts @@ -0,0 +1,252 @@ +import { App } from '@solid/community-server'; +import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory'; +import { createServer, Server } from 'node:http'; +import path from 'node:path'; +import { getPorts, instantiateFromConfig } from '../util/ServerUtil'; +import { getToken, umaFetch } from '../util/UmaUtil'; + +const [ rsPort, umaPort ] = getPorts('Partial'); + +interface UmaConfig { + issuer: string; + permission_endpoint: string; + resource_registration_endpoint: string; + token_endpoint: string; + registration_endpoint: string; +} + +describe('A server with partial results enabled', (): void => { + const owner = 'http://example.com/alice#me'; + const user = `http://example.com/bob`; + const resource = `http://localhost:${rsPort}/alice/data`; + const readScope = 'http://www.w3.org/ns/odrl/2/read'; + const writeScope = 'http://www.w3.org/ns/odrl/2/write'; + let umaApp: App; + let rsServer: Server; + let umaConfig: UmaConfig; + let pat: string; + + beforeAll(async(): Promise => { + setGlobalLoggerFactory(new WinstonLoggerFactory('off')); + + umaApp = await instantiateFromConfig( + 'urn:uma:default:App', + [ + path.join(__dirname, '../../packages/uma/config/default.json'), + path.join(__dirname, '../../packages/uma/config/enable-partial.json'), + ], + { + 'urn:uma:variables:port': umaPort, + 'urn:uma:variables:baseUrl': `http://localhost:${umaPort}/uma`, + 'urn:uma:variables:backupFilePath': '', + } + ); + await umaApp.start(); + + const configResponse = await fetch(`http://localhost:${umaPort}/uma/.well-known/uma2-configuration`); + expect(configResponse.status).toBe(200); + umaConfig = await configResponse.json() as UmaConfig; + + const registrationResponse = await fetch(umaConfig.registration_endpoint, { + method: 'POST', + headers: { + authorization: `WebID ${encodeURIComponent(owner)}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ client_uri: `http://localhost:${rsPort}/` }), + }); + expect(registrationResponse.status).toBe(201); + const { client_id, client_secret } = await registrationResponse.json() as { + client_id: string, + client_secret: string, + }; + + const authString = `${encodeURIComponent(client_id)}:${encodeURIComponent(client_secret)}`; + const credentials = `Basic ${Buffer.from(authString).toString('base64')}`; + const patResponse = await fetch(umaConfig.token_endpoint, { + method: 'POST', + headers: { + authorization: credentials, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials&scope=uma_protection', + }); + expect(patResponse.status).toBe(201); + const patJson = await patResponse.json() as { access_token: string, token_type: string }; + pat = `${patJson.token_type} ${patJson.access_token}`; + + const registrationBody = { + name: resource, + resource_scopes: [ readScope, writeScope ], + }; + const resourceRegistrationResponse = await fetch(umaConfig.resource_registration_endpoint, { + method: 'POST', + headers: { + Authorization: pat, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(registrationBody), + }); + expect(resourceRegistrationResponse.status).toBe(201); + + rsServer = createServer((request, response): void => { + void (async(): Promise => { + if (!request.url || !request.url.startsWith('/alice/data')) { + response.statusCode = 404; + response.end(); + return; + } + + const auth = request.headers.authorization; + if (hasScope(auth, resource, readScope)) { + response.statusCode = 200; + response.end('protected data'); + return; + } + + const permissionResponse = await fetch(umaConfig.permission_endpoint, { + method: 'POST', + headers: { + Authorization: pat, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify([ + { + resource_id: resource, + resource_scopes: [ readScope ], + } + ]), + }); + + if (permissionResponse.status !== 201) { + response.statusCode = 500; + response.end(await permissionResponse.text()); + return; + } + + const { ticket } = await permissionResponse.json() as { ticket: string }; + response.statusCode = 401; + response.setHeader('WWW-Authenticate', `UMA realm="solid", as_uri="${umaConfig.issuer}", ticket="${ticket}"`); + response.end(); + })().catch((error: unknown): void => { + response.statusCode = 500; + response.end(String(error)); + }); + }); + + await new Promise((resolve): void => { + rsServer.listen(rsPort, resolve); + }); + }); + + afterAll(async(): Promise => { + const shutdown: Promise[] = []; + if (umaApp) { + shutdown.push(umaApp.stop()); + } + if (rsServer) { + shutdown.push(new Promise((resolve, reject): void => { + rsServer.close((error): void => { + if (error) { + reject(error); + return; + } + resolve(); + }); + })); + } + await Promise.all(shutdown); + }); + + it('can create a policy for the protected resource.', async(): Promise => { + const policy = ` + @prefix ex: . + @prefix odrl: . + + ex:policy a odrl:Agreement ; + odrl:uid ex:policy ; + odrl:permission ex:ownerPermission, ex:userPermission . + + ex:ownerPermission a odrl:Permission ; + odrl:assignee <${owner}> ; + odrl:assigner <${owner}> ; + odrl:action odrl:create, odrl:modify ; + odrl:target , + <${resource}> . + + ex:userPermission a odrl:Permission ; + odrl:assignee <${user}> ; + odrl:assigner <${owner}> ; + odrl:action odrl:read ; + odrl:target <${resource}> .`; + + const url = `http://localhost:${umaPort}/uma/policies`; + let response = await fetch(url, { + method: 'POST', + headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' }, + body: policy, + }); + expect(response.status).toBe(201); + + response = await umaFetch(resource, {}, user); + expect(response.status).toBe(200); + }); + + it('returns partial=true when not all requested scopes are granted.', async(): Promise => { + const permissionResponse = await fetch(umaConfig.permission_endpoint, { + method: 'POST', + headers: { + Authorization: pat, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify([ + { + resource_id: resource, + resource_scopes: [ readScope, writeScope ], + } + ]), + }); + expect(permissionResponse.status).toBe(201); + const { ticket } = await permissionResponse.json() as { ticket: string }; + + const token = await getToken(ticket, umaConfig.token_endpoint, user); + + // Verify partial flag is present + expect((token as unknown as { partial?: boolean }).partial).toBe(true); + + // Verify the token contains the allowed scope + const jwtPayload = JSON.parse(Buffer.from(token.access_token.split('.')[1], 'base64').toString()); + expect(Array.isArray(jwtPayload.permissions)).toBe(true); + expect(jwtPayload.permissions).toContainEqual({ + resource_id: resource, + resource_scopes: [ readScope ] + }); + }); + + it('can access a protected resource with partial results.', async(): Promise => { + const response = await umaFetch(resource, {}, user); + expect(response.status).toBe(200); + }); +}); + +function hasScope(authHeader: string | undefined, resource: string, scope: string): boolean { + if (!authHeader?.startsWith('Bearer ')) { + return false; + } + + try { + const token = authHeader.slice('Bearer '.length); + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()) as { + permissions?: { resource_id: string, resource_scopes: string[] }[] + }; + return Array.isArray(payload.permissions) + && payload.permissions.some((permission): boolean => + permission.resource_id === resource && permission.resource_scopes.includes(scope) + ); + } catch { + return false; + } +} diff --git a/test/util/ServerUtil.ts b/test/util/ServerUtil.ts index bc66601..4faf547 100644 --- a/test/util/ServerUtil.ts +++ b/test/util/ServerUtil.ts @@ -12,6 +12,7 @@ const portNames = [ 'Demo', 'ODRL', 'OIDC', + 'Partial', 'Policies', ] as const; From d3ab8328b2a6e16a19ed4a56866139a1a4202ada Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 8 Jun 2026 10:59:07 +0200 Subject: [PATCH 4/4] docs: Update documentation to explain partial token responses --- documentation/getting-started.md | 27 +++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 29 insertions(+) diff --git a/documentation/getting-started.md b/documentation/getting-started.md index e7eb5a2..0123e23 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -43,6 +43,7 @@ so some information might change depending on which version and branch you're us - [Authentication methods](#authentication-methods) - [Customizing OIDC verification](#customizing-oidc-verification) + [Generate token](#generate-token) + - [Partial permission tokens](#partial-permission-tokens) + [Use token](#use-token) * [Policies](#policies) + [Client application identification](#client-application-identification) @@ -381,6 +382,32 @@ If successful, the server will return a 200 response with a JSON body containing an `access_token` field containing the access token, and a `token_type` field describing the token type. If the claims are insufficient, a 403 response will be given instead. +#### Partial permission tokens + +It is possible to set up the server so it also returns tokens +if only some of the requested permissions are granted, +instead of returning a 403 response. +This can be useful for setups where the RS requires only one of the requested permissions to perform a request. +The disadvantage is that the client might receive a token +that does not have all permissions to perform the intended action. + +To enable this, start the UMA server with both `default.json` and `enable-partial.json`. + +From the repository root: +```bash +yarn start:uma -- -c ./config/default.json -c ./config/enable-partial.json +``` + +From `packages/uma`: +```bash +yarn start -c ./config/default.json -c ./config/enable-partial.json +``` + +With this enabled: +- If at least one requested permission can be authorized, the AS returns `200` with an access token. +- If not all requested permissions are granted, that response body includes `partial: true`. +- If no requested permission can be authorized, the AS returns `403`. + ### Use token When receiving the access token, the client can perform the same request as it did in the first step, diff --git a/package.json b/package.json index 8e61f52..376d9ec 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "build": "yarn workspaces foreach --include 'packages/*' -A -pi -j unlimited -t run build", "test": "vitest run", "start": "yarn workspaces foreach --include 'packages/*' -A -pi -j unlimited run start", + "start:uma": "yarn workspace @solidlab/uma run start", + "start:css": "yarn workspace @solidlab/uma-css run start", "start:odrl": "yarn workspace @solidlab/uma run start:odrl & yarn workspace @solidlab/uma-css run start", "start:demo": "yarn workspaces foreach --include 'packages/*' -A -pi -j unlimited run demo", "script:demo": "yarn exec tsx ./demo/flow.ts",