Skip to content

Commit 2e2d9f8

Browse files
committed
feat: Support client authentication for UMA APIs
1 parent a586f73 commit 2e2d9f8

6 files changed

Lines changed: 140 additions & 19 deletions

File tree

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
{
22
"@context": [
3-
"https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld"
3+
"https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld",
4+
"https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^8.0.0/components/context.jsonld",
5+
"https://linkedsoftwaredependencies.org/bundles/npm/asynchronous-handlers/^1.0.0/components/context.jsonld"
46
],
57
"@graph": [
68
{
79
"@id": "urn:uma:default:RequestValidator",
8-
"@type": "PatRequestValidator",
9-
"storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" }
10+
"@type": "StatusWaterfallHandler",
11+
"handlers": [
12+
{
13+
"@type": "PatRequestValidator",
14+
"storage": { "@id": "urn:solid-server:default:ClientRegistrationStorage" }
15+
},
16+
{
17+
"@type": "CredentialVerifierRequestValidator",
18+
"credentialParser": { "@id": "urn:uma:default:CredentialParser" },
19+
"verifier": { "@id": "urn:uma:default:Verifier" }
20+
}
21+
]
1022
}
1123
]
1224
}

‎packages/uma/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export * from './util/http/server/JsonHttpErrorHandler';
8888
export * from './util/http/server/JsonFormHttpHandler';
8989
export * from './util/http/server/NodeHttpRequestResponseHandler';
9090
export * from './util/http/server/RoutedHttpRequestHandler';
91+
export * from './util/http/validate/CredentialVerifierRequestValidator';
9192
export * from './util/http/validate/PatRequestValidator';
9293
export * from './util/http/validate/RequestValidator';
9394

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { UnauthorizedHttpError } from '@solid/community-server';
2+
import { WEBID } from '../../../credentials/Claims';
3+
import { CredentialParser } from '../../../credentials/CredentialParser';
4+
import { Verifier } from '../../../credentials/verify/Verifier';
5+
import { RequestValidator, RequestValidatorInput, RequestValidatorOutput } from './RequestValidator';
6+
7+
/**
8+
* Validates requests by verifying the Credential and extracting the owner.
9+
*/
10+
export class CredentialVerifierRequestValidator extends RequestValidator {
11+
public constructor(
12+
protected readonly credentialParser: CredentialParser,
13+
protected readonly verifier: Verifier,
14+
) {
15+
super();
16+
}
17+
18+
public async canHandle({ request }: RequestValidatorInput): Promise<void> {
19+
await this.credentialParser.canHandle(request);
20+
}
21+
22+
public async handle({ request }: RequestValidatorInput): Promise<RequestValidatorOutput> {
23+
const result = await this.credentialParser.handle(request);
24+
const claims = await this.verifier.verify(result);
25+
if (!claims[WEBID] || claims[WEBID].length === 0) {
26+
throw new UnauthorizedHttpError('Could not determine owner.');
27+
}
28+
return { owner: claims[WEBID][0] as string};
29+
}
30+
}

‎packages/uma/src/util/http/validate/PatRequestValidator.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,24 @@ export class PatRequestValidator extends RequestValidator {
2727
this.storage = storage;
2828
}
2929

