mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for client_id WebIDs
This commit is contained in:
parent
60ebf5454a
commit
3bb7a32c0c
@ -4,12 +4,13 @@
|
|||||||
{
|
{
|
||||||
"comment": "An adapter is responsible for storing all interaction metadata.",
|
"comment": "An adapter is responsible for storing all interaction metadata.",
|
||||||
"@id": "urn:solid-server:default:IdpAdapterFactory",
|
"@id": "urn:solid-server:default:IdpAdapterFactory",
|
||||||
"@type": "WrappedFetchAdapterFactory",
|
"@type": "WebIdAdapterFactory",
|
||||||
"source": {
|
"source": {
|
||||||
"@type": "ExpiringAdapterFactory",
|
"@type": "ExpiringAdapterFactory",
|
||||||
"args_storageName": "/idp/oidc",
|
"args_storageName": "/idp/oidc",
|
||||||
"args_storage": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
|
"args_storage": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
|
||||||
}
|
},
|
||||||
|
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
||||||
"import": [
|
"import": [
|
||||||
"files-scs:config/identity/handler/adapter-factory/wrapped-fetch.json",
|
"files-scs:config/identity/handler/adapter-factory/webid.json",
|
||||||
"files-scs:config/identity/handler/interaction/handler.json",
|
"files-scs:config/identity/handler/interaction/handler.json",
|
||||||
"files-scs:config/identity/handler/key-value/resource-store.json",
|
"files-scs:config/identity/handler/key-value/resource-store.json",
|
||||||
"files-scs:config/identity/handler/provider-factory/identity.json"
|
"files-scs:config/identity/handler/provider-factory/identity.json"
|
||||||
|
@ -37,6 +37,7 @@
|
|||||||
"AccessToken": "jwt"
|
"AccessToken": "jwt"
|
||||||
},
|
},
|
||||||
"scopes": [ "openid", "profile", "offline_access" ],
|
"scopes": [ "openid", "profile", "offline_access" ],
|
||||||
|
"subjectTypes": [ "public", "pairwise" ],
|
||||||
"ttl": {
|
"ttl": {
|
||||||
"AccessToken": 3600,
|
"AccessToken": 3600,
|
||||||
"AuthorizationCode": 600,
|
"AuthorizationCode": 600,
|
||||||
|
@ -108,6 +108,7 @@
|
|||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
"componentsjs": "^4.3.0",
|
"componentsjs": "^4.3.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cross-fetch": "^3.1.4",
|
||||||
"ejs": "^3.1.6",
|
"ejs": "^3.1.6",
|
||||||
"end-of-stream": "^1.4.4",
|
"end-of-stream": "^1.4.4",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
@ -150,7 +151,6 @@
|
|||||||
"@typescript-eslint/parser": "^4.28.1",
|
"@typescript-eslint/parser": "^4.28.1",
|
||||||
"cheerio": "^1.0.0-rc.10",
|
"cheerio": "^1.0.0-rc.10",
|
||||||
"componentsjs-generator": "^2.4.0",
|
"componentsjs-generator": "^2.4.0",
|
||||||
"cross-fetch": "^3.1.4",
|
|
||||||
"eslint": "^7.29.0",
|
"eslint": "^7.29.0",
|
||||||
"eslint-config-es": "^3.20.3",
|
"eslint-config-es": "^3.20.3",
|
||||||
"eslint-import-resolver-typescript": "^2.4.0",
|
"eslint-import-resolver-typescript": "^2.4.0",
|
||||||
|
146
src/identity/storage/WebIdAdapterFactory.ts
Normal file
146
src/identity/storage/WebIdAdapterFactory.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import type { Response } from 'cross-fetch';
|
||||||
|
import { fetch } from 'cross-fetch';
|
||||||
|
import { Store } 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 type { AdapterFactory } from './AdapterFactory';
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This {@link Adapter} redirects the `find` call to its source adapter.
|
||||||
|
* In case no client data was found in the source for the given WebId,
|
||||||
|
* this class will do an HTTP GET request to that WebId.
|
||||||
|
* If a valid `solid:oidcRegistration` triple is found there,
|
||||||
|
* that data will be returned instead.
|
||||||
|
*/
|
||||||
|
export class WebIdAdapter implements Adapter {
|
||||||
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
|
private readonly name: string;
|
||||||
|
private readonly source: Adapter;
|
||||||
|
private readonly converter: RepresentationConverter;
|
||||||
|
|
||||||
|
public constructor(name: string, source: Adapter, converter: RepresentationConverter) {
|
||||||
|
this.name = name;
|
||||||
|
this.source = source;
|
||||||
|
this.converter = converter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
||||||
|
return this.source.upsert(id, payload, expiresIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(id: string): Promise<AdapterPayload | void> {
|
||||||
|
let payload = await this.source.find(id);
|
||||||
|
|
||||||
|
// No payload is stored for the given Client ID.
|
||||||
|
// Try to see if valid client metadata is found at the given Client ID.
|
||||||
|
// The oidc-provider library will check if the redirect_uri matches an entry in the list of redirect_uris,
|
||||||
|
// so no extra checks are needed from our side.
|
||||||
|
if (!payload && this.name === 'Client' && /^https?:\/\/.+/u.test(id)) {
|
||||||
|
this.logger.debug(`Looking for payload data at ${id}`);
|
||||||
|
// All checks based on https://solid.github.io/authentication-panel/solid-oidc/#clientids-webid
|
||||||
|
if (!/^https:|^http:\/\/localhost(?::\d+)?(?:\/|$)/u.test(id)) {
|
||||||
|
throw new Error(`SSL is required for client_id authentication unless working locally.`);
|
||||||
|
}
|
||||||
|
const response = await fetch(id);
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`Unable to access data at ${id}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
const data = await response.text();
|
||||||
|
let json: any | undefined;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(data);
|
||||||
|
// We can only parse as simple JSON if the @context is correct
|
||||||
|
if (json['@context'] !== 'https://www.w3.org/ns/solid/oidc-context.jsonld') {
|
||||||
|
throw new Error('Invalid context');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
json = undefined;
|
||||||
|
this.logger.debug(`Found unexpected client WebID for ${id}: ${createErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
// Need to make sure the document is about the id
|
||||||
|
if (json.client_id !== id) {
|
||||||
|
throw new Error('The client registration `client_id` field must match the client WebID');
|
||||||
|
}
|
||||||
|
payload = json;
|
||||||
|
} else {
|
||||||
|
// Since the WebID does not match the default JSON-LD we try to interpret it as RDF
|
||||||
|
payload = await this.parseRdfWebId(data, id, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `token_endpoint_auth_method: 'none'` prevents oidc-provider from requiring a client_secret
|
||||||
|
payload = { ...payload, token_endpoint_auth_method: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will also be returned if no valid client data was found above
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByUserCode(userCode: string): Promise<AdapterPayload | void> {
|
||||||
|
return this.source.findByUserCode(userCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByUid(uid: string): Promise<AdapterPayload | void> {
|
||||||
|
return this.source.findByUid(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(id: string): Promise<void> {
|
||||||
|
return this.source.destroy(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revokeByGrantId(grantId: string): Promise<void> {
|
||||||
|
return this.source.revokeByGrantId(grantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consume(id: string): Promise<void> {
|
||||||
|
return this.source.consume(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebIdAdapterFactory implements AdapterFactory {
|
||||||
|
private readonly source: AdapterFactory;
|
||||||
|
private readonly converter: RepresentationConverter;
|
||||||
|
|
||||||
|
public constructor(source: AdapterFactory, converter: RepresentationConverter) {
|
||||||
|
this.source = source;
|
||||||
|
this.converter = converter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createStorageAdapter(name: string): Adapter {
|
||||||
|
return new WebIdAdapter(name, this.source.createStorageAdapter(name), this.converter);
|
||||||
|
}
|
||||||
|
}
|
@ -1,121 +0,0 @@
|
|||||||
import { DataFactory } from 'n3';
|
|
||||||
import type { Adapter, AdapterPayload } from 'oidc-provider';
|
|
||||||
import type { Dataset, Quad } from 'rdf-js';
|
|
||||||
import { getLoggerFor } from '../../logging/LogUtil';
|
|
||||||
import { fetchDataset } from '../../util/FetchUtil';
|
|
||||||
import { SOLID } from '../../util/Vocabularies';
|
|
||||||
import type { AdapterFactory } from './AdapterFactory';
|
|
||||||
import namedNode = DataFactory.namedNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This {@link Adapter} redirects the `find` call to its source adapter.
|
|
||||||
* In case no client data was found in the source for the given WebId,
|
|
||||||
* this class will do an HTTP GET request to that WebId.
|
|
||||||
* If a valid `solid:oidcRegistration` triple is found there,
|
|
||||||
* that data will be returned instead.
|
|
||||||
*/
|
|
||||||
export class WrappedFetchAdapter implements Adapter {
|
|
||||||
protected readonly logger = getLoggerFor(this);
|
|
||||||
|
|
||||||
private readonly source: Adapter;
|
|
||||||
private readonly name: string;
|
|
||||||
|
|
||||||
public constructor(name: string, source: Adapter) {
|
|
||||||
this.source = source;
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
|
||||||
return this.source.upsert(id, payload, expiresIn);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async find(id: string): Promise<AdapterPayload | void> {
|
|
||||||
const payload = await this.source.find(id);
|
|
||||||
|
|
||||||
// No payload is stored for the given WebId.
|
|
||||||
// Try to see if a solid:oidcRegistration triple is stored at the WebId that can be used instead.
|
|
||||||
if (!payload && this.name === 'Client') {
|
|
||||||
this.logger.debug(`Looking for payload data at ${id}`);
|
|
||||||
let dataset: Dataset;
|
|
||||||
try {
|
|
||||||
dataset = await fetchDataset(id);
|
|
||||||
} catch {
|
|
||||||
this.logger.debug(`Looking for payload data failed at ${id}`);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the OIDC Registration JSON
|
|
||||||
const rawRegistrationJsonQuads = dataset.match(namedNode(id), SOLID.terms.oidcRegistration);
|
|
||||||
|
|
||||||
// Check all the registrations to see if any are valid.
|
|
||||||
for (const rawRegistrationJsonQuad of rawRegistrationJsonQuads) {
|
|
||||||
try {
|
|
||||||
return this.validateRegistrationQuad(rawRegistrationJsonQuad, id);
|
|
||||||
} catch {
|
|
||||||
// Keep looking for a valid quad
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.logger.debug(`No payload data was found at ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will also be returned if no valid registration data was found above
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async findByUserCode(userCode: string): Promise<AdapterPayload | void> {
|
|
||||||
return this.source.findByUserCode(userCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async findByUid(uid: string): Promise<AdapterPayload | void> {
|
|
||||||
return this.source.findByUid(uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async destroy(id: string): Promise<void> {
|
|
||||||
return this.source.destroy(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async revokeByGrantId(grantId: string): Promise<void> {
|
|
||||||
return this.source.revokeByGrantId(grantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async consume(id: string): Promise<void> {
|
|
||||||
return this.source.consume(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates if the quad object contains valid JSON with the required client_id.
|
|
||||||
* In case of success, the AdapterPayload will be returned, otherwise an error will be thrown.
|
|
||||||
*/
|
|
||||||
private validateRegistrationQuad(quad: Quad, id: string): AdapterPayload {
|
|
||||||
const rawRegistrationJson = quad.object.value;
|
|
||||||
let registrationJson;
|
|
||||||
try {
|
|
||||||
registrationJson = JSON.parse(rawRegistrationJson);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Could not parse registration JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the registration JSON matches the client WebId
|
|
||||||
if (id !== registrationJson.client_id) {
|
|
||||||
throw new Error('The client registration `client_id` field must match the Client WebId');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...registrationJson,
|
|
||||||
// Snake case is required for tokens
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
token_endpoint_auth_method: 'none',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WrappedFetchAdapterFactory implements AdapterFactory {
|
|
||||||
private readonly source: AdapterFactory;
|
|
||||||
|
|
||||||
public constructor(source: AdapterFactory) {
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createStorageAdapter(name: string): Adapter {
|
|
||||||
return new WrappedFetchAdapter(name, this.source.createStorageAdapter(name));
|
|
||||||
}
|
|
||||||
}
|
|
@ -54,7 +54,7 @@ export * from './identity/ownership/TokenOwnershipValidator';
|
|||||||
// Identity/Storage
|
// Identity/Storage
|
||||||
export * from './identity/storage/AdapterFactory';
|
export * from './identity/storage/AdapterFactory';
|
||||||
export * from './identity/storage/ExpiringAdapterFactory';
|
export * from './identity/storage/ExpiringAdapterFactory';
|
||||||
export * from './identity/storage/WrappedFetchAdapterFactory';
|
export * from './identity/storage/WebIdAdapterFactory';
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
export * from './identity/IdentityProviderHttpHandler';
|
export * from './identity/IdentityProviderHttpHandler';
|
||||||
|
166
test/unit/identity/storage/WebIdAdapterFactory.test.ts
Normal file
166
test/unit/identity/storage/WebIdAdapterFactory.test.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import fetch from 'cross-fetch';
|
||||||
|
import type { Adapter } from 'oidc-provider';
|
||||||
|
import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
|
||||||
|
import { WebIdAdapterFactory } from '../../../../src/identity/storage/WebIdAdapterFactory';
|
||||||
|
import { RdfToQuadConverter } from '../../../../src/storage/conversion/RdfToQuadConverter';
|
||||||
|
|
||||||
|
jest.mock('cross-fetch');
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
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;
|
||||||
|
let sourceFactory: AdapterFactory;
|
||||||
|
let adapter: Adapter;
|
||||||
|
const converter = new RdfToQuadConverter();
|
||||||
|
let factory: WebIdAdapterFactory;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
json = {
|
||||||
|
'@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld',
|
||||||
|
|
||||||
|
client_id: id,
|
||||||
|
client_name: 'Solid Application Name',
|
||||||
|
redirect_uris: [ 'http://test.com/' ],
|
||||||
|
scope: 'openid profile offline_access',
|
||||||
|
grant_types: [ 'refresh_token', 'authorization_code' ],
|
||||||
|
response_types: [ 'code' ],
|
||||||
|
default_max_age: 3600,
|
||||||
|
require_auth_time: true,
|
||||||
|
};
|
||||||
|
rdf = `<${id}> <http://www.w3.org/ns/solid/oidc#redirect_uris> <http://test.com>.`;
|
||||||
|
|
||||||
|
fetchMock.mockReturnValue({ text: (): any => data });
|
||||||
|
|
||||||
|
source = {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findByUserCode: jest.fn(),
|
||||||
|
findByUid: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
revokeByGrantId: jest.fn(),
|
||||||
|
consume: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceFactory = {
|
||||||
|
createStorageAdapter: jest.fn().mockReturnValue(source),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory = new WebIdAdapterFactory(sourceFactory, converter);
|
||||||
|
adapter = factory.createStorageAdapter('Client');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the call to the source for upsert.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.upsert('id', 'payload' as any, 5)).resolves.toBeUndefined();
|
||||||
|
expect(source.upsert).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.upsert).toHaveBeenLastCalledWith('id', 'payload' as any, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the call to the source for findByUserCode.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.findByUserCode('userCode')).resolves.toBeUndefined();
|
||||||
|
expect(source.findByUserCode).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.findByUserCode).toHaveBeenLastCalledWith('userCode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the call to the source for findByUid.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.findByUid('uid')).resolves.toBeUndefined();
|
||||||
|
expect(source.findByUid).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.findByUid).toHaveBeenLastCalledWith('uid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the call to the source for destroy.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.destroy('id')).resolves.toBeUndefined();
|
||||||
|
expect(source.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.destroy).toHaveBeenLastCalledWith('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the call to the source for revokeByGrantId.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.revokeByGrantId('grantId')).resolves.toBeUndefined();
|
||||||
|
expect(source.revokeByGrantId).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.revokeByGrantId).toHaveBeenLastCalledWith('grantId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the call to the source for consume.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.consume('id')).resolves.toBeUndefined();
|
||||||
|
expect(source.consume).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.consume).toHaveBeenLastCalledWith('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the source payload if there is one.', async(): Promise<void> => {
|
||||||
|
(source.find as jest.Mock).mockResolvedValueOnce('payload!');
|
||||||
|
await expect(adapter.find(id)).resolves.toBe('payload!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if this is not a Client Adapter and there is no source payload.', async(): Promise<void> => {
|
||||||
|
adapter = factory.createStorageAdapter('NotClient');
|
||||||
|
await expect(adapter.find(id)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if the client ID is not a URL.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.find('noUrl')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if the client ID is unsecure.', async(): Promise<void> => {
|
||||||
|
await expect(adapter.find('http://unsecure')).rejects
|
||||||
|
.toThrow('SSL is required for client_id authentication unless working locally.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if the client ID requests does not respond with 200.', async(): Promise<void> => {
|
||||||
|
fetchMock.mockResolvedValueOnce({ 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) });
|
||||||
|
await expect(adapter.find(id)).resolves.toEqual({
|
||||||
|
...json,
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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) });
|
||||||
|
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' }},
|
||||||
|
);
|
||||||
|
await expect(adapter.find(id)).resolves.toEqual({
|
||||||
|
client_id: id,
|
||||||
|
redirect_uris: [ 'http://test.com' ],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to RDF parsing if no valid context was found.', async(): Promise<void> => {
|
||||||
|
json = {
|
||||||
|
'@id': 'https://app.test.com/card#me',
|
||||||
|
'http://www.w3.org/ns/solid/oidc#redirect_uris': { '@id': 'http://test.com' },
|
||||||
|
'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' }},
|
||||||
|
);
|
||||||
|
await expect(adapter.find(id)).resolves.toEqual({
|
||||||
|
client_id: id,
|
||||||
|
redirect_uris: [ 'http://test.com' ],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if there is no content-type.', async(): Promise<void> => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
{ status: 200, text: (): string => rdf, headers: { get: jest.fn() }},
|
||||||
|
);
|
||||||
|
await expect(adapter.find(id)).rejects
|
||||||
|
.toThrow(`No content-type received for client WebID ${id}`);
|
||||||
|
});
|
||||||
|
});
|
@ -1,142 +0,0 @@
|
|||||||
import { literal, namedNode, quad } from '@rdfjs/data-model';
|
|
||||||
import fetch from '@rdfjs/fetch';
|
|
||||||
import type { DatasetResponse } from '@rdfjs/fetch-lite';
|
|
||||||
import type { Adapter } from 'oidc-provider';
|
|
||||||
import type { Dataset, Quad, Term } from 'rdf-js';
|
|
||||||
import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
|
|
||||||
import { WrappedFetchAdapterFactory } from '../../../../src/identity/storage/WrappedFetchAdapterFactory';
|
|
||||||
import { SOLID } from '../../../../src/util/Vocabularies';
|
|
||||||
|
|
||||||
jest.mock('@rdfjs/fetch');
|
|
||||||
|
|
||||||
describe('A WrappedFetchAdapterFactory', (): void => {
|
|
||||||
const fetchMock: jest.Mock = fetch as any;
|
|
||||||
let triples: Quad[];
|
|
||||||
const id = 'http://alice.test.com/card#me';
|
|
||||||
let source: Adapter;
|
|
||||||
let sourceFactory: AdapterFactory;
|
|
||||||
let adapter: Adapter;
|
|
||||||
let factory: WrappedFetchAdapterFactory;
|
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
|
||||||
triples = [];
|
|
||||||
|
|
||||||
const dataset: Dataset = {
|
|
||||||
match: (subject: Term, predicate: Term): Quad[] => triples.filter((triple): boolean =>
|
|
||||||
triple.subject.equals(subject) && triple.predicate.equals(predicate)),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const rawResponse: DatasetResponse<Dataset> = {
|
|
||||||
dataset: async(): Promise<Dataset> => dataset,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
fetchMock.mockReturnValue(rawResponse);
|
|
||||||
|
|
||||||
source = {
|
|
||||||
upsert: jest.fn(),
|
|
||||||
find: jest.fn(),
|
|
||||||
findByUserCode: jest.fn(),
|
|
||||||
findByUid: jest.fn(),
|
|
||||||
destroy: jest.fn(),
|
|
||||||
revokeByGrantId: jest.fn(),
|
|
||||||
consume: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
sourceFactory = {
|
|
||||||
createStorageAdapter: jest.fn().mockReturnValue(source),
|
|
||||||
};
|
|
||||||
|
|
||||||
factory = new WrappedFetchAdapterFactory(sourceFactory);
|
|
||||||
adapter = factory.createStorageAdapter('Client');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the call to the source for upsert.', async(): Promise<void> => {
|
|
||||||
await expect(adapter.upsert('id', 'payload' as any, 5)).resolves.toBeUndefined();
|
|
||||||
expect(source.upsert).toHaveBeenCalledTimes(1);
|
|
||||||
expect(source.upsert).toHaveBeenLastCalledWith('id', 'payload' as any, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the call to the source for findByUserCode.', async(): Promise<void> => {
|
|
||||||
await expect(adapter.findByUserCode('userCode')).resolves.toBeUndefined();
|
|
||||||
expect(source.findByUserCode).toHaveBeenCalledTimes(1);
|
|
||||||
expect(source.findByUserCode).toHaveBeenLastCalledWith('userCode');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the call to the source for findByUid.', async(): Promise<void> => {
|
|
||||||
await expect(adapter.findByUid('uid')).resolves.toBeUndefined();
|
|
||||||
expect(source.findByUid).toHaveBeenCalledTimes(1);
|
|
||||||
expect(source.findByUid).toHaveBeenLastCalledWith('uid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the call to the source for destroy.', async(): Promise<void> => {
|
|
||||||
await expect(adapter.destroy('id')).resolves.toBeUndefined();
|
|
||||||
expect(source.destroy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(source.destroy).toHaveBeenLastCalledWith('id');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the call to the source for revokeByGrantId.', async(): Promise<void> => {
|
|
||||||
await expect(adapter.revokeByGrantId('grantId')).resolves.toBeUndefined();
|
|
||||||
expect(source.revokeByGrantId).toHaveBeenCalledTimes(1);
|
|
||||||
expect(source.revokeByGrantId).toHaveBeenLastCalledWith('grantId');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the call to the source for consume.', async(): Promise<void> => {
|
|
||||||
await expect(adapter.consume('id')).resolves.toBeUndefined();
|
|
||||||
expect(source.consume).toHaveBeenCalledTimes(1);
|
|
||||||
expect(source.consume).toHaveBeenLastCalledWith('id');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the source find payload if there is one.', async(): Promise<void> => {
|
|
||||||
(source.find as jest.Mock).mockResolvedValueOnce('payload!');
|
|
||||||
await expect(adapter.find(id)).resolves.toBe('payload!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if this is not a Client Adapter and there is no source payload.', async(): Promise<void> => {
|
|
||||||
adapter = factory.createStorageAdapter('NotClient');
|
|
||||||
await expect(adapter.find(id)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if there was a problem accessing the id.', async(): Promise<void> => {
|
|
||||||
fetchMock.mockRejectedValueOnce(new Error('bad data!'));
|
|
||||||
await expect(adapter.find(id)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if there are no solid:oidcRegistration triples.', async(): Promise<void> => {
|
|
||||||
triples = [
|
|
||||||
quad(namedNode(id), namedNode('irrelevant'), literal('value')),
|
|
||||||
];
|
|
||||||
await expect(adapter.find(id)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if there are no valid solid:oidcRegistration triples.', async(): Promise<void> => {
|
|
||||||
triples = [
|
|
||||||
quad(namedNode(id), namedNode('irrelevant'), literal('value')),
|
|
||||||
quad(namedNode(id), SOLID.terms.oidcRegistration, literal('}{')),
|
|
||||||
];
|
|
||||||
await expect(adapter.find(id)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if there are no matching solid:oidcRegistration triples.', async(): Promise<void> => {
|
|
||||||
triples = [
|
|
||||||
quad(namedNode(id), namedNode('irrelevant'), literal('value')),
|
|
||||||
quad(namedNode(id), SOLID.terms.oidcRegistration, literal('}{')),
|
|
||||||
quad(namedNode(id), SOLID.terms.oidcRegistration, literal('{ "client_id": "invalid_id" }')),
|
|
||||||
];
|
|
||||||
await expect(adapter.find(id)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a new payload if there is a registration match.', async(): Promise<void> => {
|
|
||||||
triples = [
|
|
||||||
quad(namedNode(id), namedNode('irrelevant'), literal('value')),
|
|
||||||
quad(namedNode(id), SOLID.terms.oidcRegistration, literal('}{')),
|
|
||||||
quad(namedNode(id), SOLID.terms.oidcRegistration, literal('{ "client_id": "invalid_id" }')),
|
|
||||||
quad(namedNode(id), SOLID.terms.oidcRegistration, literal(`{ "client_id": "${id}" }`)),
|
|
||||||
];
|
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
|
||||||
await expect(adapter.find(id)).resolves.toEqual({
|
|
||||||
client_id: id,
|
|
||||||
token_endpoint_auth_method: 'none',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
Loading…
x
Reference in New Issue
Block a user