mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Change OwnershipValidator to create own token
It also no longer checks the issuer triple since that is independent of ownership.
This commit is contained in:
@@ -10,12 +10,6 @@
|
||||
"args_storageName": "/idp/oidc",
|
||||
"args_storage": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "Stores expiring data. This class has a `finalize` function to call after stopping the server.",
|
||||
"@id": "urn:solid-server:default:ExpiringIdpStorage",
|
||||
"@type": "WrappedExpiringStorage",
|
||||
"source": { "@id": "urn:solid-server:default:IdpStorage" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"postHandler": {
|
||||
"@type": "RegistrationHandler",
|
||||
"args_ownershipValidator": {
|
||||
"@type": "IssuerOwnershipValidator",
|
||||
"issuer": { "@id": "urn:solid-server:default:variable:baseUrl" }
|
||||
"@type": "TokenOwnershipValidator",
|
||||
"storage": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
|
||||
},
|
||||
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||
"args_interactionCompleter": { "@id": "urn:solid-server:auth:password:InteractionCompleter" }
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"source": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"container": "/idp/data/"
|
||||
},
|
||||
{
|
||||
"comment": "Stores expiring data. This class has a `finalize` function that needs to be called after stopping the server.",
|
||||
"@id": "urn:solid-server:default:ExpiringIdpStorage",
|
||||
"@type": "WrappedExpiringStorage",
|
||||
"source": { "@id": "urn:solid-server:default:IdpStorage" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,11 +44,10 @@ export class RegistrationHandler extends InteractionHttpHandler {
|
||||
}
|
||||
|
||||
public async handle(input: InteractionHttpHandlerInput): Promise<void> {
|
||||
const interactionDetails = await input.provider.interactionDetails(input.request, input.response);
|
||||
const { email, webId, password, remember } = await this.parseInput(input.request);
|
||||
try {
|
||||
// Check if WebId contains required triples and register new account if successful
|
||||
await this.ownershipValidator.handleSafe({ webId, interactionId: interactionDetails.uid });
|
||||
await this.ownershipValidator.handleSafe({ webId });
|
||||
await this.accountStore.create(email, webId, password);
|
||||
await this.interactionCompleter.handleSafe({
|
||||
...input,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { DataFactory } from 'n3';
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import { SOLID } from '../../../util/Vocabularies';
|
||||
import { fetchDataset } from '../../util/FetchUtil';
|
||||
import { OwnershipValidator } from './OwnershipValidator';
|
||||
const { literal, namedNode, quad } = DataFactory;
|
||||
|
||||
/**
|
||||
* Validates if a WebID can be registered based on whether it references this as an issuer.
|
||||
*/
|
||||
export class IssuerOwnershipValidator extends OwnershipValidator {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly issuer: string;
|
||||
|
||||
public constructor(issuer: string) {
|
||||
super();
|
||||
this.issuer = issuer;
|
||||
}
|
||||
|
||||
public async handle({ webId, interactionId }: { webId: string; interactionId: string }): Promise<void> {
|
||||
const dataset = await fetchDataset(webId);
|
||||
const hasIssuer = dataset.has(
|
||||
quad(namedNode(webId), SOLID.terms.oidcIssuer, namedNode(this.issuer)),
|
||||
);
|
||||
const hasRegistrationToken = dataset.has(
|
||||
quad(
|
||||
namedNode(webId),
|
||||
SOLID.terms.oidcIssuerRegistrationToken,
|
||||
literal(interactionId),
|
||||
),
|
||||
);
|
||||
if (!hasIssuer || !hasRegistrationToken) {
|
||||
this.logger.debug(`Missing issuer and/or registration token at ${webId}`);
|
||||
let errorMessage = !hasIssuer ?
|
||||
`<${webId}> <${SOLID.terms.oidcIssuer.value}> <${this.issuer}> .\n` :
|
||||
'';
|
||||
errorMessage += !hasRegistrationToken ?
|
||||
`<${webId}> <${SOLID.terms.oidcIssuerRegistrationToken.value}> "${interactionId}" .\n` :
|
||||
'';
|
||||
errorMessage += 'Must be added to the WebId';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
||||
|
||||
/**
|
||||
* A class that validates if a someone owns a WebId. Will
|
||||
* throw an error if the WebId is not valid.
|
||||
* A class that validates if a someone owns a WebId.
|
||||
* Will throw an error if the WebId is not valid or ownership could not be validated.
|
||||
* The error message should contain a description of what is wrong and how it can be resolved.
|
||||
*/
|
||||
export abstract class OwnershipValidator extends AsyncHandler<{ webId: string; interactionId: string }> {}
|
||||
export abstract class OwnershipValidator extends AsyncHandler<{ webId: string }> {}
|
||||
|
||||
73
src/identity/interaction/util/TokenOwnershipValidator.ts
Normal file
73
src/identity/interaction/util/TokenOwnershipValidator.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { DataFactory } from 'n3';
|
||||
import { v4 } from 'uuid';
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import type { ExpiringStorage } from '../../../storage/keyvalue/ExpiringStorage';
|
||||
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
|
||||
import { SOLID } from '../../../util/Vocabularies';
|
||||
import { fetchDataset } from '../../util/FetchUtil';
|
||||
import { OwnershipValidator } from './OwnershipValidator';
|
||||
const { literal, namedNode, quad } = DataFactory;
|
||||
|
||||
/**
|
||||
* Validates ownership of a WebId by seeing if a specific triple can be added.
|
||||
* `expiration` parameter is how long the token should be valid in minutes.
|
||||
*/
|
||||
export class TokenOwnershipValidator extends OwnershipValidator {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: ExpiringStorage<string, string>;
|
||||
private readonly expiration: number;
|
||||
|
||||
public constructor(storage: ExpiringStorage<string, string>, expiration = 30) {
|
||||
super();
|
||||
this.storage = storage;
|
||||
// Convert minutes to milliseconds
|
||||
this.expiration = expiration * 60 * 1000;
|
||||
}
|
||||
|
||||
public async handle({ webId }: { webId: string }): Promise<void> {
|
||||
const key = this.getTokenKey(webId);
|
||||
let token = await this.storage.get(key);
|
||||
|
||||
// No reason to fetch the WebId if we don't have a token yet
|
||||
if (!token) {
|
||||
token = this.generateToken();
|
||||
await this.storage.set(key, token, new Date(Date.now() + this.expiration));
|
||||
this.throwError(webId, token);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
this.throwError(webId, token);
|
||||
}
|
||||
await this.storage.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a key to use with the token storage.
|
||||
*/
|
||||
private getTokenKey(webId: string): string {
|
||||
return `ownershipToken${webId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random verification token;
|
||||
*/
|
||||
private generateToken(): string {
|
||||
return v4();
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error containing the description of which triple is needed for verification.
|
||||
*/
|
||||
private throwError(webId: string, token: string): never {
|
||||
this.logger.debug(`Missing verification token at ${webId}`);
|
||||
const errorMessage = [
|
||||
`<${webId}> <${SOLID.terms.oidcIssuerRegistrationToken.value}> "${token}" .`,
|
||||
'Must be added to the WebId',
|
||||
].join('\n');
|
||||
throw new BadRequestHttpError(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,9 @@ export * from './identity/interaction/util/IdpRenderHandler';
|
||||
export * from './identity/interaction/util/IdpRouteController';
|
||||
export * from './identity/interaction/util/InitialInteractionHandler';
|
||||
export * from './identity/interaction/util/InteractionCompleter';
|
||||
export * from './identity/interaction/util/IssuerOwnershipValidator';
|
||||
export * from './identity/interaction/util/OwnershipValidator';
|
||||
export * from './identity/interaction/util/TemplateRenderer';
|
||||
export * from './identity/interaction/util/TokenOwnershipValidator';
|
||||
|
||||
// Identity/Interaction
|
||||
export * from './identity/interaction/InteractionHttpHandler';
|
||||
|
||||
@@ -14,17 +14,13 @@ describe('A RegistrationHandler', (): void => {
|
||||
const email = 'alice@test.email';
|
||||
let request: HttpRequest;
|
||||
const response: HttpResponse = {} as any;
|
||||
let provider: Provider;
|
||||
const provider: Provider = {} as any;
|
||||
let ownershipValidator: OwnershipValidator;
|
||||
let accountStore: AccountStore;
|
||||
let interactionCompleter: InteractionCompleter;
|
||||
let handler: RegistrationHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
provider = {
|
||||
interactionDetails: jest.fn().mockResolvedValue({ uid: '123456' }),
|
||||
} as any;
|
||||
|
||||
ownershipValidator = {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import fetch from '@rdfjs/fetch';
|
||||
import type { DatasetResponse } from '@rdfjs/fetch-lite';
|
||||
import { DataFactory } from 'n3';
|
||||
import type { Quad } from 'n3';
|
||||
import type { DatasetCore } from 'rdf-js';
|
||||
import { IssuerOwnershipValidator } from '../../../../../src/identity/interaction/util/IssuerOwnershipValidator';
|
||||
import { SOLID } from '../../../../../src/util/Vocabularies';
|
||||
const { literal, namedNode, quad } = DataFactory;
|
||||
|
||||
jest.mock('@rdfjs/fetch');
|
||||
|
||||
function quadToString(qq: Quad): string {
|
||||
const subPred = `<${qq.subject.value}> <${qq.predicate.value}>`;
|
||||
if (qq.object.termType === 'Literal') {
|
||||
return `${subPred} "${qq.object.value}"`;
|
||||
}
|
||||
return `${subPred} <${qq.object.value}>`;
|
||||
}
|
||||
|
||||
describe('An IssuerOwnershipValidator', (): void => {
|
||||
const fetchMock: jest.Mock = fetch as any;
|
||||
const issuer = 'http://test.com/foo/';
|
||||
const webId = 'http://alice.test.com/#me';
|
||||
const interactionId = 'interaction!!';
|
||||
let rawResponse: DatasetResponse<DatasetCore>;
|
||||
let dataset: DatasetCore;
|
||||
let triples: Quad[];
|
||||
const issuerTriple = quad(namedNode(webId), SOLID.terms.oidcIssuer, namedNode(issuer));
|
||||
const tokenTriple = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(interactionId));
|
||||
let validator: IssuerOwnershipValidator;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
triples = [];
|
||||
|
||||
dataset = {
|
||||
has: (qq: Quad): boolean => triples.some((triple): boolean => triple.equals(qq)),
|
||||
} as any;
|
||||
|
||||
rawResponse = {
|
||||
dataset: async(): Promise<DatasetCore> => dataset,
|
||||
} as any;
|
||||
|
||||
fetchMock.mockReturnValue(rawResponse);
|
||||
|
||||
validator = new IssuerOwnershipValidator(issuer);
|
||||
});
|
||||
|
||||
it('errors if the expected triples are missing.', async(): Promise<void> => {
|
||||
const prom = validator.handle({ webId, interactionId });
|
||||
await expect(prom).rejects.toThrow(quadToString(issuerTriple));
|
||||
await expect(prom).rejects.toThrow(quadToString(tokenTriple));
|
||||
});
|
||||
|
||||
it('only requests the needed triples.', async(): Promise<void> => {
|
||||
triples = [ issuerTriple ];
|
||||
let prom = validator.handle({ webId, interactionId });
|
||||
await expect(prom).rejects.not.toThrow(quadToString(issuerTriple));
|
||||
await expect(prom).rejects.toThrow(quadToString(tokenTriple));
|
||||
|
||||
triples = [ tokenTriple ];
|
||||
prom = validator.handle({ webId, interactionId });
|
||||
await expect(prom).rejects.toThrow(quadToString(issuerTriple));
|
||||
await expect(prom).rejects.not.toThrow(quadToString(tokenTriple));
|
||||
});
|
||||
|
||||
it('resolves if all required triples are present.', async(): Promise<void> => {
|
||||
triples = [ issuerTriple, tokenTriple ];
|
||||
await expect(validator.handle({ webId, interactionId })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import fetch from '@rdfjs/fetch';
|
||||
import type { DatasetResponse } from '@rdfjs/fetch-lite';
|
||||
import { DataFactory } from 'n3';
|
||||
import type { Quad } from 'n3';
|
||||
import type { DatasetCore } from 'rdf-js';
|
||||
import { v4 } from 'uuid';
|
||||
import { TokenOwnershipValidator } from '../../../../../src/identity/interaction/util/TokenOwnershipValidator';
|
||||
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('uuid');
|
||||
|
||||
function quadToString(qq: Quad): string {
|
||||
const subPred = `<${qq.subject.value}> <${qq.predicate.value}>`;
|
||||
if (qq.object.termType === 'Literal') {
|
||||
return `${subPred} "${qq.object.value}"`;
|
||||
}
|
||||
return `${subPred} <${qq.object.value}>`;
|
||||
}
|
||||
|
||||
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));
|
||||
let storage: ExpiringStorage<string, string>;
|
||||
let validator: TokenOwnershipValidator;
|
||||
|
||||
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 = {
|
||||
get: jest.fn().mockImplementation((key: string): any => map.get(key)),
|
||||
set: jest.fn().mockImplementation((key: string, value: any): any => map.set(key, value)),
|
||||
delete: jest.fn().mockImplementation((key: string): any => map.delete(key)),
|
||||
} as any;
|
||||
|
||||
fetchMock.mockReturnValue(rawResponse);
|
||||
|
||||
validator = new TokenOwnershipValidator(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));
|
||||
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));
|
||||
expect(fetch).toHaveBeenCalledTimes(0);
|
||||
// Second call will fetch the WebId
|
||||
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves if the WebId contains the verification triple.', async(): Promise<void> => {
|
||||
triples = [ tokenTriple ];
|
||||
// First call will add the token to the storage
|
||||
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
|
||||
// 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')) ];
|
||||
// First call will add the token to the storage
|
||||
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
|
||||
// Second call will fail since it has the wrong verification triple
|
||||
await expect(validator.handle({ webId })).rejects.toThrow(quadToString(tokenTriple));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user