diff --git a/config/identity/handler/adapter-factory/wrapped-fetch.json b/config/identity/handler/adapter-factory/wrapped-fetch.json index 46823fc70..c7cbd1e5d 100644 --- a/config/identity/handler/adapter-factory/wrapped-fetch.json +++ b/config/identity/handler/adapter-factory/wrapped-fetch.json @@ -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" } } ] } diff --git a/config/identity/handler/interaction/handlers/registration.json b/config/identity/handler/interaction/handlers/registration.json index 718d0f7b1..1ff2d6d6d 100644 --- a/config/identity/handler/interaction/handlers/registration.json +++ b/config/identity/handler/interaction/handlers/registration.json @@ -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" } diff --git a/config/identity/handler/key-value/resource-store.json b/config/identity/handler/key-value/resource-store.json index 435b4db1a..6d8f87dbf 100644 --- a/config/identity/handler/key-value/resource-store.json +++ b/config/identity/handler/key-value/resource-store.json @@ -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" } } ] } diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index 2f7b7b4e4..114796442 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -44,11 +44,10 @@ export class RegistrationHandler extends InteractionHttpHandler { } public async handle(input: InteractionHttpHandlerInput): Promise { - 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, diff --git a/src/identity/interaction/util/IssuerOwnershipValidator.ts b/src/identity/interaction/util/IssuerOwnershipValidator.ts deleted file mode 100644 index f2dcb6f56..000000000 --- a/src/identity/interaction/util/IssuerOwnershipValidator.ts +++ /dev/null @@ -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 { - 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); - } - } -} diff --git a/src/identity/interaction/util/OwnershipValidator.ts b/src/identity/interaction/util/OwnershipValidator.ts index 5719990a4..ed9957c84 100644 --- a/src/identity/interaction/util/OwnershipValidator.ts +++ b/src/identity/interaction/util/OwnershipValidator.ts @@ -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 }> {} diff --git a/src/identity/interaction/util/TokenOwnershipValidator.ts b/src/identity/interaction/util/TokenOwnershipValidator.ts new file mode 100644 index 000000000..20a942fc6 --- /dev/null +++ b/src/identity/interaction/util/TokenOwnershipValidator.ts @@ -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; + private readonly expiration: number; + + public constructor(storage: ExpiringStorage, expiration = 30) { + super(); + this.storage = storage; + // Convert minutes to milliseconds + this.expiration = expiration * 60 * 1000; + } + + public async handle({ webId }: { webId: string }): Promise { + 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); + } +} diff --git a/src/index.ts b/src/index.ts index 40a759d1c..cbec33090 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts index 41e409111..8e26b03a8 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -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 => { - provider = { - interactionDetails: jest.fn().mockResolvedValue({ uid: '123456' }), - } as any; - ownershipValidator = { handleSafe: jest.fn(), } as any; diff --git a/test/unit/identity/interaction/util/IssuerOwnershipValidator.test.ts b/test/unit/identity/interaction/util/IssuerOwnershipValidator.test.ts deleted file mode 100644 index 31ef78646..000000000 --- a/test/unit/identity/interaction/util/IssuerOwnershipValidator.test.ts +++ /dev/null @@ -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; - 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 => { - triples = []; - - dataset = { - has: (qq: Quad): boolean => triples.some((triple): boolean => triple.equals(qq)), - } as any; - - rawResponse = { - dataset: async(): Promise => dataset, - } as any; - - fetchMock.mockReturnValue(rawResponse); - - validator = new IssuerOwnershipValidator(issuer); - }); - - it('errors if the expected triples are missing.', async(): Promise => { - 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 => { - 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 => { - triples = [ issuerTriple, tokenTriple ]; - await expect(validator.handle({ webId, interactionId })).resolves.toBeUndefined(); - }); -}); diff --git a/test/unit/identity/interaction/util/TokenOwnershipValidator.test.ts b/test/unit/identity/interaction/util/TokenOwnershipValidator.test.ts new file mode 100644 index 000000000..85151814c --- /dev/null +++ b/test/unit/identity/interaction/util/TokenOwnershipValidator.test.ts @@ -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; + let dataset: DatasetCore; + let triples: Quad[]; + const tokenTriple = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token)); + let storage: ExpiringStorage; + let validator: TokenOwnershipValidator; + + beforeEach(async(): Promise => { + 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 => dataset, + } as any; + + const map = new Map(); + 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 => { + // 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 => { + // 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 => { + 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 => { + 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)); + }); +});