Skip to content

Commit

Permalink
feat(configuration): Allow app configuration to be validated against …
Browse files Browse the repository at this point in the history
…a schema (#2590)
  • Loading branch information
daffl committed Apr 4, 2022
1 parent 5bc9d44 commit a268f86
Show file tree
Hide file tree
Showing 13 changed files with 205 additions and 21 deletions.
1 change: 1 addition & 0 deletions packages/authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
},
"devDependencies": {
"@feathersjs/memory": "^5.0.0-pre.17",
"@feathersjs/schema": "^5.0.0-pre.17",
"@types/lodash": "^4.14.181",
"@types/mocha": "^9.1.0",
"@types/node": "^17.0.23",
Expand Down
4 changes: 2 additions & 2 deletions packages/authentication/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NotAuthenticated } from '@feathersjs/errors';
import { createDebug } from '@feathersjs/commons';
import { Application, Params } from '@feathersjs/feathers';
import { IncomingMessage, ServerResponse } from 'http';
import defaultOptions from './options';
import { defaultOptions } from './options';

const debug = createDebug('@feathersjs/authentication/base');

Expand Down Expand Up @@ -167,7 +167,7 @@ export class AuthenticationBase {

/**
* Returns a single strategy by name
*
*
* @param name The strategy name
* @returns The authentication strategy or undefined
*/
Expand Down
1 change: 1 addition & 0 deletions packages/authentication/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export {
export { AuthenticationBaseStrategy } from './strategy';
export { AuthenticationService } from './service';
export { JWTStrategy } from './jwt';
export { authenticationSettingsSchema } from './options';
109 changes: 107 additions & 2 deletions packages/authentication/src/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export default {
authStrategies: [],
export const defaultOptions = {
authStrategies: [] as string[],
jwtOptions: {
header: { typ: 'access' }, // by default is an access token but can be any type
audience: 'https://yourdomain.com', // The resource server where the token is processed
Expand All @@ -8,3 +8,108 @@ export default {
expiresIn: '1d'
}
};

export const authenticationSettingsSchema = {
type: 'object',
required: ['secret', 'entity', 'authStrategies'],
properties: {
secret: {
type: 'string',
description: 'The JWT signing secret'
},
entity: {
oneOf: [{
type: 'null'
}, {
type: 'string'
}],
description: 'The name of the authentication entity (e.g. user)'
},
entityId: {
type: 'string',
description: 'The name of the authentication entity id property'
},
service: {
type: 'string',
description: 'The path of the entity service'
},
authStrategies: {
type: 'array',
items: { type: 'string' },
description: 'A list of authentication strategy names that are allowed to create JWT access tokens'
},
parseStrategies: {
type: 'array',
items: { type: 'string' },
description: 'A list of authentication strategy names that should parse HTTP headers for authentication information (defaults to `authStrategies`)'
},
jwtOptions: {
type: 'object'
},
jwt: {
type: 'object',
properties: {
header: {
type: 'string',
default: 'Authorization',
description: 'The HTTP header containing the JWT'
},
schemes: {
type: 'array',
items: { type: 'string' },
description: 'An array of schemes to support'
}
}
},
local: {
type: 'object',
required: ['usernameField', 'passwordField'],
properties: {
usernameField: {
type: 'string',
description: 'Name of the username field (e.g. `email`)'
},
passwordField: {
type: 'string',
description: 'Name of the password field (e.g. `password`)'
},
hashSize: {
type: 'number',
description: 'The BCrypt salt length'
},
errorMessage: {
type: 'string',
default: 'Invalid login',
description: 'The error message to return on errors'
},
entityUsernameField: {
type: 'string',
description: 'Name of the username field on the entity if authentication request data and entity field names are different'
},
entityPasswordField: {
type: 'string',
description: 'Name of the password field on the entity if authentication request data and entity field names are different'
}
}
},
oauth: {
type: 'object',
properties: {
redirect: {
type: 'string'
},
origins: {
type: 'array',
items: { type: 'string' }
},
defaults: {
type: 'object',
properties: {
key: { type: 'string' },
secret: { type: 'string' }
}
}
}
}
}
} as const;
17 changes: 17 additions & 0 deletions packages/authentication/test/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import assert from 'assert';
import { feathers, Application } from '@feathersjs/feathers';
import jwt from 'jsonwebtoken';
import { Infer, schema } from '@feathersjs/schema';

import { AuthenticationBase, AuthenticationRequest } from '../src/core';
import { authenticationSettingsSchema } from '../src/options';
import { Strategy1, Strategy2, MockRequest } from './fixtures';
import { ServerResponse } from 'http';

Expand Down Expand Up @@ -31,6 +33,21 @@ describe('authentication/core', () => {
});

describe('configuration', () => {
it('infers configuration from settings schema', async () => {
const settingsSchema = schema({
$id: 'AuthSettingsSchema',
...authenticationSettingsSchema
} as const);
type Settings = Infer<typeof settingsSchema>;
const config: Settings = {
entity: 'user',
secret: 'supersecret',
authStrategies: [ 'some', 'thing' ]
}

await settingsSchema.validate(config);
});

it('throws an error when app is not provided', () => {
try {
// @ts-ignore
Expand Down
2 changes: 1 addition & 1 deletion packages/authentication/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken';
import { feathers, Application } from '@feathersjs/feathers';
import { memory, Service as MemoryService } from '@feathersjs/memory';

import defaultOptions from '../src/options';
import { defaultOptions } from '../src/options';
import { AuthenticationService } from '../src';

import { Strategy1 } from './fixtures';
Expand Down
1 change: 1 addition & 0 deletions packages/configuration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"dependencies": {
"@feathersjs/commons": "^5.0.0-pre.17",
"@feathersjs/feathers": "^5.0.0-pre.17",
"@feathersjs/schema": "^5.0.0-pre.17",
"@types/config": "^0.0.41",
"config": "^3.3.7"
},
Expand Down
14 changes: 12 additions & 2 deletions packages/configuration/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Application } from '@feathersjs/feathers';
import { Application, ApplicationHookContext, NextFunction } from '@feathersjs/feathers';
import { createDebug } from '@feathersjs/commons';
import { Schema } from '@feathersjs/schema'
import config from 'config';

const debug = createDebug('@feathersjs/configuration');

export = function init () {
export = function init (schema?: Schema<any>) {
return (app?: Application) => {
if (!app) {
return config;
Expand All @@ -18,6 +19,15 @@ export = function init () {
app.set(name, value);
});

if (schema) {
app.hooks({
setup: [async (context: ApplicationHookContext, next: NextFunction) => {
await schema.validate(context.app.settings);
await next();
}]
})
}

return config;
};
}
47 changes: 44 additions & 3 deletions packages/configuration/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { strict as assert } from 'assert';
import { feathers, Application } from '@feathersjs/feathers';
import plugin from '../src';
import { Ajv, schema } from '@feathersjs/schema';
import configuration from '../src';

describe('@feathersjs/configuration', () => {
const app: Application = feathers().configure(plugin());
const app: Application = feathers().configure(configuration());

it('initialized app with default.json', () => {
assert.equal(app.get('port'), 3030);
Expand All @@ -15,9 +16,49 @@ describe('@feathersjs/configuration', () => {
});

it('works when called directly', () => {
const fn = plugin();
const fn = configuration();
const conf = fn() as any;

assert.strictEqual(conf.port, 3030);
});

it('errors on .setup when a schema is passed and the configuration is invalid', async () => {
const configurationSchema = schema({
$id: 'ConfigurationSchema',
additionalProperties: false,
type: 'object',
properties: {
port: { type: 'number' },
deep: {
type: 'object',
properties: {
base: {
type: 'boolean'
}
}
},
array: {
type: 'array',
items: { type: 'string' }
},
nullish: {
type: 'string'
}
}
} as const, new Ajv());

const schemaApp = feathers().configure(configuration(configurationSchema))

await assert.rejects(() => schemaApp.setup(), {
data: [{
instancePath: '/nullish',
keyword: 'type',
message: 'must be string',
params: {
type: 'string'
},
schemaPath: '#/properties/nullish/type'
}]
});
});
});
3 changes: 2 additions & 1 deletion packages/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"scripts": {
"prepublish": "npm run compile",
"compile": "shx rm -rf lib/ && tsc",
"test": "mocha --config ../../.mocharc.json --recursive test/**.test.ts test/**/*.test.ts"
"mocha": "mocha --config ../../.mocharc.json --recursive test/**.test.ts test/**/*.test.ts",
"test": "npm run compile && npm run mocha"
},
"directories": {
"lib": "lib"
Expand Down
7 changes: 3 additions & 4 deletions packages/schema/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BadRequest } from '@feathersjs/errors';
import { Schema } from './schema';

export type PropertyResolver<T, V, C> = (
value: V|undefined,
Expand All @@ -12,9 +13,7 @@ export type PropertyResolverMap<T, C> = {
}

export interface ResolverConfig<T, C> {
// TODO this should be `Schema<any>` but has recently produced an error, see
// https://github.com/ThomasAribart/json-schema-to-ts/issues/53
schema?: any,
schema?: Schema<T>,
validate?: 'before'|'after'|false,
properties: PropertyResolverMap<T, C>
}
Expand Down Expand Up @@ -71,7 +70,7 @@ export class Resolver<T, C> {

// Not the most elegant but better performance
await Promise.all(propertyList.map(async name => {
const value = data[name];
const value = (data as any)[name];

if (resolvers[name]) {
try {
Expand Down
16 changes: 11 additions & 5 deletions packages/schema/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import Ajv, { AsyncValidateFunction, ValidateFunction } from 'ajv';
import { FromSchema, JSONSchema } from 'json-schema-to-ts';
import { BadRequest } from '@feathersjs/errors';

export const AJV = new Ajv({
export const DEFAULT_AJV = new Ajv({
coerceTypes: true
});

export { Ajv };

export type JSONSchemaDefinition = JSONSchema & { $id: string, $async?: boolean };

export class Schema<S extends JSONSchemaDefinition> {
export interface Schema<T> {
validate <X = T> (...args: Parameters<ValidateFunction<X>>): Promise<X>;
}

export class SchemaWrapper<S extends JSONSchemaDefinition> implements Schema<FromSchema<S>> {
ajv: Ajv;
validator: AsyncValidateFunction;
readonly _type!: FromSchema<S>;

constructor (public definition: S, ajv: Ajv = AJV) {
constructor (public definition: S, ajv: Ajv = DEFAULT_AJV) {
this.ajv = ajv;
this.validator = this.ajv.compile({
$async: true,
Expand All @@ -36,6 +42,6 @@ export class Schema<S extends JSONSchemaDefinition> {
}
}

export function schema <S extends JSONSchemaDefinition> (definition: S, ajv: Ajv = AJV) {
return new Schema(definition, ajv);
export function schema <S extends JSONSchemaDefinition> (definition: S, ajv: Ajv = DEFAULT_AJV) {
return new SchemaWrapper(definition, ajv);
}
4 changes: 3 additions & 1 deletion packages/schema/test/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GeneralError } from '@feathersjs/errors';

import {
schema, resolve, Infer, resolveResult,
queryProperty, resolveQuery, resolveData
queryProperty, resolveQuery, resolveData, validateData, validateQuery
} from '../src';

export const userSchema = schema({
Expand Down Expand Up @@ -148,6 +148,7 @@ const app = feathers<ServiceTypes>()
.use('messages', memory());

app.service('messages').hooks([
validateQuery(messageQuerySchema),
resolveQuery(messageQueryResolver),
resolveResult(messageResultResolver)
]);
Expand All @@ -158,6 +159,7 @@ app.service('users').hooks([

app.service('users').hooks({
create: [
validateData(userSchema),
resolveData(userDataResolver)
]
});
Expand Down

0 comments on commit a268f86

Please sign in to comment.