Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(configuration): Allow app configuration to be validated against a schema #2590

Merged
merged 2 commits into from
Apr 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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