Skip to content

Commit 79aea8e

Browse files
committed
feat: Return tokens with partial permissions when enabled
1 parent 09fcf9a commit 79aea8e

7 files changed

Lines changed: 315 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"@context": [
3+
"https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld"
4+
],
5+
"@graph": [
6+
{
7+
"@id": "urn:uma:default:ImmediateAuthorizerStrategy",
8+
"@type": "ImmediateAuthorizerStrategy",
9+
"allowPartialSuccess": true
10+
}
11+
]
12+
}

packages/uma/config/tickets/strategy/immediate-authorizer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
1010
"derivationStore": { "@id": "urn:uma:default:DerivationStore" },
1111
"strategy": {
12+
"@id": "urn:uma:default:ImmediateAuthorizerStrategy",
1213
"@type": "ImmediateAuthorizerStrategy",
1314
"authorizer": { "@id": "urn:uma:default:Authorizer" }
1415
}

packages/uma/src/dialog/BaseNegotiator.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { TicketingStrategy } from '../ticketing/strategy/TicketingStrategy';
99
import { Ticket } from '../ticketing/Ticket';
1010
import { TokenFactory } from '../tokens/TokenFactory';
1111
import { reType } from '../util/ReType';
12+
import { Permission } from '../views/Permission';
1213
import { DialogInput } from './Input';
1314
import { Negotiator } from './Negotiator';
1415
import { DialogOutput } from './Output';
@@ -55,6 +56,7 @@ export class BaseNegotiator implements Negotiator {
5556

5657
// ... on success, create Access Token
5758
if (resolved.success) {
59+
const partial = this.isPartialResult(updatedTicket.permissions, resolved.value);
5860

5961
// Retrieve / create instantiated policy
6062
const { token, tokenType } = await this.tokenFactory.serialize({ permissions: resolved.value });
@@ -68,6 +70,7 @@ export class BaseNegotiator implements Negotiator {
6870
return ({
6971
access_token: token,
7072
token_type: tokenType,
73+
...(partial ? { partial: true } : {}),
7174
});
7275
}
7376

@@ -87,6 +90,31 @@ export class BaseNegotiator implements Negotiator {
8790
});
8891
}
8992

