feat: Full rework of account management

Complete rewrite of the account management and related systems.
Makes the architecture more modular,
allowing for easier extensions and configurations.
This commit is contained in:
Joachim Van Herwegen
2022-03-16 10:12:13 +01:00
parent ade977bb4f
commit a47f5236ef
366 changed files with 12345 additions and 5111 deletions

View File

@@ -0,0 +1,113 @@
import type { Adapter } from 'oidc-provider';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
ClientCredentialsAdapter, ClientCredentialsAdapterFactory,
} from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import type { AdapterFactory } from '../../../../../src/identity/storage/AdapterFactory';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A ClientCredentialsAdapterFactory', (): void => {
let credentialsStore: jest.Mocked<ClientCredentialsStore>;
let accountStore: jest.Mocked<AccountStore>;
let sourceAdapter: jest.Mocked<Adapter>;
let sourceFactory: jest.Mocked<AdapterFactory>;
let adapter: ClientCredentialsAdapter;
let factory: ClientCredentialsAdapterFactory;
beforeEach(async(): Promise<void> => {
sourceAdapter = {
find: jest.fn(),
} as any;
sourceFactory = {
createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter),
};
accountStore = mockAccountStore();
credentialsStore = {
get: jest.fn(),
delete: jest.fn(),
} as any;
adapter = new ClientCredentialsAdapter('Client', sourceAdapter, accountStore, credentialsStore);
factory = new ClientCredentialsAdapterFactory(sourceFactory, accountStore, credentialsStore);
});
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(credentialsStore.get).toHaveBeenCalledTimes(0);
expect(accountStore.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(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(0);
});
it('returns no result if there is no matching account.', async(): Promise<void> => {
accountStore.get.mockResolvedValueOnce(undefined);
credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' });
await expect(adapter.find('id')).resolves.toBeUndefined();
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith('accountId');
});
it('returns no result if the WebID is not linked to the account and deletes the token.', async(): Promise<void> => {
const account = createAccount();
accountStore.get.mockResolvedValueOnce(account);
credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' });
await expect(adapter.find('id')).resolves.toBeUndefined();
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith('accountId');
expect(credentialsStore.delete).toHaveBeenCalledTimes(1);
expect(credentialsStore.delete).toHaveBeenLastCalledWith('id', account);
});
it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise<void> => {
const webId = 'http://example.com/foo#me';
const account = createAccount();
account.webIds[webId] = 'resource';
accountStore.get.mockResolvedValueOnce(account);
credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId, accountId: 'accountId' });
/* 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(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith('accountId');
});
});

View File

@@ -0,0 +1,59 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
ClientCredentialsDetailsHandler,
} from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A ClientCredentialsDetailsHandler', (): void => {
const webId = 'http://example.com/card#me';
const id = 'token_id';
const target = { path: 'http://example.com/.account/my_token' };
let account: Account;
let accountStore: jest.Mocked<AccountStore>;
let clientCredentialsStore: jest.Mocked<ClientCredentialsStore>;
let handler: ClientCredentialsDetailsHandler;
beforeEach(async(): Promise<void> => {
account = createAccount();
account.clientCredentials[id] = target.path;
accountStore = mockAccountStore(account);
clientCredentialsStore = {
get: jest.fn().mockResolvedValue({ webId, accountId: account.id, secret: 'ssh!' }),
} as any;
handler = new ClientCredentialsDetailsHandler(accountStore, clientCredentialsStore);
});
it('returns the necessary information.', async(): Promise<void> => {
await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: { id, webId }});
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1);
expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id);
});
it('throws a 404 if there is no such token.', async(): Promise<void> => {
delete account.clientCredentials[id];
await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.get).toHaveBeenCalledTimes(0);
});
it('throws an error if there is a data mismatch.', async(): Promise<void> => {
clientCredentialsStore.get.mockResolvedValueOnce(undefined);
await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(InternalServerError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1);
expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id);
});
});

View File

@@ -0,0 +1,17 @@
import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import {
BaseClientCredentialsIdRoute,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
describe('A BaseClientCredentialsIdRoute', (): void => {
it('uses the Credentials ID key.', async(): Promise<void> => {
const credentialsIdRoute = new BaseClientCredentialsIdRoute(new BaseAccountIdRoute(
new AbsolutePathInteractionRoute('http://example.com/'),
));
expect(credentialsIdRoute.matchPath('http://example.com/123/456/'))
.toEqual({ accountId: '123', clientCredentialsId: '456' });
});
});

View File

@@ -0,0 +1,64 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
CreateClientCredentialsHandler,
} from '../../../../../src/identity/interaction/client-credentials/CreateClientCredentialsHandler';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
describe('A CreateClientCredentialsHandler', (): void => {
let account: Account;
const json = {
webId: 'http://example.com/foo#me',
name: 'token',
};
let accountStore: jest.Mocked<AccountStore>;
let clientCredentialsStore: jest.Mocked<ClientCredentialsStore>;
let handler: CreateClientCredentialsHandler;
beforeEach(async(): Promise<void> => {
account = createAccount();
accountStore = mockAccountStore(account);
clientCredentialsStore = {
add: jest.fn().mockReturnValue({ secret: 'secret', resource: 'resource' }),
} as any;
handler = new CreateClientCredentialsHandler(accountStore, clientCredentialsStore);
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
name: {
required: false,
type: 'string',
},
webId: {
required: true,
type: 'string',
},
},
},
});
});
it('creates a new token based on the provided settings.', async(): Promise<void> => {
await expect(handler.handle({ accountId: account.id, json } as any)).resolves.toEqual({
json: { id: 'token_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' },
});
});
it('allows token names to be empty.', async(): Promise<void> => {
await expect(handler.handle({ accountId: account.id, json: { webId: 'http://example.com/foo#me' }} as any))
.resolves.toEqual({
json: { id: '_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' },
});
});
});

View File

@@ -0,0 +1,48 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
DeleteClientCredentialsHandler,
} from '../../../../../src/identity/interaction/client-credentials/DeleteClientCredentialsHandler';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A DeleteClientCredentialsHandler', (): void => {
let account: Account;
const id = 'token_id';
const target = { path: 'http://example.com/.account/my_token' };
let accountStore: jest.Mocked<AccountStore>;
let clientCredentialsStore: jest.Mocked<ClientCredentialsStore>;
let handler: DeleteClientCredentialsHandler;
beforeEach(async(): Promise<void> => {
account = createAccount();
account.clientCredentials[id] = target.path;
accountStore = mockAccountStore(account);
clientCredentialsStore = {
delete: jest.fn(),
} as any;
handler = new DeleteClientCredentialsHandler(accountStore, clientCredentialsStore);
});
it('deletes the token.', async(): Promise<void> => {
await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: {}});
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(1);
expect(clientCredentialsStore.delete).toHaveBeenLastCalledWith(id, account);
});
it('throws a 404 if there is no such token.', async(): Promise<void> => {
delete account.clientCredentials[id];
await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,91 @@
import type { Account } from '../../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore';
import {
BaseClientCredentialsStore,
} from '../../../../../../src/identity/interaction/client-credentials/util/BaseClientCredentialsStore';
import type {
ClientCredentialsIdRoute,
} from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute';
import type {
ClientCredentials,
} from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil';
const secret = 'verylongstringof64bytes';
jest.mock('crypto', (): any => ({ randomBytes: (): string => secret }));
describe('A BaseClientCredentialsStore', (): void => {
const webId = 'http://example.com/card#me';
let account: Account;
const route: ClientCredentialsIdRoute = {
getPath: (): string => 'http://example.com/.account/resource',
matchPath: (): any => ({}),
};
let accountStore: jest.Mocked<AccountStore>;
let storage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let store: BaseClientCredentialsStore;
beforeEach(async(): Promise<void> => {
account = createAccount();
account.webIds[webId] = 'resource';
// Different account object so `safeUpdate` can be tested correctly
const oldAccount = createAccount();
oldAccount.webIds[webId] = 'resource';
accountStore = mockAccountStore(oldAccount);
storage = {
get: jest.fn().mockResolvedValue({ accountId: account.id, webId, secret: 'secret' }),
set: jest.fn(),
delete: jest.fn(),
} as any;
store = new BaseClientCredentialsStore(route, accountStore, storage);
});
it('returns the token it finds.', async(): Promise<void> => {
await expect(store.get('credentialsId')).resolves.toEqual({ accountId: account.id, webId, secret: 'secret' });
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith('credentialsId');
});
it('creates a new token and adds it to the account.', async(): Promise<void> => {
await expect(store.add('credentialsId', webId, account)).resolves
.toEqual({ secret, resource: 'http://example.com/.account/resource' });
expect(account.clientCredentials.credentialsId).toBe('http://example.com/.account/resource');
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId });
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
});
it('errors if the WebID is not registered to the account.', async(): Promise<void> => {
delete account.webIds[webId];
await expect(store.add('credentialsId', webId, account)).rejects.toThrow(BadRequestHttpError);
expect(storage.set).toHaveBeenCalledTimes(0);
expect(accountStore.update).toHaveBeenCalledTimes(0);
expect(account.clientCredentials).toEqual({});
});
it('does not update the account if something goes wrong.', async(): Promise<void> => {
storage.set.mockRejectedValueOnce(new Error('bad data'));
await expect(store.add('credentialsId', webId, account)).rejects.toThrow('bad data');
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId });
expect(accountStore.update).toHaveBeenCalledTimes(2);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.clientCredentials).toEqual({});
});
it('can delete tokens.', async(): Promise<void> => {
account.clientCredentials.credentialsId = 'resource';
await expect(store.delete('credentialsId', account)).resolves.toBeUndefined();
expect(storage.delete).toHaveBeenCalledTimes(1);
expect(storage.delete).toHaveBeenLastCalledWith('credentialsId');
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.clientCredentials).toEqual({});
});
});