Skip to content

Commit 8b40689

Browse files
authored
Merge pull request #69 from cdimascio/security
security improvements
2 parents 756ea67 + 639efe6 commit 8b40689

File tree

8 files changed

+282
-32
lines changed

8 files changed

+282
-32
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "express-openapi-validator",
3-
"version": "2.6.1",
3+
"version": "2.7.0",
44
"description": "Automatically validate API requests using an OpenAPI 3 and Express.",
55
"main": "dist/index.js",
66
"scripts": {

src/framework/openapi.spec.loader.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ export class OpenApiSpecLoader {
4444
framework.initialize({
4545
visitApi(ctx: OpenAPIFrameworkAPIContext) {
4646
const apiDoc = ctx.getApiDoc();
47-
const security = apiDoc.security;
4847
for (const bpa of basePaths) {
4948
const bp = bpa.replace(/\/$/, '');
5049
for (const [path, methods] of Object.entries(apiDoc.paths)) {
@@ -72,23 +71,12 @@ export class OpenApiSpecLoader {
7271
.map(toExpressParams)
7372
.join('/');
7473

75-
// add apply any general defined security
76-
const moddedSchema =
77-
security || schema.security
78-
? {
79-
schema,
80-
security: [
81-
...(security || []),
82-
...(schema.security || []),
83-
],
84-
}
85-
: { ...schema };
8674
routes.push({
8775
expressRoute,
8876
openApiRoute,
8977
method: method.toUpperCase(),
9078
pathParams: Array.from(pathParams),
91-
schema: moddedSchema,
79+
schema,
9280
});
9381
}
9482
}

src/middlewares/openapi.security.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ const defaultSecurityHandler = (
1111

1212
interface SecurityHandlerResult {
1313
success: boolean;
14-
status: number;
15-
error: string;
14+
status?: number;
15+
error?: string;
1616
}
1717
export function security(
1818
context: OpenApiContext,
@@ -26,13 +26,16 @@ export function security(
2626
return next();
2727
}
2828

29-
const securities = <OpenAPIV3.SecuritySchemeObject>(
30-
req.openapi.schema.security
31-
);
29+
// const securities: OpenAPIV3.SecurityRequirementObject[] =
30+
// req.openapi.schema.security;
31+
32+
// use the local security object or fallbac to api doc's security or undefined
33+
const securities: OpenAPIV3.SecurityRequirementObject[] =
34+
req.openapi.schema.security || context.apiDoc.security;
3235

3336
const path: string = req.openapi.openApiRoute;
3437

35-
if (!path || !Array.isArray(securities)) {
38+
if (!path || !Array.isArray(securities) || securities.length === 0) {
3639
return next();
3740
}
3841

@@ -54,14 +57,17 @@ export function security(
5457
// TODO handle AND'd and OR'd security
5558
// This assumes OR only! i.e. at least one security passed authentication
5659
let firstError: SecurityHandlerResult = null;
60+
let success = false;
5761
for (var r of results) {
58-
if (!r.success) {
59-
firstError = r;
62+
if (r.success) {
63+
success = true;
6064
break;
65+
} else if (!firstError) {
66+
firstError = r;
6167
}
6268
}
63-
if (firstError) throw firstError;
64-
else next();
69+
if (success) next();
70+
else throw firstError;
6571
} catch (e) {
6672
const message = (e && e.error && e.error.message) || 'unauthorized';
6773
const err = validationError(e.status, path, message);
@@ -80,7 +86,7 @@ class SecuritySchemes {
8086
this.securities = securities;
8187
}
8288

83-
executeHandlers(req: OpenApiRequest): Promise<SecurityHandlerResult[]> {
89+
async executeHandlers(req: OpenApiRequest): Promise<SecurityHandlerResult[]> {
8490
// use a fallback handler if security handlers is not specified
8591
// This means if security handlers is specified, the user must define
8692
// all security handlers
@@ -90,9 +96,15 @@ class SecuritySchemes {
9096

9197
const promises = this.securities.map(async s => {
9298
try {
99+
if (Util.isEmptyObject(s)) {
100+
// anonumous security
101+
return { success: true };
102+
}
93103
const securityKey = Object.keys(s)[0];
94104
const scheme: any = this.securitySchemes[securityKey];
95-
const handler = this.securityHandlers[securityKey] || fallbackHandler;
105+
const handler =
106+
(this.securityHandlers && this.securityHandlers[securityKey]) ||
107+
fallbackHandler;
96108
const scopesTmp = s[securityKey];
97109
const scopes = Array.isArray(scopesTmp) ? scopesTmp : [];
98110

@@ -109,7 +121,7 @@ class SecuritySchemes {
109121
throw { status: 500, message };
110122
}
111123

112-
new AuthValidator(req, scheme).validate();
124+
new AuthValidator(req, scheme, scopes).validate();
113125

114126
// expected handler results are:
115127
// - throw exception,
@@ -140,10 +152,12 @@ class AuthValidator {
140152
private req: OpenApiRequest;
141153
private scheme;
142154
private path: string;
143-
constructor(req: OpenApiRequest, scheme) {
155+
private scopes: string[];
156+
constructor(req: OpenApiRequest, scheme, scopes: string[] = []) {
144157
this.req = req;
145158
this.scheme = scheme;
146159
this.path = req.openapi.openApiRoute;
160+
this.scopes = scopes;
147161
}
148162

149163
validate() {
@@ -186,6 +200,8 @@ class AuthValidator {
186200
if (type === 'basic' && !authHeader.includes('basic')) {
187201
throw Error(`Authorization header with scheme 'Basic' required.`);
188202
}
203+
204+
this.dissallowScopes();
189205
}
190206
}
191207

@@ -202,6 +218,28 @@ class AuthValidator {
202218
}
203219
}
204220
// TODO scheme in cookie
221+
222+
this.dissallowScopes();
205223
}
206224
}
225+
226+
private dissallowScopes() {
227+
if (this.scopes.length > 0) {
228+
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#security-requirement-object
229+
throw {
230+
status: 500,
231+
message: "scopes array must be empty for security type 'http'",
232+
};
233+
}
234+
}
235+
}
236+
237+
class Util {
238+
static isEmptyObject(o: Object) {
239+
return (
240+
typeof o === 'object' &&
241+
Object.entries(o).length === 0 &&
242+
o.constructor === Object
243+
);
244+
}
207245
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
openapi: '3.0.2'
2+
info:
3+
version: 1.0.0
4+
title: security top level
5+
description: security top level
6+
7+
servers:
8+
- url: /v1/
9+
10+
security:
11+
- ApiKeyAuth: []
12+
13+
paths:
14+
/api_key:
15+
get:
16+
responses:
17+
'200':
18+
description: OK
19+
'401':
20+
description: unauthorized
21+
22+
/api_key_or_anonymous:
23+
get:
24+
security:
25+
- ApiKeyAuth: []
26+
- {}
27+
responses:
28+
'200':
29+
description: OK
30+
'401':
31+
description: unauthorized
32+
33+
/bearer:
34+
get:
35+
security:
36+
- BearerAuth: []
37+
responses:
38+
'200':
39+
description: OK
40+
'401':
41+
description: unauthorized
42+
43+
/anonymous:
44+
get:
45+
security: []
46+
responses:
47+
'200':
48+
description: OK
49+
'401':
50+
description: unauthorized
51+
52+
/anonymous_2:
53+
get:
54+
security:
55+
- {}
56+
responses:
57+
'200':
58+
description: OK
59+
'401':
60+
description: unauthorized
61+
components:
62+
securitySchemes:
63+
ApiKeyAuth:
64+
type: apiKey
65+
in: header
66+
name: X-API-Key
67+
BearerAuth:
68+
type: http
69+
scheme: bearer

test/resources/security.yaml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,43 @@ servers:
88
- url: /v1/
99

1010
paths:
11+
/no_security:
12+
get:
13+
responses:
14+
'200':
15+
description: OK
16+
1117
/api_key:
1218
get:
1319
security:
1420
- ApiKeyAuth: []
1521
responses:
1622
'200':
1723
description: OK
18-
'400':
19-
description: Bad Request
24+
'401':
25+
description: unauthorized
26+
27+
/api_key_or_anonymous:
28+
get:
29+
security:
30+
# {} means anonyous or no security - see https://github.com/OAI/OpenAPI-Specification/issues/14
31+
- {}
32+
- ApiKeyAuth: []
33+
responses:
34+
'200':
35+
description: OK
36+
'401':
37+
description: unauthorized
38+
39+
# This api key with scopes should fail validation and return 500
40+
# scopes are only allowed for oauth2 and openidconnect
41+
/api_key_with_scopes:
42+
get:
43+
security:
44+
- ApiKeyAuth: ['read', 'write']
45+
responses:
46+
'200':
47+
description: OK
2048
'401':
2149
description: unauthorized
2250

test/security.spec.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,23 @@ describe(packageJson.name, () => {
3333
.get(`/bearer`, (req, res) => res.json({ logged_in: true }))
3434
.get(`/basic`, (req, res) => res.json({ logged_in: true }))
3535
.get(`/oauth2`, (req, res) => res.json({ logged_in: true }))
36-
.get(`/openid`, (req, res) => res.json({ logged_in: true })),
36+
.get(`/openid`, (req, res) => res.json({ logged_in: true }))
37+
.get(`/api_key_or_anonymous`, (req, res) =>
38+
res.json({ logged_in: true }),
39+
)
40+
.get('/no_security', (req, res) => res.json({ logged_in: true })),
3741
);
3842
});
3943

4044
after(() => {
4145
app.server.close();
4246
});
4347

48+
it('should return 200 if no security', async () =>
49+
request(app)
50+
.get(`${basePath}/no_security`)
51+
.expect(200));
52+
4453
it('should return 401 if apikey handler throws exception', async () =>
4554
request(app)
4655
.get(`${basePath}/api_key`)
@@ -309,4 +318,35 @@ describe(packageJson.name, () => {
309318
expect(body.errors[0].path).to.equal(`${basePath}/openid`);
310319
});
311320
});
321+
322+
it('should return 500 if scopes are no allowed', async () =>
323+
request(app)
324+
.get(`${basePath}/api_key_with_scopes`)
325+
.set('X-Api-Key', 'XXX')
326+
.expect(500)
327+
.then(r => {
328+
const body = r.body;
329+
expect(body.message).to.equal(
330+
"scopes array must be empty for security type 'http'",
331+
);
332+
}));
333+
334+
it('should return 200 if api_key or anonymous and no api key is supplied', async () => {
335+
(<any>eovConf.securityHandlers).ApiKeyAuth = <any>(
336+
((req, scopes, schema) => true)
337+
);
338+
return request(app)
339+
.get(`${basePath}/api_key_or_anonymous`)
340+
.expect(200);
341+
});
342+
343+
it('should return 200 if api_key or anonymous and api key is supplied', async () => {
344+
(<any>eovConf.securityHandlers).ApiKeyAuth = <any>(
345+
((req, scopes, schema) => true)
346+
);
347+
return request(app)
348+
.get(`${basePath}/api_key_or_anonymous`)
349+
.set('x-api-key', 'XXX')
350+
.expect(200);
351+
});
312352
});

0 commit comments

Comments
 (0)