Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions documentation/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ 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)
- [Enabling optional token features](#enabling-optional-token-features)
- [Partial permission tokens](#partial-permission-tokens)
- [Include `sub` claim in access token](#include-sub-claim-in-access-token)
+ [Use token](#use-token)
* [Policies](#policies)
+ [Client application identification](#client-application-identification)
Expand Down Expand Up @@ -380,34 +382,48 @@ Based on the stored policies, it then determines if the provided claims are suff
How these policies work will be covered later on.
If successful, the server will return a 200 response with a JSON body containing, among others,
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
If the claims are insufficient, a 403 response will be given instead.

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.
#### Enabling optional token features

To enable this, start the UMA server with both `default.json` and `enable-partial.json`.
Some token-related features are optional and can be enabled by adding extra configuration files
when starting the UMA server, in addition to `default.json`.

From the repository root:
```bash
yarn start:uma -- -c ./config/default.json -c ./config/enable-partial.json
yarn start:uma -- -c ./config/default.json -c ./config/<feature-config>.json
```

From `packages/uma`:
```bash
yarn start -c ./config/default.json -c ./config/enable-partial.json
yarn start -c ./config/default.json -c ./config/<feature-config>.json
```

#### 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, use `enable-partial.json` as the feature config.

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`.

#### Include `sub` claim in access token

By default, generated access tokens do not include a `sub` claim.
To include it, use `enable-sub.json` as the feature config.

With this enabled:
- Generated access tokens include `sub`, set to the identity extracted during token exchange.

### Use token

When receiving the access token, the client can perform the same request as it did in the first step,
Expand Down
12 changes: 12 additions & 0 deletions packages/uma/config/enable-sub.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"@context": [
"https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld"
],
"@graph": [
{
"@id": "urn:uma:default:TokenFactory",
"@type": "JwtTokenFactory",
"addSub": true
}
]
}
14 changes: 14 additions & 0 deletions packages/uma/src/credentials/Claims.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@

export const WEBID = 'urn:solidlab:uma:claims:types:webid';
export const CLIENTID = 'urn:solidlab:uma:claims:types:clientid';
export const ORIGINAL = 'urn:solidlab:uma:claims:types:original';
export const PURPOSE = 'http://www.w3.org/ns/odrl/2/purpose';
export const LEGAL_BASIS = 'https://w3id.org/oac#LegalBasis';
export const ACCESS = 'urn:solidlab:uma:claims:types:access';

/**
* Resolves a claim value by preferring an ORIGINAL claim-set entry when present.
*/
export function getOriginalClaimValue(claims: NodeJS.Dict<unknown>, claimType: string): unknown {
const original = claims[ORIGINAL];
if (typeof original === 'object' && original !== null) {
const originalClaims = original as Record<string, unknown>;
return originalClaims[claimType];
}

return claims[claimType];
}
25 changes: 14 additions & 11 deletions packages/uma/src/credentials/verify/IriVerifier.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { joinUrl } from '@solid/community-server';
import { isIri } from '../../util/ConvertUtil';
import { CLIENTID, WEBID } from '../Claims';
import { CLIENTID, ORIGINAL, WEBID } from '../Claims';
import { ClaimSet } from '../ClaimSet';
import { Credential } from '../Credential';
import { Verifier } from './Verifier';
Expand All @@ -16,17 +16,20 @@ export class IriVerifier implements Verifier {

public async verify(credential: Credential): Promise<ClaimSet> {
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]) } : {},
};
}
const result = { ...claims };

const original: Record<string, string> = {};
for (const claim of [WEBID, CLIENTID]) {
if (typeof claims[claim] === 'string' && !isIri(claims[claim])) {
result[claim] = joinUrl(this.baseUrl, encodeURIComponent(claims[claim]));
original[claim] = claims[claim];
}
}

protected toIri(value: string): string {
if (isIri(value)) {
return value;
if (Object.keys(original).length > 0) {
result[ORIGINAL] = original;
}
return joinUrl(this.baseUrl, encodeURIComponent(value));

return result;
}
}
7 changes: 6 additions & 1 deletion packages/uma/src/dialog/BaseNegotiator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BadRequestHttpError, ForbiddenHttpError, HttpErrorClass, KeyValueStorage } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { randomUUID } from 'node:crypto';
import { getOriginalClaimValue, WEBID } from '../credentials/Claims';
import { Verifier } from '../credentials/verify/Verifier';
import { NeedInfoError, RequiredClaim } from '../errors/NeedInfoError';
import { getOperationLogger } from '../logging/OperationLogger';
Expand Down Expand Up @@ -57,9 +58,13 @@ export class BaseNegotiator implements Negotiator {
// ... on success, create Access Token
if (resolved.success) {
const partial = this.isPartialResult(updatedTicket.permissions, resolved.value);
const tokenSub = getOriginalClaimValue(updatedTicket.provided, WEBID);

// Retrieve / create instantiated policy
const { token, tokenType } = await this.tokenFactory.serialize({ permissions: resolved.value });
const { token, tokenType } = await this.tokenFactory.serialize({
permissions: resolved.value,
...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}),
});
this.logger.debug(`Minted token ${JSON.stringify(token)}`);

// TODO:: test logging
Expand Down
13 changes: 10 additions & 3 deletions packages/uma/src/dialog/ContractNegotiator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createErrorMessage, KeyValueStorage } from '@solid/community-server';
import { getLoggerFor } from 'global-logger-factory';
import { getOriginalClaimValue, WEBID } from '../credentials/Claims';
import { Verifier } from '../credentials/verify/Verifier';
import { RequiredClaim } from '../errors/NeedInfoError';
import { ContractManager } from '../policies/contracts/ContractManager';
Expand Down Expand Up @@ -66,7 +67,7 @@ export class ContractNegotiator extends BaseNegotiator {

if (result.success) {
// TODO:
return this.toResponse(result.value);
return this.toResponse(result.value, updatedTicket);
}

// ... on failure, deny if no solvable requirements
Expand Down Expand Up @@ -121,7 +122,7 @@ export class ContractNegotiator extends BaseNegotiator {
}

// TODO: name
protected async toResponse(contract: ODRLContract): Promise<DialogOutput> {
protected async toResponse(contract: ODRLContract, ticket: Ticket): Promise<DialogOutput> {

this.logger.debug(JSON.stringify(contract, null, 2))

Expand All @@ -148,8 +149,14 @@ export class ContractNegotiator extends BaseNegotiator {
let permissions: Permission[] = Object.values(permissionMap);
this.logger.debug(`granting permissions: ${JSON.stringify(permissions)}`);

const tokenSub = getOriginalClaimValue(ticket.provided, WEBID);

// Create response
const tokenContents: AccessToken = { permissions, contract }
const tokenContents: AccessToken = {
permissions,
contract,
...(typeof tokenSub === 'string' ? { sub: tokenSub } : {}),
};

this.logger.debug(`resolved result ${JSON.stringify(contract)}`);

Expand Down
2 changes: 1 addition & 1 deletion packages/uma/src/tokens/AccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { Type, array, optional as $, string, intersection, optional } from "../u

export const AccessToken = {
permissions: array(Permission),
sub: optional(string),
contract: optional(ODRLContract)
}

export type AccessToken = Type<typeof AccessToken>;

18 changes: 14 additions & 4 deletions packages/uma/src/tokens/JwtTokenFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ export class JwtTokenFactory extends TokenFactory {
* @param issuer - server URL to assign to the issuer field
* @param tokenStore - stores the link between JWT and access token
* @param params - additional parameters for the generated JWT
* @param addSub - if true, adds a sub claim to the JWT if available in the input token
*/
constructor(
protected readonly keyGen: JwkGenerator,
protected readonly issuer: string,
protected readonly tokenStore: KeyValueStorage<string, AccessToken>,
protected readonly params: JwtTokenParams = { expirationTime: '30m', aud: 'solid' },
protected readonly addSub = false,
) {
super();
}
Expand All @@ -44,14 +46,19 @@ export class JwtTokenFactory extends TokenFactory {
public async serialize(token: AccessToken): Promise<SerializedToken> {
const key = await this.keyGen.getPrivateKey();
const jwk = await importJWK(key, key.alg);
const jwt = await new SignJWT({ permissions: token.permissions, contract: token.contract })
let signJwt = new SignJWT({ permissions: token.permissions, contract: token.contract })
.setProtectedHeader({ alg: key.alg, kid: key.kid })
.setIssuedAt()
.setIssuer(this.issuer)
.setAudience(this.params.aud ?? AUD)
.setExpirationTime(this.params.expirationTime)
.setJti(randomUUID())
.sign(jwk);
.setJti(randomUUID());

if (this.addSub && token.sub) {
signJwt = signJwt.setSubject(token.sub);
}

const jwt = await signJwt.sign(jwk);

this.logger.debug(`Issued new JWT Token ${JSON.stringify(token)}`);
await this.tokenStore.set(jwt, token);
Expand Down Expand Up @@ -80,7 +87,10 @@ export class JwtTokenFactory extends TokenFactory {

reType(permissions, array(Permission));

return { permissions };
return {
permissions,
...(typeof payload.sub === 'string' ? { sub: payload.sub } : {}),
};
} catch (error: unknown) {
const msg = `Invalid Access Token provided, error while parsing: ${createErrorMessage(error)}`;
this.logger.warn(msg);
Expand Down
16 changes: 16 additions & 0 deletions packages/uma/test/unit/credentials/Claims.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getOriginalClaimValue, ORIGINAL, WEBID } from '../../../src/credentials/Claims';

describe('Claims', (): void => {
describe('#getOriginalClaimValue', (): void => {
it('prefers original claim values when present.', async(): Promise<void> => {
expect(getOriginalClaimValue({
[WEBID]: 'http://example.com/id/user',
[ORIGINAL]: { [WEBID]: 'user' },
}, WEBID)).toBe('user');
});

it('falls back to top-level claim values.', async(): Promise<void> => {
expect(getOriginalClaimValue({ [WEBID]: 'http://example.com/id/user' }, WEBID)).toBe('http://example.com/id/user');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Mocked } from 'vitest';
import { CLIENTID, WEBID } from '../../../../src/credentials/Claims';
import { CLIENTID, ORIGINAL, 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';
Expand Down Expand Up @@ -42,6 +42,10 @@ describe('IriVerifier', (): void => {
await expect(verifier.verify(credential)).resolves.toEqual({
[WEBID]: 'http://example.com/id/webId',
[CLIENTID]: 'http://example.com/id/clientId',
[ORIGINAL]: {
[WEBID]: 'webId',
[CLIENTID]: 'clientId',
},
fruit: 'http://example.org/apple',
});
expect(source.verify).toHaveBeenCalledTimes(1);
Expand Down
37 changes: 37 additions & 0 deletions packages/uma/test/unit/dialog/BaseNegotiator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ForbiddenHttpError, KeyValueStorage } from '@solid/community-server';
import { Mocked } from 'vitest';
import { ORIGINAL, WEBID } from '../../../src/credentials/Claims';
import { ClaimSet } from '../../../src/credentials/ClaimSet';
import { Verifier } from '../../../src/credentials/verify/Verifier';
import { BaseNegotiator } from '../../../src/dialog/BaseNegotiator';
Expand Down Expand Up @@ -172,6 +173,42 @@ describe('BaseNegotiator', (): void => {
expect(ticketingStrategy.validateClaims).toHaveBeenCalledWith(ticket, claims);
});

it('includes the WEBID claim in generated tokens as sub value.', async(): Promise<void> => {
const webId = 'https://example.com/profile/card#me';
ticketingStrategy.validateClaims.mockResolvedValueOnce({
...ticket,
provided: { [WEBID]: webId },
});

await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves
.toEqual({ access_token: 'token', token_type: 'type' });

expect(tokenFactory.serialize).toHaveBeenLastCalledWith({
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ],
sub: webId,
});
});

it('prefers the original WEBID claim over the normalized value for token sub.', async(): Promise<void> => {
ticketingStrategy.validateClaims.mockResolvedValueOnce({
...ticket,
provided: {
[WEBID]: 'http://example.com/id/user',
[ORIGINAL]: {
[WEBID]: 'user',
},
},
});

await expect(negotiator.negotiate({ ...input, claim_token: 'token', claim_token_format: 'format' })).resolves
.toEqual({ access_token: 'token', token_type: 'type' });

expect(tokenFactory.serialize).toHaveBeenLastCalledWith({
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1' ] } ],
sub: 'user',
});
});

it('includes partial=true when resolved permissions do not cover all requested scopes.', async(): Promise<void> => {
ticketingStrategy.initializeTicket.mockResolvedValueOnce({
permissions: [ { resource_id: 'id1', resource_scopes: [ 'scope1', 'scope2' ] } ],
Expand Down
Loading
Loading