Skip to content

Commit 3832454

Browse files
committed
fix: Use input WEBID claim value for output sub value in access token
1 parent 3c345ef commit 3832454

10 files changed

Lines changed: 109 additions & 19 deletions

File tree

documentation/getting-started.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ Based on the stored policies, it then determines if the provided claims are suff
380380
How these policies work will be covered later on.
381381
If successful, the server will return a 200 response with a JSON body containing, among others,
382382
an `access_token` field containing the access token, and a `token_type` field describing the token type.
383+
384+
The generated access token will also contain a `sub` claim.
385+
This value indicates the identity from the original identification input that was provided during token exchange.
386+
383387
If the claims are insufficient, a 403 response will be given instead.
384388

385389
#### Partial permission tokens
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11

22
export const WEBID = 'urn:solidlab:uma:claims:types:webid';
33
export const CLIENTID = 'urn:solidlab:uma:claims:types:clientid';
4+
export const ORIGINAL = 'urn:solidlab:uma:claims:types:original';
45
export const PURPOSE = 'http://www.w3.org/ns/odrl/2/purpose';
56
export const LEGAL_BASIS = 'https://w3id.org/oac#LegalBasis';
67
export const ACCESS = 'urn:solidlab:uma:claims:types:access';
8+
9+
/**
10+
* Resolves a claim value by preferring an ORIGINAL claim-set entry when present.
11+
*/
12+
export function getOriginalClaimValue(claims: NodeJS.Dict<unknown>, claimType: string): unknown {
13+
const original = claims[ORIGINAL];
14+
if (typeof original === 'object' && original !== null) {
15+
const originalClaims = original as Record<string, unknown>;
16+
return originalClaims[claimType];
17+
}
18+
19+
return claims[claimType];
20+
}
Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { joinUrl } from '@solid/community-server';
22
import { isIri } from '../../util/ConvertUtil';
3-
import { CLIENTID, WEBID } from '../Claims';
3+
import { CLIENTID, ORIGINAL, WEBID } from '../Claims';
44
import { ClaimSet } from '../ClaimSet';
55
import { Credential } from '../Credential';
66
import { Verifier } from './Verifier';
@@ -16,17 +16,20 @@ export class IriVerifier implements Verifier {
1616

1717
public async verify(credential: Credential): Promise<ClaimSet> {
1818
const claims = await this.verifier.verify(credential);
19-
return {
20-
...claims,
21-
...typeof claims[WEBID] === 'string' ? { [WEBID]: this.toIri(claims[WEBID]) } : {},
22-
...typeof claims[CLIENTID] === 'string' ? { [CLIENTID]: this.toIri(claims[CLIENTID]) } : {},
23-
};
24-
}
19+
const result = { ...claims };
20+
21+
const original: Record<string, string> = {};
22+
for (const claim of [WEBID, CLIENTID]) {
23+
if (typeof claims[claim] === 'string' && !isIri(claims[claim])) {
24+
result[claim] = joinUrl(this.baseUrl, encodeURIComponent(claims[claim]));
25+
original[claim] = claims[claim];
26+
}
27+
}
2528

26-
protected toIri(value: string): string {
27-
if (isIri(value)) {
28-
return value;
29+
if (Object.keys(original).length > 0) {
30+
result[ORIGINAL] = original;
2931
}
30-
return joinUrl(this.baseUrl, encodeURIComponent(value));
32+
33+
return result;
3134
}
3235
}

packages/uma/src/dialog/BaseNegotiator.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BadRequestHttpError, ForbiddenHttpError, HttpErrorClass, KeyValueStorage } from '@solid/community-server';
22
import { getLoggerFor } from 'global-logger-factory';
33
import { randomUUID } from 'node:crypto';
4-
import { WEBID } from '../credentials/Claims';
4+
import { getOriginalClaimValue, WEBID } from '../credentials/Claims';
55
import { Verifier } from '../credentials/verify/Verifier';
66
import { NeedInfoError, RequiredClaim } from '../errors/NeedInfoError';
77
import { getOperationLogger } from '../logging/OperationLogger';
@@ -58,11 +58,12 @@ export class BaseNegotiator implements Negotiator {
5858
// ... on success, create Access Token
5959
if (resolved.success) {
6060
const partial = this.isPartialResult(updatedTicket.permissions, resolved.value);
61+
const tokenSub = getOriginalClaimValue(updatedTicket.provided, WEBID);
6162

6263
// Retrieve / create instantiated policy
6364
const { token, tokenType } = await this.tokenFactory.serialize({
6465
permissions: resolved.value,
65-
...(typeof updatedTicket.provided[WEBID] === 'string' ? { sub: updatedTicket.provided[WEBID] } : {}),
66+
...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}),
6667
});
6768
this.logger.debug(`Minted token ${JSON.stringify(token)}`);
6869

