diff --git a/CHANGE_HISTORY.md b/CHANGE_HISTORY.md index 0110341b..00abeabf 100644 --- a/CHANGE_HISTORY.md +++ b/CHANGE_HISTORY.md @@ -3,7 +3,7 @@ * fix: correctly handle default values of deepObject query params (#557) ([4ce0f89](https://github.com/cdimascio/express-openapi-validator/commit/4ce0f89)), closes [#557](https://github.com/cdimascio/express-openapi-validator/issues/557) * doc: Clean up README and Nestjs Example (#559) ([305d5db](https://github.com/cdimascio/express-openapi-validator/commit/305d5db)), closes [#559](https://github.com/cdimascio/express-openapi-validator/issues/559) * doc: update README ([09980a3](https://github.com/cdimascio/express-openapi-validator/commit/09980a3)) -* feat: Add Allow Header on 405 (#560) ([45a40b7](https://github.com/cdimascio/express-openapi-validator/commit/45a40b7)), closes [#560](https://github.com/cdimascio/express-openapi-validator/issues/560) [#467](https://github.com/cdimascio/express-openapi-validator/issues/467) [#467](https://github.com/cdimascio/express-openapi-validator/issues/467) +* feat: Add Allow Header on 405 (#560) ([45a40b7](https://github.com/cdimascio/express-openapi-validator/commit/45a40b7)), closes [#560](https://github.com/cdimascio/express-openapi-validator/issues/560) [#467](https://github.com/cdimascio/express-openapi-validator/issues/467) diff --git a/README.md b/README.md index 2bf9349c..4032033d 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ app.post('/v1/pets/:id/photos', function (req, res, next) { originalname: f.originalname, encoding: f.encoding, mimetype: f.mimetype, - // Buffer of file conents + // Buffer of file contents buffer: f.buffer, })), }); @@ -155,10 +155,13 @@ app.post('/v1/pets/:id/photos', function (req, res, next) { app.use((err, req, res, next) => { // 7. Customize errors console.error(err); // dump error to console for debug - res.status(err.status || 500).json({ - message: err.message, - errors: err.errors, - }); + res + .status(err.status ?? 500) + .header(err.headers) + .json({ + message: err.message, + errors: err.errors, + }); }); http.createServer(app).listen(3000); diff --git a/examples/9-nestjs/src/filters/openapi-exception.filter.ts b/examples/9-nestjs/src/filters/openapi-exception.filter.ts index 5e7413e8..9b127f4a 100644 --- a/examples/9-nestjs/src/filters/openapi-exception.filter.ts +++ b/examples/9-nestjs/src/filters/openapi-exception.filter.ts @@ -8,7 +8,8 @@ export class OpenApiExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - response.status(error.status).header(error.headers).json(error); + const { status, headers, ...data } = error; + response.status(status).header(headers).json(data); } } diff --git a/examples/9-nestjs/tsconfig.json b/examples/9-nestjs/tsconfig.json index 3ee13e52..c2fd2f16 100644 --- a/examples/9-nestjs/tsconfig.json +++ b/examples/9-nestjs/tsconfig.json @@ -7,6 +7,7 @@ "allowSyntheticDefaultImports": true, "experimentalDecorators": true, + "emitDecoratorMetadata": true, "outDir": "dist/", "declaration": true, diff --git a/src/framework/types.ts b/src/framework/types.ts index 5c69eb4b..08e9e0af 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -535,6 +535,7 @@ export interface ValidationErrorItem { interface ErrorHeaders { Allow?: string; + 'WWW-Authenticate'?: string; } export class HttpError extends Error implements ValidationError { @@ -570,6 +571,7 @@ export class HttpError extends Error implements ValidationError { status: number; path: string; message?: string; + headers?: ErrorHeaders; errors?: ValidationErrorItem[]; }): | InternalServerError @@ -719,6 +721,7 @@ export class Unauthorized extends HttpError { constructor(err: { path: string; message?: string; + headers?: ErrorHeaders; overrideStatus?: number; }) { super({ @@ -726,6 +729,7 @@ export class Unauthorized extends HttpError { path: err.path, name: 'Unauthorized', message: err.message, + headers: err.headers, }); } } diff --git a/src/middlewares/openapi.security.ts b/src/middlewares/openapi.security.ts index c69a8e34..2767d266 100644 --- a/src/middlewares/openapi.security.ts +++ b/src/middlewares/openapi.security.ts @@ -109,17 +109,17 @@ export function security( } } catch (e) { const message = e?.error?.message || 'unauthorized'; + const headers = + e?.error?.type === 'http' && + e?.error?.scheme === 'basic' + ? { 'WWW-Authenticate': 'Basic' } + : undefined; const err = HttpError.create({ status: e.status, path: path, message: message, + headers, }); - /*const err = - e.status == 500 - ? new InternalServerError({ path: path, message: message }) - : e.status == 403 - ? new Forbidden({ path: path, message: message }) - : new Unauthorized({ path: path, message: message });*/ next(err); } }; @@ -245,21 +245,31 @@ class AuthValidator { private validateHttp(): void { const { req, scheme, path } = this; if (['http'].includes(scheme.type.toLowerCase())) { - const authHeader = - req.headers['authorization'] && - req.headers['authorization'].toLowerCase(); + const authHeader = req.headers['authorization']?.toLowerCase(); + const type = scheme.scheme?.toLowerCase(); if (!authHeader) { - throw Error(`Authorization header required`); + throw new SecurityError({ + message: `Authorization header required`, + type: 'http', + scheme: type, + }); } - const type = scheme.scheme && scheme.scheme.toLowerCase(); if (type === 'bearer' && !authHeader.includes('bearer')) { - throw Error(`Authorization header with scheme 'Bearer' required`); + throw new SecurityError({ + message: `Authorization header with scheme 'Bearer' required`, + type: 'http', + scheme: type, + }); } if (type === 'basic' && !authHeader.includes('basic')) { - throw Error(`Authorization header with scheme 'Basic' required`); + throw new SecurityError({ + message: `Authorization header with scheme 'Basic' required`, + type: 'http', + scheme: type, + }); } } } @@ -269,15 +279,24 @@ class AuthValidator { if (scheme.type === 'apiKey') { if (scheme.in === 'header') { if (!req.headers[scheme.name.toLowerCase()]) { - throw Error(`'${scheme.name}' header required`); + throw new SecurityError({ + message: `'${scheme.name}' header required`, + type: 'apiKey', + }); } } else if (scheme.in === 'query') { if (!req.query[scheme.name]) { - throw Error(`query parameter '${scheme.name}' required`); + throw new SecurityError({ + message: `query parameter '${scheme.name}' required`, + type: 'apiKey', + }); } } else if (scheme.in === 'cookie') { if (!req.cookies[scheme.name]) { - throw Error(`cookie '${scheme.name}' required`); + throw new SecurityError({ + message: `cookie '${scheme.name}' required`, + type: 'apiKey', + }); } } } @@ -293,3 +312,19 @@ class Util { ); } } + +type SecurityType = OpenAPIV3.SecuritySchemeObject['type']; +type SecurityScheme = OpenAPIV3.HttpSecurityScheme['scheme']; +class SecurityError extends Error { + type: SecurityType; + scheme?: SecurityScheme; + constructor(err: { + message: string; + type: SecurityType; + scheme?: SecurityScheme; + }) { + super(err.message); + this.type = err.type; + this.scheme = err.scheme; + } +} diff --git a/test/allow.header.spec.ts b/test/allow.header.spec.ts index 25620c81..ccdc11b4 100644 --- a/test/allow.header.spec.ts +++ b/test/allow.header.spec.ts @@ -1,17 +1,21 @@ import { expect } from 'chai'; import * as express from 'express'; -import { Server } from 'http'; import * as request from 'supertest'; -import * as packageJson from '../package.json'; -import * as OpenApiValidator from '../src'; import { OpenAPIV3 } from '../src/framework/types'; -import { startServer } from './common/app.common'; +import { createApp } from './common/app'; -describe(packageJson.name, () => { +describe('Allow Header', () => { let app = null; before(async () => { - app = await createApp(); + app = await createApp({ apiSpec: createApiSpec() }, 3001, (app) => + app.use( + express + .Router() + .get('/v1/pets/:petId', () => ['cat', 'dog']) + .post('/v1/pets/:petId', (req, res) => res.json(req.body)), + ), + ); }); after(() => { @@ -30,26 +34,6 @@ describe(packageJson.name, () => { })); }); -async function createApp(): Promise { - const app = express(); - - app.use( - OpenApiValidator.middleware({ - apiSpec: createApiSpec(), - validateRequests: true, - }), - ); - app.use( - express - .Router() - .get('/v1/pets/:petId', () => ['cat', 'dog']) - .post('/v1/pets/:petId', (req, res) => res.json(req.body)), - ); - - await startServer(app, 3001); - return app; -} - function createApiSpec(): OpenAPIV3.Document { return { openapi: '3.0.3', diff --git a/test/common/app.ts b/test/common/app.ts index cbefea40..3744c17b 100644 --- a/test/common/app.ts +++ b/test/common/app.ts @@ -4,7 +4,7 @@ import * as cookieParser from 'cookie-parser'; import * as bodyParser from 'body-parser'; import * as logger from 'morgan'; -import * as OpenApiValidator from '../../src'; +import * as OpenApiValidator from '../../src'; import { startServer, routes } from './app.common'; import { OpenApiValidatorOpts } from '../../src/framework/types'; @@ -43,10 +43,13 @@ export async function createApp( // Register error handler app.use((err, req, res, next) => { // console.error(err); - res.status(err.status ?? 500).json({ - message: err.message, - errors: err.errors, - }); + res + .status(err.status ?? 500) + .header(err.headers) + .json({ + message: err.message, + errors: err.errors, + }); }); } diff --git a/test/resources/security.yaml b/test/resources/security.yaml index 6ae8de5b..83812b25 100644 --- a/test/resources/security.yaml +++ b/test/resources/security.yaml @@ -39,7 +39,7 @@ paths: /api_key_or_anonymous: get: security: - # {} means anonyous or no security - see https://github.com/OAI/OpenAPI-Specification/issues/14 + # {} means anonymous or no security - see https://github.com/OAI/OpenAPI-Specification/issues/14 - {} - ApiKeyAuth: [] responses: diff --git a/test/www-authenticate.header.spec.ts b/test/www-authenticate.header.spec.ts new file mode 100644 index 00000000..87f2a9ee --- /dev/null +++ b/test/www-authenticate.header.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import * as express from 'express'; +import { join } from 'path'; +import * as request from 'supertest'; +import { createApp } from './common/app'; + +describe('WWW-Authenticate Header', () => { + let app = null; + + before(async () => { + app = await createApp( + { apiSpec: join(__dirname, 'resources/security.yaml') }, + 3001, + (app) => + app.use( + express + .Router() + .get('/v1/basic', (req, res) => res.json({ logged_in: true })) + .get('/v1/bearer', (req, res) => res.json({ logged_in: true })), + ), + ); + }); + + after(() => { + app.server.close(); + }); + + it('adds "WWW-Authenticate" header on 401 when using basic auth', async () => + request(app) + .get('/v1/basic') + .expect(401) + .then((response) => { + expect(response.header['www-authenticate']).to.equal('Basic'); + })); + + it('does not add "WWW-Authenticate" header on 401 when using bearer auth', async () => + request(app) + .get('/v1/bearer') + .expect(401) + .then((response) => { + expect(response.header['www-authenticate']).to.be.undefined; + })); +});