feat: Add support for client_credentials authentication

This commit is contained in:
Joachim Van Herwegen
2022-04-07 16:26:34 +02:00
parent bedab907f9
commit 2ec8fabd54
40 changed files with 1120 additions and 125 deletions

View File

@@ -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' ],

View File

@@ -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

View File

@@ -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" }
}
}
]
}

View File

@@ -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" }
]
}
]

View File

@@ -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" }
}
]
}
}
},
{
}
]
}

View File

@@ -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" }
}
]
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

13
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, ClientCredentials>;
/**
* 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<string, ClientCredentials>;
private readonly storage!: KeyValueStorage<string, unknown>;
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<UnknownObject> =>
config.extraTokenClaims = async(ctx, token): Promise<UnknownObject> =>
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: {

View File

@@ -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,

View File

@@ -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<string, ClientCredentials>;
public constructor(name: string, source: Adapter, storage: KeyValueStorage<string, ClientCredentials>) {
super(name, source);
this.storage = storage;
}
public async find(id: string): Promise<AdapterPayload | void | undefined> {
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<string, ClientCredentials>;
public constructor(source: AdapterFactory, storage: KeyValueStorage<string, ClientCredentials>) {
super(source);
this.storage = storage;
}
public createStorageAdapter(name: string): Adapter {
return new ClientCredentialsAdapter(name, this.source.createStorageAdapter(name), this.storage);
}
}

View File

@@ -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<string, ClientCredentials>;
public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage<string, ClientCredentials>) {
super();
this.accountStore = accountStore;
this.credentialStorage = credentialStorage;
}
public async canHandle({ body }: CredentialsHandlerInput): Promise<void> {
if (typeof body.name !== 'string') {
throw new NotImplementedHttpError();
}
}
public async handle({ operation, body: { webId, name }}: CredentialsHandlerInput): Promise<Representation> {
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);
}
}

View File

@@ -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<string, unknown> {
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<CredentialsHandlerInput, Representation> { }

View File

@@ -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<string, ClientCredentials>;
public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage<string, ClientCredentials>) {
super();
this.accountStore = accountStore;
this.credentialStorage = credentialStorage;
}
public async canHandle({ body }: CredentialsHandlerInput): Promise<void> {
if (typeof body.delete !== 'string') {
throw new NotImplementedHttpError();
}
}
public async handle({ operation, body }: CredentialsHandlerInput): Promise<Representation> {
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);
}
}

View File

@@ -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<Representation> {
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 }});
}
}

View File

@@ -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<Representation> {
const credentials = (await this.accountStore.getSettings(webId)).clientCredentials ?? [];
return new BasicRepresentation(JSON.stringify(credentials), operation.target, APPLICATION_JSON);
}
}

View File

@@ -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[];
}
/**

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<void | undefined> {
return this.source.upsert(id, payload, expiresIn);
}
public async find(id: string): Promise<AdapterPayload | void | undefined> {
return this.source.find(id);
}
public async findByUserCode(userCode: string): Promise<AdapterPayload | void | undefined> {
return this.source.findByUserCode(userCode);
}
public async findByUid(uid: string): Promise<AdapterPayload | void | undefined> {
return this.source.findByUid(uid);
}
public async consume(id: string): Promise<void | undefined> {
return this.source.consume(id);
}
public async destroy(id: string): Promise<void | undefined> {
return this.source.destroy(id);
}
public async revokeByGrantId(grantId: string): Promise<void | undefined> {
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);
}
}

View File

@@ -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<void> {
return this.source.upsert(id, payload, expiresIn);
}
public async find(id: string): Promise<AdapterPayload | void> {
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<AdapterPayload | void> {
return this.source.findByUserCode(userCode);
}
public async findByUid(uid: string): Promise<AdapterPayload | void> {
return this.source.findByUid(uid);
}
public async destroy(id: string): Promise<void> {
return this.source.destroy(id);
}
public async revokeByGrantId(grantId: string): Promise<void> {
return this.source.revokeByGrantId(grantId);
}
public async consume(id: string): Promise<void> {
return this.source.consume(id);
}
}
export class WebIdAdapterFactory implements AdapterFactory {
private readonly source: AdapterFactory;
export class WebIdAdapterFactory extends PassthroughAdapterFactory {
private readonly converter: RepresentationConverter;
public constructor(source: AdapterFactory, converter: RepresentationConverter) {
this.source = source;
super(source);
this.converter = converter;
}

View File

@@ -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

View File

@@ -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<Response> {
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}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${baseUrl}> .`;
const webIdTurtle = `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${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: <http://www.w3.org/ns/auth/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<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
// Create container where only webId can write
const turtle = `
@prefix acl: <http://www.w3.org/ns/auth/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<void> => {
@@ -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<void> => {
dpopKey = await generateDpopKeyPair();
});
it('can request a credentials token.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;

View File

@@ -31,6 +31,7 @@ describe('An OwnerPermissionReader', (): void => {
settings = {
useIdp: true,
podBaseUrl,
clientCredentials: [],
};
accountStore = {

View File

@@ -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/',

View File

@@ -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<InteractionHandler>;
let adapterFactory: jest.Mocked<AdapterFactory>;
let storage: jest.Mocked<KeyValueStorage<string, any>>;
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let errorHandler: jest.Mocked<ErrorHandler>;
let responseWriter: jest.Mocked<ResponseWriter>;
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,
});

View File

@@ -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<KeyValueStorage<string, ClientCredentials>>;
let sourceAdapter: jest.Mocked<Adapter>;
let sourceFactory: jest.Mocked<AdapterFactory>;
let adapter: ClientCredentialsAdapter;
let factory: ClientCredentialsAdapterFactory;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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');
});
});

View File

@@ -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<AccountStore>;
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let handler: CreateCredentialsHandler;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
accountStore.getSettings.mockResolvedValue({ useIdp: false });
await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError);
});
it('creates a new credential token.', async(): Promise<void> => {
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<void> => {
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 ]});
});
});

View File

@@ -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<AccountStore>;
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let handler: DeleteCredentialsHandler;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
});
});

View File

@@ -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<AccountStore>;
let source: jest.Mocked<CredentialsHandler>;
let handler: EmailPasswordAuthorizer;
beforeEach(async(): Promise<void> => {
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<void> => {
operation.method = 'GET';
await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
});
it('calls the source after validation.', async(): Promise<void> => {
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 }});
});
});

View File

@@ -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<AccountStore>;
let handler: ListCredentialsHandler;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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([]);
});
});

View File

@@ -64,7 +64,7 @@ describe('A LoginHandler', (): void => {
it('throws an error if the account does not have the correct settings.', async(): Promise<void> => {
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.');
});

View File

@@ -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<void> => {
const map = new Map();
@@ -92,7 +92,7 @@ describe('A BaseAccountStore', (): void => {
});
it('can update the settings.', async(): Promise<void> => {
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();

View File

@@ -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);

View File

@@ -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<AdapterFactory>;
let factory: PassthroughAdapterFactory;
beforeEach(async(): Promise<void> => {
sourceFactory = {
createStorageAdapter: jest.fn(),
};
factory = new PassthroughAdapterFactory(sourceFactory);
});
it('calls the source createStorageAdapter function.', async(): Promise<void> => {
expect(factory.createStorageAdapter('Client')).toBeUndefined();
expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Client');
});
describe('A PassthroughAdapter', (): void => {
let sourceAdapter: jest.Mocked<Adapter>;
let adapter: PassthroughAdapter;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
await expect(adapter.consume('id')).resolves.toBeUndefined();
expect(sourceAdapter.consume).toHaveBeenCalledTimes(1);
expect(sourceAdapter.consume).toHaveBeenLastCalledWith('id');
});
});
});

View File

@@ -53,42 +53,6 @@ describe('A WebIdAdapterFactory', (): void => {
adapter = factory.createStorageAdapter('Client');
});
it('passes the call to the source for upsert.', async(): Promise<void> => {
await expect(adapter.upsert('id', 'payload' as any, 5)).resolves.toBeUndefined();
expect(source.upsert).toHaveBeenCalledTimes(1);
expect(source.upsert).toHaveBeenLastCalledWith('id', 'payload' as any, 5);
});
it('passes the call to the source for findByUserCode.', async(): Promise<void> => {
await expect(adapter.findByUserCode('userCode')).resolves.toBeUndefined();
expect(source.findByUserCode).toHaveBeenCalledTimes(1);
expect(source.findByUserCode).toHaveBeenLastCalledWith('userCode');
});
it('passes the call to the source for findByUid.', async(): Promise<void> => {
await expect(adapter.findByUid('uid')).resolves.toBeUndefined();
expect(source.findByUid).toHaveBeenCalledTimes(1);
expect(source.findByUid).toHaveBeenLastCalledWith('uid');
});
it('passes the call to the source for destroy.', async(): Promise<void> => {
await expect(adapter.destroy('id')).resolves.toBeUndefined();
expect(source.destroy).toHaveBeenCalledTimes(1);
expect(source.destroy).toHaveBeenLastCalledWith('id');
});
it('passes the call to the source for revokeByGrantId.', async(): Promise<void> => {
await expect(adapter.revokeByGrantId('grantId')).resolves.toBeUndefined();
expect(source.revokeByGrantId).toHaveBeenCalledTimes(1);
expect(source.revokeByGrantId).toHaveBeenLastCalledWith('grantId');
});
it('passes the call to the source for consume.', async(): Promise<void> => {
await expect(adapter.consume('id')).resolves.toBeUndefined();
expect(source.consume).toHaveBeenCalledTimes(1);
expect(source.consume).toHaveBeenLastCalledWith('id');
});
it('returns the source payload if there is one.', async(): Promise<void> => {
(source.find as jest.Mock).mockResolvedValueOnce('payload!');
await expect(adapter.find(id)).resolves.toBe('payload!');