Skip to content

Commit

Permalink
Abort by default if a security handlers throws an error
Browse files Browse the repository at this point in the history
  • Loading branch information
satazor committed Jan 22, 2025
1 parent 96c7cb2 commit 7b064a6
Show file tree
Hide file tree
Showing 4 changed files with 77 additions and 11 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
});
```

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.

> [!TIP]
> 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:`.
Expand Down Expand Up @@ -171,8 +173,8 @@ The `securityReport` property of the unauthorized error contains an array of obj
schemes: {
OAuth2: {
ok: false,
// Error thrown by the security handler or fastify.oas.errors.ScopesMismatchError if the scopes were not satisfied.
error: new Error(),
// The error will be either be a `fastify.oas.errors.SecurityHandlerError` or a `fastify.oas.errors.ScopesMismatchError` if the scopes were not satisfied.
error: <Error>,
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/errors/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ScopesMismatchError, createScopesMismatchError } from './scopes-mismatch-error.js';
import { SecurityHandlerError, createSecurityHandlerError } from './security-handler-error.js';
import { UnauthorizedError, createUnauthorizedError } from './unauthorized-error.js';

const errors = {
ScopesMismatchError,
SecurityHandlerError,
UnauthorizedError
};

export { createScopesMismatchError, createUnauthorizedError, errors };
export { createScopesMismatchError, createUnauthorizedError, createSecurityHandlerError, errors };
22 changes: 18 additions & 4 deletions src/parser/security.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DECORATOR_NAME } from '../utils/constants.js';
import { createScopesMismatchError, createUnauthorizedError } from '../errors/index.js';
import { SecurityHandlerError } from '../errors/security-handler-error.js';
import { createScopesMismatchError, createSecurityHandlerError, createUnauthorizedError } from '../errors/index.js';
import { extractSecuritySchemeValueFromRequest, verifyScopes } from '../utils/security.js';
import _ from 'lodash-es';
import pProps from 'p-props';
Expand Down Expand Up @@ -44,7 +45,17 @@ export const applySecurity = (operation, spec, securityHandlers, securityErrorMa
promisesCache.set(name, promise);
}

return await promise;
try {
return await promise;
} catch (error) {
let handlerError = error;

if (!(handlerError instanceof SecurityHandlerError)) {
handlerError = createSecurityHandlerError(error, true);
}

throw handlerError;
}
};

// Iterate over each security on the array, calling each one a `block`.
Expand All @@ -62,7 +73,7 @@ export const applySecurity = (operation, spec, securityHandlers, securityErrorMa
}

// Iterate over each security scheme in the block and call the security handler.
// We leverage cache when calling the handler to avoid multiple calls to the same function
// We leverage cache when calling the handler to avoid multiple calls to the same function.
const blockResults = await pProps(block, async (requiredScopes, name) => {
try {
const resolved = await callSecurityHandler(name);
Expand All @@ -83,11 +94,14 @@ export const applySecurity = (operation, spec, securityHandlers, securityErrorMa

// Requirements in a block are AND'd together.
const ok = Object.values(blockResults).every(result => result.ok);
const fatal = Object.values(blockResults).some(
result => result.error instanceof SecurityHandlerError && result.error.fatal
);

report.push({ ok, schemes: blockResults });

// Blocks themselves are OR'd together, so we can break early if one block passes.
if (ok) {
if (ok || fatal) {
break;
}
}
Expand Down
56 changes: 52 additions & 4 deletions src/parser/security.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DECORATOR_NAME } from '../utils/constants.js';
import { applySecurity, validateSecurity } from './security.js';
import { createSecurityHandlerError, errors } from '../errors/index.js';
import { describe, expect, it, vi } from 'vitest';
import { errors } from '../errors/index.js';

describe('validateSecurity()', () => {
it('should throw on invalid security handler option', () => {
Expand Down Expand Up @@ -154,7 +154,7 @@ describe('applySecurity()', () => {
`);
});

it('should try second security block if the first one fails', async () => {
it.only('should try second security block if the first one fails with a non-fatal error', async () => {
const request = {
[DECORATOR_NAME]: {},
headers: {
Expand All @@ -175,7 +175,7 @@ describe('applySecurity()', () => {
};
const securityHandlers = {
ApiKey: vi.fn(() => {
throw new Error('ApiKey error');
throw createSecurityHandlerError(new Error('ApiKey error'), false);
}),
OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
};
Expand All @@ -195,7 +195,7 @@ describe('applySecurity()', () => {
"ok": false,
"schemes": {
"ApiKey": {
"error": [Error: ApiKey error],
"error": [FastifyError: Security handler has thrown an error],
"ok": false,
},
},
Expand All @@ -213,6 +213,54 @@ describe('applySecurity()', () => {
`);
});

it.only('should stop when a security block fails with a fatal error', async () => {
const request = {
[DECORATOR_NAME]: {},
headers: {
'X-API-KEY': 'api key',
authorization: 'Bearer bearer token'
}
};
const operation = {
security: [{ ApiKey: [] }, { OAuth2: [] }]
};
const spec = {
components: {
securitySchemes: {
ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
OAuth2: { type: 'oauth2' }
}
}
};
const securityHandlers = {
ApiKey: vi.fn(() => {
throw new Error('ApiKey error');
}),
OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
};

const onRequest = applySecurity(operation, spec, securityHandlers);

try {
await onRequest(request);
} catch (error) {
expect(error).toBeInstanceOf(errors.UnauthorizedError);
expect(error.securityReport).toMatchInlineSnapshot(`
[
{
"ok": false,
"schemes": {
"ApiKey": {
"error": [FastifyError: Security handler has thrown an error],
"ok": false,
},
},
},
]
`);
}
});

it('should throw an error if all security blocks fail', async () => {
const request = {
[DECORATOR_NAME]: {},
Expand Down

0 comments on commit 7b064a6

Please sign in to comment.