Skip to content

Commit

Permalink
Add securityErrorMapper option
Browse files Browse the repository at this point in the history
  • Loading branch information
satazor authored and andreffvalente committed Sep 9, 2024
1 parent a545ef2 commit 034c352
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 36 deletions.
75 changes: 42 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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)`
Expand All @@ -167,14 +207,15 @@ 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`

For your convenience, the object `request.oas` is populated with data related to the request being made. This is an object containing `{ operation, security, securityReport }`, where:

- `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`](#security-error-mapper) section for more information.

**Example**

Expand Down Expand Up @@ -205,38 +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)
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/parser/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
6 changes: 4 additions & 2 deletions src/parser/security.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [];

Expand Down Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions src/parser/security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
3 changes: 3 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,14 @@ export interface RouteOptions extends Omit<FastifyRouteOptions, "method" | "sche

export interface PluginOptions {
spec?: string | OpenAPI.OpenAPIV3.Document | OpenAPI.OpenAPIV3_1.Document
securityErrorMapper: (error: errors.UnauthorizedError) => Error | undefined
securityHandlers?: {
[key:string]: SecurityHandler
}
}

export const openApiRouterPlugin: FastifyPluginCallback<PluginOptions>

export { errors }

export default openApiRouterPlugin

0 comments on commit 034c352

Please sign in to comment.