diff --git a/README.md b/README.md index 0368804..ac21b66 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ await fastify.register(import('@fastify/fastify-openapi-router-plugin'), { | ------ | ---- | ---------- | | `spec` | `string` or `object` | **REQUIRED**. A file path or object of your OpenAPI specification. | | `securityHandlers` | `object` | An object containing the security handlers that match [Security Schemes](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object) described in your OpenAPI specification. | +| `securityErrorMapper` | `function` | A function that allows mapping the default `UnauthorizedError` to a custom error | #### `spec` @@ -141,6 +142,45 @@ await fastify.register(import('@fastify/fastify-openapi-router-plugin'), { > [!IMPORTANT] > If your specification uses `http` security schemes with `in: cookie`, you must register [@fastify/cookie](https://github.com/fastify/fastify-cookie) before this plugin. +#### `securityErrorMapper` + +The plugin will throw an `UnauthorizedError` when none of the `security` blocks succeed. By default, this error originates a `401` reply with `{ code: 'FST_OAS_UNAUTHORIZED', 'message': 'Unauthorized' }` as the payload. You can override this behavior by leveraging the `securityErrorMapper` option: + +```js +await fastify.register(import('@fastify/fastify-openapi-router-plugin'), { + spec: './petstore.json', + securityHandlers: { + OAuth2: async (request, reply) => { + // ... + } + }, + securityErrorMapper: (unauthorizedError) => { + // Use `unauthorizedError.securityReport` to perform logic and return a custom error. + return MyUnauthorizedError(); + }, +}); +``` + +The `securityReport` property of the unauthorized error contains an array of objects with the following structure: + +```js +[ + { + ok: false, + // Schemes can be an empty object if the security block was skipped due to missing values. + schemes: { + OAuth2: { + ok: false, + // Error thrown by the security handler or fastify.oas.errors.ScopesMismatchError if the scopes were not satisfied. + error: new Error(), + } + } + } +] +``` + +If you don't define a `securityErrorMapper`, you can still catch the `UnauthorizedError` in your fastify error handler. + ### Decorators #### `fastify.oas.route(options)` @@ -167,6 +207,7 @@ fastify.oas.route({ This object contains all error classes that can be thrown by the plugin: - `UnauthorizedError`: Thrown when all security schemes verification failed. +- `ScopesMismatchError`: Thrown when the scopes returned by the security handler do not satisfy the scopes defined in the API operation. #### `request.oas` @@ -174,7 +215,7 @@ For your convenience, the object `request.oas` is populated with data related to - `operation` is the raw API operation that activated the Fastify route. - `security` is an object where keys are security scheme names and values the returned `data` field from security handlers. -- `securityReport`: A detailed report of the security verification process. Check the [Error handler](#error-handler) section for more information. +- `securityReport`: A detailed report of the security verification process. Check the [`securityErrorMapper`](#map-security-error) section for more information. **Example** @@ -205,64 +246,6 @@ fastify.oas.route({ }); ``` -### Error handler - -The plugin will throw an `UnauthorizedError` when none of the `security` blocks succeed. By default, this error originates a `401` reply with `{ code: 'FST_OAS_UNAUTHORIZED', 'message': 'Unauthorized' }` as the payload. You can override this behavior by registering a fastify error handler: - -```js -fastify.setErrorHandler((error, request, reply) => { - if (error instanceof fastify.oas.errors.UnauthorizedError) { - // Do something with `error.securityReport` and call `reply` accordingly. - } - - // ... -}); -``` - -The `securityReport` property contains an array of objects with the following structure: - -```js -[ - { - ok: false, - // Schemes can be an empty object if the security block was skipped due to missing values. - schemes: { - OAuth2: { - ok: false, - // Error thrown by the security handler or fastify.oas.errors.ScopesMismatchError if the scopes were not satisfied. - error: new Error(), - } - } - } -] -``` - -## License - -[MIT](./LICENSE) - -## Contributing - -### Development - -Install dependencies: - -```bash -npm i -``` - -Run tests: - -```bash -npm run test -``` - -Run tests and update snapshots: - -```bash -npm run test -- -u -``` - ### Cutting a release The release process is automated via the [release](https://github.com/uphold/fastify-openapi-router-plugin/actions/workflows/release.yaml) GitHub workflow. Run it by clicking the "Run workflow" button. diff --git a/src/index.js b/src/index.js index 3fb55ce..5985763 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,8 @@ import { PLUGIN_NAME } from './utils/constants.js'; import fp from 'fastify-plugin'; import plugin from './plugin.js'; +export { errors } from './errors/index.js'; + export default fp(plugin, { fastify: '4.x', name: PLUGIN_NAME diff --git a/src/parser/index.js b/src/parser/index.js index 90361f9..61f40f5 100644 --- a/src/parser/index.js +++ b/src/parser/index.js @@ -28,7 +28,7 @@ export const parse = async options => { async function (request) { request[DECORATOR_NAME].operation = operation; }, - parseSecurity(operation, spec, options.securityHandlers) + parseSecurity(operation, spec, options.securityHandlers, options.securityErrorMapper) ].filter(Boolean), schema: { headers: parseParams(operation.parameters, 'header'), diff --git a/src/parser/security.js b/src/parser/security.js index b9eb7b8..7c008eb 100644 --- a/src/parser/security.js +++ b/src/parser/security.js @@ -4,7 +4,7 @@ import { extractSecuritySchemeValueFromRequest, verifyScopes } from '../utils/se import _ from 'lodash-es'; import pProps from 'p-props'; -export const parseSecurity = (operation, spec, securityHandlers) => { +export const parseSecurity = (operation, spec, securityHandlers, securityErrorMapper) => { // Use the operation security if it's defined, otherwise fallback to the spec global security. const operationSecurity = operation.security ?? spec.security ?? []; @@ -93,7 +93,9 @@ export const parseSecurity = (operation, spec, securityHandlers) => { const lastResult = report[report.length - 1]; if (!lastResult.ok) { - throw createUnauthorizedError(report); + const error = createUnauthorizedError(report); + + throw securityErrorMapper?.(error) ?? error; } // Otherwise, we can safely use the last result to decorate the request. diff --git a/src/parser/security.test.js b/src/parser/security.test.js index b4a37a9..b21de9b 100644 --- a/src/parser/security.test.js +++ b/src/parser/security.test.js @@ -519,4 +519,42 @@ describe('parseSecurity()', () => { `); } }); + + it('should map security errors by running the supplied mapper', async () => { + const request = { + [DECORATOR_NAME]: {}, + headers: { + authorization: 'Bearer bearer token' + } + }; + const operation = { + security: [{ OAuth2: [] }] + }; + const spec = { + components: { + securitySchemes: { + OAuth2: { type: 'oauth2' } + } + } + }; + const securityHandlers = { + OAuth2: vi.fn(() => { + throw new Error('OAuth2 error'); + }) + }; + const customError = new Error('Mapped error'); + const securityErrorMapper = vi.fn(() => customError); + + const onRequest = parseSecurity(operation, spec, securityHandlers, securityErrorMapper); + + expect.assertions(3); + + try { + await onRequest(request); + } catch (err) { + expect(err).toBe(customError); + expect(securityErrorMapper).toHaveBeenCalledTimes(1); + expect(securityErrorMapper.mock.calls[0][0]).toBeInstanceOf(errors.UnauthorizedError); + } + }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 7c1aea5..d84b4bc 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -47,6 +47,7 @@ export interface RouteOptions extends Omit Error | undefined securityHandlers?: { [key:string]: SecurityHandler } @@ -54,4 +55,6 @@ export interface PluginOptions { export const openApiRouterPlugin: FastifyPluginCallback +export { errors } + export default openApiRouterPlugin