feat: Rewrite TokenOwnershipValidator behaviour to remove @rdfjs/fetch dependency

This commit is contained in:
Joachim Van Herwegen 2021-07-30 16:03:51 +02:00
parent 9d337ba80c
commit 63e88578c3
10 changed files with 198 additions and 1279 deletions

View File

@ -5,6 +5,7 @@
"comment": "Determines WebID ownership by requesting a specific value to be added to the WebID document",
"@id": "urn:solid-server:auth:password:OwnershipValidator",
"@type": "TokenOwnershipValidator",
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"storage": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
}
]

1208
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -77,7 +77,6 @@
"dependencies": {
"@comunica/actor-init-sparql": "^1.21.3",
"@rdfjs/data-model": "^1.2.0",
"@rdfjs/fetch": "^2.1.0",
"@solid/access-token-verifier": "^0.10.0",
"@types/arrayify-stream": "^1.0.0",
"@types/async-lock": "^1.1.2",
@ -93,8 +92,6 @@
"@types/pump": "^1.1.1",
"@types/punycode": "^2.1.0",
"@types/rdf-js": "^4.0.2",
"@types/rdfjs__fetch": "^2.0.3",
"@types/rdfjs__fetch-lite": "^2.0.3",
"@types/redis": "^2.8.30",
"@types/redlock": "^4.0.1",
"@types/sparqljs": "^3.1.2",

View File

@ -1,6 +1,8 @@
import type { Quad } from 'n3';
import { DataFactory } from 'n3';
import { v4 } from 'uuid';
import { getLoggerFor } from '../../logging/LogUtil';
import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import type { ExpiringStorage } from '../../storage/keyvalue/ExpiringStorage';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { fetchDataset } from '../../util/FetchUtil';
@ -15,11 +17,13 @@ const { literal, namedNode, quad } = DataFactory;
export class TokenOwnershipValidator extends OwnershipValidator {
protected readonly logger = getLoggerFor(this);
private readonly converter: RepresentationConverter;
private readonly storage: ExpiringStorage<string, string>;
private readonly expiration: number;
public constructor(storage: ExpiringStorage<string, string>, expiration = 30) {
public constructor(converter: RepresentationConverter, storage: ExpiringStorage<string, string>, expiration = 30) {
super();
this.converter = converter;
this.storage = storage;
// Convert minutes to milliseconds
this.expiration = expiration * 60 * 1000;
@ -37,9 +41,7 @@ export class TokenOwnershipValidator extends OwnershipValidator {
}
// Verify if the token can be found in the WebId
const dataset = await fetchDataset(webId);
const expectedQuad = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token));
if (!dataset.has(expectedQuad)) {
if (!await this.hasToken(webId, token)) {
this.throwError(webId, token);
}
this.logger.debug(`Verified ownership of ${webId}`);
@ -60,6 +62,22 @@ export class TokenOwnershipValidator extends OwnershipValidator {
return v4();
}
/**
* Fetches data from the WebID to determine if the token is present.
*/
private async hasToken(webId: string, token: string): Promise<boolean> {
const representation = await fetchDataset(webId, this.converter);
const expectedQuad = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token));
for await (const data of representation.data) {
const triple = data as Quad;
if (triple.equals(expectedQuad)) {
representation.data.destroy();
return true;
}
}
return false;
}
/**
* Throws an error containing the description of which triple is needed for verification.
*/

View File

@ -1,12 +1,12 @@
import type { Response } from 'cross-fetch';
import { fetch } from 'cross-fetch';
import { Store } from 'n3';
import type { Quad } from 'n3';
import type { Adapter, AdapterPayload } from 'oidc-provider';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import { getLoggerFor } from '../../logging/LogUtil';
import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import { INTERNAL_QUADS } from '../../util/ContentTypes';
import { createErrorMessage } from '../../util/errors/ErrorUtil';
import { fetchDataset } from '../../util/FetchUtil';
import { OIDC } from '../../util/Vocabularies';
import type { AdapterFactory } from './AdapterFactory';
/* eslint-disable @typescript-eslint/naming-convention */
@ -84,29 +84,27 @@ export class WebIdAdapter implements Adapter {
return payload;
}
/**
* Parses RDF data found at a client WebID.
* @param data - Raw data from the WebID.
* @param id - The actual WebID.
* @param response - Response object from the request.
*/
private async parseRdfWebId(data: string, id: string, response: Response): Promise<AdapterPayload> {
const contentType = response.headers.get('content-type');
if (!contentType) {
throw new Error(`No content-type received for client WebID ${id}`);
const representation = await fetchDataset(response, this.converter, data);
// Find the valid redirect URIs
const redirectUris: string[] = [];
for await (const entry of representation.data) {
const triple = entry as Quad;
if (triple.predicate.equals(OIDC.terms.redirect_uris)) {
redirectUris.push(triple.object.value);
}
}
// Try to convert to quads
const representation = new BasicRepresentation(data, contentType);
const preferences = { type: { [INTERNAL_QUADS]: 1 }};
const converted = await this.converter.handleSafe({ representation, identifier: { path: id }, preferences });
const quads = new Store();
const importer = quads.import(converted.data);
await new Promise((resolve, reject): void => {
importer.on('end', resolve);
importer.on('error', reject);
});
// Find the valid redirect uris
const match = quads.getObjects(id, 'http://www.w3.org/ns/solid/oidc#redirect_uris', null);
return {
client_id: id,
redirect_uris: match.map((node): string => node.value),
redirect_uris: redirectUris,
};
}

View File

@ -1,29 +1,54 @@
import fetch from '@rdfjs/fetch';
import type { DatasetResponse } from '@rdfjs/fetch-lite';
import type { Dataset } from 'rdf-js';
import type { Response } from 'cross-fetch';
import { fetch } from 'cross-fetch';
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
import type { Representation } from '../ldp/representation/Representation';
import { getLoggerFor } from '../logging/LogUtil';
import { createErrorMessage } from './errors/ErrorUtil';
import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
import { INTERNAL_QUADS } from './ContentTypes';
import { BadRequestHttpError } from './errors/BadRequestHttpError';
const logger = getLoggerFor('FetchUtil');
/**
* Fetches an RDF dataset from the given URL.
* Input can also be a Response if the request was already made.
* In case the given Response object was already parsed its body can be passed along as a string.
*
* The converter will be used to convert the response body to RDF.
*
* Response will be a Representation with content-type internal/quads.
*/
export async function fetchDataset(url: string): Promise<Dataset> {
let rawResponse: DatasetResponse<Dataset>;
try {
rawResponse = (await fetch(url)) as DatasetResponse<Dataset>;
} catch (err: unknown) {
logger.error(`Cannot fetch ${url}: ${createErrorMessage(err)}`);
throw new Error(`Cannot fetch ${url}`);
export async function fetchDataset(url: string, converter: RepresentationConverter): Promise<Representation>;
export async function fetchDataset(response: Response, converter: RepresentationConverter, body?: string):
Promise<Representation>;
export async function fetchDataset(input: string | Response, converter: RepresentationConverter, body?: string):
Promise<Representation> {
let response: Response;
if (typeof input === 'string') {
response = await fetch(input);
} else {
response = input;
}
let dataset: Dataset;
try {
dataset = await rawResponse.dataset();
} catch (err: unknown) {
logger.error(`Could not parse RDF in ${url}: ${createErrorMessage(err)}`);
// Keeping the error message the same to prevent leaking possible information about intranet
throw new Error(`Cannot fetch ${url}`);
if (!body) {
body = await response.text();
}
return dataset;
// Keeping the error message the same everywhere to prevent leaking possible information about intranet.
const error = new BadRequestHttpError(`Unable to access data at ${response.url}`);
if (response.status !== 200) {
logger.warn(`Cannot fetch ${response.url}: ${body}`);
throw error;
}
const contentType = response.headers.get('content-type');
if (!contentType) {
logger.warn(`Missing content-type header from ${response.url}`);
throw error;
}
// Try to convert to quads
const representation = new BasicRepresentation(body, contentType);
const preferences = { type: { [INTERNAL_QUADS]: 1 }};
return converter.handleSafe({ representation, identifier: { path: response.url }, preferences });
}

View File

@ -100,6 +100,10 @@ export const MA = createUriAndTermNamespace('http://www.w3.org/ns/ma-ont#',
'format',
);
export const OIDC = createUriAndTermNamespace('http://www.w3.org/ns/solid/oidc#',
'redirect_uris',
);
export const PIM = createUriAndTermNamespace('http://www.w3.org/ns/pim/space#',
'Storage',
);

View File

@ -1,15 +1,14 @@
import fetch from '@rdfjs/fetch';
import type { DatasetResponse } from '@rdfjs/fetch-lite';
import { fetch } from 'cross-fetch';
import { DataFactory } from 'n3';
import type { Quad } from 'n3';
import type { DatasetCore } from 'rdf-js';
import { v4 } from 'uuid';
import { TokenOwnershipValidator } from '../../../../src/identity/ownership/TokenOwnershipValidator';
import { RdfToQuadConverter } from '../../../../src/storage/conversion/RdfToQuadConverter';
import type { ExpiringStorage } from '../../../../src/storage/keyvalue/ExpiringStorage';
import { SOLID } from '../../../../src/util/Vocabularies';
const { literal, namedNode, quad } = DataFactory;
jest.mock('@rdfjs/fetch');
jest.mock('cross-fetch');
jest.mock('uuid');
function quadToString(qq: Quad): string {
@ -24,26 +23,25 @@ describe('A TokenOwnershipValidator', (): void => {
const fetchMock: jest.Mock = fetch as any;
const webId = 'http://alice.test.com/#me';
const token = 'randomlyGeneratedToken';
let rawResponse: DatasetResponse<DatasetCore>;
let dataset: DatasetCore;
let triples: Quad[];
const tokenTriple = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token));
const tokenString = `${quadToString(tokenTriple)} .`;
const converter = new RdfToQuadConverter();
let storage: ExpiringStorage<string, string>;
let validator: TokenOwnershipValidator;
function mockFetch(body: string): void {
fetchMock.mockImplementation((url: string): any => ({
text: (): any => body,
url,
status: 200,
headers: { get: (): any => 'text/turtle' },
}));
}
beforeEach(async(): Promise<void> => {
const now = Date.now();
jest.spyOn(Date, 'now').mockReturnValue(now);
(v4 as jest.Mock).mockReturnValue(token);
triples = [];
dataset = {
has: (qq: Quad): boolean => triples.some((triple): boolean => triple.equals(qq)),
} as any;
rawResponse = {
dataset: async(): Promise<DatasetCore> => dataset,
} as any;
const map = new Map<string, any>();
storage = {
@ -52,40 +50,41 @@ describe('A TokenOwnershipValidator', (): void => {
delete: jest.fn().mockImplementation((key: string): any => map.delete(key)),
} as any;
fetchMock.mockReturnValue(rawResponse);
mockFetch('');
validator = new TokenOwnershipValidator(storage);
validator = new TokenOwnershipValidator(converter, storage);
});
it('errors if no token is stored in the storage.', async(): Promise<void> => {
// Even if the token is in the WebId, it will error since it's not in the storage
triples = [ tokenTriple ];
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
mockFetch(tokenString);
await expect(validator.handle({ webId })).rejects.toThrow(tokenString);
expect(fetch).toHaveBeenCalledTimes(0);
});
it('errors if the expected triple is missing.', async(): Promise<void> => {
// First call will add the token to the storage
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
await expect(validator.handle({ webId })).rejects.toThrow(tokenString);
expect(fetch).toHaveBeenCalledTimes(0);
// Second call will fetch the WebId
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
await expect(validator.handle({ webId })).rejects.toThrow(tokenString);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('resolves if the WebId contains the verification triple.', async(): Promise<void> => {
triples = [ tokenTriple ];
mockFetch(tokenString);
// First call will add the token to the storage
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
await expect(validator.handle({ webId })).rejects.toThrow(tokenString);
// Second call will succeed since it has the verification triple
await expect(validator.handle({ webId })).resolves.toBeUndefined();
});
it('fails if the WebId contains the wrong verification triple.', async(): Promise<void> => {
triples = [ quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal('wrongToken')) ];
const wrongQuad = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal('wrongToken'));
mockFetch(`${quadToString(wrongQuad)} .`);
// First call will add the token to the storage
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
await expect(validator.handle({ webId })).rejects.toThrow(tokenString);
// Second call will fail since it has the wrong verification triple
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
await expect(validator.handle({ webId })).rejects.toThrow(tokenString);
});
});

View File

@ -10,7 +10,6 @@ jest.mock('cross-fetch');
describe('A WebIdAdapterFactory', (): void => {
const fetchMock: jest.Mock = fetch as any;
const id = 'https://app.test.com/card#me';
let data: string;
let json: any;
let rdf: string;
let source: Adapter;
@ -34,7 +33,7 @@ describe('A WebIdAdapterFactory', (): void => {
};
rdf = `<${id}> <http://www.w3.org/ns/solid/oidc#redirect_uris> <http://test.com>.`;
fetchMock.mockReturnValue({ text: (): any => data });
fetchMock.mockImplementation((url: string): any => ({ text: (): any => '', url, status: 200 }));
source = {
upsert: jest.fn(),
@ -110,12 +109,12 @@ describe('A WebIdAdapterFactory', (): void => {
});
it('errors if the client ID requests does not respond with 200.', async(): Promise<void> => {
fetchMock.mockResolvedValueOnce({ status: 400, text: (): string => 'error' });
fetchMock.mockResolvedValueOnce({ url: id, status: 400, text: (): string => 'error' });
await expect(adapter.find(id)).rejects.toThrow(`Unable to access data at ${id}: error`);
});
it('can handle a valid JSON-LD response.', async(): Promise<void> => {
fetchMock.mockResolvedValueOnce({ status: 200, text: (): string => JSON.stringify(json) });
fetchMock.mockResolvedValueOnce({ url: id, status: 200, text: (): string => JSON.stringify(json) });
await expect(adapter.find(id)).resolves.toEqual({
...json,
token_endpoint_auth_method: 'none',
@ -124,14 +123,14 @@ describe('A WebIdAdapterFactory', (): void => {
it('errors if there is a client_id mismatch.', async(): Promise<void> => {
json.client_id = 'someone else';
fetchMock.mockResolvedValueOnce({ status: 200, text: (): string => JSON.stringify(json) });
fetchMock.mockResolvedValueOnce({ url: id, status: 200, text: (): string => JSON.stringify(json) });
await expect(adapter.find(id)).rejects
.toThrow('The client registration `client_id` field must match the client WebID');
});
it('can handle a valid RDF response.', async(): Promise<void> => {
fetchMock.mockResolvedValueOnce(
{ status: 200, text: (): string => rdf, headers: { get: (): any => 'text/turtle' }},
{ url: id, status: 200, text: (): string => rdf, headers: { get: (): any => 'text/turtle' }},
);
await expect(adapter.find(id)).resolves.toEqual({
client_id: id,
@ -147,7 +146,10 @@ describe('A WebIdAdapterFactory', (): void => {
'http://randomField': { '@value': 'this will not be there since RDF parsing only takes preset fields' },
};
fetchMock.mockResolvedValueOnce(
{ status: 200, text: (): string => JSON.stringify(json), headers: { get: (): any => 'application/ld+json' }},
{ url: id,
status: 200,
text: (): string => JSON.stringify(json),
headers: { get: (): any => 'application/ld+json' }},
);
await expect(adapter.find(id)).resolves.toEqual({
client_id: id,
@ -158,9 +160,9 @@ describe('A WebIdAdapterFactory', (): void => {
it('errors if there is no content-type.', async(): Promise<void> => {
fetchMock.mockResolvedValueOnce(
{ status: 200, text: (): string => rdf, headers: { get: jest.fn() }},
{ url: id, status: 200, text: (): string => rdf, headers: { get: jest.fn() }},
);
await expect(adapter.find(id)).rejects
.toThrow(`No content-type received for client WebID ${id}`);
.toThrow(`Unable to access data at ${id}`);
});
});

View File

@ -1,38 +1,55 @@
import fetch from '@rdfjs/fetch';
import type { DatasetResponse } from '@rdfjs/fetch-lite';
import type { Dataset } from 'rdf-js';
import arrayifyStream from 'arrayify-stream';
import { fetch } from 'cross-fetch';
import { DataFactory } from 'n3';
import { RdfToQuadConverter } from '../../../src/storage/conversion/RdfToQuadConverter';
import { fetchDataset } from '../../../src/util/FetchUtil';
const { namedNode, quad } = DataFactory;
jest.mock('@rdfjs/fetch');
jest.mock('cross-fetch');
describe('FetchUtil', (): void => {
describe('#fetchDataset', (): void => {
const fetchMock: jest.Mock = fetch as any;
const url = 'http://test.com/foo';
let datasetResponse: DatasetResponse<Dataset>;
const dataset: Dataset = {} as any;
const converter = new RdfToQuadConverter();
beforeEach(async(): Promise<void> => {
datasetResponse = {
dataset: jest.fn().mockReturnValue(dataset),
} as any;
function mockFetch(body: string, status = 200): void {
fetchMock.mockImplementation((input: string): any => ({
text: (): any => body,
url: input,
status,
headers: { get: (): any => 'text/turtle' },
}));
}
fetchMock.mockResolvedValue(datasetResponse);
});
it('errors if there was an issue fetching.', async(): Promise<void> => {
fetchMock.mockRejectedValueOnce(new Error('Invalid webId!'));
await expect(fetchDataset(url)).rejects.toThrow(`Cannot fetch ${url}`);
it('errors if the status code is not 200.', async(): Promise<void> => {
mockFetch('Invalid URL!', 404);
await expect(fetchDataset(url, converter)).rejects.toThrow(`Unable to access data at ${url}`);
expect(fetchMock).toHaveBeenCalledWith(url);
});
it('errors if there was an issue parsing the returned RDF.', async(): Promise<void> => {
(datasetResponse.dataset as jest.Mock).mockRejectedValueOnce(new Error('Invalid RDF!'));
await expect(fetchDataset(url)).rejects.toThrow(`Cannot fetch ${url}`);
it('errors if there is no content-type.', async(): Promise<void> => {
fetchMock.mockResolvedValueOnce({ url, text: (): any => '', status: 200, headers: { get: jest.fn() }});
await expect(fetchDataset(url, converter)).rejects.toThrow(`Unable to access data at ${url}`);
expect(fetchMock).toHaveBeenCalledWith(url);
});
it('returns the resulting Dataset.', async(): Promise<void> => {
await expect(fetchDataset(url)).resolves.toBe(dataset);
it('returns a Representation with quads.', async(): Promise<void> => {
mockFetch('<http://test.com/s> <http://test.com/p> <http://test.com/o>.');
const representation = await fetchDataset(url, converter);
await expect(arrayifyStream(representation.data)).resolves.toEqual([
quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')),
]);
});
it('accepts Response objects as input.', async(): Promise<void> => {
mockFetch('<http://test.com/s> <http://test.com/p> <http://test.com/o>.');
const response = await fetch(url);
const body = await response.text();
const representation = await fetchDataset(response, converter, body);
await expect(arrayifyStream(representation.data)).resolves.toEqual([
quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')),
]);
});
});
});