packages/uma/src/dialog/ContractNegotiator.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createErrorMessage, KeyValueStorage } from '@solid/community-server';
22
import { getLoggerFor } from 'global-logger-factory';
3-
import { WEBID } from '../credentials/Claims';
3+
import { getOriginalClaimValue, WEBID } from '../credentials/Claims';
44
import { Verifier } from '../credentials/verify/Verifier';
55
import { RequiredClaim } from '../errors/NeedInfoError';
66
import { ContractManager } from '../policies/contracts/ContractManager';
@@ -149,11 +149,13 @@ export class ContractNegotiator extends BaseNegotiator {
149149
let permissions: Permission[] = Object.values(permissionMap);
150150
this.logger.debug(`granting permissions: ${JSON.stringify(permissions)}`);
151151

152+
const tokenSub = getOriginalClaimValue(ticket.provided, WEBID);
153+
152154
// Create response
153155
const tokenContents: AccessToken = {
154156
permissions,
155157
contract,
156-
...(typeof ticket.provided[WEBID] === 'string' ? { sub: ticket.provided[WEBID] } : {}),
158+
...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}),
157159
};
158160

159161
this.logger.debug(`resolved result ${JSON.stringify(contract)}`);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { getOriginalClaimValue, ORIGINAL, WEBID } from '../../../src/credentials/Claims';
2+
3+
describe('Claims', (): void => {
4+
describe('#getOriginalClaimValue', (): void => {
5+
it('prefers original claim values when present.', async(): Promise<void> => {
6+
expect(getOriginalClaimValue({
7+
[WEBID]: 'http://example.com/id/user',
8+
[ORIGINAL]: { [WEBID]: 'user' },
9+
}, WEBID)).toBe('user');
10+
});
11+
12+
it('falls back to top-level claim values.', async(): Promise<void> => {
13+
expect(getOriginalClaimValue({ [WEBID]: 'http://example.com/id/user' }, WEBID)).toBe('http://example.com/id/user');
14+
});
15+
});
16+
});

packages/uma/test/unit/credentials/verify/IriVerifier.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Mocked } from 'vitest';
2-
import { CLIENTID, WEBID } from '../../../../src/credentials/Claims';
2+
import { CLIENTID, ORIGINAL, WEBID } from '../../../../src/credentials/Claims';
33
import { Credential } from '../../../../src/credentials/Credential';
44
import { IriVerifier } from '../../../../src/credentials/verify/IriVerifier';
55
import { Verifier } from '../../../../src/credentials/verify/Verifier';
@@ -42,6 +42,10 @@ describe('IriVerifier', (): void => {
4242
await expect(verifier.verify(credential)).resolves.toEqual({
4343
[WEBID]: 'http://example.com/id/webId',
4444
[CLIENTID]: 'http://example.com/id/clientId',
45+
[ORIGINAL]: {
46+
[WEBID]: 'webId',
47+
[CLIENTID]: 'clientId',
48+
},
4549
fruit: 'http://example.org/apple',
4650
});
4751
expect(source.verify).toHaveBeenCalledTimes(1);

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ForbiddenHttpError, KeyValueStorage } from '@solid/community-server';
22
import { Mocked } from 'vitest';
3-
import { WEBID } from '../../../src/credentials/Claims';
3+
import { ORIGINAL, WEBID } from '../../../src/credentials/Claims';
44
import { ClaimSet } from '../../../src/credentials/ClaimSet';
55
import { Verifier } from '../../../src/credentials/verify/Verifier';
66
import { BaseNegotiator } from '../../../src/dialog/BaseNegotiator';
@@ -189,6 +189,26 @@ describe('BaseNegotiator', (): void => {
189189
});
190190
});
191191

