@uphold/fastify-openapi-router-plugin 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -136,6 +136,8 @@ await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
136
136
  });
137
137
  ```
138
138
 
139
+ Any error thrown by the security handler will be internally wrapped in a `SecurityHandlerError` with `fatal = true`, which will stop further security blocks to be executed. If you wish to continue with the next security block, you can `throw createSecurityHandlerError(error, false)` in your handler.
140
+
139
141
  > [!TIP]
140
142
  > The `scopes` returned by the security handler can contain trailing **wildcards**. For example, if the security handler returns `{ scopes: ['pets:*'] }`, the route will be authorized for any security scope that starts with `pets:`.
141
143
 
@@ -171,8 +173,8 @@ The `securityReport` property of the unauthorized error contains an array of obj
171
173
  schemes: {
172
174
  OAuth2: {
173
175
  ok: false,
174
- // Error thrown by the security handler or fastify.oas.errors.ScopesMismatchError if the scopes were not satisfied.
175
- error: new Error(),
176
+ // The error will be either be a `fastify.oas.errors.SecurityHandlerError` or a `fastify.oas.errors.ScopesMismatchError` if the scopes were not satisfied.
177
+ error: <Error>,
176
178
  }
177
179
  }
178
180
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@uphold/fastify-openapi-router-plugin",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "A plugin for Fastify to connect routes with a OpenAPI 3.x specification",
5
5
  "main": "./src/index.js",
6
- "types": "types/index.d.ts",
6
+ "types": "./types/index.d.ts",
7
7
  "type": "module",
8
8
  "files": [
9
- "src"
9
+ "src",
10
+ "types"
10
11
  ],
11
12
  "scripts": {
12
13
  "release": "release-it",
@@ -1,9 +1,11 @@
1
1
  import { ScopesMismatchError, createScopesMismatchError } from './scopes-mismatch-error.js';
2
+ import { SecurityHandlerError, createSecurityHandlerError } from './security-handler-error.js';
2
3
  import { UnauthorizedError, createUnauthorizedError } from './unauthorized-error.js';
3
4
 
4
5
  const errors = {
5
6
  ScopesMismatchError,
7
+ SecurityHandlerError,
6
8
  UnauthorizedError
7
9
  };
8
10
 
9
- export { createScopesMismatchError, createUnauthorizedError, errors };
11
+ export { createScopesMismatchError, createUnauthorizedError, createSecurityHandlerError, errors };
@@ -0,0 +1,14 @@
1
+ import createError from '@fastify/error';
2
+
3
+ const SecurityHandlerError = createError('FST_OAS_SECURITY_HANDLER_ERROR', 'Security handler has thrown an error', 403);
4
+
5
+ const createSecurityHandlerError = (handlerError, fatal = true) => {
6
+ const err = new SecurityHandlerError();
7
+
8
+ err.fatal = fatal;
9
+ err.cause = handlerError;
10
+
11
+ return err;
12
+ };
13
+
14
+ export { SecurityHandlerError, createSecurityHandlerError };
package/src/index.js CHANGED
@@ -6,6 +6,6 @@ export * from './errors/index.js';
6
6
  export { verifyScopes } from './utils/security.js';
7
7
 
8
8
  export default fp(plugin, {
9
- fastify: '4.x',
9
+ fastify: '4.x||5.x',
10
10
  name: PLUGIN_NAME
11
11
  });
@@ -1,5 +1,6 @@
1
1
  import { DECORATOR_NAME } from '../utils/constants.js';
2
- import { createScopesMismatchError, createUnauthorizedError } from '../errors/index.js';
2
+ import { SecurityHandlerError } from '../errors/security-handler-error.js';
3
+ import { createScopesMismatchError, createSecurityHandlerError, createUnauthorizedError } from '../errors/index.js';
3
4
  import { extractSecuritySchemeValueFromRequest, verifyScopes } from '../utils/security.js';
4
5
  import _ from 'lodash-es';
5
6
  import pProps from 'p-props';
@@ -44,7 +45,17 @@ export const applySecurity = (operation, spec, securityHandlers, securityErrorMa
44
45
  promisesCache.set(name, promise);
45
46
  }
46
47
 
47
- return await promise;
48
+ try {
49
+ return await promise;
50
+ } catch (error) {
51
+ let handlerError = error;
52
+
53
+ if (!(handlerError instanceof SecurityHandlerError)) {
54
+ handlerError = createSecurityHandlerError(error, true);
55
+ }
56
+
57
+ throw handlerError;
58
+ }
48
59
  };
49
60
 
50
61
  // Iterate over each security on the array, calling each one a `block`.
@@ -62,7 +73,7 @@ export const applySecurity = (operation, spec, securityHandlers, securityErrorMa
62
73
  }
63
74
 
64
75
  // Iterate over each security scheme in the block and call the security handler.
65
- // We leverage cache when calling the handler to avoid multiple calls to the same function
76
+ // We leverage cache when calling the handler to avoid multiple calls to the same function.
66
77
  const blockResults = await pProps(block, async (requiredScopes, name) => {
67
78
  try {
68
79
  const resolved = await callSecurityHandler(name);
@@ -83,11 +94,15 @@ export const applySecurity = (operation, spec, securityHandlers, securityErrorMa
83
94
 
84
95
  // Requirements in a block are AND'd together.
85
96
  const ok = Object.values(blockResults).every(result => result.ok);
97
+ const fatal = Object.values(blockResults).some(
98
+ result => result.error instanceof SecurityHandlerError && result.error.fatal
99
+ );
86
100
 
87
101
  report.push({ ok, schemes: blockResults });
88
102
 
89
103
  // Blocks themselves are OR'd together, so we can break early if one block passes.
90
- if (ok) {
104
+ // If a fatal error is found in a block, we can break early as well.
105
+ if (ok || fatal) {
91
106
  break;
92
107
  }
93
108
  }
@@ -1,7 +1,17 @@
1
1
  import { DECORATOR_NAME } from '../utils/constants.js';
2
+ import { SecurityHandlerError } from '../errors/security-handler-error.js';
2
3
  import { applySecurity, validateSecurity } from './security.js';
4
+ import { createSecurityHandlerError, errors } from '../errors/index.js';
3
5
  import { describe, expect, it, vi } from 'vitest';
4
- import { errors } from '../errors/index.js';
6
+
7
+ expect.addSnapshotSerializer({
8
+ serialize(val, config, indentation, depth, refs, printer) {
9
+ val.cause.message = `${val.message}: ${val.cause.message}`;
10
+
11
+ return printer(val.cause, config, indentation, depth, refs);
12
+ },
13
+ test: val => val instanceof SecurityHandlerError
14
+ });
5
15
 
6
16
  describe('validateSecurity()', () => {
7
17
  it('should throw on invalid security handler option', () => {
@@ -154,7 +164,7 @@ describe('applySecurity()', () => {
154
164
  `);
