Skip to content

Improve security decorator #78

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

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
29 changes: 23 additions & 6 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 8 additions & 3 deletions src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | string ): any {
return () => { return; };
}

Expand Down
7 changes: 2 additions & 5 deletions src/metadata/controllerGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/metadata/metadataGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export interface Parameter {
}

export interface Security {
name: string;
name?: string;
scopes?: string[];
}

Expand Down
7 changes: 2 additions & 5 deletions src/metadata/methodGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 51 additions & 3 deletions src/swagger/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
45 changes: 42 additions & 3 deletions src/utils/decoratorUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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 => {
Expand Down Expand Up @@ -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];
}
Expand All @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions test/data/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,23 +375,23 @@ export class AbstractEntityEndpoint {
}

@Path('secure')
@swagger.Security('access_token')
@swagger.Security('access_token',[])
export class SecureEndpoint {
@GET
get(): string {
return 'Access Granted';
}

@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 {
Expand Down
52 changes: 39 additions & 13 deletions test/data/defaultOptions.ts
Original file line number Diff line number Diff line change
@@ -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,

};
}