192+
it('prefers the original WEBID claim over the normalized value for token sub.', async(): Promise<void> => {
193+
ticketingStrategy.validateClaims.mockResolvedValueOnce({
194+
...ticket,
195+
provided: {
196+
[WEBID]: 'http://example.com/id/user',
197+
[ORIGINAL]: {
198+
[WEBID]: 'user',
199+
},
200+
},
201+
});
202+
203+
await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves
204+
.toEqual({ access_token: 'token', token_type: 'type' });
205+
206+
expect(tokenFactory.serialize).toHaveBeenLastCalledWith({
207+
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ],
208+
sub: 'user',
209+
});
210+
});
211+
192212
it('includes partial=true when resolved permissions do not cover all requested scopes.', async(): Promise<void> => {
193213
ticketingStrategy.initializeTicket.mockResolvedValueOnce({
194214
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1', 'scope2' ] } ],

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ForbiddenHttpError, KeyValueStorage } from '@solid/community-server';
22
import { Mocked, MockInstance } from 'vitest';
3-
import { WEBID } from '../../../src/credentials/Claims';
3+
import { ORIGINAL, WEBID } from '../../../src/credentials/Claims';
44
import { ClaimSet } from '../../../src/credentials/ClaimSet';
55
import { Verifier } from '../../../src/credentials/verify/Verifier';
66
import { ContractNegotiator } from '../../../src/dialog/ContractNegotiator';
@@ -152,4 +152,25 @@ describe('ContractNegotiator', (): void => {
152152
sub: webId,
153153
});
154154
});
155+
156+
it('prefers the original WEBID claim over the normalized value for token sub.', async(): Promise<void> => {
157+
ticketingStrategy.validateClaims.mockResolvedValueOnce({
158+
...ticket,
159+
provided: {
160+
[WEBID]: 'http://example.com/id/user',
161+
[ORIGINAL]: {
162+
[WEBID]: 'user',
163+
},
164+
},
165+
});
166+
167+
await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves
168+
.toEqual({ access_token: 'token', token_type: 'type' });
169+
170+
expect(tokenFactory.serialize).toHaveBeenLastCalledWith({
171+
contract,
172+
permissions: [{ resource_id: 'target', resource_scopes: [ 'https://w3id.org/oac#action' ] }],
173+
sub: 'user',
174+
});
175+
});
155176
});

test/integration/Oidc.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AlgJwk, App, CachedJwkGenerator, MemoryMapStorage } from '@solid/community-server';
22
import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory';
3-
import { importJWK, SignJWT } from 'jose';
3+
import { decodeJwt, importJWK, SignJWT } from 'jose';
44
import { randomUUID } from 'node:crypto';
55
import { createServer, Server } from 'node:http';
66
import path from 'node:path';
@@ -156,6 +156,11 @@ describe('A server supporting OIDC tokens', (): void => {
156156
body: JSON.stringify(content),
157157
});
158158
expect(response.status).toBe(200);
159+
160+
const { access_token } = await response.json() as { access_token: string };
161+
const payload = decodeJwt(access_token);
162+
expect(payload.sub).toBe(sub);
163+
expect(payload.sub).not.toBe(`http://example.com/id/${sub}`);
159164
});
160165
});
161166

0 commit comments

Comments
 (0)