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,80 @@
import type { Adapter, AdapterPayload } from '../../../../templates/types/oidc-provider';
import { getLoggerFor } from '../../../logging/LogUtil';
import type { AdapterFactory } from '../../storage/AdapterFactory';
import { PassthroughAdapterFactory, PassthroughAdapter } from '../../storage/PassthroughAdapterFactory';
import type { AccountStore } from '../account/util/AccountStore';
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
/**
* 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 {
protected readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore;
private readonly clientCredentialsStore: ClientCredentialsStore;
public constructor(name: string, source: Adapter, accountStore: AccountStore,
clientCredentialsStore: ClientCredentialsStore) {
super(name, source);
this.accountStore = accountStore;
this.clientCredentialsStore = clientCredentialsStore;
}
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.clientCredentialsStore.get(id);
if (credentials) {
// Make sure the WebID is still linked to the account.
// Unlinking a WebID does not necessarily delete the corresponding credential tokens.
const account = await this.accountStore.get(credentials.accountId);
if (!account) {
this.logger.error(`Storage contains credentials ${id} with unknown account ID ${credentials.accountId}`);
return;
}
if (!account.webIds[credentials.webId]) {
this.logger.warn(
`Client credentials token ${id} contains WebID that is no longer linked to the account. Removing...`,
);
await this.clientCredentialsStore.delete(id, account);
return;
}
this.logger.debug(`Authenticating as ${credentials.webId} using client 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 accountStore: AccountStore;
private readonly clientCredentialsStore: ClientCredentialsStore;
public constructor(source: AdapterFactory, accountStore: AccountStore,
clientCredentialsStore: ClientCredentialsStore) {
super(source);
this.accountStore = accountStore;
this.clientCredentialsStore = clientCredentialsStore;
}
public createStorageAdapter(name: string): Adapter {
const adapter = this.source.createStorageAdapter(name);
return new ClientCredentialsAdapter(name, adapter, this.accountStore, this.clientCredentialsStore);
}
}

View File

@@ -0,0 +1,48 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import { InternalServerError } from '../../../util/errors/InternalServerError';
import type { AccountStore } from '../account/util/AccountStore';
import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
type OutType = {
id: string;
webId: string;
};
/**
* Provides a view on a client credentials token, indicating the token identifier and its associated WebID.
*/
export class ClientCredentialsDetailsHandler extends JsonInteractionHandler<OutType> {
protected readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore;
private readonly clientCredentialsStore: ClientCredentialsStore;
public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) {
super();
this.accountStore = accountStore;
this.clientCredentialsStore = clientCredentialsStore;
}
public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise<JsonRepresentation<OutType>> {
const account = await getRequiredAccount(this.accountStore, accountId);
const id = ensureResource(account.clientCredentials, target.path);
const credentials = await this.clientCredentialsStore.get(id);
if (!credentials) {
this.logger.error(
`Data inconsistency between account and credentials data for account ${account.id} and token ${id}.`,
);
throw new InternalServerError('Data inconsistency between account and client credentials data.');
}
return { json: {
id,
webId: credentials.webId,
}};
}
}

View File

@@ -0,0 +1,52 @@
import { v4 } from 'uuid';
import { object, string } from 'yup';
import { sanitizeUrlPart } from '../../../util/StringUtil';
import type { AccountStore } from '../account/util/AccountStore';
import { getRequiredAccount } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView';
import { parseSchema, validateWithError } from '../YupUtil';
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
const inSchema = object({
name: string().trim().optional(),
webId: string().trim().required(),
});
type OutType = {
id: string;
secret: string;
resource: string;
};
/**
* Handles the creation of client credential tokens.
*/
export class CreateClientCredentialsHandler extends JsonInteractionHandler<OutType> implements JsonView {
private readonly accountStore: AccountStore;
private readonly clientCredentialsStore: ClientCredentialsStore;
public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) {
super();
this.accountStore = accountStore;
this.clientCredentialsStore = clientCredentialsStore;
}
public async getView(): Promise<JsonRepresentation> {
return { json: parseSchema(inSchema) };
}
public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise<JsonRepresentation<OutType>> {
const account = await getRequiredAccount(this.accountStore, accountId);
const { name, webId } = await validateWithError(inSchema, json);
const cleanedName = name ? sanitizeUrlPart(name.trim()) : '';
const id = `${cleanedName}_${v4()}`;
const { secret, resource } = await this.clientCredentialsStore.add(id, webId, account);
return { json: { id, secret, resource }};
}
}

View File

@@ -0,0 +1,32 @@
import type { EmptyObject } from '../../../util/map/MapUtil';
import type { AccountStore } from '../account/util/AccountStore';
import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
/**
* Handles the deletion of client credentials tokens.
*/
export class DeleteClientCredentialsHandler extends JsonInteractionHandler<EmptyObject> {
private readonly accountStore: AccountStore;
private readonly clientCredentialsStore: ClientCredentialsStore;
public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) {
super();
this.accountStore = accountStore;
this.clientCredentialsStore = clientCredentialsStore;
}
public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise<JsonRepresentation<EmptyObject>> {
const account = await getRequiredAccount(this.accountStore, accountId);
const id = ensureResource(account.clientCredentials, target.path);
// This also deletes it from the account
await this.clientCredentialsStore.delete(id, account);
return { json: {}};
}
}