155
165
  });
156
166
 
157
- it('should try second security block if the first one fails', async () => {
167
+ it('should try second security block if the first one fails with a non-fatal error', async () => {
158
168
  const request = {
159
169
  [DECORATOR_NAME]: {},
160
170
  headers: {
@@ -175,7 +185,7 @@ describe('applySecurity()', () => {
175
185
  };
176
186
  const securityHandlers = {
177
187
  ApiKey: vi.fn(() => {
178
- throw new Error('ApiKey error');
188
+ throw createSecurityHandlerError(new Error('ApiKey error'), false);
179
189
  }),
180
190
  OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
181
191
  };
@@ -195,7 +205,7 @@ describe('applySecurity()', () => {
195
205
  "ok": false,
196
206
  "schemes": {
197
207
  "ApiKey": {
198
- "error": [Error: ApiKey error],
208
+ "error": [Error: Security handler has thrown an error: ApiKey error],
199
209
  "ok": false,
200
210
  },
201
211
  },
@@ -213,7 +223,7 @@ describe('applySecurity()', () => {
213
223
  `);
214
224
  });
215
225
 
216
- it('should throw an error if all security blocks fail', async () => {
226
+ it('should stop when a security block fails with a fatal error', async () => {
217
227
  const request = {
218
228
  [DECORATOR_NAME]: {},
219
229
  headers: {
@@ -236,8 +246,56 @@ describe('applySecurity()', () => {
236
246
  ApiKey: vi.fn(() => {
237
247
  throw new Error('ApiKey error');
238
248
  }),
249
+ OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
250
+ };
251
+
252
+ const onRequest = applySecurity(operation, spec, securityHandlers);
253
+
254
+ try {
255
+ await onRequest(request);
256
+ } catch (error) {
257
+ expect(error).toBeInstanceOf(errors.UnauthorizedError);
258
+ expect(error.securityReport).toMatchInlineSnapshot(`
259
+ [
260
+ {
261
+ "ok": false,
262
+ "schemes": {
263
+ "ApiKey": {
264
+ "error": [Error: Security handler has thrown an error: ApiKey error],
265
+ "ok": false,
266
+ },
267
+ },
268
+ },
269
+ ]
270
+ `);
271
+ }
272
+ });
273
+
274
+ it('should throw an error if all security blocks fail', async () => {
275
+ const request = {
276
+ [DECORATOR_NAME]: {},
277
+ headers: {
278
+ 'X-API-KEY': 'api key',
279
+ authorization: 'Bearer bearer token'
280
+ }
281
+ };
282
+ const operation = {
283
+ security: [{ ApiKey: [] }, { OAuth2: [] }]
284
+ };
285
+ const spec = {
286
+ components: {
287
+ securitySchemes: {
288
+ ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
289
+ OAuth2: { type: 'oauth2' }
290
+ }
291
+ }
292
+ };
293
+ const securityHandlers = {
294
+ ApiKey: vi.fn(() => {
295
+ throw createSecurityHandlerError(new Error('ApiKey error'), false);
296
+ }),
239
297
  OAuth2: vi.fn(() => {
240
- throw new Error('OAuth2 error');
298
+ throw createSecurityHandlerError(new Error('OAuth2 error'), false);
241
299
  })
242
300
  };
243
301
 
@@ -255,7 +313,7 @@ describe('applySecurity()', () => {
255
313
  "ok": false,
256
314
  "schemes": {
257
315
  "ApiKey": {
258
- "error": [Error: ApiKey error],
316
+ "error": [Error: Security handler has thrown an error: ApiKey error],
259
317
  "ok": false,
260
318
  },
261
319
  },
@@ -264,7 +322,7 @@ describe('applySecurity()', () => {
264
322
  "ok": false,
265
323
  "schemes": {
266
324
  "OAuth2": {
267
- "error": [Error: OAuth2 error],
325
+ "error": [Error: Security handler has thrown an error: OAuth2 error],
268
326
  "ok": false,
269
327
  },
270
328
  },
@@ -0,0 +1,60 @@
1
+ import { RouteOptions as FastifyRouteOptions, FastifyPluginCallback, FastifyRequest } from 'fastify'
2
+ import OpenAPI from 'openapi-types';
3
+ import { DECORATOR_NAME } from '../src/utils/constants'
4
+ import { errors } from '../src/errors'
5
+
6
+ declare module 'fastify' {
7
+ interface FastifyRequest {
8
+ [DECORATOR_NAME]: {
9
+ operation: OpenAPI.OpenAPIV3_1.OperationObject,
10
+ security: SecurityData
11
+ securityReport: SecurityReport
12
+ }
13
+ }
14
+
15
+ interface FastifyInstance {
16
+ [DECORATOR_NAME]: {
17
+ errors: typeof errors
18
+ route: (opts: RouteOptions) => FastifyInstance;
19
+ }
20
+ }
21
+ }
22
+
23
+ export interface SecurityData {
24
+ [key:string]: any
25
+ }
26
+
27
+ export type SecurityReport = SecurityReportBlock[]
28
+
29
+ export interface SecurityReportBlock {
30
+ ok: boolean
31
+ schemes: {
32
+ [key:string]: {
33
+ ok: boolean,
34
+ data?: any
35
+ error?: any
36
+ }
37
+ }
38
+ }
39
+
40
+ export type SecurityHandler = (value: string, request: FastifyRequest) => SecurityHandlerReturn | undefined
41
+
42
+ export interface SecurityHandlerReturn { data?: any, scopes?: string[] }
43
+
44
+ export interface RouteOptions extends Omit<FastifyRouteOptions, "method" | "schema" | "url"> {
45
+ operationId: string
46
+ }
47
+
48
+ export interface PluginOptions {
49
+ spec?: string | OpenAPI.OpenAPIV3.Document | OpenAPI.OpenAPIV3_1.Document
50
+ securityErrorMapper: (error: errors.UnauthorizedError) => Error | undefined
51
+ securityHandlers?: {
52
+ [key:string]: SecurityHandler
53
+ }
54
+ }
55
+
56
+ export const openApiRouterPlugin: FastifyPluginCallback<PluginOptions>
57
+
58
+ export { errors }
59
+
60
+ export default openApiRouterPlugin