@uphold/fastify-openapi-router-plugin 0.6.0 → 0.7.1
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 +4 -2
- package/package.json +4 -3
- package/src/errors/index.js +3 -1
- package/src/errors/security-handler-error.js +14 -0
- package/src/parser/security.js +19 -4
- package/src/parser/security.test.js +66 -8
- package/types/index.d.ts +68 -0
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
|
-
//
|
|
175
|
-
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
package/src/errors/index.js
CHANGED
|
@@ -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/parser/security.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DECORATOR_NAME } from '../utils/constants.js';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
},
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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'
|
|
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 function verifyScopes(providedScopes: string[], requiredScopes: string[]): string[]
|
|
59
|
+
|
|
60
|
+
export function createSecurityHandlerError(error: Error, force?: boolean): typeof errors.SecurityHandlerError
|
|
61
|
+
|
|
62
|
+
export function createScopesMismatchError(providedScopes: string[], requiredScopes: string[], missingScopes: string[]): typeof errors.ScopesMismatchError
|
|
63
|
+
|
|
64
|
+
export function createUnauthorizedError(securityReport: SecurityReport): typeof errors.UnauthorizedError
|
|
65
|
+
|
|
66
|
+
export { errors }
|
|
67
|
+
|
|
68
|
+
export default openApiRouterPlugin
|