View File

@@ -0,0 +1,63 @@
import { randomBytes } from 'crypto';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage';
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
import type { Account } from '../../account/util/Account';
import type { AccountStore } from '../../account/util/AccountStore';
import { safeUpdate } from '../../account/util/AccountUtil';
import type { ClientCredentialsIdRoute } from './ClientCredentialsIdRoute';
import type { ClientCredentials, ClientCredentialsStore } from './ClientCredentialsStore';
/**
* A {@link ClientCredentialsStore} that uses a {@link KeyValueStorage} for storing the tokens.
*/
export class BaseClientCredentialsStore implements ClientCredentialsStore {
private readonly logger = getLoggerFor(this);
private readonly clientCredentialsRoute: ClientCredentialsIdRoute;
private readonly accountStore: AccountStore;
private readonly storage: KeyValueStorage<string, ClientCredentials>;
public constructor(clientCredentialsRoute: ClientCredentialsIdRoute, accountStore: AccountStore,
storage: KeyValueStorage<string, ClientCredentials>) {
this.clientCredentialsRoute = clientCredentialsRoute;
this.accountStore = accountStore;
this.storage = storage;
}
public async get(id: string): Promise<ClientCredentials | undefined> {
return this.storage.get(id);
}
public async add(id: string, webId: string, account: Account): Promise<{ secret: string; resource: string }> {
if (typeof account.webIds[webId] !== 'string') {
this.logger.warn(`Trying to create token for ${webId} which does not belong to account ${account.id}`);
throw new BadRequestHttpError('WebID does not belong to this account.');
}
const secret = randomBytes(64).toString('hex');
const resource = this.clientCredentialsRoute.getPath({ accountId: account.id, clientCredentialsId: id });
account.clientCredentials[id] = resource;
await safeUpdate(account,
this.accountStore,
(): Promise<any> => this.storage.set(id, { accountId: account.id, secret, webId }));
this.logger.debug(`Created client credentials token ${id} for WebID ${webId} and account ${account.id}`);
return { secret, resource };
}
public async delete(id: string, account: Account): Promise<void> {
const link = account.clientCredentials[id];
if (link) {
delete account.clientCredentials[id];
await safeUpdate(account,
this.accountStore,
(): Promise<any> => this.storage.delete(id));
this.logger.debug(`Deleted client credentials token ${id} for account ${account.id}`);
}
}
}

View File

@@ -0,0 +1,20 @@
import type { AccountIdKey, AccountIdRoute } from '../../account/AccountIdRoute';
import { IdInteractionRoute } from '../../routing/IdInteractionRoute';
import type { ExtendedRoute } from '../../routing/InteractionRoute';
export type CredentialsIdKey = 'clientCredentialsId';
/**
* An {@link AccountIdRoute} that also includes a credentials identifier.
*/
export type ClientCredentialsIdRoute = ExtendedRoute<AccountIdRoute, CredentialsIdKey>;
/**
* Implementation of an {@link ClientCredentialsIdRoute}
* that adds the identifier relative to a base {@link AccountIdRoute}.
*/
export class BaseClientCredentialsIdRoute extends IdInteractionRoute<AccountIdKey, CredentialsIdKey> {
public constructor(base: AccountIdRoute) {
super(base, 'clientCredentialsId');
}
}

View File

@@ -0,0 +1,47 @@
import type { Account } from '../../account/util/Account';
/**
* A client credentials token.
* If at some point the WebID is no longer registered to the account stored in this token,
* the token should be invalidated.
*/
export interface ClientCredentials {
/**
* The identifier of the account that created the token.
*/
accountId: string;
/**
* The secret of the token.
*/
secret: string;
/**
* The WebID users will be identified as after using the token.
*/
webId: string;
}
/**
* Stores and creates {@link ClientCredentials}.
*/
export interface ClientCredentialsStore {
/**
* Find the {@link ClientCredentials} with the given label. Undefined if there is no match.
* @param label - Label of the credentials.
*/
get: (label: string) => Promise<ClientCredentials | undefined>;
/**
* Creates new {@link ClientCredentials} and adds a reference to the account.
* Will error if the WebID is not registered to the account.
*
* @param label - Identifier to use for the new credentials.
* @param webId - WebID to identify as when using this token.
* @param account - Account that is associated with this token.
*/
add: (label: string, webId: string, account: Account) => Promise<{ secret: string; resource: string }>;
/**
* Deletes the token with the given identifier and removes the reference from the account.
* @param label - Identifier of the token.
* @param account - Account this token belongs to.
*/
delete: (label: string, account: Account) => Promise<void>;
}