diff --git a/README.MD b/README.MD index 04b3e08..7b80652 100644 --- a/README.MD +++ b/README.MD @@ -165,17 +165,34 @@ class PeopleService { #### @Security -Add a security constraint to method generated docs, referencing the security name from securityDefinitions. +Add a security constraint to generated method docs, referencing the security name and any scopes (or, roles) from securityDefinitions. + `@Security` can be used at the controller and method level; if defined on both, method security overwrites controller security. Multiple security schemes may be specified to require all of them. +When used with a single parameter, this will be interpreted as the scopes, which can be a string or an array. With two parameters, the first parameter should be the name of a securityDefinition defined in swagger.config.json. The second parameter is the scopes, which can be a string or an array of strings. + +Note that where multiple scopes are specified, this implies that any one of those scopes will grant access. + ```typescript -@Path('people') -class PeopleService { - @Security('api_key') +@Path('secure') +class SecureService { + @Security('basic_auth', []) @GET - getPeople(@Param('name') name: string) { - // ... + getBasicAuthContent(@Param('id') id: string) { + // this method is only accessible by those authenticated with valid credentials for the basic_auth securityDefinition + } + + @Security('read_profile') + @GET + getProfile(@Param('id') id: string) { + // this method is only accessible by those authenticated with valid credentials with a grant for 'read_profile' for any securityDefinition containing the 'read_profile' scope + } + + @Security('oauth',['read_profile']) + @GET + getOauthSpecificProfile(@Param('id') id: string) { + // this method is only accessible by those authenticated with valid credentials for the oauth securityDefinition with a grant for the 'read_profile' scope } } ``` diff --git a/package.json b/package.json index 3026853..80d3d82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typescript-rest-swagger", - "version": "0.1.0", + "version": "0.2.0", "description": "Generate Swagger files from a typescript-rest project", "keywords": [ "typescript", diff --git a/src/decorators.ts b/src/decorators.ts index af6e42c..1d81ce1 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -68,10 +68,15 @@ export function Tags(...values: string[]): any { /** * Add a security constraint to method generated docs. - * @param {name} security name from securityDefinitions - * @param {scopes} security scopes from securityDefinitions + * Name is optional, if omitted all securityDefinitions containing the specified scopes will be included. Otherwise, specific to the named securityDefinition. + * Scopes is optional, if omitted all defined securityDefinitions will be included, implying that any security type + * with any scope will suffice. + * NOTE: When supplying only one parameter, it will be interpreted as scopes. This is for typescript-rest compatibility. + * @summary Add a security constraint to method generated docs. + * @param name security name from securityDefinitions + * @param scopes security scopes from securityDefinitions */ -export function Security(name: string, scopes?: string[]): any { +export function Security( name?: string, scopes?: Array | string ): any { return () => { return; }; } diff --git a/src/metadata/controllerGenerator.ts b/src/metadata/controllerGenerator.ts index 9363945..f7e8993 100644 --- a/src/metadata/controllerGenerator.ts +++ b/src/metadata/controllerGenerator.ts @@ -2,7 +2,7 @@ import * as ts from 'typescript'; import { Controller } from './metadataGenerator'; import { getSuperClass } from './resolveType'; import { MethodGenerator } from './methodGenerator'; -import { getDecorators, getDecoratorTextValue } from '../utils/decoratorUtils'; +import { getDecorators, getDecoratorTextValue, parseSecurityDecoratorArguments } from '../utils/decoratorUtils'; import {normalizePath} from '../utils/pathUtils'; import * as _ from 'lodash'; @@ -85,9 +85,6 @@ export class ControllerGenerator { const securityDecorators = getDecorators(this.node, decorator => decorator.text === 'Security'); if (!securityDecorators || !securityDecorators.length) { return undefined; } - return securityDecorators.map(d => ({ - name: d.arguments[0], - scopes: d.arguments[1] ? (d.arguments[1] as any).elements.map((e: any) => e.text) : undefined - })); + return securityDecorators.map(parseSecurityDecoratorArguments); } } diff --git a/src/metadata/metadataGenerator.ts b/src/metadata/metadataGenerator.ts index 4c6ddcd..1cca0d7 100644 --- a/src/metadata/metadataGenerator.ts +++ b/src/metadata/metadataGenerator.ts @@ -128,7 +128,7 @@ export interface Parameter { } export interface Security { - name: string; + name?: string; scopes?: string[]; } diff --git a/src/metadata/methodGenerator.ts b/src/metadata/methodGenerator.ts index 5930552..ebaad68 100644 --- a/src/metadata/methodGenerator.ts +++ b/src/metadata/methodGenerator.ts @@ -3,7 +3,7 @@ import { Method, ResponseData, ResponseType, Type } from './metadataGenerator'; import { resolveType } from './resolveType'; import { ParameterGenerator } from './parameterGenerator'; import { getJSDocDescription, getJSDocTag, isExistJSDocTag } from '../utils/jsDocUtils'; -import { getDecorators } from '../utils/decoratorUtils'; +import { getDecorators, parseSecurityDecoratorArguments } from '../utils/decoratorUtils'; import { normalizePath } from '../utils/pathUtils'; import * as pathUtil from 'path'; @@ -220,10 +220,7 @@ export class MethodGenerator { const securityDecorators = getDecorators(this.node, decorator => decorator.text === 'Security'); if (!securityDecorators || !securityDecorators.length) { return undefined; } - return securityDecorators.map(d => ({ - name: d.arguments[0], - scopes: d.arguments[1] ? (d.arguments[1] as any).elements.map((e: any) => e.text) : undefined - })); + return securityDecorators.map(parseSecurityDecoratorArguments); } private getInitializerValue(initializer: any) { diff --git a/src/swagger/generator.ts b/src/swagger/generator.ts index 9c404b3..080cb3b 100644 --- a/src/swagger/generator.ts +++ b/src/swagger/generator.ts @@ -122,9 +122,57 @@ export class SpecGenerator { if (method.deprecated) { pathMethod.deprecated = method.deprecated; } if (method.tags.length) { pathMethod.tags = method.tags; } if (method.security) { - pathMethod.security = method.security.map(s => ({ - [s.name]: s.scopes || [] - })); + // prepare an empty array for the pathMethod security fields + pathMethod.security = []; + + // process each security decorator in turn + method.security.forEach(securityDecoratorInfo => { + if (securityDecoratorInfo.name) { + const securityDefinition = this.config.securityDefinitions && this.config.securityDefinitions[securityDecoratorInfo.name]; + if (!securityDefinition) { + throw new Error(`Unknown securityDefinition '${securityDecoratorInfo.name}' used on method '${controllerName}.${method.method}'`); + } + // the scopes specified in the securityDecoratorInfo must align with those named in securityDefinitions + const missingScopes = _.difference(securityDecoratorInfo.scopes || [], Object.keys(securityDefinition.scopes || {})); + if (missingScopes.length > 0) { + throw new Error(`The securityDefinition '${securityDecoratorInfo.name}' used on method '${controllerName}.${method.method}' is missing specified scope(s): '${missingScopes.join(',')}'`); + } + pathMethod.security.push({[securityDecoratorInfo.name]: securityDecoratorInfo.scopes || []}); + + } else { + // when no name was specified, we need to find all those securityDefinitions whose scopes contain our specified scopes + const requiredScopes = securityDecoratorInfo.scopes || []; + let remainingScopes = requiredScopes; + + // iterate over securityDefinitions, adding all with matching scopes + if (this.config.securityDefinitions) { + for (const securityDefinitionName in this.config.securityDefinitions) { + const securityDefinition = this.config.securityDefinitions[securityDefinitionName]; + const availableScopes = Object.keys(securityDefinition.scopes || {}); + + // find all scopes in the current security definition relevant to this decorator + const relevantScopes = _.intersection(requiredScopes, availableScopes); + + // remove relevantScopes from remainingScopes + remainingScopes = _.difference(remainingScopes, relevantScopes); + + if (relevantScopes.length || requiredScopes.length === 0) { + pathMethod.security.push({[securityDefinitionName]: relevantScopes}); + } + } + } else { + throw new Error('No securityDefinitions were defined in swagger.config.json, but one or more @Security decorators are present.'); + } + + if (remainingScopes.length > 0) { + throw new Error(`The security decorator on method '${controllerName}.${method.method}' could not find a match for the following scope(s): '${remainingScopes.join(',')}'`); + } else if (remainingScopes === requiredScopes) { + // if remainingScopes has not been reassigned, this means there were no securityDefinitions defined + throw new Error('There are no securityDefinitions in swagger.config.json, but one or more @Security decorators have been used.'); + } + } + } + ); } this.handleMethodConsumes(method, pathMethod); diff --git a/src/utils/decoratorUtils.ts b/src/utils/decoratorUtils.ts index 998fc2a..4c3972f 100644 --- a/src/utils/decoratorUtils.ts +++ b/src/utils/decoratorUtils.ts @@ -1,8 +1,45 @@ import * as ts from 'typescript'; +import {Security} from '../metadata/metadataGenerator'; +import {SyntaxKind} from 'typescript'; + +export function parseSecurityDecoratorArguments(decoratorData: DecoratorData): Security { + if (decoratorData.arguments.length === 1) { + // according to typescript-rest @Security decorator definition, when only one argument has been provided, + // scopes must be the only parameter + return {name: undefined, scopes: parseScopesArgument(decoratorData.arguments[0])}; + } else if (decoratorData.arguments.length === 2) { + // in all other cases, maintain previous functionality - assume two parameters: name, scopes + + // nameArgument might be metadata which would result in a confusing error message + const nameArgument = decoratorData.arguments[0]; + if (typeof nameArgument !== 'string') { + throw new Error('name argument to @Security decorator must always be a string'); + } + + return {name: nameArgument, scopes: parseScopesArgument(decoratorData.arguments[1])}; + } else { + return {name: undefined, scopes: undefined}; + } + + function parseScopesArgument(arg: any): Array | undefined { + // typescript-rest @Security allows scopes to be a string or an array, so we need to support both + if (typeof arg === 'string') { + // wrap in an array for compatibility with upstream generator logic + return [arg]; + } else if (arg && arg.kind === SyntaxKind.UndefinedKeyword || arg.kind === SyntaxKind.NullKeyword) { + return undefined; + } else { + // array from metadata needs to be extracted and converted to normal string array + return arg ? (arg as any).elements.map((e: any) => e.text) : undefined; + } + } +} export function getDecorators(node: ts.Node, isMatching: (identifier: DecoratorData) => boolean): DecoratorData[] { const decorators = node.decorators; - if (!decorators || !decorators.length) { return []; } + if (!decorators || !decorators.length) { + return []; + } return decorators .map(d => { @@ -36,7 +73,9 @@ export function getDecorators(node: ts.Node, isMatching: (identifier: DecoratorD function getDecorator(node: ts.Node, isMatching: (identifier: DecoratorData) => boolean) { const decorators = getDecorators(node, isMatching); - if (!decorators || !decorators.length) { return; } + if (!decorators || !decorators.length) { + return; + } return decorators[0]; } @@ -53,7 +92,7 @@ export function getDecoratorTextValue(node: ts.Node, isMatching: (identifier: De export function getDecoratorOptions(node: ts.Node, isMatching: (identifier: DecoratorData) => boolean) { const decorator = getDecorator(node, isMatching); - return decorator && typeof decorator.arguments[1] === 'object' ? decorator.arguments[1] as {[key: string]: any} : undefined; + return decorator && typeof decorator.arguments[1] === 'object' ? decorator.arguments[1] as { [key: string]: any } : undefined; } export function isDecorator(node: ts.Node, isMatching: (identifier: DecoratorData) => boolean) { diff --git a/test/data/apis.ts b/test/data/apis.ts index 1a10cf6..5ac9113 100644 --- a/test/data/apis.ts +++ b/test/data/apis.ts @@ -375,7 +375,7 @@ export class AbstractEntityEndpoint { } @Path('secure') -@swagger.Security('access_token') +@swagger.Security('access_token',[]) export class SecureEndpoint { @GET get(): string { @@ -383,15 +383,15 @@ export class SecureEndpoint { } @POST - @swagger.Security('user_email') + @swagger.Security('user_email',[]) post(): string { return 'Posted'; } } @Path('supersecure') -@swagger.Security('access_token') -@swagger.Security('user_email') +@swagger.Security('access_token',[]) +@swagger.Security('user_email',[]) export class SuperSecureEndpoint { @GET get(): string { diff --git a/test/data/defaultOptions.ts b/test/data/defaultOptions.ts index 47ab329..512853c 100644 --- a/test/data/defaultOptions.ts +++ b/test/data/defaultOptions.ts @@ -1,15 +1,41 @@ -import { SwaggerConfig } from './../../src/config'; +import {SwaggerConfig} from './../../src/config'; + export function getDefaultOptions(): SwaggerConfig { - return { - basePath: '/', - collectionFormat: 'multi', - description: 'Description of a test API', - entryFile: '', - host: 'localhost:3000', - license: 'MIT', - name: 'Test API', - outputDirectory: '', - version: '1.0.0', - yaml: false - }; + return { + basePath: '/', + collectionFormat: 'multi', + description: 'Description of a test API', + entryFile: '', + host: 'localhost:3000', + license: 'MIT', + name: 'Test API', + outputDirectory: '', + 'securityDefinitions': { + 'access_token': { + 'in': 'header', + 'name': 'authorization', + 'type': 'apiKey' + }, + 'api_key': { + 'in': 'query', + 'name': 'access_token', + 'type': 'apiKey', + }, + 'user_email': { + 'in': 'header', + 'name': 'x-user-email', + 'type': 'apiKey' + } + }, + 'spec': { + 'api_key': { + 'in': 'header', + 'name': 'api_key', + 'type': 'apiKey' + } + }, + version: '1.0.0', + yaml: false, + + }; }