30-
public async handle({ request }: RequestValidatorInput): Promise<RequestValidatorOutput> {
30+
public async canHandle({ request }: RequestValidatorInput): Promise<void> {
3131
const { authorization } = request.headers;
3232
if (!authorization || !/^Bearer /ui.test(authorization)) {
3333
throw new UnauthorizedHttpError('No Bearer Authorization header specified.');
3434
}
3535

3636
const token = authorization?.replace(/^Bearer/, '')?.trimStart();
37-
const patEntries = await this.storage.find(PAT_STORAGE_TYPE, { pat: token });
38-
if (patEntries.length === 0) {
37+
const patIds = await this.storage.findIds(PAT_STORAGE_TYPE, { pat: token });
38+
if (patIds.length === 0) {
3939
throw new ForbiddenHttpError('Unknown PAT.');
4040
}
41+
}
42+
43+
public async handle({ request }: RequestValidatorInput): Promise<RequestValidatorOutput> {
44+
const { authorization } = request.headers;
45+
46+
const token = authorization?.replace(/^Bearer/, '')?.trimStart();
47+
const patEntries = await this.storage.find(PAT_STORAGE_TYPE, { pat: token });
4148
if (patEntries[0].expiration < Date.now()) {
4249
throw new ForbiddenHttpError('Expired PAT.');
4350
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { BadRequestHttpError } from '@solid/community-server';
2+
import { Mocked } from 'vitest';
3+
import { WEBID } from '../../../../../src/credentials/Claims';
4+
import { ClaimSet } from '../../../../../src/credentials/ClaimSet';
5+
import { CredentialParser } from '../../../../../src/credentials/CredentialParser';
6+
import { Verifier } from '../../../../../src/credentials/verify/Verifier';
7+
import {
8+
CredentialVerifierRequestValidator
9+
} from '../../../../../src/util/http/validate/CredentialVerifierRequestValidator';
10+
import { RequestValidatorInput } from '../../../../../src/util/http/validate/RequestValidator';
11+
12+
describe('CredentialVerifierRequestValidator', (): void => {
13+
const webid = 'https://example.com/profile/card#me';
14+
let input: RequestValidatorInput;
15+
let claims: ClaimSet;
16+
let tokenResponse: { token: string, format: string };
17+
18+
let credentialParser: Mocked<CredentialParser>;
19+
let verifier: Mocked<Verifier>;
20+
let validator: CredentialVerifierRequestValidator;
21+
22+
beforeEach(async(): Promise<void> => {
23+
input = {
24+
request: {
25+
url: new URL('http://example.com/foo'),
26+
method: 'GET',
27+
headers: { authorization: 'Bearer token' },
28+
}
29+
};
30+
31+
tokenResponse = {
32+
token: 'token',
33+
format: 'format',
34+
};
35+
36+
claims = {
37+
[WEBID]: [ webid ],
38+
};
39+
40+
credentialParser = {
41+
canHandle: vi.fn(),
42+
handle: vi.fn().mockResolvedValue(tokenResponse),
43+
} satisfies Partial<CredentialParser> as any;
44+
45+
verifier = {
46+
verify: vi.fn().mockResolvedValue(claims),
47+
};
48+
49+
validator = new CredentialVerifierRequestValidator(credentialParser, verifier);
50+
});
51+
52+
it('can handle requests the verifier can handle.', async(): Promise<void> => {
53+
await expect(validator.canHandle(input)).resolves.toBeUndefined();
54+
expect(credentialParser.canHandle).toHaveBeenLastCalledWith(input.request);
55+
56+
credentialParser.canHandle.mockRejectedValueOnce(new BadRequestHttpError('bad data'));
57+
await expect(validator.canHandle(input)).rejects.toThrow('bad data');
58+
});
59+
60+
it('returns the WEBID claim as owner.', async(): Promise<void> => {
61+
await expect(validator.handle(input)).resolves.toEqual({ owner: webid });
62+
expect(credentialParser.handle).toHaveBeenLastCalledWith(input.request);
63+
expect(verifier.verify).toHaveBeenLastCalledWith(tokenResponse);
64+
});
65+
66+
it('errors if there is no WEBID claim.', async(): Promise<void> => {
67+
claims = {};
68+
verifier.verify.mockResolvedValueOnce(claims);
69+
await expect(validator.handle(input)).rejects.toThrow('Could not determine owner.');
70+
});
71+
});

‎packages/uma/test/unit/util/http/validate/PatRequestValidator.test.ts‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,31 @@ describe('PatRequestValidator', (): void => {
3030

3131
storage = {
3232
find: vi.fn().mockResolvedValue([{ expiration: Date.now() + 5000, registration: registrationId }]),
33+
findIds: vi.fn().mockResolvedValue([ registrationId ]),
3334
get: vi.fn().mockResolvedValue({ userId }),
3435
} as any;
3536

3637
validator = new PatRequestValidator(storage as any);
3738
});
3839

39-
it('returns the stored user as owner.', async(): Promise<void> => {
40-
await expect(validator.handle({ request })).resolves.toEqual({ owner: userId });
41-
expect(storage.find).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { pat });
42-
expect(storage.get).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, registrationId);
40+
it('can only handle Bearer tokens.', async(): Promise<void> => {
41+
request.headers.authorization = 'Basic 1234';
42+
await expect(validator.canHandle({ request })).rejects.toThrow('No Bearer Authorization header specified.');
4343
});
4444

45-
it('errors on non-Bearer tokens.', async(): Promise<void> => {
46-
request.headers.authorization = 'Basic 1234';
47-
await expect(validator.handle({ request })).rejects.toThrow('No Bearer Authorization header specified.');
45+
it('can only handle known tokens.', async(): Promise<void> => {
46+
storage.findIds.mockResolvedValueOnce([]);
47+
await expect(validator.canHandle({ request })).rejects.toThrow('Unknown PAT.');
4848
});
4949

50-
it('errors on non-Bearer tokens.', async(): Promise<void> => {
51-
request.headers.authorization = 'Basic 1234';
52-
await expect(validator.handle({ request })).rejects.toThrow('No Bearer Authorization header specified.');
50+
it('can handle known Bearer tokens.', async(): Promise<void> => {
51+
await expect(validator.canHandle({ request })).resolves.toBeUndefined();
5352
});
5453

55-
it('errors if no matched token was found.', async(): Promise<void> => {
56-
storage.find.mockResolvedValueOnce([]);
57-
await expect(validator.handle({ request })).rejects.toThrow('Unknown PAT.');
54+
it('returns the stored user as owner.', async(): Promise<void> => {
55+
await expect(validator.handle({ request })).resolves.toEqual({ owner: userId });
56+
expect(storage.find).toHaveBeenLastCalledWith(PAT_STORAGE_TYPE, { pat });
57+
expect(storage.get).toHaveBeenLastCalledWith(CLIENT_REGISTRATION_STORAGE_TYPE, registrationId);
5858
});
5959

6060
it('errors if the PAT is expired.', async(): Promise<void> => {

0 commit comments

Comments
 (0)