diff --git a/.eslintrc.js b/.eslintrc.js index aa273da5e..d00e31993 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -87,6 +87,8 @@ module.exports = { 'unicorn/prefer-at': 'off', // Does not make sense for more complex cases 'unicorn/prefer-object-from-entries': 'off', + // Only supported in Node v15 + 'unicorn/prefer-string-replace-all' : 'off', // Can get ugly with large single statements 'unicorn/prefer-ternary': 'off', 'yield-star-spacing': [ 'error', 'after' ], diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0c1bf6453..8888320d3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,7 +3,9 @@ ## v4.0.0 ### New features - The server can be started with a new parameter to automatically generate accounts and pods, - for more info see [here](guides/seeding-pods.md). + for more info see [here](documentation/seeding-pods.md). +- It is now possible to automate authentication requests using Client Credentials, + for more info see [here](documentation/client-credentials.md). - A new `RedirectingHttpHandler` class has been added which can be used to redirect certain URLs. - A new default configuration `config/https-file-cli.json` that can set the HTTPS parameters through the CLI has been added. @@ -23,6 +25,9 @@ The following changes are relevant for v3 custom configs that replaced certain f All storages there that were only relevant for 1 class have been moved to the config of that class. - Due to a parameter rename in `CombinedSettingsResolver`, `config/app/variables/resolver/resolver.json` has been updated. +- The OIDC provider setup was changed to add client_credentials support. + - `/identity/handler/adapter-factory/webid.json` + - `/identity/handler/provider-factory/identity.json` ### Interface changes These changes are relevant if you wrote custom modules for the server that depend on existing interfaces. @@ -33,6 +38,7 @@ These changes are relevant if you wrote custom modules for the server that depen - Several `ModesExtractor`s `PermissionBasedAuthorizer` now take a `ResourceSet` as constructor parameter. - `RepresentationMetadata` no longer accepts strings for predicates in any of its functions. - `CombinedSettingsResolver` parameter `computers` has been renamed to `resolvers`. +- `IdentityProviderFactory` requires an additional `credentialStorage` parameter. ## v3.0.0 ### New features diff --git a/config/identity/handler/adapter-factory/webid.json b/config/identity/handler/adapter-factory/webid.json index 5d9a5bd4e..3202e629d 100644 --- a/config/identity/handler/adapter-factory/webid.json +++ b/config/identity/handler/adapter-factory/webid.json @@ -4,16 +4,20 @@ { "comment": "An adapter is responsible for storing all interaction metadata.", "@id": "urn:solid-server:default:IdpAdapterFactory", - "@type": "WebIdAdapterFactory", + "@type": "ClientCredentialsAdapterFactory", + "storage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" }, "source": { - "@type": "ExpiringAdapterFactory", - "storage": { - "@type": "EncodingPathStorage", - "relativePath": "/idp/adapter/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + "@type": "WebIdAdapterFactory", + "converter": {"@id": "urn:solid-server:default:RepresentationConverter" }, + "source": { + "@type": "ExpiringAdapterFactory", + "storage": { + "@type": "EncodingPathStorage", + "relativePath": "/idp/adapter/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + } } - }, - "converter": { "@id": "urn:solid-server:default:RepresentationConverter" } + } } ] } diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index e49083ba2..71202b09c 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -2,6 +2,7 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/handler/interaction/routes/consent.json", + "files-scs:config/identity/handler/interaction/routes/credentials.json", "files-scs:config/identity/handler/interaction/routes/forgot-password.json", "files-scs:config/identity/handler/interaction/routes/index.json", "files-scs:config/identity/handler/interaction/routes/login.json", @@ -49,7 +50,8 @@ { "@id": "urn:solid-server:auth:password:LoginRouteHandler" }, { "@id": "urn:solid-server:auth:password:ConsentRouteHandler" }, { "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler" }, - { "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" } + { "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" }, + { "@id": "urn:solid-server:auth:password:CredentialsRouteHandler" } ] } ] diff --git a/config/identity/handler/interaction/routes/credentials.json b/config/identity/handler/interaction/routes/credentials.json new file mode 100644 index 000000000..2e99a86a3 --- /dev/null +++ b/config/identity/handler/interaction/routes/credentials.json @@ -0,0 +1,50 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Stores all client credential tokens.", + "@id": "urn:solid-server:auth:password:CredentialsStorage", + "@type": "EncodingPathStorage", + "relativePath": "/idp/accounts/credentials/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + }, + { + "comment": "Handles credential tokens. These can be used to automate clients. See documentation for more info.", + "@id": "urn:solid-server:auth:password:CredentialsRouteHandler", + "@type":"InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:CredentialsRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/credentials/" + }, + "source": { + "@id": "urn:solid-server:auth:password:CredentialsHandler", + "@type": "EmailPasswordAuthorizer", + "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, + "source": { + "@type": "WaterfallHandler", + "handlers": [ + { + "@type": "CreateCredentialsHandler", + "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, + "credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" } + }, + { + "@type": "DeleteCredentialsHandler", + "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, + "credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" } + }, + { + "@type": "ListCredentialsHandler", + "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } + } + ] + } + } + }, + { + + } + ] +} diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json index ab514e16a..cb8c4a7fb 100644 --- a/config/identity/handler/interaction/views/controls.json +++ b/config/identity/handler/interaction/views/controls.json @@ -20,6 +20,10 @@ { "ControlHandler:_controls_key": "forgotPassword", "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } + }, + { + "ControlHandler:_controls_key": "credentials", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:CredentialsRoute" } } ] } diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index 04d9e2e69..7e0e82c63 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -12,6 +12,7 @@ "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_oidcPath": "/.oidc", "args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" }, + "args_credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" }, "args_storage": { "@type": "EncodingPathStorage", "relativePath": "/idp/keys/", @@ -30,6 +31,7 @@ }, "features": { "claimsParameter": { "enabled": true }, + "clientCredentials": { "enabled": true }, "devInteractions": { "enabled": false }, "dPoP": { "enabled": true, "ack": "draft-03" }, "introspection": { "enabled": true }, @@ -43,6 +45,7 @@ "AccessToken": 3600, "AuthorizationCode": 600, "BackchannelAuthenticationRequest": 600, + "ClientCredentials": 600, "DeviceCode": 600, "Grant": 1209600, "IdToken": 3600, diff --git a/documentation/README.md b/documentation/README.md index a27249f4c..452f7d26f 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -26,6 +26,7 @@ the [changelog](https://github.com/CommunitySolidServer/CommunitySolidServer/blo * [Basic example HTTP requests](example-requests.md) * [How to use the Identity Provider](identity-provider.md) + * [How to automate authentication](client-credentials.md) * [How to automatically seed pods on startup](seeding-pods.md) ## What the internals look like diff --git a/documentation/client-credentials.md b/documentation/client-credentials.md new file mode 100644 index 000000000..f3e80dd86 --- /dev/null +++ b/documentation/client-credentials.md @@ -0,0 +1,103 @@ +# Automating authentication with Client Credentials + +One potential issue for scripts and other applications is that it requires user interaction to log in and authenticate. +The CSS offers an alternative solution for such cases by making use of Client Credentials. +Once you have created an account as described in the [Identity Provider section](dependency-injection.md), +users can request a token that apps can use to authenticate without user input. + +All requests to the client credentials API currently require you +to send along the email and password of that account to identify yourself. +This is a temporary solution until the server has more advanced account management, +after which this API will change. + +Below is example code of how to make use of these tokens. +It makes use of several utility functions from the +[Solid Authentication Client](https://github.com/inrupt/solid-client-authn-js). +Note that the code below uses top-level `await`, which not all JavaScript engines support, +so this should all be contained in an `async` function. + +## Generating a token + +The code below generates a token linked to your account and WebID. +This only needs to be done once, afterwards this token can be used for all future requests. + +```ts +import fetch from 'node-fetch'; + +// This assumes your server is started under http://localhost:3000/. +// This URL can also be found by checking the controls in JSON responses when interacting with the IDP API, +// as described in the Identity Provider section. +const response = await fetch('http://localhost:3000/idp/credentials/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // The email/password fields are those of your account. + // The name field will be used when generating the ID of your token. + body: JSON.stringify({ email: 'my-email@example.com', password: 'my-account-password', name: 'my-token' }), +}); + +// These are the identifier and secret of your token. +// Store the secret somewhere safe as there is no way to request it again from the server! +const { id, secret } = await response.json(); +``` + +## Requesting an Access token + +The ID and secret combination generated above can be used to request an Access Token from the server. +This Access Token is only valid for a certain amount of time, after which a new one needs to be requested. + +```ts +import { createDpopHeader, generateDpopKeyPair } from '@inrupt/solid-client-authn-core'; +import fetch from 'node-fetch'; + +// A key pair is needed for encryption. +// This function from `solid-client-authn` generates such a pair for you. +const dpopKey = await generateDpopKeyPair(); + +// These are the ID and secret generated in the previous step. +// Both the ID and the secret need to be form-encoded. +const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; +// This URL can be found by looking at the "token_endpoint" field at +// http://localhost:3000/.well-known/openid-configuration +// if your server is hosted at http://localhost:3000/. +const tokenUrl = 'http://localhost:3000/.oidc/token'; +const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + // The header needs to be in base64 encoding. + authorization: `Basic ${Buffer.from(authString).toString('base64')}`, + 'content-type': 'application/x-www-form-urlencoded', + dpop: await createDpopHeader(tokenUrl, 'POST', dpopKey), + }, + body: 'grant_type=client_credentials&scope=webid', +}); + +// This is the Access token that will be used to do an authenticated request to the server. +// The JSON also contains an "expires_in" field in seconds, +// which you can use to know when you need request a new Access token. +const { access_token: accessToken } = await response.json(); +``` + +## Using the Access token to make an authenticated request + +Once you have an Access token, you can use it for authenticated requests until it expires. + +```ts +import { buildAuthenticatedFetch } from '@inrupt/solid-client-authn-core'; +import fetch from 'node-fetch'; + +// The DPoP key needs to be the same key as the one used in the previous step. +// The Access token is the one generated in the previous step. +const authFetch = await buildAuthenticatedFetch(fetch, accessToken, { dpopKey }); +// authFetch can now be used as a standard fetch function that will authenticate as your WebID. +// This request will do a simple GET for example. +const response = await authFetch('http://localhost:3000/private'); +``` + +## Deleting a token + +You can see all your existing tokens by doing a POST to `http://localhost:3000/idp/credentials/` +with as body a JSON object containing your email and password. +The response will be a JSON list containing all your tokens. + +Deleting a token requires also doing a POST to the same URL, +but adding a `delete` key to the JSON input object with as value the ID of the token you want to remove. diff --git a/documentation/idp.md b/documentation/idp.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/package-lock.json b/package-lock.json index fcc482a84..d71be8762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "community-solid-server": "bin/server.js" }, "devDependencies": { + "@inrupt/solid-client-authn-core": "^1.11.7", "@inrupt/solid-client-authn-node": "^1.11.5", "@microsoft/tsdoc-config": "^0.15.2", "@tsconfig/node12": "^1.0.9", @@ -3733,9 +3734,9 @@ } }, "node_modules/@inrupt/solid-client-authn-core": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz", - "integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.7.tgz", + "integrity": "sha512-PjrZ13tmFkamro/+JzE3jURmL7vwzHYUIq81pvn6kTYBa9C/2eHwnZbOZD2T1OplBSddKr9sJshISJJTQc5s6w==", "dev": true, "dependencies": { "@inrupt/solid-common-vocab": "^1.0.0", @@ -17971,9 +17972,9 @@ "dev": true }, "@inrupt/solid-client-authn-core": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz", - "integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.7.tgz", + "integrity": "sha512-PjrZ13tmFkamro/+JzE3jURmL7vwzHYUIq81pvn6kTYBa9C/2eHwnZbOZD2T1OplBSddKr9sJshISJJTQc5s6w==", "dev": true, "requires": { "@inrupt/solid-common-vocab": "^1.0.0", diff --git a/package.json b/package.json index 2ff26ab73..993f8cf23 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "yargs": "^17.3.1" }, "devDependencies": { + "@inrupt/solid-client-authn-core": "^1.11.5", "@inrupt/solid-client-authn-node": "^1.11.5", "@microsoft/tsdoc-config": "^0.15.2", "@tsconfig/node12": "^1.0.9", diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index 1c206812c..a3cdec659 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -6,7 +6,6 @@ import type { JWK } from 'jose'; import { exportJWK, generateKeyPair } from 'jose'; import type { Account, Adapter, - CanBePromise, Configuration, ErrorOut, KoaContextWithOIDC, @@ -21,6 +20,7 @@ import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import { InternalServerError } from '../../util/errors/InternalServerError'; import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; import { joinUrl } from '../../util/PathUtil'; +import type { ClientCredentials } from '../interaction/email-password/credentials/ClientCredentialsAdapterFactory'; import type { InteractionHandler } from '../interaction/InteractionHandler'; import type { AdapterFactory } from '../storage/AdapterFactory'; import type { ProviderFactory } from './ProviderFactory'; @@ -42,6 +42,10 @@ export interface IdentityProviderFactoryArgs { * The handler responsible for redirecting interaction requests to the correct URL. */ interactionHandler: InteractionHandler; + /** + * Storage containing the generated client credentials with their associated WebID. + */ + credentialStorage: KeyValueStorage; /** * Storage used to store cookie and JWT keys so they can be re-used in case of multithreading. */ @@ -72,6 +76,7 @@ export class IdentityProviderFactory implements ProviderFactory { private readonly baseUrl!: string; private readonly oidcPath!: string; private readonly interactionHandler!: InteractionHandler; + private readonly credentialStorage!: KeyValueStorage; private readonly storage!: KeyValueStorage; private readonly errorHandler!: ErrorHandler; private readonly responseWriter!: ResponseWriter; @@ -220,10 +225,10 @@ export class IdentityProviderFactory implements ProviderFactory { // Add extra claims in case an AccessToken is being issued. // Specifically this sets the required webid and client_id claims for the access token // See https://solid.github.io/solid-oidc/#resource-access-validation - config.extraTokenClaims = (ctx, token): CanBePromise => + config.extraTokenClaims = async(ctx, token): Promise => this.isAccessToken(token) ? { webid: token.accountId } : - {}; + { webid: token.client && (await this.credentialStorage.get(token.client.clientId))?.webId }; config.features = { ...config.features, @@ -239,8 +244,8 @@ export class IdentityProviderFactory implements ProviderFactory { // See https://github.com/panva/node-oidc-provider/discussions/959#discussioncomment-524757 getResourceServerInfo: (): ResourceServer => ({ // The scopes of the Resource Server. - // Since this is irrelevant at the moment, an empty string is fine. - scope: '', + // These get checked when requesting client credentials. + scope: 'webid', audience: 'solid', accessTokenFormat: 'jwt', jwt: { diff --git a/src/identity/interaction/ControlHandler.ts b/src/identity/interaction/ControlHandler.ts index 34eb78500..97b26d8ef 100644 --- a/src/identity/interaction/ControlHandler.ts +++ b/src/identity/interaction/ControlHandler.ts @@ -7,7 +7,7 @@ import type { InteractionHandlerInput } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler'; import type { InteractionRoute } from './routing/InteractionRoute'; -const INTERNAL_API_VERSION = '0.3'; +const INTERNAL_API_VERSION = '0.4'; /** * Adds `controls` and `apiVersion` fields to the output of its source handler, diff --git a/src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.ts b/src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.ts new file mode 100644 index 000000000..d19713336 --- /dev/null +++ b/src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.ts @@ -0,0 +1,56 @@ +import type { AdapterPayload, Adapter } from 'oidc-provider'; +import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; +import type { AdapterFactory } from '../../../storage/AdapterFactory'; +import { PassthroughAdapterFactory, PassthroughAdapter } from '../../../storage/PassthroughAdapterFactory'; + +export interface ClientCredentials { + secret: string; + webId: string; +} + +/** + * A {@link PassthroughAdapter} that overrides the `find` function + * by checking if there are stored client credentials for the given ID + * if no payload is found in the source. + */ +export class ClientCredentialsAdapter extends PassthroughAdapter { + private readonly storage: KeyValueStorage; + + public constructor(name: string, source: Adapter, storage: KeyValueStorage) { + super(name, source); + this.storage = storage; + } + + public async find(id: string): Promise { + let payload = await this.source.find(id); + + if (!payload && this.name === 'Client') { + const credentials = await this.storage.get(id); + if (credentials) { + /* eslint-disable @typescript-eslint/naming-convention */ + payload = { + client_id: id, + client_secret: credentials.secret, + grant_types: [ 'client_credentials' ], + redirect_uris: [], + response_types: [], + }; + /* eslint-enable @typescript-eslint/naming-convention */ + } + } + return payload; + } +} + +export class ClientCredentialsAdapterFactory extends PassthroughAdapterFactory { + private readonly storage: KeyValueStorage; + + public constructor(source: AdapterFactory, storage: KeyValueStorage) { + super(source); + this.storage = storage; + } + + public createStorageAdapter(name: string): Adapter { + return new ClientCredentialsAdapter(name, this.source.createStorageAdapter(name), this.storage); + } +} diff --git a/src/identity/interaction/email-password/credentials/CreateCredentialsHandler.ts b/src/identity/interaction/email-password/credentials/CreateCredentialsHandler.ts new file mode 100644 index 000000000..2cfe6615e --- /dev/null +++ b/src/identity/interaction/email-password/credentials/CreateCredentialsHandler.ts @@ -0,0 +1,54 @@ +import { randomBytes } from 'crypto'; +import { v4 } from 'uuid'; +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; +import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError'; +import type { AccountStore } from '../storage/AccountStore'; +import type { ClientCredentials } from './ClientCredentialsAdapterFactory'; +import type { CredentialsHandlerInput } from './CredentialsHandler'; +import { CredentialsHandler } from './CredentialsHandler'; + +/** + * Handles the creation of credential tokens. + * Requires a `name` field in the input JSON body, + * that will be used to generate the ID token. + */ +export class CreateCredentialsHandler extends CredentialsHandler { + private readonly accountStore: AccountStore; + private readonly credentialStorage: KeyValueStorage; + + public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage) { + super(); + this.accountStore = accountStore; + this.credentialStorage = credentialStorage; + } + + public async canHandle({ body }: CredentialsHandlerInput): Promise { + if (typeof body.name !== 'string') { + throw new NotImplementedHttpError(); + } + } + + public async handle({ operation, body: { webId, name }}: CredentialsHandlerInput): Promise { + const settings = await this.accountStore.getSettings(webId); + + if (!settings.useIdp) { + throw new BadRequestHttpError('This server is not an identity provider for this account.'); + } + + const id = `${(name as string).replace(/\W/gu, '-')}_${v4()}`; + const secret = randomBytes(64).toString('hex'); + + // Store the credentials, and point to them from the account + settings.clientCredentials = settings.clientCredentials ?? []; + settings.clientCredentials.push(id); + await this.accountStore.updateSettings(webId, settings); + await this.credentialStorage.set(id, { secret, webId }); + + const response = { id, secret }; + return new BasicRepresentation(JSON.stringify(response), operation.target, APPLICATION_JSON); + } +} diff --git a/src/identity/interaction/email-password/credentials/CredentialsHandler.ts b/src/identity/interaction/email-password/credentials/CredentialsHandler.ts new file mode 100644 index 000000000..44e605efc --- /dev/null +++ b/src/identity/interaction/email-password/credentials/CredentialsHandler.ts @@ -0,0 +1,23 @@ +import type { Operation } from '../../../../http/Operation'; +import type { Representation } from '../../../../http/representation/Representation'; +import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; + +export interface CredentialsHandlerBody extends Record { + email: string; + webId: string; +} + +/** + * `body` is the parsed JSON from `operation.body.data` with the WebID of the account having been added. + * This means that the data stream in the Operation can not be read again. + */ +export interface CredentialsHandlerInput { + operation: Operation; + body: CredentialsHandlerBody; +} + +/** + * Handles a request after the user has been authenticated + * by providing a valid email/password combination in the JSON body. + */ +export abstract class CredentialsHandler extends AsyncHandler { } diff --git a/src/identity/interaction/email-password/credentials/DeleteCredentialsHandler.ts b/src/identity/interaction/email-password/credentials/DeleteCredentialsHandler.ts new file mode 100644 index 000000000..2dd94a306 --- /dev/null +++ b/src/identity/interaction/email-password/credentials/DeleteCredentialsHandler.ts @@ -0,0 +1,47 @@ +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; +import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError'; +import type { AccountStore } from '../storage/AccountStore'; +import type { ClientCredentials } from './ClientCredentialsAdapterFactory'; +import type { CredentialsHandlerInput } from './CredentialsHandler'; +import { CredentialsHandler } from './CredentialsHandler'; + +/** + * Handles the deletion of credential tokens. + * Expects the JSON body to have a `delete` field with as value the ID of the token to be deleted. + * This should be replaced to be an actual DELETE request once the API supports it. + */ +export class DeleteCredentialsHandler extends CredentialsHandler { + private readonly accountStore: AccountStore; + private readonly credentialStorage: KeyValueStorage; + + public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage) { + super(); + this.accountStore = accountStore; + this.credentialStorage = credentialStorage; + } + + public async canHandle({ body }: CredentialsHandlerInput): Promise { + if (typeof body.delete !== 'string') { + throw new NotImplementedHttpError(); + } + } + + public async handle({ operation, body }: CredentialsHandlerInput): Promise { + const id = body.delete as string; + const settings = await this.accountStore.getSettings(body.webId); + settings.clientCredentials = settings.clientCredentials ?? []; + const idx = settings.clientCredentials.indexOf(id); + if (idx < 0) { + throw new BadRequestHttpError('No credential with this ID exists for this account.'); + } + + await this.credentialStorage.delete(id); + settings.clientCredentials.splice(idx, 1); + await this.accountStore.updateSettings(body.webId, settings); + return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON); + } +} diff --git a/src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.ts b/src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.ts new file mode 100644 index 000000000..255a68628 --- /dev/null +++ b/src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.ts @@ -0,0 +1,37 @@ +import assert from 'assert'; +import type { Representation } from '../../../../http/representation/Representation'; +import { MethodNotAllowedHttpError } from '../../../../util/errors/MethodNotAllowedHttpError'; +import { readJsonStream } from '../../../../util/StreamUtil'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import { InteractionHandler } from '../../InteractionHandler'; +import type { AccountStore } from '../storage/AccountStore'; +import type { CredentialsHandler } from './CredentialsHandler'; + +/** + * Authenticates a user by the email/password in a JSON POST body. + * Passes the body and the WebID associated with that account to the source handler. + */ +export class EmailPasswordAuthorizer extends InteractionHandler { + private readonly accountStore: AccountStore; + private readonly source: CredentialsHandler; + + public constructor(accountStore: AccountStore, source: CredentialsHandler) { + super(); + this.accountStore = accountStore; + this.source = source; + } + + public async handle({ operation }: InteractionHandlerInput): Promise { + if (operation.method !== 'POST') { + throw new MethodNotAllowedHttpError([ operation.method ], 'Only POST requests are supported.'); + } + const json = await readJsonStream(operation.body.data); + const { email, password } = json; + assert(typeof email === 'string' && email.length > 0, 'Email required'); + assert(typeof password === 'string' && password.length > 0, 'Password required'); + const webId = await this.accountStore.authenticate(email, password); + // Password no longer needed from this point on + delete json.password; + return this.source.handleSafe({ operation, body: { ...json, email, webId }}); + } +} diff --git a/src/identity/interaction/email-password/credentials/ListCredentialsHandler.ts b/src/identity/interaction/email-password/credentials/ListCredentialsHandler.ts new file mode 100644 index 000000000..275ebc8e4 --- /dev/null +++ b/src/identity/interaction/email-password/credentials/ListCredentialsHandler.ts @@ -0,0 +1,24 @@ +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; +import type { AccountStore } from '../storage/AccountStore'; +import type { CredentialsHandlerInput } from './CredentialsHandler'; +import { CredentialsHandler } from './CredentialsHandler'; + +/** + * Returns a list of all credential tokens associated with this account. + * Note that this only returns the ID tokens, not the associated secrets. + */ +export class ListCredentialsHandler extends CredentialsHandler { + private readonly accountStore: AccountStore; + + public constructor(accountStore: AccountStore) { + super(); + this.accountStore = accountStore; + } + + public async handle({ operation, body: { webId }}: CredentialsHandlerInput): Promise { + const credentials = (await this.accountStore.getSettings(webId)).clientCredentials ?? []; + return new BasicRepresentation(JSON.stringify(credentials), operation.target, APPLICATION_JSON); + } +} diff --git a/src/identity/interaction/email-password/storage/AccountStore.ts b/src/identity/interaction/email-password/storage/AccountStore.ts index f21a0cb2b..bdbf3dcf8 100644 --- a/src/identity/interaction/email-password/storage/AccountStore.ts +++ b/src/identity/interaction/email-password/storage/AccountStore.ts @@ -10,6 +10,10 @@ export interface AccountSettings { * The base URL of the pod associated with this account, if there is one. */ podBaseUrl?: string; + /** + * All credential tokens associated with this account. + */ + clientCredentials?: string[]; } /** diff --git a/src/identity/interaction/email-password/util/RegistrationManager.ts b/src/identity/interaction/email-password/util/RegistrationManager.ts index 387158010..623d20bf4 100644 --- a/src/identity/interaction/email-password/util/RegistrationManager.ts +++ b/src/identity/interaction/email-password/util/RegistrationManager.ts @@ -193,6 +193,7 @@ export class RegistrationManager { const settings: AccountSettings = { useIdp: input.register, podBaseUrl: podBaseUrl?.path, + clientCredentials: [], }; await this.accountStore.create(input.email, input.webId!, input.password, settings); diff --git a/src/identity/storage/AdapterFactory.ts b/src/identity/storage/AdapterFactory.ts index 4ea072a36..d2e75d78c 100644 --- a/src/identity/storage/AdapterFactory.ts +++ b/src/identity/storage/AdapterFactory.ts @@ -1,8 +1,11 @@ import type { Adapter } from 'oidc-provider'; /** - * A factory that generates a StorageAdapter to be used - * by the IDP to persist information. + * A factory that generates an `Adapter` to be used by the IDP to persist information. + * + * The `oidc-provider` library will call the relevant functions when it needs to find/create/delete metadata. + * For a full explanation of how these functions work and what is expected, + * have a look at https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js */ export interface AdapterFactory { createStorageAdapter: (name: string) => Adapter; diff --git a/src/identity/storage/PassthroughAdapterFactory.ts b/src/identity/storage/PassthroughAdapterFactory.ts new file mode 100644 index 000000000..f41d9b5d7 --- /dev/null +++ b/src/identity/storage/PassthroughAdapterFactory.ts @@ -0,0 +1,57 @@ +import type { Adapter, AdapterPayload } from 'oidc-provider'; +import type { AdapterFactory } from './AdapterFactory'; + +/** + * OIDC Adapter that calls the corresponding functions of the source Adapter. + * Can be extended by adapters that do not want to override all functions + * by implementing a decorator pattern. + */ +export class PassthroughAdapter implements Adapter { + protected readonly name: string; + protected readonly source: Adapter; + + public constructor(name: string, source: Adapter) { + this.name = name; + this.source = source; + } + + public async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { + return this.source.upsert(id, payload, expiresIn); + } + + public async find(id: string): Promise { + return this.source.find(id); + } + + public async findByUserCode(userCode: string): Promise { + return this.source.findByUserCode(userCode); + } + + public async findByUid(uid: string): Promise { + return this.source.findByUid(uid); + } + + public async consume(id: string): Promise { + return this.source.consume(id); + } + + public async destroy(id: string): Promise { + return this.source.destroy(id); + } + + public async revokeByGrantId(grantId: string): Promise { + return this.source.revokeByGrantId(grantId); + } +} + +export class PassthroughAdapterFactory implements AdapterFactory { + protected readonly source: AdapterFactory; + + public constructor(source: AdapterFactory) { + this.source = source; + } + + public createStorageAdapter(name: string): Adapter { + return this.source.createStorageAdapter(name); + } +} diff --git a/src/identity/storage/WebIdAdapterFactory.ts b/src/identity/storage/WebIdAdapterFactory.ts index 9abf04a2f..e4b489c07 100644 --- a/src/identity/storage/WebIdAdapterFactory.ts +++ b/src/identity/storage/WebIdAdapterFactory.ts @@ -9,6 +9,7 @@ import { responseToDataset } from '../../util/FetchUtil'; import { hasScheme } from '../../util/HeaderUtil'; import { OIDC } from '../../util/Vocabularies'; import type { AdapterFactory } from './AdapterFactory'; +import { PassthroughAdapter, PassthroughAdapterFactory } from './PassthroughAdapterFactory'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -19,23 +20,16 @@ import type { AdapterFactory } from './AdapterFactory'; * If a valid `solid:oidcRegistration` triple is found there, * that data will be returned instead. */ -export class WebIdAdapter implements Adapter { +export class WebIdAdapter extends PassthroughAdapter { 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; + super(name, source); this.converter = converter; } - public async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { - return this.source.upsert(id, payload, expiresIn); - } - public async find(id: string): Promise { let payload = await this.source.find(id); @@ -108,34 +102,13 @@ export class WebIdAdapter implements Adapter { redirect_uris: redirectUris, }; } - - public async findByUserCode(userCode: string): Promise { - return this.source.findByUserCode(userCode); - } - - public async findByUid(uid: string): Promise { - return this.source.findByUid(uid); - } - - public async destroy(id: string): Promise { - return this.source.destroy(id); - } - - public async revokeByGrantId(grantId: string): Promise { - return this.source.revokeByGrantId(grantId); - } - - public async consume(id: string): Promise { - return this.source.consume(id); - } } -export class WebIdAdapterFactory implements AdapterFactory { - private readonly source: AdapterFactory; +export class WebIdAdapterFactory extends PassthroughAdapterFactory { private readonly converter: RepresentationConverter; public constructor(source: AdapterFactory, converter: RepresentationConverter) { - this.source = source; + super(source); this.converter = converter; } diff --git a/src/index.ts b/src/index.ts index 7053c6b06..5697205d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -130,6 +130,14 @@ export * from './http/UnsecureWebSocketsProtocol'; export * from './identity/configuration/IdentityProviderFactory'; export * from './identity/configuration/ProviderFactory'; +// Identity/Interaction/Email-Password/Credentials +export * from './identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; +export * from './identity/interaction/email-password/credentials/EmailPasswordAuthorizer'; +export * from './identity/interaction/email-password/credentials/CreateCredentialsHandler'; +export * from './identity/interaction/email-password/credentials/CredentialsHandler'; +export * from './identity/interaction/email-password/credentials/DeleteCredentialsHandler'; +export * from './identity/interaction/email-password/credentials/ListCredentialsHandler'; + // Identity/Interaction/Email-Password/Handler export * from './identity/interaction/email-password/handler/ForgotPasswordHandler'; export * from './identity/interaction/email-password/handler/LoginHandler'; @@ -172,6 +180,7 @@ export * from './identity/ownership/TokenOwnershipValidator'; // Identity/Storage export * from './identity/storage/AdapterFactory'; export * from './identity/storage/ExpiringAdapterFactory'; +export * from './identity/storage/PassthroughAdapterFactory'; export * from './identity/storage/WebIdAdapterFactory'; // Identity diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 6f258d48e..5a28fc4ce 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -1,10 +1,16 @@ import { stringify } from 'querystring'; import { URL } from 'url'; +import type { KeyPair } from '@inrupt/solid-client-authn-core'; +import { + buildAuthenticatedFetch, + createDpopHeader, + generateDpopKeyPair, +} from '@inrupt/solid-client-authn-core'; import { load } from 'cheerio'; import type { Response } from 'cross-fetch'; import { fetch } from 'cross-fetch'; import type { App } from '../../src/init/App'; -import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; import { joinUrl } from '../../src/util/PathUtil'; import { getPort } from '../util/Util'; import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config'; @@ -37,6 +43,7 @@ async function postForm(url: string, formBody: string): Promise { describe('A Solid server with IDP', (): void => { let app: App; const redirectUrl = 'http://mockedredirect/'; + const container = new URL('secret/', baseUrl).href; const oidcIssuer = baseUrl; const card = joinUrl(baseUrl, 'profile/card'); const webId = `${card}#me`; @@ -61,11 +68,26 @@ describe('A Solid server with IDP', (): void => { await app.start(); // Create a simple webId - const turtle = `<${webId}> <${baseUrl}> .`; + const webIdTurtle = `<${webId}> <${baseUrl}> .`; await fetch(card, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, - body: turtle, + body: webIdTurtle, + }); + + // Create container where only webId can write + const aclTurtle = ` +@prefix acl: . +<#owner> a acl:Authorization; + acl:agent <${webId}>; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. +`; + await fetch(`${container}.acl`, { + method: 'PUT', + headers: { 'content-type': 'text/turtle' }, + body: aclTurtle, }); }); @@ -112,25 +134,9 @@ describe('A Solid server with IDP', (): void => { describe('authenticating', (): void => { let state: IdentityTestState; - const container = new URL('secret/', baseUrl).href; beforeAll(async(): Promise => { state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); - - // Create container where only webId can write - const turtle = ` -@prefix acl: . -<#owner> a acl:Authorization; - acl:agent <${webId}>; - acl:accessTo <./>; - acl:default <./>; - acl:mode acl:Read, acl:Write, acl:Control. -`; - await fetch(`${container}.acl`, { - method: 'PUT', - headers: { 'content-type': 'text/turtle' }, - body: turtle, - }); }); afterAll(async(): Promise => { @@ -262,6 +268,97 @@ describe('A Solid server with IDP', (): void => { }); }); + describe('using client_credentials', (): void => { + const credentialsUrl = joinUrl(baseUrl, '/idp/credentials/'); + const tokenUrl = joinUrl(baseUrl, '.oidc/token'); + let dpopKey: KeyPair; + let id: string | undefined; + let secret: string | undefined; + let accessToken: string | undefined; + + beforeAll(async(): Promise => { + dpopKey = await generateDpopKeyPair(); + }); + + it('can request a credentials token.', async(): Promise => { + const res = await fetch(credentialsUrl, { + method: 'POST', + headers: { + 'content-type': APPLICATION_JSON, + }, + body: JSON.stringify({ email, password, name: 'token' }), + }); + expect(res.status).toBe(200); + ({ id, secret } = await res.json()); + expect(typeof id).toBe('string'); + expect(typeof secret).toBe('string'); + expect(id).toMatch(/^token/u); + }); + + it('can request an access token using the credentials.', async(): Promise => { + const dpopHeader = await createDpopHeader(tokenUrl, 'POST', dpopKey); + const authString = `${encodeURIComponent(id!)}:${encodeURIComponent(secret!)}`; + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { + authorization: `Basic ${Buffer.from(authString).toString('base64')}`, + 'content-type': APPLICATION_X_WWW_FORM_URLENCODED, + dpop: dpopHeader, + }, + body: 'grant_type=client_credentials&scope=webid', + }); + expect(res.status).toBe(200); + const json = await res.json(); + ({ access_token: accessToken } = json); + expect(typeof accessToken).toBe('string'); + }); + + it('can use the generated access token to do an authenticated call.', async(): Promise => { + const authFetch = await buildAuthenticatedFetch(fetch, accessToken!, { dpopKey }); + let res = await fetch(container); + expect(res.status).toBe(401); + res = await authFetch(container); + expect(res.status).toBe(200); + }); + + it('can see all credentials.', async(): Promise => { + const res = await fetch(credentialsUrl, { + method: 'POST', + headers: { + 'content-type': APPLICATION_JSON, + }, + body: JSON.stringify({ email, password }), + }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual([ id ]); + }); + + it('can delete credentials.', async(): Promise => { + let res = await fetch(credentialsUrl, { + method: 'POST', + headers: { + 'content-type': APPLICATION_JSON, + }, + body: JSON.stringify({ email, password, delete: id }), + }); + expect(res.status).toBe(200); + + // Client_credentials call should fail now + const dpopHeader = await createDpopHeader(tokenUrl, 'POST', dpopKey); + const authString = `${encodeURIComponent(id!)}:${encodeURIComponent(secret!)}`; + res = await fetch(tokenUrl, { + method: 'POST', + headers: { + authorization: `Basic ${Buffer.from(authString).toString('base64')}`, + 'content-type': APPLICATION_X_WWW_FORM_URLENCODED, + dpop: dpopHeader, + }, + body: 'grant_type=client_credentials&scope=webid', + }); + expect(res.status).toBe(401); + }); + }); + describe('resetting password', (): void => { let nextUrl: string; diff --git a/test/unit/authorization/OwnerPermissionReader.test.ts b/test/unit/authorization/OwnerPermissionReader.test.ts index 765c13fd7..9d01788b6 100644 --- a/test/unit/authorization/OwnerPermissionReader.test.ts +++ b/test/unit/authorization/OwnerPermissionReader.test.ts @@ -31,6 +31,7 @@ describe('An OwnerPermissionReader', (): void => { settings = { useIdp: true, podBaseUrl, + clientCredentials: [], }; accountStore = { diff --git a/test/unit/identity/ControlHandler.test.ts b/test/unit/identity/ControlHandler.test.ts index cafa667f2..c2742b3a1 100644 --- a/test/unit/identity/ControlHandler.test.ts +++ b/test/unit/identity/ControlHandler.test.ts @@ -42,7 +42,7 @@ describe('A ControlHandler', (): void => { const result = await handler.handle(input); await expect(readJsonStream(result.data)).resolves.toEqual({ data: 'data', - apiVersion: '0.3', + apiVersion: '0.4', controls: { login: 'http://example.com/login/', register: 'http://example.com/register/', diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index 10b4aa10f..1786d6add 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -3,6 +3,9 @@ import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandle import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory'; +import type { + ClientCredentials, +} from '../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; import type { Interaction, InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; @@ -40,6 +43,7 @@ describe('An IdentityProviderFactory', (): void => { let interactionHandler: jest.Mocked; let adapterFactory: jest.Mocked; let storage: jest.Mocked>; + let credentialStorage: jest.Mocked>; let errorHandler: jest.Mocked; let responseWriter: jest.Mocked; let factory: IdentityProviderFactory; @@ -68,6 +72,11 @@ describe('An IdentityProviderFactory', (): void => { set: jest.fn((id: string, value: any): any => map.set(id, value)), } as any; + credentialStorage = { + get: jest.fn((id: string): any => map.get(id)), + set: jest.fn((id: string, value: any): any => map.set(id, value)), + } as any; + errorHandler = { handleSafe: jest.fn().mockResolvedValue({ statusCode: 500 }), } as any; @@ -80,6 +89,7 @@ describe('An IdentityProviderFactory', (): void => { oidcPath, interactionHandler, storage, + credentialStorage, errorHandler, responseWriter, }); @@ -114,14 +124,19 @@ describe('An IdentityProviderFactory', (): void => { findResult = await config.findAccount?.({ oidc: {}} as any, webId); await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId }); - expect((config.extraTokenClaims as any)({}, {})).toEqual({}); - expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) - .toEqual({ webid: webId }); + await expect((config.extraTokenClaims as any)({}, {})).resolves.toEqual({}); + const client = { clientId: 'my_id' }; + await expect((config.extraTokenClaims as any)({}, { client })).resolves.toEqual({}); + await credentialStorage.set('my_id', { webId: 'http://example.com/foo', secret: 'my-secret' }); + await expect((config.extraTokenClaims as any)({}, { client })) + .resolves.toEqual({ webid: 'http://example.com/foo' }); + await expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) + .resolves.toEqual({ webid: webId }); expect(config.features?.resourceIndicators?.enabled).toBe(true); expect((config.features?.resourceIndicators?.defaultResource as any)()).toBe('http://example.com/'); expect((config.features?.resourceIndicators?.getResourceServerInfo as any)()).toEqual({ - scope: '', + scope: 'webid', audience: 'solid', accessTokenFormat: 'jwt', jwt: { sign: { alg: 'ES256' }}, @@ -158,6 +173,7 @@ describe('An IdentityProviderFactory', (): void => { oidcPath, interactionHandler, storage, + credentialStorage, errorHandler, responseWriter, }); @@ -180,6 +196,7 @@ describe('An IdentityProviderFactory', (): void => { oidcPath, interactionHandler, storage, + credentialStorage, errorHandler, responseWriter, }); diff --git a/test/unit/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.test.ts b/test/unit/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.test.ts new file mode 100644 index 000000000..82fbafcfa --- /dev/null +++ b/test/unit/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.test.ts @@ -0,0 +1,74 @@ +import type { Adapter } from 'oidc-provider'; +import { + ClientCredentialsAdapter, + ClientCredentialsAdapterFactory, +} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; +import type { + ClientCredentials, +} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; +import type { AdapterFactory } from '../../../../../../src/identity/storage/AdapterFactory'; +import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; + +describe('A ClientCredentialsAdapterFactory', (): void => { + let storage: jest.Mocked>; + let sourceAdapter: jest.Mocked; + let sourceFactory: jest.Mocked; + let adapter: ClientCredentialsAdapter; + let factory: ClientCredentialsAdapterFactory; + + beforeEach(async(): Promise => { + storage = { + get: jest.fn(), + } as any; + + sourceAdapter = { + find: jest.fn(), + } as any; + + sourceFactory = { + createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter), + }; + + adapter = new ClientCredentialsAdapter('Client', sourceAdapter, storage); + factory = new ClientCredentialsAdapterFactory(sourceFactory, storage); + }); + + it('calls the source factory when creating a new Adapter.', async(): Promise => { + expect(factory.createStorageAdapter('Name')).toBeInstanceOf(ClientCredentialsAdapter); + expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1); + expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Name'); + }); + + it('returns the result from the source.', async(): Promise => { + sourceAdapter.find.mockResolvedValue({ payload: 'payload' }); + await expect(adapter.find('id')).resolves.toEqual({ payload: 'payload' }); + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(storage.get).toHaveBeenCalledTimes(0); + }); + + it('tries to find a matching client credentials token if no result was found.', async(): Promise => { + await expect(adapter.find('id')).resolves.toBeUndefined(); + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith('id'); + }); + + it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise => { + storage.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me' }); + /* eslint-disable @typescript-eslint/naming-convention */ + await expect(adapter.find('id')).resolves.toEqual({ + client_id: 'id', + client_secret: 'super_secret', + grant_types: [ 'client_credentials' ], + redirect_uris: [], + response_types: [], + }); + /* eslint-enable @typescript-eslint/naming-convention */ + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith('id'); + }); +}); diff --git a/test/unit/identity/interaction/email-password/credentials/CreateCredentialsHandler.test.ts b/test/unit/identity/interaction/email-password/credentials/CreateCredentialsHandler.test.ts new file mode 100644 index 000000000..5018f70c0 --- /dev/null +++ b/test/unit/identity/interaction/email-password/credentials/CreateCredentialsHandler.test.ts @@ -0,0 +1,91 @@ +import type { Operation } from '../../../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; +import type { + ClientCredentials, +} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; +import { + CreateCredentialsHandler, +} from '../../../../../../src/identity/interaction/email-password/credentials/CreateCredentialsHandler'; +import type { + CredentialsHandlerBody, +} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; +import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; +import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; +import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; + +describe('A CreateCredentialsHandler', (): void => { + let operation: Operation; + let body: CredentialsHandlerBody; + let accountStore: jest.Mocked; + let credentialStorage: jest.Mocked>; + let handler: CreateCredentialsHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'POST', + body: new BasicRepresentation(), + target: { path: 'http://example.com/foo' }, + preferences: {}, + }; + + body = { + email: 'test@example.com', + webId: 'http://example.com/foo#me', + name: 'token', + }; + + accountStore = { + getSettings: jest.fn().mockResolvedValue({ useIdp: true, clientCredentials: []}), + updateSettings: jest.fn(), + } as any; + + credentialStorage = { + set: jest.fn(), + } as any; + + handler = new CreateCredentialsHandler(accountStore, credentialStorage); + }); + + it('only supports bodies with a name entry.', async(): Promise => { + await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined(); + delete body.name; + await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError); + }); + + it('rejects requests for accounts not using the IDP.', async(): Promise => { + accountStore.getSettings.mockResolvedValue({ useIdp: false }); + await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError); + }); + + it('creates a new credential token.', async(): Promise => { + const response = await handler.handle({ operation, body }); + expect(response.metadata.contentType).toBe(APPLICATION_JSON); + const { id, secret } = await readJsonStream(response.data); + expect(id).toMatch(/^token_/u); + expect(credentialStorage.set).toHaveBeenCalledTimes(1); + expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret }); + expect(accountStore.getSettings).toHaveBeenCalledTimes(1); + expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId); + expect(accountStore.updateSettings).toHaveBeenCalledTimes(1); + expect(accountStore.updateSettings) + .toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]}); + }); + + it('can handle account settings with undefined client credentials.', async(): Promise => { + accountStore.getSettings.mockResolvedValue({ useIdp: true }); + const response = await handler.handle({ operation, body }); + expect(response.metadata.contentType).toBe(APPLICATION_JSON); + const { id, secret } = await readJsonStream(response.data); + expect(id).toMatch(/^token_/u); + expect(credentialStorage.set).toHaveBeenCalledTimes(1); + expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret }); + expect(accountStore.getSettings).toHaveBeenCalledTimes(1); + expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId); + expect(accountStore.updateSettings).toHaveBeenCalledTimes(1); + expect(accountStore.updateSettings) + .toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]}); + }); +}); diff --git a/test/unit/identity/interaction/email-password/credentials/DeleteCredentialsHandler.test.ts b/test/unit/identity/interaction/email-password/credentials/DeleteCredentialsHandler.test.ts new file mode 100644 index 000000000..932f99b28 --- /dev/null +++ b/test/unit/identity/interaction/email-password/credentials/DeleteCredentialsHandler.test.ts @@ -0,0 +1,76 @@ +import type { Operation } from '../../../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; +import type { + ClientCredentials, +} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; +import type { + CredentialsHandlerBody, +} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; +import { + DeleteCredentialsHandler, +} from '../../../../../../src/identity/interaction/email-password/credentials/DeleteCredentialsHandler'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; +import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; +import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; +import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError'; + +describe('A DeleteCredentialsHandler', (): void => { + let operation: Operation; + const id = 'token_id'; + let body: CredentialsHandlerBody; + let accountStore: jest.Mocked; + let credentialStorage: jest.Mocked>; + let handler: DeleteCredentialsHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'POST', + body: new BasicRepresentation(), + target: { path: 'http://example.com/foo' }, + preferences: {}, + }; + + body = { + email: 'test@example.com', + webId: 'http://example.com/foo#me', + delete: id, + }; + + accountStore = { + getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}), + updateSettings: jest.fn(), + } as any; + + credentialStorage = { + delete: jest.fn(), + } as any; + + handler = new DeleteCredentialsHandler(accountStore, credentialStorage); + }); + + it('only supports bodies with a delete entry.', async(): Promise => { + await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined(); + delete body.delete; + await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError); + }); + + it('deletes the token.', async(): Promise => { + const response = await handler.handle({ operation, body }); + expect(response.metadata.contentType).toBe(APPLICATION_JSON); + expect(credentialStorage.delete).toHaveBeenCalledTimes(1); + expect(credentialStorage.delete).toHaveBeenLastCalledWith(id); + expect(accountStore.getSettings).toHaveBeenCalledTimes(1); + expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId); + expect(accountStore.updateSettings).toHaveBeenCalledTimes(1); + expect(accountStore.updateSettings).toHaveBeenLastCalledWith(body.webId, { clientCredentials: []}); + }); + + it('errors if the account has no such token.', async(): Promise => { + accountStore.getSettings.mockResolvedValue({ useIdp: true, clientCredentials: []}); + await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError); + + accountStore.getSettings.mockResolvedValue({ useIdp: true }); + await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError); + }); +}); diff --git a/test/unit/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.test.ts b/test/unit/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.test.ts new file mode 100644 index 000000000..19bb17d59 --- /dev/null +++ b/test/unit/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.test.ts @@ -0,0 +1,57 @@ +import type { Operation } from '../../../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../../../src/http/representation/Representation'; +import type { + CredentialsHandler, +} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; +import { + EmailPasswordAuthorizer, +} from '../../../../../../src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../../../../../src/util/errors/MethodNotAllowedHttpError'; + +describe('An EmailPasswordAuthorizer', (): void => { + const email = 'test@example.com'; + const password = 'super_secret'; + const webId = 'http://example.com/profile#me'; + let operation: Operation; + let response: Representation; + let accountStore: jest.Mocked; + let source: jest.Mocked; + let handler: EmailPasswordAuthorizer; + + beforeEach(async(): Promise => { + operation = { + method: 'POST', + body: new BasicRepresentation(JSON.stringify({ email, password }), APPLICATION_JSON), + target: { path: 'http://example.com/foo' }, + preferences: {}, + }; + + response = new BasicRepresentation(); + + accountStore = { + authenticate: jest.fn().mockResolvedValue(webId), + } as any; + + source = { + handleSafe: jest.fn().mockResolvedValue(response), + } as any; + + handler = new EmailPasswordAuthorizer(accountStore, source); + }); + + it('requires POST methods.', async(): Promise => { + operation.method = 'GET'; + await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + }); + + it('calls the source after validation.', async(): Promise => { + await expect(handler.handle({ operation })).resolves.toBe(response); + expect(accountStore.authenticate).toHaveBeenCalledTimes(1); + expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, password); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe).toHaveBeenLastCalledWith({ operation, body: { email, webId }}); + }); +}); diff --git a/test/unit/identity/interaction/email-password/credentials/ListCredentialsHandler.test.ts b/test/unit/identity/interaction/email-password/credentials/ListCredentialsHandler.test.ts new file mode 100644 index 000000000..e01c6928d --- /dev/null +++ b/test/unit/identity/interaction/email-password/credentials/ListCredentialsHandler.test.ts @@ -0,0 +1,58 @@ +import type { Operation } from '../../../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; +import type { + CredentialsHandlerBody, +} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; +import { + ListCredentialsHandler, +} from '../../../../../../src/identity/interaction/email-password/credentials/ListCredentialsHandler'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; + +describe('A ListCredentialsHandler', (): void => { + let operation: Operation; + const id = 'token_id'; + let body: CredentialsHandlerBody; + let accountStore: jest.Mocked; + let handler: ListCredentialsHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'POST', + body: new BasicRepresentation(), + target: { path: 'http://example.com/foo' }, + preferences: {}, + }; + + body = { + email: 'test@example.com', + webId: 'http://example.com/foo#me', + delete: id, + }; + + accountStore = { + getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}), + updateSettings: jest.fn(), + } as any; + + handler = new ListCredentialsHandler(accountStore); + }); + + it('lists all tokens.', async(): Promise => { + const response = await handler.handle({ operation, body }); + expect(response).toBeDefined(); + expect(response.metadata.contentType).toEqual(APPLICATION_JSON); + const list = await readJsonStream(response.data); + expect(list).toEqual([ id ]); + }); + + it('returns an empty array if there are no tokens.', async(): Promise => { + accountStore.getSettings.mockResolvedValue({ useIdp: true }); + const response = await handler.handle({ operation, body }); + expect(response).toBeDefined(); + expect(response.metadata.contentType).toEqual(APPLICATION_JSON); + const list = await readJsonStream(response.data); + expect(list).toEqual([]); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index 2ea04b774..6053562d4 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -64,7 +64,7 @@ describe('A LoginHandler', (): void => { it('throws an error if the account does not have the correct settings.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); - accountStore.getSettings.mockResolvedValueOnce({ useIdp: false }); + accountStore.getSettings.mockResolvedValueOnce({ useIdp: false, clientCredentials: []}); await expect(handler.handle(input)) .rejects.toThrow('This server is not an identity provider for this account.'); }); diff --git a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts index e4cb7c122..83b8891cc 100644 --- a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts +++ b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts @@ -14,7 +14,7 @@ describe('A BaseAccountStore', (): void => { const email = 'test@test.com'; const webId = 'http://test.com/#webId'; const password = 'password!'; - const settings: AccountSettings = { useIdp: true }; + const settings: AccountSettings = { useIdp: true, clientCredentials: []}; beforeEach(async(): Promise => { const map = new Map(); @@ -92,7 +92,7 @@ describe('A BaseAccountStore', (): void => { }); it('can update the settings.', async(): Promise => { - const newSettings = { webId, useIdp: false }; + const newSettings = { webId, useIdp: false, clientCredentials: []}; await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); await expect(store.verify(email)).resolves.toBeUndefined(); await expect(store.updateSettings(webId, newSettings)).resolves.toBeUndefined(); diff --git a/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts b/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts index 5a3643ec5..be4244de3 100644 --- a/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts +++ b/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts @@ -172,7 +172,8 @@ describe('A RegistrationManager', (): void => { expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true }); + expect(accountStore.create) + .toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, clientCredentials: []}); expect(accountStore.verify).toHaveBeenCalledTimes(1); expect(accountStore.verify).toHaveBeenLastCalledWith(email); @@ -200,7 +201,8 @@ describe('A RegistrationManager', (): void => { expect(podManager.createPod).toHaveBeenCalledTimes(1); expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: false, podBaseUrl }); + expect(accountStore.create) + .toHaveBeenLastCalledWith(email, webId, password, { useIdp: false, podBaseUrl, clientCredentials: []}); expect(accountStore.verify).toHaveBeenCalledTimes(1); expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); @@ -222,7 +224,8 @@ describe('A RegistrationManager', (): void => { expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl }); + expect(accountStore.create) + .toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl, clientCredentials: []}); expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); expect(podManager.createPod).toHaveBeenCalledTimes(1); @@ -242,7 +245,8 @@ describe('A RegistrationManager', (): void => { expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl }); + expect(accountStore.create) + .toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl, clientCredentials: []}); expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); expect(podManager.createPod).toHaveBeenCalledTimes(1); @@ -275,7 +279,7 @@ describe('A RegistrationManager', (): void => { expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password, - { useIdp: true, podBaseUrl }); + { useIdp: true, podBaseUrl, clientCredentials: []}); expect(accountStore.verify).toHaveBeenCalledTimes(1); expect(accountStore.verify).toHaveBeenLastCalledWith(email); expect(podManager.createPod).toHaveBeenCalledTimes(1); @@ -306,7 +310,7 @@ describe('A RegistrationManager', (): void => { expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, - { useIdp: false, podBaseUrl: baseUrl }); + { useIdp: false, podBaseUrl: baseUrl, clientCredentials: []}); expect(accountStore.verify).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenCalledTimes(0); diff --git a/test/unit/identity/storage/PassthroughAdapterFactory.test.ts b/test/unit/identity/storage/PassthroughAdapterFactory.test.ts new file mode 100644 index 000000000..fc4a9bcb2 --- /dev/null +++ b/test/unit/identity/storage/PassthroughAdapterFactory.test.ts @@ -0,0 +1,86 @@ +import type { Adapter } from 'oidc-provider'; +import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; +import { + PassthroughAdapter, + PassthroughAdapterFactory, +} from '../../../../src/identity/storage/PassthroughAdapterFactory'; + +describe('A PassthroughAdapterFactory', (): void => { + let sourceFactory: jest.Mocked; + let factory: PassthroughAdapterFactory; + + beforeEach(async(): Promise => { + sourceFactory = { + createStorageAdapter: jest.fn(), + }; + + factory = new PassthroughAdapterFactory(sourceFactory); + }); + + it('calls the source createStorageAdapter function.', async(): Promise => { + expect(factory.createStorageAdapter('Client')).toBeUndefined(); + expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1); + expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Client'); + }); + + describe('A PassthroughAdapter', (): void => { + let sourceAdapter: jest.Mocked; + let adapter: PassthroughAdapter; + + beforeEach(async(): Promise => { + sourceAdapter = { + upsert: jest.fn(), + find: jest.fn(), + findByUserCode: jest.fn(), + findByUid: jest.fn(), + consume: jest.fn(), + destroy: jest.fn(), + revokeByGrantId: jest.fn(), + }; + + adapter = new PassthroughAdapter('Name', sourceAdapter); + }); + + it('passes the call to the source for upsert.', async(): Promise => { + await expect(adapter.upsert('id', 'payload' as any, 5)).resolves.toBeUndefined(); + expect(sourceAdapter.upsert).toHaveBeenCalledTimes(1); + expect(sourceAdapter.upsert).toHaveBeenLastCalledWith('id', 'payload' as any, 5); + }); + + it('passes the call to the source for find.', async(): Promise => { + await expect(adapter.find('id')).resolves.toBeUndefined(); + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + }); + + it('passes the call to the source for findByUserCode.', async(): Promise => { + await expect(adapter.findByUserCode('userCode')).resolves.toBeUndefined(); + expect(sourceAdapter.findByUserCode).toHaveBeenCalledTimes(1); + expect(sourceAdapter.findByUserCode).toHaveBeenLastCalledWith('userCode'); + }); + + it('passes the call to the source for findByUid.', async(): Promise => { + await expect(adapter.findByUid('uid')).resolves.toBeUndefined(); + expect(sourceAdapter.findByUid).toHaveBeenCalledTimes(1); + expect(sourceAdapter.findByUid).toHaveBeenLastCalledWith('uid'); + }); + + it('passes the call to the source for destroy.', async(): Promise => { + await expect(adapter.destroy('id')).resolves.toBeUndefined(); + expect(sourceAdapter.destroy).toHaveBeenCalledTimes(1); + expect(sourceAdapter.destroy).toHaveBeenLastCalledWith('id'); + }); + + it('passes the call to the source for revokeByGrantId.', async(): Promise => { + await expect(adapter.revokeByGrantId('grantId')).resolves.toBeUndefined(); + expect(sourceAdapter.revokeByGrantId).toHaveBeenCalledTimes(1); + expect(sourceAdapter.revokeByGrantId).toHaveBeenLastCalledWith('grantId'); + }); + + it('passes the call to the source for consume.', async(): Promise => { + await expect(adapter.consume('id')).resolves.toBeUndefined(); + expect(sourceAdapter.consume).toHaveBeenCalledTimes(1); + expect(sourceAdapter.consume).toHaveBeenLastCalledWith('id'); + }); + }); +}); diff --git a/test/unit/identity/storage/WebIdAdapterFactory.test.ts b/test/unit/identity/storage/WebIdAdapterFactory.test.ts index be901987d..9bf1979a8 100644 --- a/test/unit/identity/storage/WebIdAdapterFactory.test.ts +++ b/test/unit/identity/storage/WebIdAdapterFactory.test.ts @@ -53,42 +53,6 @@ describe('A WebIdAdapterFactory', (): void => { adapter = factory.createStorageAdapter('Client'); }); - it('passes the call to the source for upsert.', async(): Promise => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { (source.find as jest.Mock).mockResolvedValueOnce('payload!'); await expect(adapter.find(id)).resolves.toBe('payload!');