93+
/**
94+
* Checks if the granted permissions are a partial result of the requested permissions.
95+
*
96+
* Currently, the responsibility to verify that a result is partial lies here and not with the strategy.
97+
* An alternative would be to let the strategy include an additional parameter indicating the result is partial.
98+
*/
99+
protected isPartialResult(requested: Permission[], granted: Permission[]): boolean {
100+
if (requested.length !== granted.length) {
101+
return true;
102+
}
103+
104+
for (const request of requested) {
105+
const match = granted.find((grant): boolean =>
106+
grant.resource_id === request.resource_id &&
107+
grant.resource_scopes.length === request.resource_scopes.length &&
108+
request.resource_scopes.every((scope): boolean => grant.resource_scopes.includes(scope))
109+
);
110+
if (!match) {
111+
return true;
112+
}
113+
}
114+
115+
return false;
116+
}
117+
90118
/**
91119
* Helper function that retrieves a Ticket from the TicketStore if it exists,
92120
* or initializes a new one otherwise.

packages/uma/src/dialog/Output.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const DialogOutput = ({
77
access_token: string,
88
refresh_token: $(string),
99
token_type: string,
10+
partial: $(boolean),
1011
expires_in: $(number),
1112
upgraded: $(boolean),
1213
derivation_resource_id: $(string),

packages/uma/test/unit/dialog/BaseNegotiator.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('BaseNegotiator', (): void => {
4949
validateClaims: vi.fn().mockResolvedValue(ticket),
5050
resolveTicket: vi.fn().mockResolvedValue({
5151
success: true,
52-
value: { resource_id: 'id1', resource_scopes: [ 'scope1' ] },
52+
value: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ],
5353
}),
5454
};
5555

@@ -76,7 +76,7 @@ describe('BaseNegotiator', (): void => {
7676
expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(0);
7777
expect(tokenFactory.serialize).toHaveBeenCalledTimes(1);
7878
expect(tokenFactory.serialize).toHaveBeenLastCalledWith(
79-
{ permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } });
79+
{ permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] });
8080
});
8181

8282
it('errors if there is no existing ticket and no permission request.', async(): Promise<void> => {
@@ -128,7 +128,7 @@ describe('BaseNegotiator', (): void => {
128128
expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(0);
129129
expect(tokenFactory.serialize).toHaveBeenCalledTimes(1);
130130
expect(tokenFactory.serialize).toHaveBeenLastCalledWith(
131-
{ permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } });
131+
{ permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] });
132132
});
133133

134134
it('errors if invalid credentials are provided.', async(): Promise<void> => {
@@ -152,7 +152,7 @@ describe('BaseNegotiator', (): void => {
152152
expect(ticketingStrategy.validateClaims).toHaveBeenLastCalledWith(ticket, claims);
153153
expect(tokenFactory.serialize).toHaveBeenCalledTimes(1);
154154
expect(tokenFactory.serialize).toHaveBeenLastCalledWith(
155-
{ permissions: { resource_id: 'id1', resource_scopes: [ 'scope1' ] } });
155+
{ permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ] });
156156
});
157157

158158
it('supports multiple claim tokens.', async(): Promise<void> => {
@@ -171,4 +171,20 @@ describe('BaseNegotiator', (): void => {
171171
expect(ticketingStrategy.validateClaims).toHaveBeenCalledTimes(2);
172172
expect(ticketingStrategy.validateClaims).toHaveBeenCalledWith(ticket, claims);
173173
});
174+
175+
it('includes partial=true when resolved permissions do not cover all requested scopes.', async(): Promise<void> => {
176+
ticketingStrategy.initializeTicket.mockResolvedValueOnce({
177+
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1', 'scope2' ] } ],
178+
provided: {},
179+
});
180+
ticketingStrategy.resolveTicket.mockResolvedValueOnce({
181+
success: true,
182+
value: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ],
183+
});
184+
185+
await expect(negotiator.negotiate({
186+
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1', 'scope2' ] } ]
187+
}))
188+
.resolves.toEqual({ access_token: 'token', token_type: 'type', partial: true });
189+
});
174190
});

test/integration/Partial.test.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { App } from '@solid/community-server';
2+
import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory';
3+
import { createServer, Server } from 'node:http';
4+
import path from 'node:path';
5+
import { getPorts, instantiateFromConfig } from '../util/ServerUtil';
6+
import { getToken, umaFetch } from '../util/UmaUtil';
7+
8+
const [ rsPort, umaPort ] = getPorts('Partial');
9+
10+
interface UmaConfig {
11+
issuer: string;
12+
permission_endpoint: string;
13+
resource_registration_endpoint: string;
14+
token_endpoint: string;
15+
registration_endpoint: string;
16+
}
17+
18+
describe('A server with partial results enabled', (): void => {
19+
const owner = 'http://example.com/alice#me';
20+
const user = `http://example.com/bob`;
21+
const resource = `http://localhost:${rsPort}/alice/data`;
22+
const readScope = 'http://www.w3.org/ns/odrl/2/read';
23+
const writeScope = 'http://www.w3.org/ns/odrl/2/write';
24+
let umaApp: App;
25+
let rsServer: Server;
26+
let umaConfig: UmaConfig;
27+
let pat: string;
28+
29+
beforeAll(async(): Promise<void> => {
30+
setGlobalLoggerFactory(new WinstonLoggerFactory('off'));
31+
32+
umaApp = await instantiateFromConfig(
33+
'urn:uma:default:App',
34+
[
35+
path.join(__dirname, '../../packages/uma/config/default.json'),
36+
path.join(__dirname, '../../packages/uma/config/enable-partial.json'),
37+
],
38+
{
39+
'urn:uma:variables:port': umaPort,
40+
'urn:uma:variables:baseUrl': `http://localhost:${umaPort}/uma`,
41+
'urn:uma:variables:backupFilePath': '',
42+
}
43+
);
44+
await umaApp.start();
45+
46+
const configResponse = await fetch(`http://localhost:${umaPort}/uma/.well-known/uma2-configuration`);
47+
expect(configResponse.status).toBe(200);
48+
umaConfig = await configResponse.json() as UmaConfig;
49+
50+
const registrationResponse = await fetch(umaConfig.registration_endpoint, {
51+
method: 'POST',
52+
headers: {
53+
authorization: `WebID ${encodeURIComponent(owner)}`,
54+
'content-type': 'application/json',
55+
},
56+
body: JSON.stringify({ client_uri: `http://localhost:${rsPort}/` }),
57+
});
58+
expect(registrationResponse.status).toBe(201);
59+
const { client_id, client_secret } = await registrationResponse.json() as {
60+
client_id: string,
61+
client_secret: string,
62+
};
63+
64+
const authString = `${encodeURIComponent(client_id)}:${encodeURIComponent(client_secret)}`;
65+
const credentials = `Basic ${Buffer.from(authString).toString('base64')}`;
66+
const patResponse = await fetch(umaConfig.token_endpoint, {
67+
method: 'POST',
68+
headers: {
69+
authorization: credentials,
70+
'content-type': 'application/x-www-form-urlencoded',
71+
},
72+
body: 'grant_type=client_credentials&scope=uma_protection',
73+
});
74+
expect(patResponse.status).toBe(201);
75+
const patJson = await patResponse.json() as { access_token: string, token_type: string };
76+
pat = `${patJson.token_type} ${patJson.access_token}`;
77+
78+
const registrationBody = {
79+
name: resource,
80+
resource_scopes: [ readScope, writeScope ],
81+
};
82+
const resourceRegistrationResponse = await fetch(umaConfig.resource_registration_endpoint, {
83+
method: 'POST',
84+
headers: {
85+
Authorization: pat,
86+
'Content-Type': 'application/json',
87+
Accept: 'application/json',
88+
},
89+
body: JSON.stringify(registrationBody),
90+
});
91+
expect(resourceRegistrationResponse.status).toBe(201);
92+
93+
rsServer = createServer((request, response): void => {
94+
void (async(): Promise<void> => {
95+
if (!request.url || !request.url.startsWith('/alice/data')) {
96+
response.statusCode = 404;
97+
response.end();
98+
return;
99+
}
100+
101+
const auth = request.headers.authorization;
102+
if (hasScope(auth, resource, readScope)) {
103+
response.statusCode = 200;
104+
response.end('protected data');
105+
return;
106+
}
107+
108+
const permissionResponse = await fetch(umaConfig.permission_endpoint, {
109+
method: 'POST',
110+
headers: {
111+
Authorization: pat,
112+
'Content-Type': 'application/json',
113+
Accept: 'application/json',
114+
},
115+
body: JSON.stringify([
116+
{
117+
resource_id: resource,
118+
resource_scopes: [ readScope ],
119+
}
120+
]),
121+
});
122+
123+
if (permissionResponse.status !== 201) {
124+
response.statusCode = 500;
125+
response.end(await permissionResponse.text());
126+
return;
127+
}
128+
129+
const { ticket } = await permissionResponse.json() as { ticket: string };
130+
response.statusCode = 401;
131+
response.setHeader('WWW-Authenticate', `UMA realm="solid", as_uri="${umaConfig.issuer}", ticket="${ticket}"`);
132+
response.end();
133+
})().catch((error: unknown): void => {
134+
response.statusCode = 500;
135+
response.end(String(error));
136+
});
137+
});
138+
139+
await new Promise<void>((resolve): void => {
140+
rsServer.listen(rsPort, resolve);
141+
});
142+
});
143+
144+
afterAll(async(): Promise<void> => {
145+
const shutdown: Promise<unknown>[] = [];
146+
if (umaApp) {
147+
shutdown.push(umaApp.stop());
148+
}
149+
if (rsServer) {
150+
shutdown.push(new Promise<void>((resolve, reject): void => {
151+
rsServer.close((error): void => {
152+
if (error) {
153+
reject(error);
154+
return;
155+
}
156+
resolve();
157+
});
158+
}));
159+
}
160+
await Promise.all(shutdown);
161+
});
162+
163+
it('can create a policy for the protected resource.', async(): Promise<void> => {
164+
const policy = `
165+
@prefix ex: <http://example.org/> .
166+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
167+
168+
ex:policy a odrl:Agreement ;
169+
odrl:uid ex:policy ;
170+
odrl:permission ex:ownerPermission, ex:userPermission .
171+
172+
ex:ownerPermission a odrl:Permission ;
173+
odrl:assignee <${owner}> ;
174+
odrl:assigner <${owner}> ;
175+
odrl:action odrl:create, odrl:modify ;
176+
odrl:target <http://localhost:${rsPort}/alice/> ,
177+
<${resource}> .
178+
179+
ex:userPermission a odrl:Permission ;
180+
odrl:assignee <${user}> ;
181+
odrl:assigner <${owner}> ;
182+
odrl:action odrl:read ;
183+
odrl:target <${resource}> .`;
184+
185+
const url = `http://localhost:${umaPort}/uma/policies`;
186+
let response = await fetch(url, {
187+
method: 'POST',
188+
headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
189+
body: policy,
190+
});
191+
expect(response.status).toBe(201);
192+
193+
response = await umaFetch(resource, {}, user);
194+
expect(response.status).toBe(200);
195+
});
196+
197+
it('returns partial=true when not all requested scopes are granted.', async(): Promise<void> => {
198+
const permissionResponse = await fetch(umaConfig.permission_endpoint, {
199+
method: 'POST',
200+
headers: {
201+
Authorization: pat,
202+
'Content-Type': 'application/json',
203+
Accept: 'application/json',
204+
},
205+
body: JSON.stringify([
206+
{
207+
resource_id: resource,
208+
resource_scopes: [ readScope, writeScope ],
209+
}
210+
]),
211+
});
212+
expect(permissionResponse.status).toBe(201);
213+
const { ticket } = await permissionResponse.json() as { ticket: string };
214+
215+
const token = await getToken(ticket, umaConfig.token_endpoint, user);
216+
217+
// Verify partial flag is present
218+
expect((token as unknown as { partial?: boolean }).partial).toBe(true);
219+
220+
// Verify the token contains the allowed scope
221+
const jwtPayload = JSON.parse(Buffer.from(token.access_token.split('.')[1], 'base64').toString());
222+
expect(Array.isArray(jwtPayload.permissions)).toBe(true);
223+
expect(jwtPayload.permissions).toContainEqual({
224+
resource_id: resource,
225+
resource_scopes: [ readScope ]
226+
});
227+
});
228+
229+
it('can access a protected resource with partial results.', async(): Promise<void> => {
230+
const response = await umaFetch(resource, {}, user);
231+
expect(response.status).toBe(200);
232+
});
233+
});
234+
235+
function hasScope(authHeader: string | undefined, resource: string, scope: string): boolean {
236+
if (!authHeader?.startsWith('Bearer ')) {
237+
return false;
238+
}
239+
240+
try {
241+
const token = authHeader.slice('Bearer '.length);
242+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()) as {
243+
permissions?: { resource_id: string, resource_scopes: string[] }[]
244+
};
245+
return Array.isArray(payload.permissions)
246+
&& payload.permissions.some((permission): boolean =>
247+
permission.resource_id === resource && permission.resource_scopes.includes(scope)
248+
);
249+
} catch {
250+
return false;
251+
}
252+
}

0 commit comments

Comments
 (0)